Compare commits

..

146 Commits

Author SHA1 Message Date
Ren Amamiya
32843018aa chore(release): v1.2.7 2023-10-04 14:27:48 +07:00
Ren Amamiya
9df4835be3 fix ci 2023-10-04 11:17:04 +07:00
Ren Amamiya
8e39bca57c fix build 2023-10-04 07:48:20 +07:00
Ren Amamiya
8d9ec0dcfd update config 2023-10-03 18:43:39 +07:00
Ren Amamiya
cdeb5afd28 upgrade to tauri v1.5.1 2023-10-03 07:38:23 +07:00
Ren Amamiya
1f3ba09cec new app icon 2023-10-02 15:36:20 +07:00
Ren Amamiya
4915b833e7 small fixed 2023-10-01 15:00:38 +07:00
Ren Amamiya
674e5f0339 finish relay manegament screen 2023-10-01 09:02:14 +07:00
Ren Amamiya
11ed618a7f wip: relay manegament screen 2023-09-30 19:07:17 +07:00
Ren Amamiya
a2e3247432 wip 2023-09-30 15:12:33 +07:00
Ren Amamiya
09b3eeda99 small fixes and bump version 2023-09-29 12:40:02 +07:00
Ren Amamiya
700f3eb85f upgrade to tauri v1.5.0 2023-09-29 09:19:40 +07:00
Ren Amamiya
2f87ed8949 polish widget code 2023-09-29 09:11:38 +07:00
Ren Amamiya
cb3c95b133 clean up and improve perf 2023-09-28 16:18:04 +07:00
Ren Amamiya
4f4e2f5ccd wip: multi account 2023-09-28 14:00:52 +07:00
Ren Amamiya
0e6fc65b08 Merge pull request #91 from luminous-devs/feat/ui-patch
v1.2.6
2023-09-28 08:28:34 +07:00
Ren Amamiya
876d351358 update dependencies and polish 2023-09-28 08:22:38 +07:00
Ren Amamiya
c80414a72d small fixes and support $ boost sign 2023-09-28 07:29:05 +07:00
Ren Amamiya
7cef6efa6f fix crash on windows 2023-09-27 18:24:58 +07:00
Ren Amamiya
74ff49b8db fix app window is not resizable 2023-09-27 16:11:33 +07:00
Ren Amamiya
2b50fc438f improve startup time 2023-09-27 14:53:01 +07:00
Ren Amamiya
b339e842ca perf improve 2023-09-27 08:32:19 +07:00
Ren Amamiya
1d93f8cf6a clean up 2023-09-26 09:40:02 +07:00
Ren Amamiya
236131087a polish 2023-09-26 09:05:39 +07:00
Ren Amamiya
a66770989b wip: finish browse users 2023-09-25 14:35:47 +07:00
Ren Amamiya
9ff74599eb customize traffic light on macOS 2023-09-25 07:43:13 +07:00
Ren Amamiya
c049fa8865 wip: update browse user screen 2023-09-24 15:42:49 +07:00
Ren Amamiya
41b12746a7 wip 2023-09-24 09:13:42 +07:00
Ren Amamiya
50f90ddcc2 wip 2023-09-24 07:55:27 +07:00
Ren Amamiya
c9bc7b81dd wip: browse user 2023-09-22 14:13:55 +07:00
Ren Amamiya
18a9ba3fb0 wip: replace ndk cache with metadata table 2023-09-21 16:11:35 +07:00
Ren Amamiya
413571ee7f wip 2023-09-21 15:28:01 +07:00
Ren Amamiya
17fe3bb1f6 wip: timeline 2023-09-21 09:11:45 +07:00
Ren Amamiya
0e5adb246f resizable widget 2023-09-20 14:31:14 +07:00
Ren Amamiya
296136203a update dependencies 2023-09-20 08:22:02 +07:00
Ren Amamiya
1bbfebc2b8 fix ci again 2023-09-19 16:31:38 +07:00
Ren Amamiya
d84e97b0d4 fix ci 2023-09-19 15:59:50 +07:00
Ren Amamiya
824aa8fa28 back to pnpm, bun is fun but cannot generate tauri build 2023-09-19 15:56:33 +07:00
Ren Amamiya
2b34ef3b7a fix build error 2023-09-19 15:29:26 +07:00
Ren Amamiya
4fa8f40e6a improve nwc 2023-09-19 15:06:12 +07:00
Ren Amamiya
c1bddeb6ed bump version 2023-09-19 11:20:15 +07:00
Ren Amamiya
5c2bfa0ea3 improve notification 2023-09-19 11:15:35 +07:00
Ren Amamiya
60e93965ea respect user's relay list (kind 10002) 2023-09-19 08:01:57 +07:00
Ren Amamiya
380d1fb930 temporary using default relays 2023-09-18 15:42:17 +07:00
Ren Amamiya
53aa13c8aa clean up messy code 2023-09-18 09:50:15 +07:00
Ren Amamiya
13f5190ba1 update dependencies and better handle repost 2023-09-17 16:14:04 +07:00
Ren Amamiya
c590e290e0 Merge pull request #87 from luminous-devs/feat/improve-onboarding
merge now, improve later
2023-09-17 08:44:16 +07:00
Ren Amamiya
cdf86a2613 polish 2023-09-17 08:43:42 +07:00
Ren Amamiya
8726e22b38 replace nostr.com with njump.me 2023-09-17 08:03:29 +07:00
Ren Amamiya
1206486016 partial support replaceable event 2023-09-16 16:06:01 +07:00
Ren Amamiya
11ad281d72 wip 2023-09-16 08:57:24 +07:00
Ren Amamiya
fe4bfa1699 wip: learn nostr widget 2023-09-16 07:47:44 +07:00
Ren Amamiya
c6a0636e8c add complete screen 2023-09-15 10:29:39 +07:00
Ren Amamiya
d3db6492d9 update onboarding 2023-09-15 08:58:09 +07:00
Ren Amamiya
8f8617d8f9 update import key flow 2023-09-14 16:51:38 +07:00
Ren Amamiya
8e513404c3 update create account flow 2023-09-14 09:20:36 +07:00
Ren Amamiya
5a6dd172b1 small fixes 2023-09-13 11:10:24 +07:00
Ren Amamiya
fa0d7cac31 hide nwc secret in frontend 2023-09-12 16:16:57 +07:00
Ren Amamiya
432b2ae185 polish nwc connection flow 2023-09-12 16:00:41 +07:00
Ren Amamiya
fb8a6581dd replace pnpm with bun 2023-09-12 08:43:12 +07:00
Ren Amamiya
a4f221f868 wip: redesign nwc 2023-09-12 08:27:29 +07:00
Ren Amamiya
602d012efe fix alby connection 2023-09-11 07:52:43 +07:00
Ren Amamiya
5bf816eba2 fully suport alby nostr-wallet-connect 2023-09-10 16:25:35 +07:00
Ren Amamiya
a33c9d3517 wip: integrate alby 2023-09-10 07:19:36 +07:00
Ren Amamiya
1553f5ced2 bump version 2023-09-09 07:21:02 +07:00
Ren Amamiya
41901b2174 fix hashtag step in onboarding 2023-09-08 17:02:01 +07:00
Ren Amamiya
177e4c1ff7 fix some bugs 2023-09-08 15:29:41 +07:00
Ren Amamiya
10036500cb small fixes 2023-09-08 09:22:09 +07:00
Ren Amamiya
a1fa777f8c fix logout function, prepare for multi-account support 2023-09-08 08:36:15 +07:00
Ren Amamiya
472925bb05 small fixes 2023-09-07 12:19:28 +07:00
Ren Amamiya
8eb11efb34 update notification 2023-09-07 11:34:26 +07:00
Ren Amamiya
48066a4018 bump version 2023-09-06 16:53:05 +07:00
Ren Amamiya
5c8850ea8f redesign widget list 2023-09-06 14:30:57 +07:00
Ren Amamiya
09aea3cff5 update widgets 2023-09-06 08:58:02 +07:00
Ren Amamiya
45c5a890b9 update tauri config 2023-09-06 07:46:31 +07:00
Ren Amamiya
69a3e85cb3 small updates and bump version 2023-09-05 17:25:00 +07:00
Ren Amamiya
224439f62b support drag and drop upload in composer 2023-09-05 13:07:21 +07:00
Ren Amamiya
2389ad5fdc replace void.cat with nostr.build 2023-09-05 12:46:00 +07:00
Ren Amamiya
4019623d99 small updates 2023-09-05 08:50:13 +07:00
Ren Amamiya
57c17ffbf9 use nostr.com to display unfound event 2023-09-04 18:09:41 +07:00
Ren Amamiya
98d2ccfc86 small fixes 2023-09-04 17:18:28 +07:00
Ren Amamiya
c74a81cfdb re-add link preview 2023-09-04 14:35:57 +07:00
Ren Amamiya
3ebcf4a981 new parser, faster than before 50% 2023-09-04 14:05:04 +07:00
Ren Amamiya
5d45027776 fix build issue 2023-09-04 07:44:57 +07:00
Ren Amamiya
21ea8309c7 fix ci (final) 2023-09-03 12:02:59 +07:00
Ren Amamiya
431331174a fix ci again 2023-09-03 11:30:45 +07:00
Ren Amamiya
c26cfc038d fix ci 2023-09-03 11:08:13 +07:00
Ren Amamiya
39b7b34bb7 new mention popup in composer 2023-09-03 08:43:08 +07:00
Ren Amamiya
a4cf65e7c2 update ui consistent for cross platform 2023-09-03 07:43:38 +07:00
Ren Amamiya
37668393f1 expt: disable note metadata 2023-09-02 17:28:05 +07:00
Ren Amamiya
4309f734b6 Merge pull request #80 from luminous-devs/revert-to-tauri-v1
Revert to Tauri v1.4.0
2023-09-02 12:49:51 +07:00
Ren Amamiya
b4957bae1f native fetch and shadow 2023-09-02 12:49:04 +07:00
Ren Amamiya
7a3b19bf7b revert to tauri v1 2023-09-02 12:15:48 +07:00
Ren Amamiya
1931373515 update composer 2023-09-02 08:50:54 +07:00
Ren Amamiya
28939d1733 improve hashtag parser 2023-09-01 18:26:22 +07:00
Ren Amamiya
e6d35bc635 fix mention in composer and improve error handling 2023-09-01 15:57:31 +07:00
Ren Amamiya
cc315a190a fully support nip05 2023-09-01 08:58:33 +07:00
Ren Amamiya
0d207d471c fix nip94 widget 2023-08-31 09:04:09 +07:00
Ren Amamiya
f2eb7a90ad update settings screen 2023-08-31 08:51:23 +07:00
Ren Amamiya
c29ed9669e bump version 2023-08-30 16:23:38 +07:00
Ren Amamiya
aced6077bd refactor widget 2023-08-30 16:21:42 +07:00
Ren Amamiya
abe4d11498 clean up & polish 2023-08-30 09:03:02 +07:00
Ren Amamiya
91e50efb1a yup, lume is very solid now 2023-08-29 16:11:17 +07:00
Ren Amamiya
2914c54a47 Merge pull request #78 from luminous-devs/wip/ui
Update for UI consistent
2023-08-29 12:15:19 +07:00
Ren Amamiya
d1701eff20 add focus button to note actionbar 2023-08-29 12:14:45 +07:00
Ren Amamiya
d4eb237e40 expandable composer 2023-08-29 11:13:36 +07:00
Ren Amamiya
f4b2458417 ui consistent 2023-08-29 08:24:18 +07:00
Ren Amamiya
c89e7e48ee wip: cross platform ui 2023-08-28 16:00:11 +07:00
Ren Amamiya
5a3207f878 tauri config per platform 2023-08-28 12:19:40 +07:00
Ren Amamiya
3d4afb40bc temporary using custom tauri build 2023-08-28 10:39:18 +07:00
Ren Amamiya
bf91187c1f update notification screen 2023-08-27 14:46:48 +07:00
Ren Amamiya
963328e064 update notification screen 2023-08-27 10:38:32 +07:00
Ren Amamiya
53227c7050 clean up & update edit profile modal 2023-08-27 08:19:42 +07:00
Ren Amamiya
fe28cd95bd clean up & small fixes 2023-08-26 14:52:02 +07:00
Ren Amamiya
0f212828a7 update note replies component 2023-08-26 10:54:06 +07:00
Ren Amamiya
bfb7d7915f update single note screen 2023-08-26 09:45:39 +07:00
Ren Amamiya
92d49c306b update user screen 2023-08-25 09:50:04 +07:00
Ren Amamiya
b2df8ae320 update widgets 2023-08-24 16:44:55 +07:00
Ren Amamiya
98687bd78b fix small issues 2023-08-24 15:23:54 +07:00
Ren Amamiya
970115d059 update message form 2023-08-24 11:20:27 +07:00
Ren Amamiya
4893ebd932 re-enable nip-04 with more polish, prepare for nip-44 2023-08-24 09:05:34 +07:00
Ren Amamiya
3455eb701f rename some files and add nip 94 widget 2023-08-23 15:18:59 +07:00
Ren Amamiya
c97c685149 refactor note component & add support for kind 30023 2023-08-23 09:48:22 +07:00
Ren Amamiya
0912948b31 add notifications screen 2023-08-22 16:34:47 +07:00
Ren Amamiya
4830f0b236 update space screen 2023-08-22 09:10:04 +07:00
Ren Amamiya
917e49b25d Merge pull request #75 from luminous-devs/wip/optimize
[WIP]: Refactor DB and optimize codebase
2023-08-20 15:59:36 +07:00
Ren Amamiya
fe8c2fd2c6 update depedencies 2023-08-20 15:59:07 +07:00
Ren Amamiya
c4a7ef8867 update space screen 2023-08-20 15:55:31 +07:00
Ren Amamiya
bac70b19ec polish 2023-08-19 15:27:10 +07:00
Ren Amamiya
08e3a66ece update default avatar 2023-08-19 11:18:27 +07:00
Ren Amamiya
eda18f8c34 fix some errors cause app crash 2023-08-19 08:56:19 +07:00
Ren Amamiya
c85502e427 small fixes 2023-08-18 17:42:25 +07:00
Ren Amamiya
5626579b3f wip: refactor 2023-08-18 07:37:11 +07:00
Ren Amamiya
414dd50a5c wip: refactor 2023-08-17 15:11:40 +07:00
Ren Amamiya
ab61bfb2cd wip: clean up & refactor 2023-08-16 20:52:09 +07:00
Ren Amamiya
c05bb54976 wip: refactor 2023-08-16 11:43:04 +07:00
Ren Amamiya
2d53019c10 wip: refactor 2023-08-15 21:13:58 +07:00
Ren Amamiya
6e28bcdb96 wip: use new storage layer 2023-08-15 08:29:04 +07:00
Ren Amamiya
adca37223c refactor storage layer 2023-08-14 18:15:58 +07:00
Ren Amamiya
823b203b73 update useNostr hook 2023-08-14 14:12:54 +07:00
Ren Amamiya
6c6f50444e polish splash screen 2023-08-14 09:34:38 +07:00
Ren Amamiya
c42c78fc98 clean up and refactor open graph 2023-08-14 09:03:58 +07:00
Ren Amamiya
33fd7512e7 update zap modal to match new ui 2023-08-13 15:30:33 +07:00
Ren Amamiya
3b02b3f554 update build config for linux 2023-08-13 14:31:10 +07:00
Ren Amamiya
a02577bb55 fix build errors again 2023-08-13 12:28:10 +07:00
Ren Amamiya
f8753eca90 fix build errors 2023-08-13 12:03:00 +07:00
309 changed files with 14119 additions and 13012 deletions

View File

@@ -1,8 +1,5 @@
name: 'publish'
on:
push:
branches:
- release
name: 'Publish'
on: workflow_dispatch
env:
CARGO_INCREMENTAL: 0
@@ -17,7 +14,7 @@ jobs:
settings:
- platform: 'macos-latest'
args: '--target universal-apple-darwin'
- platform: 'ubuntu-20.04'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: '--target x86_64-pc-windows-msvc'
@@ -32,14 +29,14 @@ jobs:
with:
targets: aarch64-apple-darwin
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-20.04'
if: matrix.settings.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y build-essential libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 7.x.x
version: 8.x.x
run_install: false
- name: Setup node and cache for package data
uses: actions/setup-node@v3
@@ -62,10 +59,12 @@ jobs:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.settings.args }}
includeDebug: true

2
.gitignore vendored
View File

@@ -14,9 +14,9 @@ out
*.local
.next
.vscode
pnpm-lock.yaml
*.db
*.db-journal
bun.lockb
# Editor directories and files
.vscode/*

View File

@@ -8,6 +8,12 @@ Download Lume for your platform here: [https://github.com/luminous-devs/lume/rel
Supported platform: macOS, Windows and Linux
### Prerequisites
- PNPM or Bun (experiment)
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
### Develop
Clone project
@@ -22,20 +28,14 @@ Install packages
pnpm install
```
Run dev
Run dev build
```
pnpm tauri dev
```
Build
Generate production build
```
pnpm tauri build
```
(Advance) - Generate SQLite migration
```
pnpm add-migrate <migrate_name>
```

View File

@@ -1,7 +1,8 @@
{
"name": "lume",
"description": "the communication app",
"private": true,
"version": "1.2.0",
"version": "1.2.7",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -17,99 +18,88 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@headlessui/react": "^1.7.16",
"@nostr-dev-kit/ndk": "^0.8.11",
"@nostr-fetch/adapter-ndk": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@dnd-kit/core": "^6.0.8",
"@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^1.3.1",
"@nostr-dev-kit/ndk-cache-dexie": "^1.3.0",
"@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.32.6",
"@tanstack/react-query-devtools": "^4.32.6",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "2.0.0-alpha.5",
"@tauri-apps/plugin-app": "github:tauri-apps/tauri-plugin-app#v2",
"@tauri-apps/plugin-autostart": "github:tauri-apps/tauri-plugin-autostart#v2",
"@tauri-apps/plugin-clipboard-manager": "github:tauri-apps/tauri-plugin-clipboard-manager#v2",
"@tauri-apps/plugin-dialog": "github:tauri-apps/tauri-plugin-dialog#v2",
"@tauri-apps/plugin-fs": "github:tauri-apps/tauri-plugin-fs#v2",
"@tauri-apps/plugin-http": "github:tauri-apps/tauri-plugin-http#v2",
"@tauri-apps/plugin-notification": "github:tauri-apps/tauri-plugin-notification#v2",
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
"@tauri-apps/plugin-process": "github:tauri-apps/tauri-plugin-process#v2",
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",
"@tauri-apps/plugin-sql": "github:tauri-apps/tauri-plugin-sql#v2",
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#v2",
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
"@tauri-apps/plugin-window": "2.0.0-alpha.0",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"@void-cat/api": "^1.0.7",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^1.2.2",
"get-urls": "^11.0.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^4.35.7",
"@tauri-apps/api": "^1.5.0",
"@tiptap/extension-image": "^2.1.11",
"@tiptap/extension-mention": "^2.1.11",
"@tiptap/extension-placeholder": "^2.1.11",
"@tiptap/pm": "^2.1.11",
"@tiptap/react": "^2.1.11",
"@tiptap/starter-kit": "^2.1.11",
"@tiptap/suggestion": "^2.1.11",
"@vidstack/react": "^1.1.5",
"dayjs": "^1.11.10",
"destr": "^2.0.1",
"html-to-text": "^9.0.5",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"nostr-fetch": "^0.12.2",
"nostr-tools": "^1.14.0",
"minidenticons": "^4.2.0",
"nostr-fetch": "^0.13.0",
"nostr-tools": "^1.16.0",
"qrcode.react": "^3.1.0",
"re-resizable": "^6.9.11",
"react": "^18.2.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-hotkeys-hook": "^4.4.1",
"react-hook-form": "^7.47.0",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.15.0",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.5.0",
"react-router-dom": "^6.16.0",
"react-textarea-autosize": "^8.5.3",
"reactflow": "^11.9.2",
"remark-gfm": "^3.0.1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql#v1",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
"tippy.js": "^6.3.7",
"zustand": "^4.4.1"
"virtua": "^0.9.1",
"zustand": "^4.4.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "2.0.0-alpha.10",
"@tailwindcss/typography": "^0.5.10",
"@tauri-apps/cli": "^1.5.1",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^18.17.5",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"@types/html-to-text": "^9.0.2",
"@types/node": "^20.8.2",
"@types/react": "^18.2.24",
"@types/react-dom": "^18.2.8",
"@types/youtube-player": "^5.5.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-react-swc": "^3.4.0",
"autoprefixer": "^10.4.16",
"clsx": "^2.0.0",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.47.0",
"eslint-config-prettier": "^8.10.0",
"eslint": "^8.50.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"lint-staged": "^14.0.1",
"postcss": "^8.4.31",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.5",
"prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.5",
"vite": "^4.4.9",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
"typescript": "^5.2.2",
"vite": "^4.4.10",
"vite-tsconfig-paths": "^4.2.1"
}
}

5184
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/ghost.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
public/zap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

2129
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,63 @@
[package]
name = "lume"
version = "1.2.0"
description = "nostr client"
version = "1.2.7"
description = "the communication app"
authors = ["Ren Amamiya"]
license = ""
repository = ""
license = "GPL-3.0"
repository = "https://github.com/luminous-devs/lume"
edition = "2021"
rust-version = "1.71"
rust-version = "1.66"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0-alpha.6", features = [] }
tauri-build = { version = "1.5", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-alpha.10", features = [
tauri = { version = "1.5", features = [
"macos-private-api",
"system-tray",
"window-close",
"window-print",
"window-create",
"fs-read-dir",
"fs-read-file",
"window-start-dragging",
"path-all",
"http-all",
"clipboard-write-text",
"os-all",
"notification-all",
"clipboard-read-text",
"window-set-resizable",
"window-set-size",
"shell-open",
"fs-write-file",
"app-all",
"fs-remove-file",
"window-center",
"dialog-all",
"http-multipart",
] }
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-stronghold = { 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-updater = { 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-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-fs = { 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-notification = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-app = { 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-os = { 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-store = { 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-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
"sqlite",
] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy", branch = "dev" }
sqlx-cli = { version = "0.7.0", default-features = false, features = [
"sqlite",
] }
rust-argon2 = "1.0"
rand = "0.8.5"
webpage = { version = "1.6.0", features = ["serde"] }
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"
branch = "v2"
features = ["sqlite"]
[target.'cfg(any(target_os = "macos"))'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
[features]
# by default Tauri runs in production mode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,13 @@
-- Add migration script here
CREATE TABLE
events (
id TEXT NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL,
event TEXT NOT NULL,
author TEXT NOT NULL,
kind NUMBER NOT NULL DEFAULt 1,
root_id TEXT,
reply_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);

View File

@@ -0,0 +1,8 @@
-- Add migration script here
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS chats;
DROP TABLE IF EXISTS metadata;
DROP TABLE IF EXISTS replies;

View File

@@ -0,0 +1,3 @@
-- Add migration script here
ALTER TABLE accounts
ADD COLUMN last_login_at NUMBER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
-- Add migration script here
CREATE UNIQUE INDEX unique_relay ON relays (relay);

View File

@@ -3,11 +3,24 @@
windows_subsystem = "windows"
)]
// use rand::distributions::{Alphanumeric, DistString};
use tauri::Manager;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
use std::time::Duration;
use tauri::{Manager, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind};
use window_vibrancy::{apply_mica, apply_vibrancy, NSVisualEffectMaterial};
use webpage::{Webpage, WebpageOptions};
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
#[cfg(target_os = "macos")]
use traffic_light::TrafficLight;
#[cfg(target_os = "macos")]
mod traffic_light;
#[derive(Clone, serde::Serialize)]
struct Payload {
@@ -15,6 +28,71 @@ struct Payload {
cwd: String,
}
#[derive(serde::Serialize)]
struct OpenGraphResponse {
title: String,
description: String,
url: String,
image: String,
}
async fn fetch_opengraph(url: String) -> OpenGraphResponse {
let options = WebpageOptions {
allow_insecure: false,
max_redirections: 3,
timeout: Duration::from_secs(15),
useragent: "lume - desktop app".to_string(),
..Default::default()
};
let result = match Webpage::from_url(&url, options) {
Ok(webpage) => webpage,
Err(_) => {
return OpenGraphResponse {
title: "".to_string(),
description: "".to_string(),
url: "".to_string(),
image: "".to_string(),
}
}
};
let html = result.html;
return OpenGraphResponse {
title: html
.opengraph
.properties
.get("title")
.cloned()
.unwrap_or_default(),
description: html
.opengraph
.properties
.get("description")
.cloned()
.unwrap_or_default(),
url: html
.opengraph
.properties
.get("url")
.cloned()
.unwrap_or_default(),
image: html
.opengraph
.images
.get(0)
.and_then(|i| Some(i.url.clone()))
.unwrap_or_default(),
};
}
#[tauri::command]
async fn opengraph(url: String) -> OpenGraphResponse {
let result = fetch_opengraph(url).await;
return result;
}
#[tauri::command]
async fn close_splashscreen(window: tauri::Window) {
// Close splashscreen
@@ -27,6 +105,25 @@ async fn close_splashscreen(window: tauri::Window) {
fn main() {
tauri::Builder::default()
.setup(|app| {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
#[cfg(target_os = "macos")]
window.position_traffic_lights(16.0, 25.0);
Ok(())
})
.on_window_event(|e| {
#[cfg(target_os = "macos")]
if let WindowEvent::Resized(..) = e.event() {
let window = e.window();
window.position_traffic_lights(16.0, 25.0);
}
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
@@ -116,6 +213,30 @@ fn main() {
sql: include_str!("../migrations/20230811074423_rename_blocks_to_widgets.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230814083543,
description: "add events",
sql: include_str!("../migrations/20230814083543_add_events_table.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230816090508,
description: "clean up tables",
sql: include_str!("../migrations/20230816090508_clean_up_tables.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230817014932,
description: "add last login to account",
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230918235335,
description: "add unique to relay",
sql: include_str!("../migrations/20230918235335_add_uniq_to_relay.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
@@ -131,7 +252,6 @@ fn main() {
..Default::default()
};
// let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12);
let key = argon2::hash_raw(
password.as_ref(),
b"LUME_NEED_RUST_DEVELOPER_HELP_MAKE_SALT_RANDOM",
@@ -153,33 +273,9 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_app::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
#[cfg(target_os = "windows")]
apply_mica(&window, None, None)
.expect("Unsupported platform! 'apply_blur' is only supported on Windows");
Ok(())
})
.invoke_handler(tauri::generate_handler![close_splashscreen])
.invoke_handler(tauri::generate_handler![close_splashscreen, opengraph])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,60 @@
use tauri::{Runtime, Window};
pub trait TrafficLight {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self, transparent: bool);
fn position_traffic_lights(&self, x: f64, y: f64);
}
impl<R: Runtime> TrafficLight for Window<R> {
#[cfg(target_os = "macos")]
fn set_transparent_titlebar(&self, transparent: bool) {
use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
let window = self.ns_window().unwrap() as cocoa::base::id;
unsafe {
window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
if transparent {
window.setTitlebarAppearsTransparent_(cocoa::base::YES);
} else {
window.setTitlebarAppearsTransparent_(cocoa::base::NO);
}
}
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(&self, x: f64, y: f64) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let window = self.ns_window().unwrap() as cocoa::base::id;
unsafe {
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
let miniaturize = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between);
button.setFrameOrigin(rect.origin);
}
}
}
}

View File

@@ -1,47 +1,86 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm run build",
"beforeDevCommand": "pnpm run dev",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.2.0"
},
"plugins": {
"fs": {
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"scope": [
"http://**/",
"https://**/"
]
},
"shell": {
"open": true
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
}
"version": "1.2.7"
},
"tauri": {
"allowlist": {
"app": {
"all": true,
"show": true,
"hide": true
},
"path": {
"all": true
},
"dialog": {
"all": true,
"ask": true,
"confirm": true,
"message": true,
"open": true,
"save": true
},
"fs": {
"all": false,
"removeFile": true,
"writeFile": true,
"readDir": true,
"readFile": true,
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"all": true,
"scope": [
"http://**",
"https://**"
]
},
"shell": {
"all": false,
"open": true
},
"os": {
"all": true
},
"window": {
"all": false,
"center": true,
"setResizable": true,
"setSize": true,
"startDragging": true,
"create": true,
"close": true,
"print": true
},
"clipboard": {
"all": false,
"writeText": true,
"readText": true
},
"notification": {
"all": true
}
},
"bundle": {
"active": true,
"appimage": {
@@ -67,59 +106,34 @@
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
"signingIdentity": null,
"minimumSystemVersion": "10.15.0",
"license": "../LICENSE"
},
"resources": [],
"shortDescription": "",
"targets": "all",
"updater": {
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
"windows": {
"installMode": "passive"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
},
"security": {
"csp": "upgrade-insecure-requests"
},
"systemTray": {
"iconAsTemplate": true,
"iconPath": "icons/icon.png"
},
"windows": [
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"titleBarStyle": "Overlay",
"transparent": true,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false
},
{
"width": 400,
"height": 500,
"decorations": true,
"hiddenTitle": true,
"center": true,
"resizable": false,
"titleBarStyle": "Overlay",
"label": "splashscreen",
"url": "splashscreen"
}
],
"macOSPrivateApi": true
"dangerousRemoteDomainIpcAccess": [
{
"scheme": "https",
"domain": "nwc.getalby.com",
"windows": ["alby"],
"enableTauriAPI": true
}
]
}
}
}

View File

@@ -0,0 +1,32 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 300,
"height": 300,
"decorations": false,
"title": "Lume",
"center": true,
"resizable": false,
"label": "splashscreen",
"url": "splashscreen"
},
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"transparent": false,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true
}
]
}
}

View File

@@ -0,0 +1,36 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"macOSPrivateApi": true,
"windows": [
{
"width": 300,
"height": 300,
"decorations": false,
"title": "Lume",
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"center": true,
"resizable": false,
"label": "splashscreen",
"url": "splashscreen"
},
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"titleBarStyle": "Overlay",
"transparent": true,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true
}
]
}
}

View File

@@ -0,0 +1,32 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 300,
"height": 300,
"decorations": false,
"title": "Lume",
"center": true,
"resizable": false,
"label": "splashscreen",
"url": "splashscreen"
},
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"transparent": false,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true
}
]
}
}

View File

@@ -1,249 +1,324 @@
import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom';
import { message } from '@tauri-apps/api/dialog';
import { fetch } from '@tauri-apps/api/http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { AuthCreateScreen } from '@app/auth/create';
import { AuthImportScreen } from '@app/auth/import';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore';
import { getActiveAccount } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { AppLayout } from '@shared/appLayout';
import { AuthLayout } from '@shared/authLayout';
import { Frame } from '@shared/frame';
import { LoaderIcon } from '@shared/icons';
import { SettingsLayout } from '@shared/settingsLayout';
import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth';
import { NoteLayout } from '@shared/layouts/note';
import { SettingsLayout } from '@shared/layouts/settings';
import './index.css';
const appLoader = async () => {
const account = await getActiveAccount();
const stronghold = sessionStorage.getItem('stronghold');
const privkey = JSON.parse(stronghold).state.privkey || null;
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) {
return redirect(step);
}
if (!account) {
return redirect('/auth/welcome');
}
if (account && account.privkey.length > 35) {
return redirect('/auth/migrate');
}
if (account && !privkey) {
return redirect('/auth/unlock');
}
return null;
};
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <ErrorScreen />,
loader: appLoader,
children: [
{
path: '',
async lazy() {
const { SpaceScreen } = await import('@app/space');
return { Component: SpaceScreen };
},
},
{
path: 'trending',
async lazy() {
const { TrendingScreen } = await import('@app/trending');
return { Component: TrendingScreen };
},
},
{
path: 'events/:id',
async lazy() {
const { EventScreen } = await import('@app/events');
return { Component: EventScreen };
},
},
{
path: 'users/:pubkey',
async lazy() {
const { UserScreen } = await import('@app/users');
return { Component: UserScreen };
},
},
{
path: 'chats/:pubkey',
async lazy() {
const { ChatScreen } = await import('@app/chats');
return { Component: ChatScreen };
},
},
],
},
{
path: '/splashscreen',
errorElement: <ErrorScreen />,
async lazy() {
const { SplashScreen } = await import('@app/splash');
return { Component: SplashScreen };
},
},
{
path: '/auth',
element: <AuthLayout />,
children: [
{
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{
path: 'import',
element: <AuthImportScreen />,
children: [
{
path: '',
async lazy() {
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
return { Component: ImportStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
return { Component: ImportStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { ImportStep3Screen } = await import('@app/auth/import/step-3');
return { Component: ImportStep3Screen };
},
},
],
},
{
path: 'create',
element: <AuthCreateScreen />,
children: [
{
path: '',
async lazy() {
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
return { Component: CreateStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
return { Component: CreateStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { CreateStep3Screen } = await import('@app/auth/create/step-3');
return { Component: CreateStep3Screen };
},
},
],
},
{
path: 'onboarding',
element: <OnboardingScreen />,
children: [
{
path: '',
async lazy() {
const { OnboardStep1Screen } = await import('@app/auth/onboarding/step-1');
return { Component: OnboardStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { OnboardStep2Screen } = await import('@app/auth/onboarding/step-2');
return { Component: OnboardStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { OnboardStep3Screen } = await import('@app/auth/onboarding/step-3');
return { Component: OnboardStep3Screen };
},
},
],
},
{
path: 'unlock',
async lazy() {
const { UnlockScreen } = await import('@app/auth/unlock');
return { Component: UnlockScreen };
},
},
{
path: 'migrate',
async lazy() {
const { MigrateScreen } = await import('@app/auth/migrate');
return { Component: MigrateScreen };
},
},
{
path: 'reset',
async lazy() {
const { ResetScreen } = await import('@app/auth/reset');
return { Component: ResetScreen };
},
},
],
},
{
path: '/settings',
element: <SettingsLayout />,
children: [
{
path: 'general',
async lazy() {
const { GeneralSettingsScreen } = await import('@app/settings/general');
return { Component: GeneralSettingsScreen };
},
},
{
path: 'shortcuts',
async lazy() {
const { ShortcutsSettingsScreen } = await import('@app/settings/shortcuts');
return { Component: ShortcutsSettingsScreen };
},
},
{
path: 'account',
async lazy() {
const { AccountSettingsScreen } = await import('@app/settings/account');
return { Component: AccountSettingsScreen };
},
},
],
},
]);
export default function App() {
const { db } = useStorage();
const accountLoader = async () => {
try {
const totalAccount = await db.checkAccount();
const stronghold = sessionStorage.getItem('stronghold');
const privkey = JSON.parse(stronghold).state.privkey || null;
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (totalAccount === 0) {
return redirect('/auth/welcome');
} else {
if (step) {
return redirect(step);
}
if (!privkey) {
return redirect('/auth/unlock');
}
}
return null;
} catch (e) {
await message(e, { title: 'An unexpected error has occurred', type: 'error' });
}
};
const relayLoader = async ({ params }) => {
return defer({
relay: fetch(`https://${params.url}`, {
method: 'GET',
headers: {
Accept: 'application/nostr+json',
},
}).then((res) => res.data),
});
};
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <ErrorScreen />,
loader: accountLoader,
children: [
{
path: '',
async lazy() {
const { SpaceScreen } = await import('@app/space');
return { Component: SpaceScreen };
},
},
{
path: 'users/:pubkey',
async lazy() {
const { UserScreen } = await import('@app/users');
return { Component: UserScreen };
},
},
{
path: 'chats/:pubkey',
async lazy() {
const { ChatScreen } = await import('@app/chats');
return { Component: ChatScreen };
},
},
{
path: 'notifications',
async lazy() {
const { NotificationScreen } = await import('@app/notifications');
return { Component: NotificationScreen };
},
},
{
path: 'nwc',
async lazy() {
const { NWCScreen } = await import('@app/nwc');
return { Component: NWCScreen };
},
},
{
path: 'explore',
element: (
<ReactFlowProvider>
<ExploreScreen />
</ReactFlowProvider>
),
errorElement: <ErrorScreen />,
},
{
path: 'relays',
async lazy() {
const { RelaysScreen } = await import('@app/relays');
return { Component: RelaysScreen };
},
},
{
path: 'relays/:url',
loader: relayLoader,
async lazy() {
const { RelayScreen } = await import('@app/relays/relay');
return { Component: RelayScreen };
},
},
],
},
{
path: '/notes',
element: <NoteLayout />,
errorElement: <ErrorScreen />,
children: [
{
path: 'text/:id',
async lazy() {
const { TextNoteScreen } = await import('@app/notes/text');
return { Component: TextNoteScreen };
},
},
{
path: 'article/:id',
async lazy() {
const { ArticleNoteScreen } = await import('@app/notes/article');
return { Component: ArticleNoteScreen };
},
},
],
},
{
path: '/splashscreen',
errorElement: <ErrorScreen />,
async lazy() {
const { SplashScreen } = await import('@app/splash');
return { Component: SplashScreen };
},
},
{
path: '/auth',
element: <AuthLayout />,
errorElement: <ErrorScreen />,
children: [
{
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{
path: 'import',
element: <AuthImportScreen />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
return { Component: ImportStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
return { Component: ImportStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { ImportStep3Screen } = await import('@app/auth/import/step-3');
return { Component: ImportStep3Screen };
},
},
],
},
{
path: 'create',
element: <AuthCreateScreen />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
return { Component: CreateStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
return { Component: CreateStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { CreateStep3Screen } = await import('@app/auth/create/step-3');
return { Component: CreateStep3Screen };
},
},
],
},
{
path: 'onboarding',
element: <OnboardingScreen />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { OnboardStep1Screen } = await import(
'@app/auth/onboarding/step-1'
);
return { Component: OnboardStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { OnboardStep2Screen } = await import(
'@app/auth/onboarding/step-2'
);
return { Component: OnboardStep2Screen };
},
},
],
},
{
path: 'complete',
async lazy() {
const { CompleteScreen } = await import('@app/auth/complete');
return { Component: CompleteScreen };
},
},
{
path: 'unlock',
async lazy() {
const { UnlockScreen } = await import('@app/auth/unlock');
return { Component: UnlockScreen };
},
},
{
path: 'lock',
async lazy() {
const { LockScreen } = await import('@app/auth/lock');
return { Component: LockScreen };
},
},
{
path: 'migrate',
async lazy() {
const { MigrateScreen } = await import('@app/auth/migrate');
return { Component: MigrateScreen };
},
},
{
path: 'reset',
async lazy() {
const { ResetScreen } = await import('@app/auth/reset');
return { Component: ResetScreen };
},
},
],
},
{
path: '/settings',
element: <SettingsLayout />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { GeneralSettingsScreen } = await import('@app/settings/general');
return { Component: GeneralSettingsScreen };
},
},
{
path: 'backup',
async lazy() {
const { AccountSettingsScreen } = await import('@app/settings/account');
return { Component: AccountSettingsScreen };
},
},
],
},
]);
return (
<RouterProvider
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center bg-black/90">
<Frame className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
</div>
</Frame>
}
future={{ v7_startTransition: true }}
/>

42
src/app/auth/complete.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export function CompleteScreen() {
const navigate = useNavigate();
const [count, setCount] = useState(5);
useEffect(() => {
let counter: NodeJS.Timeout;
if (count > 0) {
counter = setTimeout(() => setCount(count - 1), 1000);
}
if (count === 0) {
navigate('/', { replace: true });
}
return () => {
clearTimeout(counter);
};
}, [count]);
return (
<div className="relative flex h-full w-full flex-col items-center justify-center">
<div className="mx-auto flex max-w-xl flex-col gap-1.5 text-center">
<h1 className="text-2xl font-light leading-none text-white">
<span className="font-semibold">You&apos;re ready</span>, redirecting in {count}
</h1>
<p className="text-white/70">
Thank you for using Lume. Lume doesn&apos;t use telemetry. If you encounter any
problems, please submit a report via the &quot;Report Issue&quot; button.
<br />
You can find it while using the application.
</p>
</div>
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 transform items-center justify-center">
<img src="/lume.png" alt="lume" className="h-auto w-1/5" />
</div>
</div>
);
}

View File

@@ -1,43 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-white">
{user?.name || user?.display_name || user?.nip05}
</span>
<span className="max-w-[15rem] truncate text-base leading-tight text-white/50">
{user?.nip05?.toLowerCase() || displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function UserRelay({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10" />
</div>
</div>
);
}
return (
<div className="inline-flex items-center gap-2 text-white/50">
<span className="text-sm">Use by</span>
<div className="inline-flex items-center gap-1">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-5 w-5 shrink-0 rounded object-cover"
/>
<span className="truncate text-sm font-medium leading-none text-white">
{user?.name || user?.display_name || user?.nip05 || displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,28 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-fs';
import { writeText } from '@tauri-apps/api/clipboard';
import { message, save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';
import { downloadDir } from '@tauri-apps/api/path';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { CopyIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
export function CreateStep1Screen() {
const queryClient = useQueryClient();
const { db } = useStorage();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
@@ -30,131 +31,122 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (privkeyInput === 'password') {
setPrivkeyInput('text');
} else {
setPrivkeyInput('password');
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: downloadPath + '/' + fileName,
});
if (filePath) {
await writeTextFile(
filePath,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
const download = async () => {
await writeTextFile('lume-keys.txt', `Public key: ${npub}\nPrivate key: ${nsec}`, {
dir: BaseDirectory.Download,
});
setDownloaded(true);
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[][];
is_active: number;
}) => {
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const submit = () => {
const submit = async () => {
setLoading(true);
// update state
setPrivkey(privkey);
setTempPrivkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
// save to database
await db.createAccount(npub, pubkey);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
navigate('/auth/create/step-2', { replace: true });
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/create/step-1');
setStep('/auth/create');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Save your access key!</h1>
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
This is your new Nostr account
</h1>
<p className="mb-2 text-white/70">
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. There is no way to reset your
private key.
</p>
<p className="text-white/70">
Public key is used for sharing with other people so that they can find you using
the public key.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Public Key</span>
<input
readOnly
value={npub}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/50"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private Key</span>
<div className="relative">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Private Key</span>
<div className="relative">
<input
readOnly
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-white/20 px-2.5 text-sm hover:bg-white/30"
>
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Public Key</span>
<input
readOnly
type={privkeyInput}
value={nsec}
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
value={npub}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
>
{privkeyInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="mt-2 text-sm text-white/50">
<p>
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. There is no way
to reset your private key.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
onClick={() => download()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>I have saved my key, continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
{downloaded ? (
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50">
Saved in Download folder
</span>
) : (
<Button preset="large-alt" onClick={() => download()}>
Download
</Button>
)}
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white hover:bg-white/30 focus:outline-none"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved in
a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div>
</div>

View File

@@ -1,6 +1,10 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
@@ -8,8 +12,6 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -37,7 +39,7 @@ export function CreateStep2Screen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { save } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@@ -58,8 +60,13 @@ export function CreateStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage
await save(pubkey, privkey, data.password);
await db.secureSave(pubkey, privkey);
// redirect to next step
navigate('/auth/create/step-3', { replace: true });
@@ -79,10 +86,16 @@ export function CreateStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key
</h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you just need to
copy your private key.
</p>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -91,12 +104,13 @@ export function CreateStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none placeholder:text-white/50"
placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -105,13 +119,6 @@ export function CreateStep2Screen() {
)}
</button>
</div>
<div className="text-sm text-white/50">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -120,12 +127,12 @@ export function CreateStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -1,28 +1,30 @@
import { useQueryClient } from '@tanstack/react-query';
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
export function CreateStep3Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr();
const {
register,
@@ -43,14 +45,15 @@ export function CreateStep3Screen() {
const event = await publish({
content: JSON.stringify(profile),
kind: 0,
kind: NDKKind.Metadata,
tags: [],
});
queryClient.invalidateQueries(['currentAccount']);
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) {
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1000);
navigate('/auth/onboarding', { replace: true });
}
} catch (e) {
console.log('error: ', e);
@@ -65,32 +68,41 @@ export function CreateStep3Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Create your profile</h1>
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Personalize your Nostr profile
</h1>
<p className="text-white/70">
Nostr profile is synchronous across all Nostr clients. If you create a profile
on Lume, it will also work well with other Nostr clients. If you update your
profile on another Nostr client, it will also sync to Lume.
</p>
</div>
<div className="w-full overflow-hidden rounded-xl bg-white/10">
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative">
<div className="relative h-44 w-full bg-white/10">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
<div className="relative h-36 w-full bg-white/10 backdrop-blur-xl">
{banner ? (
<Image
src={banner}
alt="user's banner"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-white/20" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14">
<div className="relative z-10 -mt-8 h-16 w-16">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10"
className="h-16 w-16 rounded-lg object-cover ring-2 ring-white/20"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
@@ -100,55 +112,45 @@ export function CreateStep3Screen() {
</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-white/50"
>
<label htmlFor="name" className="font-medium text-white">
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
minLength: 1,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="about" className="font-medium text-white">
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
className="relative h-20 w-full resize-none rounded-lg bg-white/20 px-3 py-2 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="website" className="font-medium text-white">
Website
</label>
<input
type={'text'}
{...register('website', {
required: false,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>

View File

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

View File

@@ -1,6 +1,10 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
@@ -8,8 +12,6 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -37,7 +39,7 @@ export function ImportStep2Screen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { save } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@@ -58,8 +60,13 @@ export function ImportStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage
await save(pubkey, privkey, data.password);
await db.secureSave(pubkey, privkey);
// redirect to next step
navigate('/auth/import/step-3', { replace: true });
@@ -79,10 +86,16 @@ export function ImportStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key
</h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you only need to
copy your private key.
</p>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -91,12 +104,13 @@ export function ImportStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none placeholder:text-white/50"
placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -105,11 +119,6 @@ export function ImportStep2Screen() {
)}
</button>
</div>
<p className="text-sm text-white/50">
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -118,12 +127,12 @@ export function ImportStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

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

10
src/app/auth/lock.tsx Normal file
View File

@@ -0,0 +1,10 @@
export function LockScreen() {
return (
<div
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: 'url(/wallpapers/1.png)' }}
>
<p>TODO</p>
</div>
);
}

View File

@@ -1,17 +1,16 @@
import { useQueryClient } from '@tanstack/react-query';
import { appConfigDir } from '@tauri-apps/api/path';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { removePrivkey } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -38,8 +37,7 @@ export function MigrateScreen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { save } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@@ -63,13 +61,18 @@ export function MigrateScreen() {
// load private in secure storage
try {
// save privkey to secure storage
await save(account.pubkey, account.privkey, data.password);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
await db.secureSave(db.account.pubkey, db.account.privkey);
// add privkey to state
setPrivkey(account.privkey);
setPrivkey(db.account.privkey);
// remove privkey in db
await removePrivkey();
await db.removePrivkey();
// clear cache
await queryClient.invalidateQueries(['currentAccount']);
await queryClient.invalidateQueries(['account']);
// redirect to home
navigate('/', { replace: true });
} catch {
@@ -96,7 +99,7 @@ export function MigrateScreen() {
Upgrade security for your account
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<div className="w-full rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<div className="flex flex-col gap-4">
<div>
<div className="mt-1">
@@ -116,7 +119,7 @@ export function MigrateScreen() {
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<div className="flex flex-col gap-1">
<span className="font-medium text-zinc-200">
<span className="font-medium text-white">
Set a password to protect your key
</span>
<div className="relative">
@@ -124,12 +127,12 @@ export function MigrateScreen() {
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
>
{passwordInput === 'password' ? (
<EyeOffIcon

View File

@@ -1,27 +1,24 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { updateAccount } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardStep1Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { publish, fetchNotes } = useNostr();
const { account } = useAccount();
const { status, data } = useQuery(['trending-profiles'], async () => {
const { publish, fetchUserData } = useNostr();
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');
@@ -44,22 +41,21 @@ export function OnboardStep1Screen() {
try {
setLoading(true);
const tags = arrayToNIP02([...follows, account.pubkey]);
const tags = arrayToNIP02([...follows, db.account.pubkey]);
const event = await publish({ content: '', kind: 3, tags: tags });
await updateAccount('follows', follows);
// prefetch notes with current follows
const notes = await fetchNotes(follows);
// prefetch data
const user = await fetchUserData(follows);
// redirect to next step
if (event && notes) {
setTimeout(() => {
queryClient.invalidateQueries(['currentAccount']);
navigate('/auth/onboarding/step-2', { replace: true });
}, 1000);
if (event && user.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);
}
} catch {
console.log('error');
} catch (e) {
setLoading(false);
console.log('error: ', e);
}
};
@@ -69,45 +65,51 @@ export function OnboardStep1Screen() {
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Enrich your network'}
<div className="flex h-full w-full flex-col justify-center">
<div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
{loading ? 'Loading...' : 'Enrich your network'}
</h1>
<p className="text-sm text-white/50">Choose account you want to follow</p>
<p className="text-white/70">
Choose the account you want to follow. These accounts are trending in the last
24 hours. If none of the accounts interest you, you can explore more options and
add them later.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
)
)
)}
</div>
<div className="scrollbar-hide flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</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] rounded-lg border-t border-white/10 bg-white/20 px-4 py-4 hover:bg-white/30"
>
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-4 w-full max-w-md">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={submit}
disabled={loading || follows.length === 0}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
@@ -125,7 +127,7 @@ export function OnboardStep1Screen() {
</button>
<Link
to="/auth/onboarding/step-2"
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</Link>

View File

@@ -1,16 +1,18 @@
import { message } from '@tauri-apps/api/dialog';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { createWidget } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
const data = [
{ hashtag: '#bitcoin' },
{ hashtag: '#nostr' },
{ hashtag: '#nostrdesign' },
{ hashtag: '#zap' },
{ hashtag: '#LFG' },
{ hashtag: '#zapchain' },
@@ -19,6 +21,10 @@ const data = [
{ hashtag: '#hodl' },
{ hashtag: '#stacksats' },
{ hashtag: '#nokyc' },
{ hashtag: '#meme' },
{ hashtag: '#memes' },
{ hashtag: '#memestr' },
{ hashtag: '#penisbutter' },
{ hashtag: '#anime' },
{ hashtag: '#waifu' },
{ hashtag: '#manga' },
@@ -28,11 +34,13 @@ const data = [
export function OnboardStep2Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>());
const { db } = useStorage();
const toggleTag = (tag: string) => {
if (tags.has(tag)) {
setTags((prev) => {
@@ -45,17 +53,34 @@ export function OnboardStep2Screen() {
}
};
const skip = async () => {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
};
const submit = async () => {
try {
setLoading(true);
for (const tag of tags) {
await createWidget(BLOCK_KINDS.hashtag, tag, tag.replace('#', ''));
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
}
setTimeout(() => navigate('/auth/onboarding/step-3', { replace: true }), 1000);
} catch {
console.log('error');
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
} catch (e) {
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
}
};
@@ -66,20 +91,23 @@ export function OnboardStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
Choose {tags.size}/3 your favorite tags
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Choose {tags.size}/3 your favorite hashtags
</h1>
<p className="text-sm text-white/50">Customize your space which hashtag widget</p>
<p className="text-white/70">
Hashtags are an easy way to discover more content. By adding a hashtag, Lume
will show all related posts. You can always add more later.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10">
<div className="scrollbar-hide flex h-[450px] w-full flex-col divide-y divide-white/5 overflow-y-auto rounded-xl bg-white/20 backdrop-blur-xl">
{data.map((item: { hashtag: string }) => (
<button
key={item.hashtag}
type="button"
onClick={() => toggleTag(item.hashtag)}
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/10"
>
<p className="text-white">{item.hashtag}</p>
{tags.has(item.hashtag) && (
@@ -95,7 +123,7 @@ export function OnboardStep2Screen() {
type="button"
onClick={submit}
disabled={loading || tags.size === 0 || tags.size > 3}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
@@ -111,12 +139,15 @@ export function OnboardStep2Screen() {
</>
)}
</button>
<Link
to="/auth/onboarding/step-3"
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
{!loading ? (
<button
type="button"
onClick={() => skip()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</button>
) : null}
</div>
</div>
</div>

View File

@@ -2,17 +2,15 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserRelay } from '@app/auth/components/userRelay';
import { useNDK } from '@libs/ndk/provider';
import { createRelay } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
export function OnboardStep3Screen() {
@@ -23,13 +21,16 @@ export function OnboardStep3Screen() {
const [relays, setRelays] = useState(new Set<string>());
const { publish } = useNostr();
const { account } = useAccount();
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery(
['relays'],
async () => {
const tmp = new Map<string, string>();
const events = await ndk.fetchEvents({ kinds: [10002], authors: account.follows });
const events = await ndk.fetchEvents({
kinds: [10002],
authors: db.account.follows,
});
if (events) {
events.forEach((event) => {
@@ -42,10 +43,13 @@ export function OnboardStep3Screen() {
return tmp;
},
{
enabled: account ? true : false,
enabled: db.account ? true : false,
refetchOnWindowFocus: false,
}
);
const relaysAsArray = Array.from(data?.keys() || []);
const toggleRelay = (relay: string) => {
if (relays.has(relay)) {
setRelays((prev) => {
@@ -58,33 +62,33 @@ export function OnboardStep3Screen() {
};
const submit = async (skip?: boolean) => {
setLoading(true);
try {
setLoading(true);
if (!skip) {
for (const relay of relays) {
await createRelay(relay);
await db.createRelay(relay);
}
const tags = Array.from(relays).map((relay) => ['r', relay.replace(/\/+$/, '')]);
await publish({ content: '', kind: 10002, tags: tags });
} else {
for (const relay of FULL_RELAYS) {
await createRelay(relay);
await db.createRelay(relay);
}
}
setTimeout(() => {
clearStep();
navigate('/', { replace: true });
}, 1000);
// update last login
await db.updateLastLogin();
clearStep();
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
console.log('error: ', e);
}
};
const relaysAsArray = Array.from(data?.keys() || []);
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/onboarding/step-3');
@@ -108,16 +112,17 @@ export function OnboardStep3Screen() {
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide relative flex h-[500px] w-full flex-col divide-y divide-white/10 overflow-y-auto rounded-xl bg-white/10">
<div className="scrollbar-hide relative flex h-[500px] w-full flex-col divide-y divide-white/10 overflow-y-auto rounded-xl bg-white/10 backdrop-blur-xl">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : relaysAsArray.length === 0 ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-white/50">
Can&apos;t found any relays, you can skip this step and use default relays
instead
Lume couldn&apos;t find any relays from your follows.
<br />
You can skip this step and use default relays instead.
</p>
</div>
) : (
@@ -126,11 +131,11 @@ export function OnboardStep3Screen() {
key={item + index}
type="button"
onClick={() => toggleRelay(item)}
className="inline-flex transform items-start justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
className="inline-flex transform items-start justify-between bg-white/10 px-4 py-2 backdrop-blur-xl hover:bg-white/20"
>
<div className="flex flex-col items-start gap-1">
<p className="max-w-[15rem] truncate">{item.replace(/\/+$/, '')}</p>
<UserRelay pubkey={data.get(item)} />
<User pubkey={data.get(item)} variant="mention" />
</div>
{relays.has(item) && (
<div className="pt-1.5">
@@ -172,9 +177,9 @@ export function OnboardStep3Screen() {
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, use default relays
Skip, use Lume default relays
</button>
</div>
</div>

View File

@@ -1,15 +1,16 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
privkey: string;
@@ -36,8 +37,7 @@ export function ResetScreen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { save, reset } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@@ -66,7 +66,7 @@ export function ResetScreen() {
const tmpPubkey = getPublicKey(privkey);
if (tmpPubkey !== account.pubkey) {
if (tmpPubkey !== db.account.pubkey) {
setLoading(false);
setError('password', {
type: 'custom',
@@ -75,11 +75,20 @@ export function ResetScreen() {
});
} else {
// remove old stronghold
await reset();
await db.secureReset();
// save privkey to secure storage
await save(account.pubkey, account.privkey, data.password);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(
`${dir}/lume.stronghold`,
data.password
);
if (!db.secureDB) db.secureDB = stronghold;
await db.secureSave(db.account.pubkey, db.account.privkey);
// add privkey to state
setPrivkey(account.privkey);
setPrivkey(db.account.privkey);
// redirect to home
navigate('/auth/unlock', { replace: true });
}
@@ -107,33 +116,33 @@ export function ResetScreen() {
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white/50">
<label htmlFor="privkey" className="font-medium text-white">
Private key
</label>
<div className="relative">
<input
{...register('privkey', { required: true })}
type="text"
placeholder="nsec..."
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="font-medium text-white/50">
<label htmlFor="password" className="font-medium text-white">
Set a new password to protect your key
</label>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
placeholder="Min. 4 characters"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
@@ -146,7 +155,7 @@ export function ResetScreen() {
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<div className="flex flex-col items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
@@ -158,6 +167,12 @@ export function ResetScreen() {
'Continue →'
)}
</button>
<Link
to="/auth/unlock"
className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
>
Back
</Link>
</div>
</form>
</div>

View File

@@ -1,14 +1,16 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -30,22 +32,13 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const resetStronghold = useStronghold((state) => state.reset);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { load } = useSecureStorage();
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const { db } = useStorage();
const {
register,
setError,
@@ -54,78 +47,119 @@ export function UnlockScreen() {
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// load private in secure storage
try {
const privkey = await load(account.pubkey, data.password);
setPrivkey(privkey);
// redirect to home
navigate('/', { replace: true });
} catch {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Wrong password',
});
}
} else {
try {
setLoading(true);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey);
const uri = await db.secureLoad('walletConnectURL', 'nwc');
if (privkey) setPrivkey(privkey);
if (uri) setWalletConnectURL(uri);
// redirect to home
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
message: e,
});
}
};
const logout = async () => {
// remove account
db.accountLogout();
// reset stronghold
resetStronghold();
// redirect to welcome screen
navigate('/auth/welcome');
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Enter password to unlock
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="flex flex-col rounded-lg bg-white/5">
<div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4">
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-12 w-full rounded-lg bg-white/10 py-1 text-center text-white !outline-none placeholder:text-white/10"
{...register('password', { required: true, minLength: 4 })}
type={showPassword ? 'text' : 'password'}
placeholder="Password"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/50"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
onClick={() => setShowPassword((prev) => !prev)}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
>
{passwordInput === 'password' ? (
{showPassword ? (
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex flex-col items-center justify-center">
<span className="mb-3 text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-12 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
<>
<span className="w-5" />
<span>Unlocking...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
'Continue →'
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link
to="/auth/reset"
className="inline-flex h-14 items-center justify-center text-center text-white/50"
>
Reset password
</Link>
<div className="mt-8 w-full">
<div className="flex items-center gap-2.5">
<div className="h-px flex-1 bg-white/10" />
<p className="shrink-0 text-sm font-medium text-white/50">
Forgot password?
</p>
<div className="h-px flex-1 bg-white/10" />
</div>
<div className="mt-2 flex flex-col">
<Link
to="/auth/reset"
className="inline-flex h-10 w-full items-center justify-center rounded-lg text-center text-sm font-medium text-white/70 hover:bg-white/20"
>
Reset password if you still have a private key
</Link>
<button
type="button"
onClick={logout}
className="inline-flex h-10 w-full items-center justify-center rounded-lg text-center text-sm font-medium text-white/70 hover:bg-white/20"
>
Login with another account
</button>
</div>
</div>
</div>
</form>
</div>

View File

@@ -1,41 +1,22 @@
import { LogicalSize, appWindow } from '@tauri-apps/plugin-window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() {
useEffect(() => {
async function setWindow() {
await appWindow.setSize(new LogicalSize(400, 500));
await appWindow.setResizable(false);
await appWindow.center();
}
setWindow();
return () => {
appWindow.setSize(new LogicalSize(1080, 800)).then(() => {
appWindow.setResizable(false);
appWindow.center();
});
};
}, []);
return (
<div className="flex h-screen w-full flex-col justify-between bg-white/10">
<div className="mx-auto flex h-screen w-full max-w-md flex-col justify-center">
<div className="flex flex-col gap-10 pt-16">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1>
<h3 className="mx-auto w-2/3 text-white/50">
<div className="flex flex-col gap-1.5 text-center">
<h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
<p className="mx-auto w-2/3 leading-tight text-white/50">
Let&apos;s get you up and connecting with all peoples around the world on
Nostr
</h3>
</p>
</div>
<div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10">
<Link
to="/auth/import"
className="inline-flex h-11 w-2/3 items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-3/4 items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-4 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
<span className="w-5" />
<span>Login with private key</span>
@@ -43,14 +24,14 @@ export function WelcomeScreen() {
</Link>
<Link
to="/auth/create"
className="inline-flex h-11 w-2/3 items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-zinc-200 hover:bg-white/20 focus:outline-none"
className="inline-flex h-12 w-3/4 items-center justify-center gap-2 rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Create new key
</Link>
</div>
</div>
<div className="flex flex-1 items-end justify-center pb-10">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 transform">
<img src="/lume.png" alt="lume" className="mx-auto h-auto w-1/4" />
</div>
</div>
);

View File

@@ -1,58 +0,0 @@
import { Popover, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { MutedItem } from '@app/channel/components/mutedItem';
import { MuteIcon } from '@shared/icons';
export function ChannelBlackList({ blacklist }: { blacklist: any }) {
return (
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
}`}
>
<MuteIcon
width={16}
height={16}
className="text-white/50 group-hover:text-white"
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
<div className="shadow-popover flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
<div className="flex flex-col gap-0.5">
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
Your muted list
</h3>
<p className="text-base leading-tight text-white/50">
Currently, unmute only affect locally, when you move to new client,
muted list will loaded again
</p>
</div>
</div>
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
{blacklist.map((item: any) => (
<MutedItem key={item.id} data={item} />
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
}

View File

@@ -1,269 +0,0 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { createChannel } from '@libs/storage';
import { AvatarUploader } from '@shared/avatarUploader';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelCreateModal() {
const { ndk } = useNDK();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const addChannel = useMutation({
mutationFn: (event: any) => {
return createChannel(
event.id,
event.pubkey,
event.name,
event.picture,
event.about,
event.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
try {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 40;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// insert to database
addChannel.mutate({
...event,
name: data.name,
picture: data.picture,
about: data.about,
});
// reset form
reset();
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/channel/${event.id}`);
}, 1000);
} catch (e) {
console.log('error: ', e);
}
};
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-white/50" />
</div>
<div>
<h5 className="font-medium text-white/50">Create channel</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-white"
>
Create channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-white/50">
Channels are freedom square, everyone can speech freely, no one can
stop you or deceive what to speech
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<input
type={'hidden'}
{...register('picture')}
value={image}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-white/50 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-white/50"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium uppercase tracking-wider text-white/50">
Picture
</span>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader setPicture={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Channel name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Description
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
/>
</div>
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-1">
<span className="font-semibold leading-none text-white">
Encrypted
</span>
<p className="w-4/5 text-sm leading-none text-white/50">
All messages are encrypted and only invited members can view and
send message
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-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" />
) : (
'Create channel →'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,34 +0,0 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-white' : ''
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-xs text-white">#</span>
</div>
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{data.new_messages && (
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

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

View File

@@ -1,24 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function Member({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<>
{isError || isLoading ? (
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
) : (
<Image
className="inline-block h-7 w-7 rounded"
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
/>
)}
</>
);
}

View File

@@ -1,28 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Member } from '@app/channel/components/member';
import { getChannelUsers } from '@libs/storage';
export function ChannelMembers({ id }: { id: string }) {
const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
return await getChannelUsers(id);
});
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 flex w-full flex-wrap gap-1.5">
{status === 'loading' || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</div>
</div>
);
}

View File

@@ -1,115 +0,0 @@
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useContext, useState } from 'react';
import { UserReply } from '@app/channel/components/messages/userReply';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelMessageForm({ channelID }: { channelID: string }) {
const { ndk } = useNDK();
const [value, setValue] = useState('');
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const { account } = useAccount();
const submit = () => {
let tags: string[][];
if (replyTo.id !== null) {
tags = [
['e', channelID, '', 'root'],
['e', replyTo.id, '', 'reply'],
['p', replyTo.pubkey, ''],
];
} else {
tags = [['e', channelID, '', 'root']];
}
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
// reset state
setValue('');
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const stopReply = () => {
closeReply();
};
return (
<div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
{replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]">
<div className="text-base text-white">{replyTo.content}</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={12} height={12} className="text-white" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-white/50`}
/>
<div className="absolute bottom-0 right-2 h-11">
<div className="flex h-full items-center justify-end gap-3 text-white/50">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,132 +0,0 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, HideIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageHideButton({ id }: { id: string }) {
const { ndk } = useNDK();
const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const hideMessage = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 43;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['e', id]];
// publish event
event.publish();
// update state
hide(id);
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-white/50">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-white/50 hover:bg-zinc-800 hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,56 +0,0 @@
import { MessageHideButton } from '@app/channel/components/messages/hideButton';
import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function ChannelMessageItem({ data }: { data: LumeEvent }) {
const content = parser(data);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

@@ -1,132 +0,0 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useContext, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, MuteIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const { ndk } = useNDK();
const mute = useChannelMessages((state: any) => state.muteUser);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const muteUser = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 44;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['p', pubkey]];
// publish event
event.publish();
// update state
mute(pubkey);
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-white/50">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-white/50 hover:bg-zinc-800 hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,29 +0,0 @@
import { ReplyMessageIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
export function MessageReplyButton({
id,
pubkey,
content,
}: {
id: string;
pubkey: string;
content: string;
}) {
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => {
openReply(id, pubkey, content);
};
return (
<button
type="button"
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
);
}

View File

@@ -1,40 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function ChannelMessageUserMute({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="flex items-center gap-3">
{isError || isLoading ? (
<>
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-center justify-between">
<div className="flex items-baseline gap-2 text-base">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-center justify-between">
<span className="leading-none text-zinc-300">
You has been muted this user
</span>
</div>
</>
)}
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-2">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-white/50" />
</>
) : (
<>
<div className="relative h-9 w-9 shrink overflow-hidden rounded">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-9 w-9 rounded object-cover"
/>
</div>
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-white/50">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { nip19 } from 'nostr-tools';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
import { CopyIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ChannelMetadata({ id }: { id: string }) {
const metadata = useChannelProfile(id);
const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => {
const { writeText } = await import('@tauri-apps/plugin-clipboard-manager');
if (noteID) {
await writeText(noteID);
}
};
return (
<div className="flex flex-col gap-2">
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={metadata?.picture}
fallback={DEFAULT_AVATAR}
alt={id}
className="h-11 w-11 rounded-md bg-zinc-900 object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1">
<h5 className="text-lg font-semibold leading-none">{metadata?.name}</h5>
<button type="button" onClick={() => copyNoteID()}>
<CopyIcon width={14} height={14} className="text-white/50" />
</button>
</div>
<p className="leading-tight text-white/50">
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
</p>
</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { useState } from 'react';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function MutedItem({ data }: { data: any }) {
const { user, isError, isLoading } = useProfile(data.content);
const [status, setStatus] = useState(data.status);
const unmute = async () => {
const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 0);
if (res) {
setStatus(0);
}
};
const mute = async () => {
const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 1);
if (res) {
setStatus(1);
}
};
return (
<div className="flex items-center justify-between">
{isError || isLoading ? (
<>
<div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<div className="h-3 w-16 animate-pulse bg-zinc-800" />
<div className="h-2 w-10 animate-pulse bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.content}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<span className="truncate text-base font-medium leading-none text-white">
{user?.displayName || user?.name || 'Pleb'}
</span>
<span className="text-base leading-none text-white/50">
{shortenKey(data.content)}
</span>
</div>
</div>
<div>
{status === 1 ? (
<button
type="button"
onClick={() => unmute()}
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
>
Unmute
</button>
) : (
<button
type="button"
onClick={() => mute()}
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
>
Mute
</button>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { getChannel, updateChannelMetadata } from '@libs/storage';
export function useChannelProfile(id: string) {
const { ndk } = useNDK();
const { data } = useQuery(['channel-metadata', id], async () => {
return await getChannel(id);
});
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [41],
},
{
closeOnEose: true,
}
);
sub.addListener('event', (event: { content: string }) => {
// update in local database
updateChannelMetadata(id, event.content);
});
return () => {
sub.stop();
};
}, []);
return data;
}

View File

@@ -1,149 +0,0 @@
import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ChannelMembers } from '@app/channel/components/members';
import { ChannelMessageForm } from '@app/channel/components/messages/form';
import { ChannelMetadata } from '@app/channel/components/metadata';
import { useNDK } from '@libs/ndk/provider';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix, getHourAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
import { ChannelMessageItem } from './components/messages/item';
const now = new Date();
const Header = (
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white/50 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
);
const Empty = (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-white/50">
Be the first to share a message in this channel.
</p>
</div>
);
export function ChannelScreen() {
const { ndk } = useNDK();
const virtuosoRef = useRef(null);
const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] = useChannelMessages(
(state: any) => [state.messages, state.fetch, state.add, state.clear]
);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false }
);
sub.addListener('event', (event: LumeEvent) => {
addMessage(id, event);
});
return () => {
clearMessages();
sub.stop();
};
}, []);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={messages[index]} />;
},
[messages]
);
const computeItemKey = useCallback(
(index: string | number) => {
return messages[index].event_id;
},
[messages]
);
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-white">Public Channel</h3>
</div>
<div className="h-full w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-full w-full flex-1">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
</div>
<div className="col-span-1 flex flex-col">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/>
<div className="flex flex-col gap-3 p-3">
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>
);
}

View File

@@ -3,55 +3,46 @@ import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { Chats } from '@utils/types';
export function ChatsListItem({ data }: { data: Chats }) {
const { status, user } = useProfile(data.sender_pubkey);
export function ChatsListItem({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-3">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
);
}
return (
<NavLink
to={`/chats/${data.sender_pubkey}`}
to={`/chats/${pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
isActive ? 'bg-white/10 text-white' : 'text-white/80'
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/70'
)
}
>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey}
className="h-6 w-6 shrink-0 rounded object-cover"
alt={pubkey}
className="h-7 w-7 shrink-0 rounded"
/>
<div className="inline-flex w-full flex-1 items-center justify-between">
<h5 className="max-w-[10rem] truncate">
{user?.nip05 ||
user?.name ||
{user?.name ||
user?.display_name ||
displayNpub(data.sender_pubkey, 16)}
user?.displayName ||
displayNpub(pubkey, 16)}
</h5>
<div className="flex items-center">
{data.new_messages > 0 && (
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);

View File

@@ -3,28 +3,29 @@ import { useCallback } from 'react';
import { ChatsListItem } from '@app/chats/components/item';
import { NewMessageModal } from '@app/chats/components/modal';
import { ChatsListSelfItem } from '@app/chats/components/self';
import { UnknownsModal } from '@app/chats/components/unknowns';
import { getChats } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { useAccount } from '@utils/hooks/useAccount';
import { Chats } from '@utils/types';
import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatsList() {
const { account } = useAccount();
const {
status,
data: chats,
isFetching,
} = useQuery(['chats'], async () => {
return await getChats();
});
const { db } = useStorage();
const { fetchNIP04Chats } = useNostr();
const { status, data: chats } = useQuery(
['nip04-chats'],
async () => {
return await fetchNIP04Chats();
},
{ refetchOnWindowFocus: false }
);
const renderItem = useCallback(
(item: Chats) => {
if (account?.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
(item: string) => {
if (db.account.pubkey !== item) {
return <ChatsListItem key={item} pubkey={item} />;
}
},
[chats]
@@ -33,13 +34,11 @@ export function ChatsList() {
if (status === 'loading') {
return (
<div className="flex flex-col">
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
<div className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
<h5 className="text-white/50">Loading messages...</h5>
</div>
</div>
);
@@ -47,22 +46,8 @@ export function ChatsList() {
return (
<div className="flex flex-col">
{account ? (
<ChatsListSelfItem data={account} />
) : (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
)}
{chats.follows.map((item) => renderItem(item))}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
)}
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
{chats?.follows?.map((item) => renderItem(item))}
{chats?.unknowns?.length > 0 && <UnknownsModal data={chats.unknowns} />}
<NewMessageModal />
</div>
);

View File

@@ -1,8 +1,10 @@
import { nip04 } from 'nostr-tools';
import { useCallback, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { MediaUploader } from '@app/chats/components/messages/mediaUploader';
import { EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { useNostr } from '@utils/hooks/useNostr';
@@ -34,7 +36,7 @@ export function ChatMessageForm({
const handleEnterPress = (e: {
key: string;
shiftKey: any;
shiftKey: KeyboardEvent['shiftKey'];
preventDefault: () => void;
}) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -44,27 +46,28 @@ export function ChatMessageForm({
};
return (
<div className="relative h-11 w-full">
<input
<div className="flex w-full items-center justify-between rounded-md bg-white/20 px-3">
<TextareaAutosize
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Message"
className="relative h-11 w-full resize-none rounded-md bg-white/10 px-5 text-white !outline-none placeholder:text-white/50"
className="min-h-[44px] flex-1 resize-none bg-transparent py-3 text-white !outline-none placeholder:text-white"
/>
<div className="absolute right-2 top-0 h-11">
<div className="flex h-full items-center justify-end gap-3 text-white/50">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
<div className="inline-flex items-center gap-2">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1.5 text-sm font-medium leading-none text-white/50"
>
<EnterIcon className="h-5 w-5" />
Send
</button>
</div>
</div>
);

View File

@@ -1,33 +1,32 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
import { NoteContent } from '@shared/notes';
import { TextNote } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
export function ChatMessageItem({
data,
message,
userPubkey,
userPrivkey,
}: {
data: any;
message: NDKEvent;
userPubkey: string;
userPrivkey: string;
}) {
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
const decryptedContent = useDecryptMessage(message, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data['content'] = decryptedContent;
message['content'] = decryptedContent;
}
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-white/10">
<div className="flex flex-col">
<User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{data.content}
</p>
<User pubkey={message.pubkey} time={message.created_at} variant="chat" />
<div className="-mt-5 flex items-start gap-3">
<div className="w-10 shrink-0" />
<TextNote content={message.content} />
</div>
</div>
</div>

View File

@@ -1,22 +1,25 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { useState } from 'react';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, MediaIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ setState }: { setState: any }) {
const upload = useImageUploader();
export function MediaUploader({
setState,
}: {
setState: Dispatch<SetStateAction<string>>;
}) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {
setLoading(true);
const image = await upload(null);
if (image.url) {
// update state
setState((prev: string) => `${prev}\n${image.url}`);
// stop loading
setLoading(false);
}
setLoading(false);
};
return (
@@ -26,12 +29,12 @@ export function MediaUploader({ setState }: { setState: any }) {
<button
type="button"
onClick={() => uploadMedia()}
className="group inline-flex h-8 w-8 items-center justify-center rounded hover:bg-white/10"
className="group inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
{loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
) : (
<MediaIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
<MediaIcon className="h-5 w-5 text-white" />
)}
</button>
</Tooltip.Trigger>

View File

@@ -2,17 +2,16 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
import { CancelIcon, PlusIcon } from '@shared/icons';
import { User } from '@shared/user';
export function NewMessageModal() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { status, account } = useAccount();
const { db } = useStorage();
const openChat = (pubkey: string) => {
setOpen(false);
@@ -24,10 +23,10 @@ export function NewMessageModal() {
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2"
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-3"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
<PlusIcon className="h-3 w-3 text-white" />
<div className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<PlusIcon className="h-4 w-4 text-white" />
</div>
<div>
<h5 className="text-white/50">New chat</h5>
@@ -35,9 +34,9 @@ export function NewMessageModal() {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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-white/10">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@@ -54,29 +53,23 @@ export function NewMessageModal() {
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{status === 'loading' ? (
<div className="inline-flex items-center justify-center px-4 py-3">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
account?.follows?.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(follow)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>
</div>
{db.account?.follows?.map((pubkey) => (
<div
key={pubkey}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={pubkey} variant="simple" />
<div>
<button
type="button"
onClick={() => openChat(pubkey)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>
</div>
))
)}
</div>
))}
</div>
</div>
</Dialog.Content>

View File

@@ -1,48 +0,0 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
const { status, user } = useProfile(data.pubkey);
if (status === 'loading') {
return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
</div>
);
}
return (
<NavLink
to={`/chats/${data.pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
isActive ? 'bg-white/10 text-white' : 'text-white/80'
)
}
>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.pubkey}
className="h-6 w-6 shrink-0 rounded bg-white object-cover"
/>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate">
{user?.nip05 || user?.name || displayNpub(data.pubkey, 16)}
</h5>
<span className="text-white/50">(you)</span>
</div>
</NavLink>
);
}

View File

@@ -1,8 +1,7 @@
import { Link } from 'react-router-dom';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { NIP05 } from '@shared/nip05';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@@ -16,7 +15,6 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
@@ -24,17 +22,25 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-none">
{user?.display_name || user?.name}
{user?.name || user?.display_name || user?.displayName}
</h3>
<h5 className="leading-none text-white/50">
{user?.nip05 || displayNpub(pubkey, 16)}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="leading-none text-white/50"
/>
) : (
<span className="leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>
<Link
to={`/users/${pubkey}`}
className="mt-3 inline-flex h-10 w-full items-center justify-center rounded-md bg-white/10 text-sm font-medium text-white hover:bg-fuchsia-500"
className="mt-3 inline-flex h-10 w-full items-center justify-center rounded-md bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-fuchsia-500"
>
View full profile
</Link>

View File

@@ -2,14 +2,12 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, PlusIcon } from '@shared/icons';
import { CancelIcon, StrangersIcon } from '@shared/icons';
import { User } from '@shared/user';
import { compactNumber } from '@utils/number';
import { Chats } from '@utils/types';
export function UnknownsModal({ data }: { data: Chats[] }) {
export function UnknownsModal({ data }: { data: string[] }) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
@@ -23,10 +21,10 @@ export function UnknownsModal({ data }: { data: Chats[] }) {
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2"
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-3"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
<PlusIcon className="h-3 w-3 text-white" />
<div className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<StrangersIcon className="h-4 w-4 text-white" />
</div>
<div>
<h5 className="text-white/50">
@@ -36,9 +34,9 @@ export function UnknownsModal({ data }: { data: Chats[] }) {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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-white/10">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@@ -55,16 +53,16 @@ export function UnknownsModal({ data }: { data: Chats[] }) {
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{data.map((user) => (
{data.map((pubkey) => (
<div
key={user.sender_pubkey}
key={pubkey}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={user.sender_pubkey} />
<User pubkey={pubkey} variant="simple" />
<div>
<button
type="button"
onClick={() => openChat(user.sender_pubkey)}
onClick={() => openChat(pubkey)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat

View File

@@ -1,16 +1,21 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip04 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { Chats } from '@utils/types';
export function useDecryptMessage(data: Chats, userPubkey: string, userPriv: string) {
const [content, setContent] = useState(data.content);
export function useDecryptMessage(
message: NDKEvent,
userPubkey: string,
userPriv: string
) {
const [content, setContent] = useState(message.content);
useEffect(() => {
async function decrypt() {
const pubkey =
userPubkey === data.sender_pubkey ? data.receiver_pubkey : data.sender_pubkey;
const result = await nip04.decrypt(userPriv, pubkey, data.content);
userPubkey === message.pubkey
? message.tags.find((el) => el[0] === 'p')[1]
: message.pubkey;
const result = await nip04.decrypt(userPriv, pubkey, message.content);
setContent(result);
}

View File

@@ -1,46 +1,40 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { VList, VListHandle } from 'virtua';
import { ChatMessageForm } from '@app/chats/components/messages/form';
import { ChatMessageItem } from '@app/chats/components/messages/item';
import { ChatSidebar } from '@app/chats/components/sidebar';
import { useNDK } from '@libs/ndk/provider';
import { createChat, getChatMessages } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { Chats } from '@utils/types';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatScreen() {
const queryClient = useQueryClient();
const virtuosoRef = useRef(null);
const { ndk } = useNDK();
const { pubkey } = useParams();
const { account } = useAccount();
const { status, data } = useQuery(
['chat', pubkey],
async () => {
return await getChatMessages(account.pubkey, pubkey);
},
{
enabled: account ? true : false,
}
);
const listRef = useRef<VListHandle>(null);
const userPrivkey = useStronghold((state) => state.privkey);
const itemContent = useCallback(
(index: string | number) => {
const { db } = useStorage();
const { ndk } = useNDK();
const { pubkey } = useParams();
const { fetchNIP04Messages } = useNostr();
const { status, data } = useQuery(['nip04-dm', pubkey], async () => {
return await fetchNIP04Messages(pubkey);
});
const renderItem = useCallback(
(message: NDKEvent) => {
return (
<ChatMessageItem
data={data[index]}
userPubkey={account.pubkey}
message={message}
userPubkey={db.account.pubkey}
userPrivkey={userPrivkey}
/>
);
@@ -48,34 +42,15 @@ export function ChatScreen() {
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
const chat = useMutation({
mutationFn: (data: Chats) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chat', pubkey] });
},
});
useEffect(() => {
if (data.length > 0) listRef.current?.scrollToIndex(data.length);
}, [data]);
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
{
kinds: [4],
authors: [account.pubkey],
authors: [db.account.pubkey],
'#p': [pubkey],
since: Math.floor(Date.now() / 1000),
},
@@ -85,14 +60,7 @@ export function ChatScreen() {
);
sub.addListener('event', (event) => {
chat.mutate({
id: event.id,
receiver_pubkey: pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
console.log(event);
});
return () => {
@@ -101,35 +69,35 @@ export function ChatScreen() {
}, [pubkey]);
return (
<div className="grid h-full w-full grid-cols-3 bg-white/10">
<div className="grid h-full w-full grid-cols-3 bg-white/10 backdrop-blur-xl">
<div className="col-span-2 border-r border-white/5">
<div className="h-full w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl bg-white/10">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-full w-full flex-1">
{status === 'loading' ? (
<p>Loading...</p>
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/50">Loading messages</p>
</div>
</div>
) : data.length === 0 ? (
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-white/50">
You two didn&apos;t talk yet, let&apos;s send first message
</p>
</div>
) : (
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide relative overflow-y-auto"
components={{
EmptyPlaceholder: () => Empty,
}}
/>
<VList ref={listRef} className="scrollbar-hide h-full" mode="reverse">
{data.map((message) => renderItem(message))}
</VList>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5">
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5 backdrop-blur-xl">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPubkey={db.account.pubkey}
userPrivkey={userPrivkey}
/>
</div>
@@ -142,12 +110,3 @@ export function ChatScreen() {
</div>
);
}
const Empty = (
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-white/50">
You two didn&apos;t talk yet, let&apos;s send first message
</p>
</div>
);

View File

@@ -1,22 +1,93 @@
import { useRouteError } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useLocation, useRouteError } from 'react-router-dom';
interface IRouteError {
import { Frame } from '@shared/frame';
interface RouteError {
statusText: string;
message: string;
}
interface DebugInfo {
os: null | string;
version: null | string;
appDir: null | string;
}
export function ErrorScreen() {
const error = useRouteError() as IRouteError;
const error = useRouteError() as RouteError;
const location = useLocation();
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
os: null,
version: null,
appDir: null,
});
useEffect(() => {
async function getInformation() {
const { platform, version } = await import('@tauri-apps/api/os');
const { getVersion } = await import('@tauri-apps/api/app');
const { appConfigDir } = await import('@tauri-apps/api/path');
const platformName = await platform();
const osVersion = await version();
const appVersion = await getVersion();
const appDir = await appConfigDir();
setDebugInfo({
os: platformName + ' ' + osVersion,
version: appVersion,
appDir: appDir,
});
}
getInformation();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
<Frame className="flex h-full items-center justify-center">
<div className="flex w-full flex-col gap-4 px-4 md:max-w-lg md:px-0">
<div className="flex flex-col">
<h1 className="mb-1 text-2xl font-semibold text-white">
Sorry, an unexpected error has occurred.
</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">
<p className="select-text text-sm font-medium text-red-400">
{error.statusText || error.message}
</p>
</div>
<div className="mt-4">
<p className="font-medium text-white/50">
Current location: {location.pathname}
</p>
<p className="font-medium text-white/50">App version: {debugInfo.version}</p>
<p className="font-medium text-white/50">Platform: {debugInfo.os}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<a
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
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"
>
Click here to report the issue on GitHub
</a>
<button
type="button"
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"
>
Reload app
</button>
<button
type="button"
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"
>
Reset app
</button>
</div>
</div>
</div>
</Frame>
);
}

View File

@@ -1,51 +0,0 @@
import { useParams } from 'react-router-dom';
import {
NoteActions,
NoteContent,
NoteReplyForm,
NoteStats,
ThreadUser,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { useAccount } from '@utils/hooks/useAccount';
import { useEvent } from '@utils/hooks/useEvent';
export function EventScreen() {
const { id } = useParams();
const { account } = useAccount();
const { status, data } = useEvent(id);
return (
<div className="mx-auto w-[600px]">
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pt-11">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div className="h-min w-full px-3 pt-1.5">
<div className="rounded-xl bg-white/10 px-3 pt-3">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-2">
<NoteContent content={data.content} />
</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} noOpenThread={true} />
<NoteStats id={id} />
</div>
</div>
</div>
)}
<div className="px-3">
<NoteReplyForm id={id} pubkey={account.pubkey} />
<RepliesList id={id} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { BaseEdge, EdgeProps, getBezierPath } from 'reactflow';
export function Edge({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{ ...style, stroke: '#71717a' }}
/>
);
}

View File

@@ -0,0 +1,17 @@
import { memo } from 'react';
import { useProfile } from '@utils/hooks/useProfile';
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
}
return (
<h3 className="text-sm font-semibold text-fuchsia-500">{`${
user.name || user.display_name
}'s network`}</h3>
);
});

View File

@@ -0,0 +1,14 @@
export function Line({ fromX, fromY, toX, toY }) {
return (
<g>
<path
fill="none"
stroke="#f5d0fe"
strokeWidth={1.5}
className="animated"
d={`M${fromX},${fromY} C ${fromX} ${toY} ${fromX} ${toY} ${toX},${toY}`}
/>
<circle cx={toX} cy={toY} fill="#fff" r={3} stroke="#f5d0fe" strokeWidth={1.5} />
</g>
);
}

View File

@@ -0,0 +1,34 @@
import { Handle, Position } from 'reactflow';
import { UserWithDrawer } from '@app/explore/components/userWithDrawer';
import { GroupTitle } from './groupTitle';
export function UserGroupNode({ data }) {
return (
<>
<Handle
type="target"
position={Position.Top}
className="h-2 w-5 rounded-full border-none !bg-fuchsia-400"
/>
<div className="relative mx-3 my-3 flex flex-col gap-1">
{data.title ? (
<h3 className="text-sm font-semibold text-fuchsia-500">{data.title}</h3>
) : (
<GroupTitle pubkey={data.pubkey} />
)}
<div className="grid grid-cols-5 gap-6 rounded-lg border border-fuchsia-500/50 bg-fuchsia-500/10 p-4">
{data.list.map((user: string) => (
<UserWithDrawer key={user} pubkey={user} />
))}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-5 rounded-full border-none !bg-fuchsia-400"
/>
</>
);
}

View File

@@ -0,0 +1,80 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { LoaderIcon } from '@shared/icons';
import {
ArticleNote,
FileNote,
NoteWrapper,
Repost,
TextNote,
UnknownNote,
} from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr';
export function UserLatestPosts({ pubkey }: { pubkey: string }) {
const { getEventsByPubkey } = useNostr();
const { status, data } = useQuery(['user-posts', pubkey], async () => {
return await getEventsByPubkey(pubkey);
});
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return (
<NoteWrapper key={event.id} event={event}>
<TextNote />
</NoteWrapper>
);
case NDKKind.Repost:
return <Repost 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:
return (
<NoteWrapper key={event.id} event={event}>
<UnknownNote />
</NoteWrapper>
);
}
},
[data]
);
return (
<div className="mt-4 border-t border-white/5 pt-3">
<h3 className="mb-4 px-3 font-semibold text-white">Latest post</h3>
<div>
{status === 'loading' ? (
<div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-white/10 text-sm font-medium text-white/70">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
Loading latest posts...
</div>
</div>
) : data.length < 1 ? (
<div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white/70">
No posts from 24 hours ago
</div>
</div>
) : (
data.map((event) => renderItem(event))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Handle, Position } from 'reactflow';
import { User } from '@shared/user';
export function UserNode({ data }) {
return (
<>
<div className="relative mx-3 my-3 inline-flex h-12 w-12 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-8 w-8 animate-ping rounded-lg bg-green-400 opacity-75"></span>
<div className="relative z-10">
<User pubkey={data.pubkey} variant="avatar" />
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-2 rounded-full border-none !bg-white/20"
/>
</>
);
}

View File

@@ -0,0 +1,130 @@
import * as Dialog from '@radix-ui/react-dialog';
import { memo, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { TextNote } from '@shared/notes';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { UserLatestPosts } from './userLatestPosts';
export const UserWithDrawer = memo(function UserWithDrawer({
pubkey,
}: {
pubkey: string;
}) {
const { addContact, removeContact } = useNostr();
const { db } = useStorage();
const { status, user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => {
try {
addContact(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
removeContact(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (db.account.follows.includes(pubkey)) {
setFollowed(true);
}
}, []);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button type="button">
<User pubkey={pubkey} variant="avatar" />
</button>
</Dialog.Trigger>
<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">
<div className="h-full w-full overflow-y-auto rounded-lg border-t border-white/10 bg-white/20 py-3 backdrop-blur-3xl">
{status === 'loading' ? (
<div>
<p>Loading...</p>
</div>
) : (
<>
<div className="flex flex-col gap-3 px-3">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-12 w-12 rounded-lg"
/>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-1.5">
<h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || 'No name'}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
/>
) : (
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
{user?.about ? <TextNote content={user?.about} /> : null}
</div>
<div className="mt-3 inline-flex items-center gap-2">
{followed ? (
<button
type="button"
onClick={() => unfollowUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => followUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
>
Message
</Link>
</div>
</div>
</div>
<UserLatestPosts pubkey={pubkey} />
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
});

116
src/app/explore/index.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { useCallback, useMemo, useRef } from 'react';
import ReactFlow, {
Background,
ConnectionMode,
addEdge,
useEdgesState,
useNodesState,
useReactFlow,
} from 'reactflow';
import { Edge } from '@app/explore/components/edge';
import { Line } from '@app/explore/components/line';
import { UserGroupNode } from '@app/explore/components/userGroupNode';
import { UserNode } from '@app/explore/components/userNode';
import { useStorage } from '@libs/storage/provider';
import { useNostr } from '@utils/hooks/useNostr';
import { getMultipleRandom } from '@utils/transform';
let id = 2;
const getId = () => `${id++}`;
const nodeTypes = { user: UserNode, userGroup: UserGroupNode };
const edgeTypes = { buttonedge: Edge };
export function ExploreScreen() {
const { db } = useStorage();
const { getContactsByPubkey } = useNostr();
const { project } = useReactFlow();
const defaultContacts = useMemo(() => getMultipleRandom(db.account.follows, 10), []);
const reactFlowWrapper = useRef(null);
const connectingNodeId = useRef(null);
const initialNodes = [
{
id: '0',
type: 'user',
position: { x: 141, y: 0 },
data: { list: [], title: '', pubkey: db.account.pubkey },
},
{
id: '1',
type: 'userGroup',
position: { x: 0, y: 200 },
data: { list: defaultContacts, title: 'Starting Point', pubkey: '' },
},
];
const initialEdges = [{ id: 'e0-1', type: 'buttonedge', source: '0', target: '1' }];
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
const onConnectStart = useCallback((_, { nodeId }) => {
connectingNodeId.current = nodeId;
}, []);
const onConnectEnd = useCallback(
async (event) => {
const targetIsPane = event.target.classList.contains('react-flow__pane');
if (targetIsPane) {
const { top, left } = reactFlowWrapper.current.getBoundingClientRect();
const id = getId();
const prevData = nodes.slice(-1)[0];
const randomPubkey = getMultipleRandom(prevData.data.list, 1)[0];
const newContactList = await getContactsByPubkey(randomPubkey);
const newNode = {
id,
type: 'userGroup',
position: project({ x: event.clientX - left, y: event.clientY - top }),
data: { list: newContactList, title: null, pubkey: randomPubkey },
};
setNodes((nds) => nds.concat(newNode));
setEdges((eds) =>
eds.concat({
id,
type: 'buttonedge',
source: connectingNodeId.current,
target: id,
})
);
}
},
[project]
);
return (
<div className="h-full w-full" ref={reactFlowWrapper}>
<ReactFlow
proOptions={{ hideAttribution: true }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineComponent={Line}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
connectionMode={ConnectionMode.Loose}
minZoom={0.8}
maxZoom={1.2}
fitView
>
<Background color="#3f3f46" />
</ReactFlow>
</div>
);
}

130
src/app/notes/article.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { AddressPointer, EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
import {
ArticleDetailNote,
NoteActions,
NoteReplyForm,
NoteStats,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function ArticleNoteScreen() {
const navigate = useNavigate();
const replyRef = useRef(null);
const { id } = useParams();
const { db } = useStorage();
const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null;
const { status, data } = useEvent(id, naddr);
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
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);
};
const scrollToReply = () => {
replyRef.current.scrollIntoView();
};
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Article:
return <ArticleDetailNote event={event} />;
default:
return <UnknownNote event={event} />;
}
};
return (
<div className="scrollbar-hide h-full w-full overflow-y-auto scroll-smooth">
<div className="container mx-auto px-4 pt-16 sm:px-6 lg:px-8">
<div className="grid grid-cols-5">
<div className="col-span-1 pr-8">
<div className="sticky top-16 flex flex-col items-end gap-4">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl border border-white/10 bg-white/5"
>
<ArrowLeftIcon className="h-5 w-5 text-white" />
</button>
<div className="flex flex-col divide-y divide-white/5 rounded-xl border border-white/10 bg-white/5">
<button
type="button"
onClick={share}
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-t-xl "
>
{isCopy ? (
<CheckCircleIcon className="h-5 w-5 text-green-500" />
) : (
<ShareIcon className="h-5 w-5 text-white" />
)}
</button>
<button
type="button"
onClick={scrollToReply}
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
>
<ReplyIcon className="h-5 w-5 text-white" />
</button>
</div>
</div>
</div>
<div className="col-span-3 flex flex-col gap-1.5">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<NoteSkeleton />
</div>
</div>
) : (
<>
<div className="h-min w-full px-3">
<div className="rounded-xl border-t border-white/10 bg-white/20 px-3 pt-3">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions
id={data.id}
pubkey={data.pubkey}
extraButtons={false}
/>
<NoteStats id={data.id} />
</div>
</div>
</div>
<div ref={replyRef} className="px-3">
<NoteReplyForm id={data.id} pubkey={db.account.pubkey} />
<RepliesList id={data.id} />
</div>
</>
)}
</div>
<div className="col-span-1" />
</div>
</div>
</div>
);
}

128
src/app/notes/text.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
import {
ArticleNote,
FileNote,
NoteActions,
NoteReplyForm,
NoteStats,
TextNote,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function TextNoteScreen() {
const navigate = useNavigate();
const replyRef = useRef(null);
const { id } = useParams();
const { db } = useStorage();
const { status, data } = useEvent(id);
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
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);
};
const scrollToReply = () => {
replyRef.current.scrollIntoView();
};
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote content={event.content} />;
case NDKKind.Article:
return <ArticleNote event={event} />;
case 1063:
return <FileNote event={event} />;
default:
return <UnknownNote event={event} />;
}
};
return (
<div className="scrollbar-hide h-full w-full overflow-y-auto scroll-smooth">
<div className="container mx-auto px-4 pt-16 sm:px-6 lg:px-8">
<div className="grid grid-cols-5">
<div className="col-span-1 pr-8">
<div className="sticky top-16 flex flex-col items-end gap-4">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl border border-white/10 bg-white/5"
>
<ArrowLeftIcon className="h-5 w-5 text-white" />
</button>
<div className="flex flex-col divide-y divide-white/5 rounded-xl border border-white/10 bg-white/5">
<button
type="button"
onClick={share}
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-t-xl "
>
{isCopy ? (
<CheckCircleIcon className="h-5 w-5 text-green-500" />
) : (
<ShareIcon className="h-5 w-5 text-white" />
)}
</button>
<button
type="button"
onClick={scrollToReply}
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
>
<ReplyIcon className="h-5 w-5 text-white" />
</button>
</div>
</div>
</div>
<div className="col-span-3 flex flex-col gap-1.5">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<NoteSkeleton />
</div>
</div>
) : (
<div className="h-min w-full px-3">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
<NoteStats id={id} />
</div>
</div>
</div>
)}
<div ref={replyRef} className="px-3">
<NoteReplyForm id={id} pubkey={db.account.pubkey} />
<RepliesList id={id} />
</div>
</div>
<div className="col-span-1" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Hashtag, MentionUser } from '@shared/notes';
import { RichContent } from '@utils/types';
export function NotiContent({ content }: { content: RichContent }) {
return (
<>
<ReactMarkdown
className="markdown"
remarkPlugins={[remarkGfm]}
components={{
del: ({ children }) => {
const key = children[0] as string;
if (key.startsWith('pub') && key.length > 50 && key.length < 100)
return <MentionUser pubkey={key.replace('pub-', '')} />;
if (key.startsWith('tag')) return <Hashtag tag={key.replace('tag-', '')} />;
},
}}
>
{content?.parsed}
</ReactMarkdown>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt';
export function NotiMention({ event }: { event: NDKEvent }) {
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-center gap-2">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">has mention you · {createdAt}</p>
</div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,27 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt';
export function NotiReaction({ event }: { event: NDKEvent }) {
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-center gap-2">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
reacted {event.content} · {createdAt}
</p>
</div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,33 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { NotiUser } from '@app/notifications/components/user';
import { useStorage } from '@libs/storage/provider';
import { formatCreatedAt } from '@utils/createdAt';
export function NotiRepost({ event }: { event: NDKEvent }) {
const { db } = useStorage();
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-center gap-2">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
repost{' '}
{event.pubkey !== db.account.pubkey ? 'a post that mention you' : 'your post'}{' '}
· {createdAt}
</p>
</div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,61 @@
import { memo } from 'react';
import { useStorage } from '@libs/storage/provider';
import { NoteSkeleton } from '@shared/notes';
import { User } from '@shared/user';
import { WidgetKinds, useWidgets } from '@stores/widgets';
import { useEvent } from '@utils/hooks/useEvent';
export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
const { db } = useStorage();
const { status, data } = useEvent(id);
const setWidget = useWidgets((state) => state.setWidget);
const openThread = (event, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread });
} else {
event.stopPropagation();
}
};
if (status === 'loading') {
return (
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
<NoteSkeleton />
</div>
);
}
if (status === 'error') {
return (
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
<p>Can&apos;t get event from relay</p>
</div>
);
}
return (
<div
onClick={(e) => openThread(e, id)}
onKeyDown={(e) => openThread(e, id)}
role="button"
tabIndex={0}
className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl"
>
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="markdown">
<p>
{data.content.length > 200
? data.content.substring(0, 200) + '...'
: data.content}
</p>
</div>
</div>
);
});

View File

@@ -1,7 +1,5 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@@ -11,24 +9,23 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
if (status === 'loading') {
return (
<div className="flex items-start gap-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded-md bg-zinc-800" />
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded-md bg-white/10" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-zinc-800" />
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
</div>
</div>
);
}
return (
<div className="flex shrink-0 items-start justify-start gap-3">
<div className="flex shrink-0 items-center justify-start gap-2">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 shrink-0 rounded-md object-cover"
className="h-8 w-8 shrink-0 rounded-md object-cover"
/>
<span className="max-w-[10rem] flex-1 truncate font-medium leading-none text-white">
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)}
<span className="max-w-[10rem] truncate font-medium leading-none text-white">
{user?.name || user?.display_name || user?.displayName || displayNpub(pubkey, 16)}
</span>
</div>
);

View File

@@ -0,0 +1,83 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useCallback, useEffect } from 'react';
import { NotiMention } from '@app/notifications/components/mention';
import { NotiReaction } from '@app/notifications/components/reaction';
import { NotiRepost } from '@app/notifications/components/repost';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr';
export function NotificationScreen() {
const { db } = useStorage();
const { fetchActivities } = useNostr();
const [activities, setActivities, clearTotalNewActivities] = useActivities((state) => [
state.activities,
state.setActivities,
state.clearTotalNewActivities,
]);
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case 1:
return <NotiMention key={event.id} event={event} />;
case 6:
return <NotiRepost key={event.id} event={event} />;
case 7:
return <NotiReaction key={event.id} event={event} />;
default:
return null;
}
},
[activities]
);
useEffect(() => {
async function getActivities() {
const events = await fetchActivities();
setActivities(events, db.account.last_login_at);
}
getActivities();
// clear total new activities
clearTotalNewActivities();
}, []);
return (
<div className="scrollbar-hide h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl">
<div className="grid h-full grid-cols-3">
<div className="col-span-2 flex flex-col border-r border-white/5">
<TitleBar title="Activities in the last 24 hours" />
<div className="flex h-full flex-col">
{!activities ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/50">Loading</p>
</div>
</div>
) : activities.length <= 1 ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<p className="mb-1 text-4xl">🎉</p>
<p className="font-medium text-white/50">
Yo!, no new activities around you in the last 24 hours
</p>
</div>
) : (
activities.map((event) => renderItem(event))
)}
</div>
</div>
</div>
</div>
);
}

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