Compare commits

..

91 Commits

Author SHA1 Message Date
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
190 changed files with 9409 additions and 6837 deletions

View File

@@ -1,4 +1,4 @@
name: 'publish'
name: 'Publish'
on: workflow_dispatch
env:
@@ -24,7 +24,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 18
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
@@ -32,7 +32,7 @@ jobs:
if: matrix.settings.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libsoup-3.0 libgtk-3-dev webkit2gtk-4.1 javascriptcoregtk-4.1 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:
@@ -66,3 +66,4 @@ jobs:
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
- Bun: https://bun.sh/docs/installation
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
### Develop
Clone project
@@ -19,23 +25,17 @@ git clone https://github.com/luminous-devs/lume.git && cd lume
Install packages
```
pnpm install
bun install
```
Run dev
Run dev build
```
pnpm tauri dev
bun tauri dev
```
Build
Generate production build
```
pnpm tauri build
```
(Advance) - Generate SQLite migration
```
pnpm add-migrate <migrate_name>
```
bun tauri build
```

View File

@@ -2,7 +2,7 @@
"name": "lume",
"description": "the communication app",
"private": true,
"version": "1.2.1",
"version": "1.2.6",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -18,96 +18,88 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@nostr-dev-kit/ndk": "^0.8.23",
"@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.4",
"@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-dropdown-menu": "^2.0.5",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "2.0.0-alpha.6",
"@tauri-apps/cli": "2.0.0-alpha.11",
"@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": "github:tauri-apps/tauri-plugin-window#v2",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-mention": "^2.1.7",
"@tiptap/extension-placeholder": "^2.1.7",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.7",
"@tiptap/suggestion": "^2.1.7",
"@void-cat/api": "^1.0.7",
"dayjs": "^1.11.9",
"@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.3",
"@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.0.5",
"dayjs": "^1.11.10",
"destr": "^2.0.1",
"get-urls": "^12.1.0",
"html-to-text": "^9.0.5",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"minidenticons": "^4.2.0",
"nostr-fetch": "^0.12.2",
"nostr-tools": "^1.14.2",
"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.46.2",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.15.0",
"react-router-dom": "^6.16.0",
"react-textarea-autosize": "^8.5.3",
"react-virtuoso": "^4.5.0",
"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",
"virtua": "^0.9.1",
"zustand": "^4.4.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tailwindcss/typography": "^0.5.10",
"@tauri-apps/cli": "^1.5.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^20.5.6",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"@types/html-to-text": "^9.0.2",
"@types/node": "^20.8.0",
"@types/react": "^18.2.23",
"@types/react-dom": "^18.2.8",
"@types/youtube-player": "^5.5.8",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"@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.48.0",
"eslint": "^8.50.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^14.0.1",
"postcss": "^8.4.28",
"prettier": "^3.0.2",
"prettier-plugin-tailwindcss": "^0.5.3",
"postcss": "^8.4.31",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.4",
"prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-tsconfig-paths": "^4.2.0"
"vite-tsconfig-paths": "^4.2.1"
}
}

4334
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1982
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,63 @@
[package]
name = "lume"
version = "1.2.1"
description = "nostr client"
version = "1.2.6"
description = "the communication app"
authors = ["Ren Amamiya"]
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.8", features = [] }
tauri-build = { version = "1.4.1", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { git = "https://git.nextgraph.org/NextGraph/tauri.git", branch = "alpha.11-nextgraph", features = [
"protocol-asset",
tauri = { version = "1.5", features = [
"macos-private-api",
"no-ipc-custom-protocol",
"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/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-autostart = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-stronghold = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-upload = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-updater = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-dialog = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-http = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-fs = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-clipboard-manager = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-notification = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-app = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-process = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-os = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-window = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-store = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-shell = { git = "https://github.com/luminous-devs/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"
webpage = { version = "1.6.0", features = ["serde"] }
[dependencies.tauri-plugin-sql]
git = "https://github.com/luminous-devs/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
@@ -53,7 +65,7 @@ features = ["sqlite"]
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = []
custom-protocol = ["tauri/custom-protocol"]
# Optimized for bundle size. If you want faster builds comment out/delete this section.
[profile.release]

View File

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

View File

@@ -3,9 +3,24 @@
windows_subsystem = "windows"
)]
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 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 {
@@ -13,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
@@ -25,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(
@@ -132,6 +231,12 @@ fn main() {
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(),
@@ -168,20 +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())
.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.1"
},
"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.6"
},
"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": {
@@ -79,20 +118,25 @@
"timestampUrl": ""
}
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
},
"security": {
"csp": {
"connect-src": "ipc: https://ipc.localhost",
"content-security-policy": "upgrade-insecure-requests"
},
"freezePrototype": false,
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$APPCONFIG/*.db", "$RESOURCE/**"],
"deny": ["$APPCONFIG/*.stronghold"]
"dangerousUseHttpScheme": true,
"dangerousRemoteDomainIpcAccess": [
{
"scheme": "https",
"domain": "nwc.getalby.com",
"windows": ["alby"],
"enableTauriAPI": true
}
}
},
"macOSPrivateApi": true
]
}
}
}

View File

@@ -1,9 +1,10 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 400,
"height": 500,
"width": 300,
"height": 300,
"decorations": false,
"title": "Lume",
"center": true,

View File

@@ -1,10 +1,12 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"macOSPrivateApi": true,
"windows": [
{
"width": 400,
"height": 500,
"decorations": true,
"width": 300,
"height": 300,
"decorations": false,
"title": "Lume",
"titleBarStyle": "Overlay",
"hiddenTitle": true,
@@ -27,11 +29,7 @@
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true,
"windowEffects": {
"effects": ["hudWindow"],
"state": "followsWindowActiveState"
}
"fileDropEnabled": true
}
]
}

View File

@@ -1,9 +1,10 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 400,
"height": 500,
"width": 300,
"height": 300,
"decorations": false,
"title": "Lume",
"center": true,
@@ -19,16 +20,12 @@
"resizable": true,
"theme": "Dark",
"title": "Lume",
"transparent": true,
"transparent": false,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true,
"windowEffects": {
"effects": ["micaDark", "micaLight", "acrylic"],
"state": "followsWindowActiveState"
}
"fileDropEnabled": true
}
]
}

View File

@@ -1,270 +1,326 @@
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 { useStorage } from '@libs/storage/provider';
import { Frame } from '@shared/frame';
import { LoaderIcon } from '@shared/icons';
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 { checkActiveAccount } from '@utils/checkActiveAccount';
import './index.css';
async function Loader() {
try {
const account = await checkActiveAccount();
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');
} else {
if (!privkey) {
return redirect('/auth/unlock');
}
}
return null;
} catch (e) {
throw new Error('App failed to load');
}
}
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <ErrorScreen />,
loader: Loader,
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: '/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: '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: 'hard-reset',
async lazy() {
const { HardResetScreen } = await import('@app/auth/hardReset');
return { Component: HardResetScreen };
},
},
],
},
{
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 };
},
},
],
},
]);
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 backdrop-blur-xl">
<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,38 +0,0 @@
import { Image } from '@shared/image';
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 backdrop-blur-xl" />
<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 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-10 w-10 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col items-start text-start">
<p className="max-w-[15rem] truncate font-medium leading-tight text-white">
{user?.name || user?.display_name || user?.nip05}
</p>
<span className="max-w-[15rem] truncate leading-tight text-white/50">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { Image } from '@shared/image';
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 backdrop-blur-xl" />
<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 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</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}
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,13 +1,14 @@
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 { 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';
@@ -21,8 +22,8 @@ export function CreateStep1Screen() {
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,27 +31,39 @@ 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(
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
`Generated by Lume (lume.nu)\nPublic 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 submit = () => {
const submit = async () => {
setLoading(true);
// update state
@@ -59,7 +72,7 @@ export function CreateStep1Screen() {
setPubkey(pubkey);
// save to database
db.createAccount(npub, pubkey);
await db.createAccount(npub, pubkey);
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
@@ -67,81 +80,73 @@ export function CreateStep1Screen() {
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 backdrop-blur-xl 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 backdrop-blur-xl 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 backdrop-blur-xl 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,8 +1,8 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
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';
@@ -61,9 +61,9 @@ export function CreateStep2Screen() {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
db.secureDB = stronghold;
if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage
await db.secureSave(pubkey, privkey);
@@ -86,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">
@@ -98,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 backdrop-blur-xl 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 backdrop-blur-xl 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" />
@@ -112,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>
@@ -127,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,7 +1,10 @@
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';
@@ -9,6 +12,7 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
@@ -20,6 +24,7 @@ export function CreateStep3Screen() {
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr();
const {
register,
@@ -40,10 +45,13 @@ export function CreateStep3Screen() {
const event = await publish({
content: JSON.stringify(profile),
kind: 0,
kind: NDKKind.Metadata,
tags: [],
});
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) {
navigate('/auth/onboarding', { replace: true });
}
@@ -60,30 +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 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 backdrop-blur-xl">
<Image
src={banner}
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}
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} />
@@ -93,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 backdrop-blur-xl 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 backdrop-blur-xl 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 backdrop-blur-xl 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,7 +0,0 @@
export function HardResetScreen() {
return (
<div>
<p>hard reset</p>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
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';
@@ -37,6 +37,7 @@ export function ImportStep1Screen() {
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const { db } = useStorage();
const {
@@ -64,12 +65,13 @@ export function ImportStep1Screen() {
setPubkey(pubkey);
// add account to local database
db.createAccount(npub, pubkey);
await db.createAccount(npub, pubkey);
// redirect to step 2
navigate('/auth/import/step-2', { replace: true });
// 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',
@@ -77,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 backdrop-blur-xl 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>
@@ -105,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,8 +1,8 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
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';
@@ -61,9 +61,9 @@ export function ImportStep2Screen() {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
db.secureDB = stronghold;
if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage
await db.secureSave(pubkey, privkey);
@@ -86,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">
@@ -98,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 backdrop-blur-xl 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 backdrop-blur-xl 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" />
@@ -112,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>
@@ -125,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,13 +1,13 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
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 { useNostr } from '@utils/hooks/useNostr';
@@ -15,10 +15,10 @@ export function ImportStep3Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const { fetchUserData, prefetchEvents } = useNostr();
const { fetchUserData } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
@@ -27,13 +27,14 @@ export function ImportStep3Screen() {
// prefetch data
const user = await fetchUserData();
const data = await prefetchEvents();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step
if (user.status === 'ok' && data.status === 'ok') {
if (user.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
console.log('error: ', data.message);
setLoading(false);
}
} catch (e) {
@@ -49,17 +50,19 @@ 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 backdrop-blur-xl">
<div className="flex flex-col gap-3">
<User pubkey={db.account.pubkey} />
<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-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"
onClick={() => submit()}
>
{loading ? (
@@ -76,6 +79,10 @@ export function ImportStep3Screen() {
</>
)}
</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,9 +1,9 @@
import { useQueryClient } from '@tanstack/react-query';
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
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 { useStorage } from '@libs/storage/provider';

View File

@@ -2,11 +2,10 @@ 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 { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
@@ -17,9 +16,9 @@ export function OnboardStep1Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { publish, fetchUserData, prefetchEvents } = useNostr();
const { publish, fetchUserData } = useNostr();
const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles'], async () => {
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');
@@ -47,14 +46,12 @@ export function OnboardStep1Screen() {
// prefetch data
const user = await fetchUserData(follows);
const data = await prefetchEvents();
// redirect to next step
if (event && user.status === 'ok' && data.status === 'ok') {
if (event && user.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);
console.log('error: ', data.message);
}
} catch (e) {
setLoading(false);
@@ -68,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 py-2 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>
) : (
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 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 ? (
<>
@@ -124,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 backdrop-blur-xl 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,5 +1,6 @@
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 { useStorage } from '@libs/storage/provider';
@@ -11,6 +12,7 @@ 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,8 +34,8 @@ 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>());
@@ -47,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 db.createWidget(WidgetKinds.hashtag, tag, tag.replace('#', ''));
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
}
navigate('/auth/onboarding/step-3', { replace: true });
} 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' });
}
};
@@ -68,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 backdrop-blur-xl">
<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 backdrop-blur-xl 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) && (
@@ -97,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 ? (
<>
@@ -113,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 backdrop-blur-xl 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,12 +2,11 @@ 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 { 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';
@@ -49,6 +48,8 @@ export function OnboardStep3Screen() {
}
);
const relaysAsArray = Array.from(data?.keys() || []);
const toggleRelay = (relay: string) => {
if (relays.has(relay)) {
setRelays((prev) => {
@@ -61,8 +62,9 @@ export function OnboardStep3Screen() {
};
const submit = async (skip?: boolean) => {
setLoading(true);
try {
setLoading(true);
if (!skip) {
for (const relay of relays) {
await db.createRelay(relay);
@@ -87,8 +89,6 @@ export function OnboardStep3Screen() {
}
};
const relaysAsArray = Array.from(data?.keys() || []);
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/onboarding/step-3');
@@ -118,10 +118,11 @@ export function OnboardStep3Screen() {
<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>
) : (
@@ -134,7 +135,7 @@ export function OnboardStep3Screen() {
>
<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">
@@ -145,7 +146,7 @@ export function OnboardStep3Screen() {
))
)}
{relays.size > 5 && (
<div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl backdrop-blur-xl">
<div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl">
<p className="text-sm text-orange-400">
Using too much relay can cause high resource usage
</p>
@@ -178,7 +179,7 @@ export function OnboardStep3Screen() {
onClick={() => submit(true)}
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,9 +1,9 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
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';
@@ -116,28 +116,28 @@ 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 backdrop-blur-xl 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 backdrop-blur-xl 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"
@@ -155,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}
@@ -167,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,13 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useStronghold } from '@stores/stronghold';
@@ -33,6 +32,8 @@ 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 [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
@@ -55,8 +56,10 @@ export function UnlockScreen() {
if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey);
const uri = await db.secureLoad('walletConnectURL', 'nwc');
setPrivkey(privkey);
if (privkey) setPrivkey(privkey);
if (uri) setWalletConnectURL(uri);
// redirect to home
navigate('/', { replace: true });
} catch (e) {
@@ -68,23 +71,34 @@ export function UnlockScreen() {
}
};
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">
<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} />
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="relative">
<input
{...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 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
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"
@@ -106,12 +120,12 @@ export function UnlockScreen() {
<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 disabled:opacity-50"
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 ? (
<>
<span className="w-5" />
<span>Decryting...</span>
<span>Unlocking...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
@@ -122,12 +136,30 @@ export function UnlockScreen() {
</>
)}
</button>
<Link
to="/auth/reset"
className="mt-1 inline-flex h-11 w-full items-center justify-center rounded-lg text-center text-white/50 hover:bg-white/10"
>
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,46 +1,22 @@
import { LogicalSize, getCurrent } from '@tauri-apps/plugin-window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() {
const appWindow = getCurrent();
async function setWindow() {
await appWindow.setSize(new LogicalSize(400, 500));
await appWindow.setResizable(false);
await appWindow.center();
}
async function resetWindow() {
await appWindow.setSize(new LogicalSize(1080, 800));
await appWindow.setResizable(false);
await appWindow.center();
}
useEffect(() => {
setWindow();
return () => {
resetWindow();
};
}, []);
return (
<div className="flex h-screen w-full flex-col justify-between bg-white/10 backdrop-blur-xl">
<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="sflex 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>
@@ -48,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-white/50 backdrop-blur-xl 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

@@ -11,7 +11,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
if (status === 'loading') {
return (
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2">
<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>
@@ -24,10 +24,10 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
'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/80'
: 'border-transparent text-white/70'
)
}
>
@@ -38,7 +38,10 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
/>
<div className="inline-flex w-full flex-1 items-center justify-between">
<h5 className="max-w-[10rem] truncate">
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)}
{user?.name ||
user?.display_name ||
user?.displayName ||
displayNpub(pubkey, 16)}
</h5>
</div>
</NavLink>

View File

@@ -7,6 +7,8 @@ import { UnknownsModal } from '@app/chats/components/unknowns';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatsList() {
@@ -33,12 +35,10 @@ export function ChatsList() {
return (
<div className="flex flex-col">
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<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>
);
@@ -46,8 +46,8 @@ export function ChatsList() {
return (
<div className="flex flex-col">
{chats.follows.map((item) => renderItem(item))}
{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

@@ -2,6 +2,7 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
import { TextNote } from '@shared/notes';
import { User } from '@shared/user';
export function ChatMessageItem({
@@ -20,13 +21,12 @@ export function ChatMessageItem({
}
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 backdrop-blur-xl hover:bg-white/10">
<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={message.pubkey} time={message.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{message.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

@@ -3,14 +3,14 @@ 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: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {

View File

@@ -2,11 +2,10 @@ 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, PlusIcon } from '@shared/icons';
import { User } from '@shared/user';
export function NewMessageModal() {
const navigate = useNavigate();
@@ -24,7 +23,7 @@ export function NewMessageModal() {
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-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-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<PlusIcon className="h-4 w-4 text-white" />
@@ -35,7 +34,7 @@ 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 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -44,7 +43,7 @@ export function NewMessageModal() {
<Dialog.Title className="text-lg font-semibold leading-none text-white">
New chat
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
@@ -54,17 +53,17 @@ export function NewMessageModal() {
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{db.account?.follows?.map((follow) => (
{db.account?.follows?.map((pubkey) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
key={pubkey}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={follow} />
<User pubkey={pubkey} variant="simple" />
<div>
<button
type="button"
onClick={() => openChat(follow)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500 group-hover:inline-flex"
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>

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@@ -21,11 +22,19 @@ 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>

View File

@@ -2,9 +2,8 @@ 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, StrangersIcon } from '@shared/icons';
import { User } from '@shared/user';
import { compactNumber } from '@utils/number';
@@ -22,7 +21,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-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-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<StrangersIcon className="h-4 w-4 text-white" />
@@ -35,7 +34,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
</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 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -44,7 +43,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
<Dialog.Title className="text-lg font-semibold leading-none text-white">
{data.length} unknowns
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
@@ -57,14 +56,14 @@ export function UnknownsModal({ data }: { data: string[] }) {
{data.map((pubkey) => (
<div
key={pubkey}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={pubkey} />
<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 backdrop-blur-xl hover:bg-fuchsia-500 group-hover:inline-flex"
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>

View File

@@ -1,8 +1,8 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
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';
@@ -18,7 +18,7 @@ import { useStronghold } from '@stores/stronghold';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatScreen() {
const virtuosoRef = useRef(null);
const listRef = useRef<VListHandle>(null);
const userPrivkey = useStronghold((state) => state.privkey);
const { db } = useStorage();
@@ -29,10 +29,8 @@ export function ChatScreen() {
return await fetchNIP04Messages(pubkey);
});
const itemContent = useCallback(
(index: string | number) => {
const message = data[index];
if (!message) return;
const renderItem = useCallback(
(message: NDKEvent) => {
return (
<ChatMessageItem
message={message}
@@ -44,12 +42,9 @@ export function ChatScreen() {
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
useEffect(() => {
if (data.length > 0) listRef.current?.scrollToIndex(data.length);
}, [data]);
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
@@ -86,22 +81,17 @@ export function ChatScreen() {
<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 backdrop-blur-xl">
@@ -120,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,40 +1,53 @@
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 IDebugInfo {
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<IDebugInfo>({ os: null, version: null });
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
os: null,
version: null,
appDir: null,
});
useEffect(() => {
async function getInformation() {
const { platform, version } = await import('@tauri-apps/plugin-os');
const { getVersion } = await import('@tauri-apps/plugin-app');
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 });
setDebugInfo({
os: platformName + ' ' + osVersion,
version: appVersion,
appDir: appDir,
});
}
getInformation();
}, []);
return (
<div className="flex h-full items-center justify-center bg-black/90 backdrop-blur-xl">
<div className="flex max-w-lg flex-col gap-4">
<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.
@@ -57,24 +70,24 @@ export function ErrorScreen() {
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 bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20"
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 bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20"
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 bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20"
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

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

View File

@@ -1,7 +1,7 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { AddressPointer, EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -13,11 +13,11 @@ import {
NoteActions,
NoteReplyForm,
NoteStats,
ThreadUser,
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';
@@ -27,13 +27,15 @@ export function ArticleNoteScreen() {
const { id } = useParams();
const { db } = useStorage();
const { status, data } = useEvent(id);
const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null;
const { status, data } = useEvent(id, naddr);
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
await writeText(
'https://nostr.com/' +
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
@@ -98,21 +100,27 @@ export function ArticleNoteScreen() {
</div>
</div>
) : (
<div className="h-min w-full px-3">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
<NoteStats id={id} />
<>
<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>
<div ref={replyRef} className="px-3">
<NoteReplyForm id={data.id} pubkey={db.account.pubkey} />
<RepliesList id={data.id} />
</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>

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
@@ -15,11 +15,11 @@ import {
NoteReplyForm,
NoteStats,
TextNote,
ThreadUser,
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';
@@ -35,7 +35,7 @@ export function TextNoteScreen() {
const share = async () => {
await writeText(
'https://nostr.com/' +
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
@@ -51,7 +51,7 @@ export function TextNoteScreen() {
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote event={event} />;
return <TextNote content={event.content} />;
case NDKKind.Article:
return <ArticleNote event={event} />;
case 1063:
@@ -106,7 +106,7 @@ export function TextNoteScreen() {
) : (
<div className="h-min w-full px-3">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<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} />

View File

@@ -1,32 +1,25 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { NotiContent } from '@app/notifications/components/content';
import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt';
import { parser } from '@utils/parser';
export function NotiMention({ event }: { event: NDKEvent }) {
const createdAt = formatCreatedAt(event.created_at);
const content = useMemo(() => parser(event), [event]);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">has reply you post · {createdAt}</p>
</div>
</div>
<div className="f- relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="mb-2 mt-3 w-full cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
<NotiContent content={content} />
</div>
<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>
</div>
</Link>
);
}

View File

@@ -1,30 +1,27 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { SimpleNote } from '@app/notifications/components/simpleNote';
import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt';
export function NotiReaction({ event }: { event: NDKEvent }) {
const root = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
reacted {event.content} · {createdAt}
</p>
</div>
</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
<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>
</div>
</Link>
);
}

View File

@@ -1,6 +1,6 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { SimpleNote } from '@app/notifications/components/simpleNote';
import { NotiUser } from '@app/notifications/components/user';
import { useStorage } from '@libs/storage/provider';
@@ -10,29 +10,24 @@ import { formatCreatedAt } from '@utils/createdAt';
export function NotiRepost({ event }: { event: NDKEvent }) {
const { db } = useStorage();
const root = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<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>
</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
<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>
</div>
</Link>
);
}

View File

@@ -18,7 +18,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
const openThread = (event, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
setWidget(db, { kind: WidgetKinds.thread, title: 'Thread', content: thread });
setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread });
} else {
event.stopPropagation();
}
@@ -48,7 +48,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
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} size="small" />
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="markdown">
<p>
{data.content.length > 200

View File

@@ -18,14 +18,14 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
}
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}
alt={pubkey}
className="h-11 w-11 shrink-0 rounded-lg 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

@@ -1,6 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { NotiMention } from '@app/notifications/components/mention';
import { NotiReaction } from '@app/notifications/components/reaction';
@@ -11,18 +10,19 @@ 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 { status, data } = useQuery(
['notifications', db.account.pubkey],
async () => {
return await fetchActivities();
},
{ refetchOnWindowFocus: false }
);
const [activities, setActivities, clearTotalNewActivities] = useActivities((state) => [
state.activities,
state.setActivities,
state.clearTotalNewActivities,
]);
const renderItem = useCallback(
(event: NDKEvent) => {
@@ -37,34 +37,44 @@ export function NotificationScreen() {
return null;
}
},
[data]
[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 gap-1.5">
<div className="flex h-full flex-col">
{status === 'loading' ? (
<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 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>
) : data?.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>
) : (
data.map((event) => renderItem(event))
)}
</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>

View File

@@ -0,0 +1,152 @@
import { webln } from '@getalby/sdk';
import * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/api/dialog';
import { WebviewWindow } from '@tauri-apps/api/window';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
AlbyIcon,
ArrowRightCircleIcon,
CancelIcon,
CheckCircleIcon,
LoaderIcon,
} from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
export function NWCAlby() {
const { db } = useStorage();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const initAlby = async () => {
try {
setIsloading(true);
const provider = webln.NostrWebLNProvider.withNewSecret();
const walletConnectURL = provider.getNostrWalletConnectUrl(true);
// get auth url
const authURL = provider.getAuthorizationUrl({ name: 'Lume' });
// open auth window
const webview = new WebviewWindow('alby', {
title: 'Connect Alby',
url: authURL.href,
center: true,
width: 400,
height: 650,
});
webview.listen('tauri://close-requested', async () => {
await db.secureSave('walletConnectURL', walletConnectURL, 'nwc');
setWalletConnectURL(walletConnectURL);
setIsConnected(true);
setIsloading(false);
});
} catch (e) {
setIsloading(false);
await message(e.toString(), { title: 'Connect Alby', type: 'error' });
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-orange-200">
<AlbyIcon className="h-8 w-8" />
</div>
<div>
<h5 className="font-semibold leading-tight text-white">Alby</h5>
<p className="text-sm leading-tight text-white/50">Require alby account</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-md border-t border-white/10 bg-white/20 px-3 text-sm font-medium text-white hover:bg-green-500"
>
Connect
</button>
</Dialog.Trigger>
</div>
<Dialog.Portal className="relative z-10">
<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 backdrop-blur-xl">
<div className="h-min w-full shrink-0 rounded-t-xl 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">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Alby integration (Beta)
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">
<div className="relative flex h-40 items-center justify-center gap-4">
<div className="inline-flex h-16 w-16 items-end justify-center rounded-lg bg-black pb-2">
<img src="/lume.png" className="w-1/3" alt="Lume Logo" />
</div>
<div className="w-20 border border-dashed border-white/5" />
<div className="inline-flex h-16 w-16 items-center justify-center rounded-lg bg-white">
<AlbyIcon className="h-8 w-8" />
</div>
{isConnected ? (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<CheckCircleIcon className="h-5 w-5 text-green-500" />
</div>
) : null}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-white/50">
When you click &quot;Connect&quot;, a new window will open and you need
to click the &quot;Connect Wallet&quot; button to grant Lume permission
to integrate with your Alby account.
</p>
<p className="text-sm text-white/50">
All information will be encrypted and stored on the local machine.
</p>
</div>
<button
type="button"
onClick={() => initAlby()}
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"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : isConnected ? (
<>
<span className="w-5" />
<span>Connected</span>
<CheckCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,168 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CancelIcon, LoaderIcon, WorldIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
type FormValues = {
uri: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.uri ? values : {},
errors: !values.uri
? {
uri: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function NWCOther() {
const { db } = useStorage();
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const onSubmit = async (data: { [x: string]: string }) => {
try {
if (!data.uri.startsWith('nostr+walletconnect:')) {
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
return;
}
setIsloading(true);
const uriObj = new URL(data.uri);
const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) {
await db.secureSave('walletConnectURL', data.uri, 'nwc');
setWalletConnectURL(data.uri);
setIsloading(false);
setIsOpen(false);
}
} catch (e) {
setIsloading(false);
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between pt-4">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-white/10">
<WorldIcon className="h-5 w-5" />
</div>
<div>
<h5 className="font-semibold leading-tight text-white">URI String</h5>
<p className="text-sm leading-tight text-white/50">
Using format nostr+walletconnect:
</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-md border-t border-white/10 bg-white/20 px-3 text-sm font-medium text-white hover:bg-green-500"
>
Connect
</button>
</Dialog.Trigger>
</div>
<Dialog.Portal className="relative z-10">
<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 backdrop-blur-xl">
<div className="h-min w-full shrink-0 rounded-t-xl 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">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Nostr Wallet Connect
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex flex-col gap-3 px-5 py-5"
>
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Connect URI
</label>
<input
{...register('uri', { required: true })}
placeholder="nostr+walletconnect:"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<span className="text-sm text-red-400">
{errors.uri && <p>{errors.uri.message}</p>}
</span>
</div>
<div className="flex flex-col gap-1 text-center">
<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 disabled:opacity-50"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<span className="text-sm text-white/50">
All information will be encrypted and stored on the local machine.
</span>
</div>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

124
src/app/nwc/index.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { NWCAlby } from '@app/nwc/components/alby';
import { NWCOther } from '@app/nwc/components/other';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
export function NWCScreen() {
const { db } = useStorage();
const [walletConnectURL, setWalletConnectURL] = useStronghold((state) => [
state.walletConnectURL,
state.setWalletConnectURL,
]);
const remove = async () => {
setWalletConnectURL('');
await db.secureSave('walletConnectURL', '', 'nwc');
};
return (
<div className="flex h-full w-full items-center justify-center bg-white/5">
<div className="flex w-full flex-col gap-5">
<div className="text-center">
<h3 className="text-2xl font-bold leading-tight">
Nostr Wallet Connect (Beta)
</h3>
<p className="leading-tight text-white/70">
Sending tips easily via Bitcoin Lightning.
</p>
</div>
<div className="mx-auto max-w-lg">
{!walletConnectURL ? (
<div className="flex w-full flex-col gap-4 divide-y divide-white/5 rounded-xl border-t border-white/10 bg-white/20 p-3">
<NWCAlby />
<NWCOther />
</div>
) : (
<div className="flex w-full flex-col rounded-xl border-t border-white/10 bg-white/20 p-3">
<div className="mb-1 inline-flex items-center gap-1.5 text-sm text-green-500">
<CheckCircleIcon className="h-4 w-4" />
<p>You&apos;re using nostr wallet connect</p>
</div>
<div className="flex flex-col gap-2">
<textarea
readOnly
value={walletConnectURL.substring(0, 120) + '****'}
className="relative h-40 w-full resize-none rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<button
type="button"
onClick={() => remove()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-red-500 hover:bg-white/20 focus:outline-none disabled:opacity-50"
>
Remove connection
</button>
</div>
</div>
)}
<div className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">Introduction</h5>
<p className="text-sm text-white/70">
Nostr Wallet Connect (NWC) is a way for applications like Nostr clients to
access a remote Lightning wallet through a standardized protocol.
</p>
<p className="text-sm text-white/70">
To learn more about the details have a look at{' '}
<a
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
target="_blank"
className="text-fuchsia-300"
rel="noreferrer"
>
the specs (NIP47)
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">About tipping</h5>
<p className="text-sm text-white/70">
Also known as Zap in other Nostr client.
</p>
<p className="text-sm text-white/70">
Lume doesn&apos;t take any commission or platform fees when you tip
someone.
</p>
<p className="text-sm text-white/70">Lume doesn&apos;t hold your Bitcoin</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">
Recommend wallet that support NWC
</h5>
<p className="text-sm text-white/70">
Mutiny Wallet:{' '}
<a
href="https://www.mutinywallet.com/"
target="_blank"
rel="noreferrer"
className="text-fuchsia-300"
>
website
</a>
</p>
<p className="text-sm text-white/70">
Self hosted NWC on Umbrel :{' '}
<a
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
target="_blank"
rel="noreferrer"
className="text-fuchsia-300"
>
website
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons';
import {
ArticleNote,
FileNote,
NoteWrapper,
Repost,
TextNote,
UnknownNote,
} from '@shared/notes';
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
const { fetcher } = useNDK();
const { status, data } = useQuery(
['relay-event'],
async () => {
const url = 'wss://' + relayUrl;
const events = await fetcher.fetchLatestEvents(
[url],
{
kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article],
},
100
);
return events as unknown as NDKEvent[];
},
{ refetchOnWindowFocus: false }
);
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="h-full">
<div className="mx-auto w-full max-w-[500px]">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
</div>
</div>
) : (
<VList className="scrollbar-hide h-full">
<div className="h-10" />
{data.map((item) => renderItem(item))}
<div className="h-16" />
</VList>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { PlusIcon } from '@shared/icons';
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
export function RelayForm() {
const { db } = useStorage();
const queryClient = useQueryClient();
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const createRelay = async () => {
if (url.length < 1) return setError('Please enter relay url');
try {
const relay = new URL(url.replace(/\s/g, ''));
if (
domainRegex.test(relay.host) &&
(relay.protocol === 'wss:' || relay.protocol === 'ws:')
) {
const res = await db.createRelay(url);
if (!res) return setError("You're already using this relay");
queryClient.invalidateQueries(['user-relay']);
setError('');
setUrl('');
} else {
return setError(
'URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again'
);
}
} catch {
return setError('Relay URL is not valid. Please check again');
}
};
return (
<div className="flex flex-col gap-1">
<div className="flex h-10 items-center justify-between rounded-lg bg-white/10 pr-1.5">
<input
className="h-full w-full bg-transparent pl-3 pr-1.5 placeholder:text-white/70 focus:outline-none"
type="url"
placeholder="wss://"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button
type="button"
onClick={() => createRelay()}
className="inline-flex h-6 w-6 items-center justify-center rounded bg-fuchsia-500 text-white hover:bg-fuchsia-600"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<span className="text-sm text-red-400">{error}</span>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { message } from '@tauri-apps/api/dialog';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useNavigate } from 'react-router-dom';
import { VList } from 'virtua';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon, PlusIcon, ShareIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
export function RelayList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { getAllRelaysByUsers } = useNostr();
const { db } = useStorage();
const { status, data } = useQuery(
['relays'],
async () => {
return await getAllRelaysByUsers();
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
}
);
const inspectRelay = (relayUrl: string) => {
const url = new URL(relayUrl);
navigate(`/relays/${url.hostname}`);
};
const connectRelay = async (relayUrl: string) => {
const url = normalizeRelayUrl(relayUrl);
const res = await db.createRelay(url);
if (!res) await message("You're aldready connected to this relay");
queryClient.invalidateQueries(['user-relay']);
};
return (
<div className="col-span-2 border-r border-white/5">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center pb-10">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p>Loading relay...</p>
</div>
</div>
) : (
<VList className="scrollbar-hide mt-20 h-full">
<div className="inline-flex h-16 w-full items-center border-b border-white/5 px-3">
<h3 className="bg-gradient-to-r from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text font-semibold text-transparent">
All relays used by your follows
</h3>
</div>
{[...data].map(([key, value]) => (
<div
key={key}
className="flex h-14 w-full items-center justify-between border-b border-white/5 px-3"
>
<div className="inline-flex items-center gap-2 divide-x divide-white/10">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => inspectRelay(key)}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-white/10 px-1.5 text-sm font-medium hover:bg-white/20"
>
<ShareIcon className="h-3 w-3" />
Inspect
</button>
<button
type="button"
onClick={() => connectRelay(key)}
className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-white/10"
>
<PlusIcon className="h-3 w-3" />
</button>
</div>
<div className="inline-flex items-center gap-2 pl-3">
<span className="text-sm font-semibold text-white/70">Relay: </span>
<span className="max-w-[200px] truncate text-sm font-medium text-white">
{key}
</span>
</div>
</div>
<div className="isolate flex -space-x-2">
{value.slice(0, 4).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 4 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-black/80 ring-1 ring-white/10 backdrop-blur-xl">
<span className="text-xs font-semibold">+{value.length}</span>
</div>
) : null}
</div>
</div>
))}
<div className="h-16" />
</VList>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon } from '@shared/icons';
export function UserRelay() {
const queryClient = useQueryClient();
const { relayUrls } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery(
['user-relay'],
async () => {
return await db.getExplicitRelayUrls();
},
{ refetchOnWindowFocus: false }
);
const removeRelay = async (relayUrl: string) => {
await db.removeRelay(relayUrl);
queryClient.invalidateQueries(['user-relay']);
};
return (
<div className="mt-3 px-3">
{status === 'loading' ? (
<p>Loading...</p>
) : (
<div className="flex flex-col gap-2">
{data.map((item) => (
<div
key={item}
className="group flex h-10 items-center justify-between rounded-lg bg-white/10 pl-3 pr-1.5"
>
<div className="inline-flex items-center gap-2.5">
{relayUrls.includes(item) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium leading-none">
{item}
</p>
</div>
<button
type="button"
onClick={() => removeRelay(item)}
className="hidden h-6 w-6 items-center justify-center rounded hover:bg-white/10 group-hover:inline-flex"
>
<CancelIcon className="h-4 w-4 text-white" />
</button>
</div>
))}
<RelayForm />
</div>
)}
</div>
);
}

16
src/app/relays/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { RelayList } from '@app/relays/components/relayList';
import { UserRelay } from '@app/relays/components/userRelay';
export function RelaysScreen() {
return (
<div className="grid h-full w-full grid-cols-3">
<RelayList />
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center border-b border-white/5 px-3">
<h3 className="font-semibold text-white">Connected relays</h3>
</div>
<UserRelay />
</div>
</div>
);
}

165
src/app/relays/relay.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { Suspense } from 'react';
import { Await, useLoaderData, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { RelayEventList } from './components/relayEventList';
export function RelayScreen() {
const { url } = useParams();
const data: { relay?: { [key: string]: string } } = useLoaderData();
const navigate = useNavigate();
const getSoftwareName = (url: string) => {
const filename = url.substring(url.lastIndexOf('/') + 1);
return filename.replace('.git', '');
};
const titleCase = (s: string) => {
return s
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
.replace(/[-_]+(.)/g, (_, c) => ' ' + c.toUpperCase());
};
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 border-r border-white/5">
<div className="inline-flex h-16 w-full items-center gap-2.5 border-b border-white/5 px-3">
<button type="button" onClick={() => navigate(-1)}>
<ArrowLeftIcon className="h-5 w-5 text-white/70 hover:text-white" />
</button>
<h3 className="font-semibold text-white">Global events</h3>
</div>
<RelayEventList relayUrl={url} />
</div>
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center border-b border-white/5 px-3">
<h3 className="font-semibold text-white">Information</h3>
</div>
<div className="mt-4 px-3">
<Suspense
fallback={
<div className="flex items-center gap-2 text-sm font-medium text-white">
<LoaderIcon className="h-4 w-4 animate-spin" />
Loading...
</div>
}
>
<Await
resolve={data.relay}
errorElement={
<div className="text-sm font-medium">
<p>Could not load relay information 😬</p>
</div>
}
>
{(resolvedRelay) => (
<div className="flex flex-col gap-5">
<div>
<h3 className="font-semibold leading-tight text-white">
{resolvedRelay.name}
</h3>
<p className="text-sm font-medium text-white/70">
{resolvedRelay.description}
</p>
</div>
{resolvedRelay.pubkey ? (
<div className="flex flex-col gap-1">
<h5 className="text-sm font-semibold text-white/70">Owner:</h5>
<div className="w-full rounded-lg bg-white/10 px-2 py-2">
<User pubkey={resolvedRelay.pubkey} variant="simple" />
</div>
</div>
) : null}
{resolvedRelay.contact ? (
<div>
<h5 className="text-sm font-semibold text-white/70">Contact:</h5>
<a
href={`mailto:${resolvedRelay.contact}`}
target="_blank"
className="underline after:content-['_↗'] hover:text-fuchsia-500"
rel="noreferrer"
>
mailto:{resolvedRelay.contact}
</a>
</div>
) : null}
<div>
<h5 className="text-sm font-semibold text-white/70">Software:</h5>
<a
href={resolvedRelay.software}
target="_blank"
rel="noreferrer"
className="underline after:content-['_↗'] hover:text-fuchsia-500"
>
{getSoftwareName(resolvedRelay.software) +
' - ' +
resolvedRelay.version}
</a>
</div>
<div>
<h5 className="text-sm font-semibold text-white/70">
Supported NIPs:
</h5>
<div className="mt-2 grid grid-cols-7 gap-2">
{resolvedRelay.supported_nips.map((item: string) => (
<a
key={item}
href={`https://nips.be/${item}`}
target="_blank"
rel="noreferrer"
className="inline-flex aspect-square h-full w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
>
{item}
</a>
))}
</div>
</div>
{resolvedRelay.limitation ? (
<div>
<h5 className="text-sm font-semibold text-white/70">Limitation</h5>
<div className="flex flex-col gap-2 divide-y divide-white/5">
{Object.keys(resolvedRelay.limitation).map((key, index) => {
return (
<div
key={index}
className="flex items-baseline justify-between pt-2"
>
<p className="text-sm font-medium text-white">
{titleCase(key)}:
</p>
<p className="text-sm font-medium text-white/70">
{resolvedRelay.limitation[key].toString()}
</p>
</div>
);
})}
</div>
</div>
) : null}
{resolvedRelay.payments_url ? (
<div className="flex flex-col gap-1">
<a
href={resolvedRelay.payments_url}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-fuchsia-500 text-sm font-medium hover:bg-fuchsia-600"
>
Open payment website
</a>
<span className="text-center text-xs text-white/70">
You need to make a payment to connect this relay
</span>
</div>
) : null}
</div>
)}
</Await>
</Suspense>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { getVersion } from '@tauri-apps/plugin-app';
import { getVersion } from '@tauri-apps/api/app';
import { useEffect, useState } from 'react';
export function VersionSetting() {

View File

@@ -1,6 +1,6 @@
import { useStorage } from '@libs/storage/provider';
import { PlusIcon } from '@shared/icons';
import { HandArrowDownIcon, PlusIcon } from '@shared/icons';
import { WidgetKinds, useWidgets } from '@stores/widgets';
@@ -9,17 +9,22 @@ export function ToggleWidgetList() {
const setWidget = useWidgets((state) => state.setWidget);
return (
<div className="relative flex h-full shrink-0 grow-0 basis-[400px] items-center justify-center">
<button
type="button"
onClick={() =>
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
}
className="inline-flex items-center gap-2 text-white"
>
<PlusIcon className="h-4 w-4 text-white" />
<p className="text-sm font-bold leading-none">Add widget</p>
</button>
<div className="flex h-full shrink-0 grow-0 basis-[400px] items-center justify-center">
<div className="relative">
<div className="absolute -top-44 left-1/2 -translate-x-1/2 transform">
<HandArrowDownIcon className="text-white/5" />
</div>
<button
type="button"
onClick={() =>
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
}
className="inline-flex h-9 items-center gap-2 rounded-lg border-t border-white/10 bg-white/20 px-3 text-white hover:bg-white/30"
>
<PlusIcon className="h-4 w-4 text-white" />
<p className="text-sm font-semibold leading-none">Add widget</p>
</button>
</div>
</div>
);
}

View File

@@ -2,9 +2,18 @@ import { useCallback } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
ArticleIcon,
FileIcon,
FollowsIcon,
GroupFeedsIcon,
HashtagIcon,
ThreadsIcon,
TrendingIcon,
} from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { DefaultWidgets, useWidgets } from '@stores/widgets';
import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets';
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
@@ -20,16 +29,63 @@ export function WidgetList({ params }: { params: Widget }) {
removeWidget(db, params.id);
};
const renderIcon = useCallback(
(kind: number) => {
switch (kind) {
case WidgetKinds.tmp.xfeed:
return <GroupFeedsIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.follows:
return <FollowsIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.files:
case WidgetKinds.global.files:
return <FileIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.articles:
case WidgetKinds.global.articles:
return <ArticleIcon className="h-5 w-5 text-white" />;
case WidgetKinds.tmp.xhashtag:
return <HashtagIcon className="h-5 w-4 text-white" />;
case WidgetKinds.nostrBand.trendingAccounts:
case WidgetKinds.nostrBand.trendingNotes:
return <TrendingIcon className="h-5 w-4 text-white" />;
case WidgetKinds.other.learnNostr:
return <ThreadsIcon className="h-5 w-4 text-white" />;
default:
return null;
}
},
[DefaultWidgets]
);
const renderItem = useCallback(
(row: WidgetGroup) => {
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-white/50">{row.title}</h3>
<div className="grid grid-cols-3 gap-6">
<div className="flex flex-col divide-y divide-white/5 overflow-hidden rounded-xl bg-white/10">
{row.data.map((item, index) => (
<button onClick={() => openWidget(item)} key={index}>
<div className="inline-flex aspect-square h-full w-full transform-gpu flex-col items-center justify-center gap-2.5 rounded-2xl bg-white/5 hover:bg-white/10">
<h5 className="text-sm font-medium">{item.title}</h5>
<button
onClick={() => openWidget(item)}
key={index}
className="flex items-center gap-2.5 px-4 hover:bg-white/10"
>
{item.icon ? (
<div className="h-10 w-10 shrink-0 rounded-md">
<img
src={item.icon}
alt={item.title}
className="h-10 w-10 object-cover"
/>
</div>
) : (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10">
{renderIcon(item.kind)}
</div>
)}
<div className="inline-flex h-16 w-full flex-col items-start justify-center gap-1">
<h5 className="line-clamp-1 font-medium leading-none">{item.title}</h5>
<p className="line-clamp-1 text-xs leading-none text-white/50">
{item.description}
</p>
</div>
</button>
))}
@@ -41,10 +97,26 @@ export function WidgetList({ params }: { params: Widget }) {
);
return (
<div className="relative h-full shrink-0 grow-0 basis-[400px] overflow-hidden">
<div className="relative h-full shrink-0 grow-0 basis-[400px] bg-white/10">
<TitleBar id={params.id} title="Add widget" />
<div className="flex flex-col gap-8 px-3">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
<div className="scrollbar-hide h-full overflow-y-auto pb-20">
<div className="flex flex-col gap-6 px-3">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
<div className="border-t border-white/5 pt-6">
<button
type="button"
disabled
className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-white/5 text-sm font-medium text-white/50"
>
Build your own widget{' '}
<div className="-rotate-3 transform rounded-md border border-white/20 bg-white/10 px-1.5 py-1">
<span className="bg-gradient-to-t from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text text-xs text-transparent">
Coming soon
</span>
</div>
</button>
</div>
</div>
</div>
</div>
);

View File

@@ -10,9 +10,11 @@ import {
GlobalArticlesWidget,
GlobalFilesWidget,
GlobalHashtagWidget,
LearnNostrWidget,
LocalArticlesWidget,
LocalFeedsWidget,
LocalFilesWidget,
LocalFollowsWidget,
LocalNetworkWidget,
LocalThreadWidget,
LocalUserWidget,
@@ -40,6 +42,8 @@ export function SpaceScreen() {
switch (widget.kind) {
case WidgetKinds.local.network:
return <LocalNetworkWidget key={widget.id} />;
case WidgetKinds.local.follows:
return <LocalFollowsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.feeds:
return <LocalFeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.files:
@@ -61,13 +65,15 @@ export function SpaceScreen() {
case WidgetKinds.nostrBand.trendingNotes:
return <TrendingNotesWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.xfeed:
return <XhashtagWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.xhashtag:
return <XfeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.xhashtag:
return <XhashtagWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.list:
return <WidgetList key={widget.id} params={widget} />;
case WidgetKinds.other.learnNostr:
return <LearnNostrWidget key={widget.id} params={widget} />;
default:
break;
return null;
}
},
[widgets]

View File

@@ -1,5 +1,6 @@
import { message } from '@tauri-apps/api/dialog';
import { invoke } from '@tauri-apps/api/tauri';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
@@ -10,83 +11,53 @@ import { useNostr } from '@utils/hooks/useNostr';
export function SplashScreen() {
const { db } = useStorage();
const { ndk, relayUrls } = useNDK();
const { fetchUserData, prefetchEvents } = useNostr();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const skip = async () => {
await invoke('close_splashscreen');
};
const prefetch = async () => {
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) await invoke('close_splashscreen');
try {
const user = await fetchUserData();
const data = await prefetchEvents();
if (user.status === 'ok' && data.status === 'ok') {
await db.updateLastLogin();
await invoke('close_splashscreen');
} else {
setIsLoading(false);
setErrorMessage(user.message);
console.log('fetch failed, error: ', user.message);
}
} catch (e) {
setIsLoading(false);
setErrorMessage(e);
console.log('prefetch failed, error: ', e);
}
};
const { ndk } = useNDK();
const { fetchUserData } = useNostr();
useEffect(() => {
if (!db.account) {
invoke('close_splashscreen');
async function syncUserData() {
if (!db.account) {
await invoke('close_splashscreen');
} else {
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) {
await invoke('close_splashscreen');
} else {
try {
const userData = await fetchUserData();
if (userData.status === 'ok') {
// update last login = current time
await db.updateLastLogin();
// close splash screen and open main app screen
await invoke('close_splashscreen');
}
} catch (e) {
await message(e, {
title: 'An unexpected error has occurred',
type: 'error',
});
await invoke('close_splashscreen');
}
}
}
}
if (ndk && db.account) {
console.log('prefetching...');
prefetch();
if (ndk) {
syncUserData();
}
}, [ndk, db.account]);
return (
<div className="relative flex h-screen w-screen items-center justify-center bg-black">
<div data-tauri-drag-region className="absolute left-0 top-0 z-10 h-11 w-full" />
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-4">
<div className="flex min-h-0 w-full flex-1 items-center justify-center px-8">
<div className="flex flex-col items-center justify-center gap-6">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
{isLoading ? (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white">
{!ndk
? 'Connecting to relay...'
: `Connected to ${relayUrls.length} relays`}
</h3>
<p className="text-sm text-white/50">
This may take a few seconds, please don&apos;t close app.
</p>
</div>
) : (
<div className="mt-2 flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white">
Something wrong!
</h3>
<p className="text-sm text-white/50">{errorMessage}</p>
<button
type="button"
onClick={skip}
className="mx-auto mt-4 inline-flex h-10 w-max items-center justify-center rounded-md bg-white/10 px-8 text-sm font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20"
>
Skip
</button>
</div>
)}
<h3 className="text-lg font-semibold leading-none text-white">
{!ndk ? 'Connecting...' : 'Syncing...'}
</h3>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/api/http';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
@@ -13,6 +14,12 @@ import { Image } from '@shared/image';
import { useNostr } from '@utils/hooks/useNostr';
interface NIP05 {
names: {
[key: string]: string;
};
}
export function EditProfileModal() {
const queryClient = useQueryClient();
@@ -47,19 +54,26 @@ export function EditProfileModal() {
});
const verifyNIP05 = async (nip05: string) => {
const url = nip05.split('@');
const username = url[0];
const service = url[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`;
const localPath = nip05.split('@')[0];
const service = nip05.split('@')[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: 'GET',
timeout: 10,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
console.log(res);
return true;
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data = res.data as NIP05;
if (data.names) {
if (data.names[localPath] !== db.account.pubkey) return false;
return true;
}
return false;
};
const onSubmit = async (data: NDKUserProfile) => {
@@ -131,7 +145,7 @@ export function EditProfileModal() {
</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 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -275,7 +289,7 @@ export function EditProfileModal() {
<button
type="submit"
disabled={!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"
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg 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" />

View File

@@ -7,10 +7,11 @@ import { UserStats } from '@app/users/components/stats';
import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { displayNpub } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) {
const { db } = useStorage();
@@ -57,7 +58,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-black/50" />
<div className="h-full w-full bg-black" />
)}
</div>
<div className="-mt-7 flex w-full flex-col items-center px-5">
@@ -68,13 +69,21 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
/>
<div className="mt-2 flex flex-1 flex-col gap-6">
<div className="flex flex-col items-center gap-1">
<div className="inline-flex flex-col gap-1.5">
<div className="inline-flex flex-col items-center gap-1.5">
<h5 className="text-center text-xl font-semibold leading-none">
{user.display_name || user.displayName || user.name || 'No name'}
{user.name || user.display_name || user.displayName || 'No name'}
</h5>
<span className="max-w-[15rem] truncate text-center leading-none text-white/50">
{user.nip05 || user.username || shortenKey(pubkey)}
</span>
{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>
)}
</div>
<div className="flex flex-col gap-6">
{user.about || user.bio ? (
@@ -82,7 +91,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
{user.about || user.bio}
</p>
) : (
<></>
<div />
)}
<UserStats pubkey={pubkey} />
</div>

View File

@@ -1,8 +1,8 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useRef } from 'react';
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { WVList } from 'virtua';
import { UserProfile } from '@app/users/components/profile';
@@ -32,79 +32,35 @@ export function UserScreen() {
return [...events] as unknown as NDKEvent[];
});
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 650,
overscan: 4,
});
const items = virtualizer.getVirtualItems();
// render event match event kind
const renderItem = useCallback(
(index: string | number) => {
const event: NDKEvent = data[index];
if (!event) return;
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteWrapper event={event}>
<TextNote event={event} />
</NoteWrapper>
</div>
<NoteWrapper key={event.id} event={event}>
<TextNote />
</NoteWrapper>
);
case NDKKind.Repost:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={event.id} event={event} />
</div>
);
return <Repost key={event.id} event={event} />;
case 1063:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteWrapper event={event}>
<FileNote event={event} />
</NoteWrapper>
</div>
<NoteWrapper key={event.id} event={event}>
<FileNote />
</NoteWrapper>
);
case NDKKind.Article:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteWrapper event={event}>
<ArticleNote event={event} />
</NoteWrapper>
</div>
<NoteWrapper key={event.id} event={event}>
<ArticleNote />
</NoteWrapper>
);
default:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteWrapper event={event}>
<UnknownNote event={event} />
</NoteWrapper>
</div>
<NoteWrapper key={event.id} event={event}>
<UnknownNote />
</NoteWrapper>
);
}
},
@@ -112,10 +68,7 @@ export function UserScreen() {
);
return (
<div
ref={parentRef}
className="scrollbar-hide relative h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl"
>
<div className="scrollbar-hide relative h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl">
<div data-tauri-drag-region className="absolute left-0 top-0 h-11 w-full" />
<UserProfile pubkey={pubkey} />
<div className="mt-6 h-full w-full border-t border-white/5 px-1.5">
@@ -129,7 +82,7 @@ export function UserScreen() {
<NoteSkeleton />
</div>
</div>
) : items.length === 0 ? (
) : data.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6 backdrop-blur-xl">
<div className="flex flex-col items-center gap-4">
@@ -140,22 +93,10 @@ export function UserScreen() {
</div>
</div>
) : (
<div
style={{
position: 'relative',
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${items[0].start}px)`,
}}
>
{items.map((item) => renderItem(item.index))}
</div>
</div>
<WVList>
{data.map((item) => renderItem(item))}
<div className="h-16" />
</WVList>
)}
</div>
</div>

View File

@@ -1,3 +1,8 @@
@import 'reactflow/dist/style.css';
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/audio.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -14,8 +19,46 @@ button {
@apply cursor-default focus:outline-none;
}
iframe {
height: auto !important;
}
span[data-slate-placeholder] {
margin-top: 0;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
line-height: normal;
}
.border {
background-clip: padding-box;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.markdown {
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.markdown-article {
@apply prose prose-white max-w-none select-text hyphens-auto text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.markdown-simple {
@apply prose prose-white max-w-none select-text hyphens-auto text-white/70 prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.ProseMirror p.is-empty::before {
@@ -30,25 +73,32 @@ button {
@apply outline-fuchsia-500;
}
iframe {
height: auto !important;
.player {
--brand-color: #f5f5f5;
--focus-color: #4e9cf6;
--audio-brand: var(--brand-color);
--audio-focus-ring-color: var(--focus-color);
--audio-border-radius: 2px;
--video-brand: var(--brand-color);
--video-focus-ring-color: var(--focus-color);
--video-border-radius: 8px;
/* 👉 https://vidstack.io/docs/player/components/layouts/default#css-variables for more. */
width: 100%;
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar {
display: none;
.player[data-view-type='video'] {
aspect-ratio: 16 /9;
}
/* For IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.border {
background-clip: padding-box;
}
span[data-slate-placeholder] {
@apply top-0;
.src-buttons {
display: flex;
align-items: center;
justify-content: space-evenly;
margin-top: 40px;
margin-inline: auto;
max-width: 300px;
}

View File

@@ -1,6 +1,6 @@
import { NDKCacheAdapter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { Store } from '@tauri-apps/plugin-store';
import { Store } from 'tauri-plugin-store-api';
export default class TauriAdapter implements NDKCacheAdapter {
public store: Store;

View File

@@ -1,85 +1,74 @@
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { message } from '@tauri-apps/api/dialog';
import { fetch } from '@tauri-apps/api/http';
import { NostrFetcher } from 'nostr-fetch';
import { useEffect, useMemo, useState } from 'react';
import TauriAdapter from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider';
import { FULL_RELAYS } from '@stores/constants';
export const NDKInstance = () => {
const { db } = useStorage();
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
const { db } = useStorage();
const fetcher = useMemo(
() => (ndk ? NostrFetcher.withCustomPool(ndkAdapter(ndk)) : null),
[ndk]
);
// TODO: fully support NIP-11
async function verifyRelays(relays: string[]) {
async function getExplicitRelays() {
try {
const urls: string[] = relays.map((relay) => {
if (relay.startsWith('ws')) {
return relay.replace('ws', 'http');
// get relays
const relays = await db.getExplicitRelayUrls();
const onlineRelays = new Set(relays);
for (const relay of relays) {
const url = new URL(relay);
try {
const res = await fetch(`https://${url.hostname}`, {
method: 'GET',
timeout: { secs: 5, nanos: 0 },
headers: {
Accept: 'application/nostr+json',
},
});
if (!res.ok) {
console.info(`${relay} is not working, skipping...`);
onlineRelays.delete(relay);
}
} catch {
console.warn(`${relay} is not working, skipping...`);
onlineRelays.delete(relay);
}
if (relay.startsWith('wss')) {
return relay.replace('wss', 'https');
}
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 5000);
const requests = urls.map((url) =>
fetch(url, {
headers: { Accept: 'application/nostr+json' },
signal: controller.signal,
})
);
const responses = await Promise.all(requests);
const errors = responses.filter((response) => !response.ok);
if (errors.length > 0) {
throw errors.map((response) => Error(response.statusText));
}
const verifiedRelays: string[] = responses.map((res) => {
if (res.url.startsWith('http')) {
return res.url.replace('htto', 'ws');
}
if (res.url.startsWith('https')) {
return res.url.replace('https', 'wss');
}
});
// clear timeout
clearTimeout(timeoutId);
// return all validate relays
return verifiedRelays;
// return all online relays
return [...onlineRelays];
} catch (e) {
console.error('ndk instance error: ', e);
console.error(e);
}
}
async function initNDK() {
let explicitRelayUrls: string[];
const explicitRelayUrlsFromDB = await db.getExplicitRelayUrls();
console.log('relays in db: ', explicitRelayUrlsFromDB);
if (explicitRelayUrlsFromDB) {
explicitRelayUrls = await verifyRelays(explicitRelayUrlsFromDB);
} else {
explicitRelayUrls = await verifyRelays(FULL_RELAYS);
}
console.log('ndk cache adapter: ', cacheAdapter);
const instance = new NDK({ explicitRelayUrls, cacheAdapter });
const explicitRelayUrls = await getExplicitRelays();
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'lume_ndkcache' });
const instance = new NDK({
explicitRelayUrls,
// @ts-expect-error, wtf?
cacheAdapter: dexieAdapter,
});
try {
await instance.connect(10000);
} catch (error) {
throw new Error('NDK instance init failed: ', error);
await message(`NDK instance init failed: ${error}`, {
title: 'Lume',
type: 'error',
});
}
setNDK(instance);
@@ -88,14 +77,11 @@ export const NDKInstance = () => {
useEffect(() => {
if (!ndk) initNDK();
return () => {
cacheAdapter.saveCache();
};
}, []);
return {
ndk,
relayUrls,
fetcher,
};
};

View File

@@ -1,27 +1,31 @@
// source: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { NostrFetcher } from 'nostr-fetch';
import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext {
ndk: NDK;
ndk: undefined | NDK;
relayUrls: string[];
fetcher: NostrFetcher;
}
const NDKContext = createContext<NDKContext>({
ndk: new NDK({}),
ndk: undefined,
relayUrls: [],
fetcher: undefined,
});
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
const { ndk, relayUrls } = NDKInstance();
const { ndk, relayUrls, fetcher } = NDKInstance();
return (
<NDKContext.Provider
value={{
ndk,
relayUrls,
fetcher,
}}
>
{children}

View File

@@ -1,44 +1,53 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { BaseDirectory, removeFile } from '@tauri-apps/plugin-fs';
import Database from '@tauri-apps/plugin-sql';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { BaseDirectory, removeFile } from '@tauri-apps/api/fs';
import { Platform } from '@tauri-apps/api/os';
import Database from 'tauri-plugin-sql-api';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { FULL_RELAYS } from '@stores/constants';
import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage {
public db: Database;
public secureDB: Stronghold;
public account: Account | null = null;
public account: Account | null;
public platform: Platform | null;
constructor(sqlite: Database, stronghold?: Stronghold) {
constructor(sqlite: Database, platform?: Platform, stronghold?: Stronghold) {
this.db = sqlite;
this.secureDB = stronghold ?? undefined;
this.account = null;
this.platform = platform ?? undefined;
}
private async getSecureClient() {
private async getSecureClient(key?: string) {
try {
return await this.secureDB.loadClient('lume');
return await this.secureDB.loadClient(key ?? 'lume');
} catch {
return await this.secureDB.createClient('lume');
return await this.secureDB.createClient(key ?? 'lume');
}
}
public async secureSave(key: string, value: string) {
public async secureSave(key: string, value: string, clientKey?: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const client = await this.getSecureClient(clientKey);
const store = client.getStore();
await store.insert(key, Array.from(new TextEncoder().encode(value)));
return await this.secureDB.save();
await this.secureDB.save();
}
public async secureLoad(key: string) {
public async secureLoad(key: string, clientKey?: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const client = await this.getSecureClient(clientKey);
const store = client.getStore();
const value = await store.get(key);
if (!value) return null;
const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded;
}
@@ -47,6 +56,13 @@ export class LumeStorage {
return await removeFile('lume.stronghold', { dir: BaseDirectory.AppConfig });
}
public async checkAccount() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM accounts;'
);
return parseInt(result[0].total);
}
public async getActiveAccount() {
const results: Array<Account> = await this.db.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
@@ -73,16 +89,24 @@ export class LumeStorage {
}
public async createAccount(npub: string, pubkey: string) {
const res = await this.db.execute(
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, is_active) VALUES ($1, $2, $3, $4);',
[npub, pubkey, 'privkey is stored in secure storage', 1]
const existAccounts: Array<Account> = await this.db.select(
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
[pubkey]
);
if (res) {
const account = await this.getActiveAccount();
return account;
if (existAccounts.length > 0) {
await this.db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
pubkey,
]);
} else {
console.error('create account failed');
await this.db.execute(
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, is_active) VALUES ($1, $2, $3, $4);',
[npub, pubkey, 'privkey is stored in secure storage', 1]
);
}
const account = await this.getActiveAccount();
return account;
}
public async updateAccount(column: string, value: string | string[]) {
@@ -99,6 +123,7 @@ export class LumeStorage {
public async updateLastLogin() {
const now = Math.floor(Date.now() / 1000);
this.account.last_login_at = now;
return await this.db.execute(
'UPDATE accounts SET last_login_at = $1 WHERE id = $2;',
[now, this.account.id]
@@ -134,18 +159,29 @@ export class LumeStorage {
return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
}
public async createEvent(
id: string,
event: string,
author: string,
kind: number,
root_id: string,
reply_id: string,
created_at: number
) {
public async createEvent(event: NDKEvent) {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
return await this.db.execute(
'INSERT OR IGNORE INTO events (id, account_id, event, author, kind, root_id, reply_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);',
[id, this.account.id, event, author, kind, root_id, reply_id, created_at]
[
event.id,
this.account.id,
JSON.stringify(event),
event.pubkey,
event.kind,
root,
reply,
event.created_at,
]
);
}
@@ -161,7 +197,8 @@ export class LumeStorage {
public async countTotalEvents() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM events;'
'SELECT COUNT(*) AS "total" FROM events WHERE account_id = $1;',
[this.account.id]
);
return parseInt(result[0].total);
}
@@ -176,8 +213,8 @@ export class LumeStorage {
};
const query: DBEvent[] = await this.db.select(
'SELECT * FROM events GROUP BY root_id ORDER BY created_at DESC LIMIT $1 OFFSET $2;',
[limit, offset]
'SELECT * FROM events WHERE account_id = $1 GROUP BY root_id ORDER BY created_at DESC LIMIT $2 OFFSET $3;',
[this.account.id, limit, offset]
);
if (query && query.length > 0) {
@@ -234,8 +271,8 @@ export class LumeStorage {
};
const query: DBEvent[] = await this.db.select(
`SELECT * FROM events WHERE kinds IN (${authorsArr}) ORDER BY created_at DESC LIMIT $1 OFFSET $2;`,
[limit, offset]
`SELECT * FROM events WHERE kinds IN (${authorsArr}) AND account_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3;`,
[this.account.id, limit, offset]
);
if (query && query.length > 0) {
@@ -254,24 +291,32 @@ export class LumeStorage {
public async isEventsEmpty() {
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events ORDER BY id DESC LIMIT 1;'
'SELECT * FROM events WHERE account_id = $1 ORDER BY id DESC LIMIT 1;',
[this.account.id]
);
return results.length < 1;
}
public async getExplicitRelayUrls() {
if (!this.account) return null;
if (!this.account) return FULL_RELAYS;
const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
);
if (result.length < 1) return null;
if (!result || result.length < 1) return FULL_RELAYS;
return result.map((el) => el.relay);
}
public async createRelay(relay: string, purpose?: string) {
const existRelays: Relays[] = await this.db.select(
'SELECT * FROM relays WHERE relay = $1 AND account_id = $2 ORDER BY id DESC LIMIT 1;',
[relay, this.account.id]
);
if (existRelays.length > 0) return false;
return await this.db.execute(
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES ($1, $2, $3);',
[this.account.id, relay, purpose || '']
@@ -288,6 +333,16 @@ export class LumeStorage {
);
}
public async accountLogout() {
// update current account status
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
this.account = null;
return true;
}
public async close() {
return this.db.close();
}

View File

@@ -1,5 +1,7 @@
import Database from '@tauri-apps/plugin-sql';
import { message } from '@tauri-apps/api/dialog';
import { platform } from '@tauri-apps/api/os';
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
import Database from 'tauri-plugin-sql-api';
import { LumeStorage } from '@libs/storage/instance';
@@ -14,13 +16,21 @@ const StorageContext = createContext<StorageContext>({
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const [db, setDB] = useState<LumeStorage>(undefined);
async function initLumeStorage() {
const sqlite = await Database.load('sqlite:lume.db');
const lumeStorage = new LumeStorage(sqlite);
const initLumeStorage = async () => {
try {
const sqlite = await Database.load('sqlite:lume.db');
const platformName = await platform();
const lumeStorage = new LumeStorage(sqlite, platformName);
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
setDB(lumeStorage);
}
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
setDB(lumeStorage);
} catch (e) {
await message(`Cannot initialize database: ${e}`, {
title: 'Lume',
type: 'error',
});
}
};
useEffect(() => {
if (!db) initLumeStorage();

View File

@@ -1,10 +1,14 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AccountMoreActions } from '@shared/accounts/more';
import { SettingsIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { Logout } from '@shared/logout';
import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
@@ -16,65 +20,71 @@ export function ActiveAccount() {
const { status, user } = useProfile(db.account.pubkey);
const { sub } = useNostr();
const addActivity = useActivities((state) => state.addActivity);
useEffect(() => {
const filter: NDKFilter = {
'#p': [db.account.pubkey],
kinds: [
NDKKind.Text,
NDKKind.Contacts,
NDKKind.Repost,
NDKKind.Reaction,
NDKKind.Zap,
],
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
since: Math.floor(Date.now() / 1000),
};
sub(filter, async (event) => {
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification('Mention');
case NDKKind.Contacts:
return await sendNativeNotification("You've new follower");
case NDKKind.Repost:
return await sendNativeNotification('Repost');
case NDKKind.Reaction:
return await sendNativeNotification('Reaction');
case NDKKind.Zap:
return await sendNativeNotification('Zap');
default:
console.log('[notify] new event: ', event);
break;
}
});
sub(
filter,
async (event) => {
addActivity(event);
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification('Mention');
case NDKKind.Repost:
return await sendNativeNotification('Repost');
case NDKKind.Reaction:
return await sendNativeNotification('Reaction');
case NDKKind.Zap:
return await sendNativeNotification('Zap');
default:
break;
}
},
false
);
}, []);
if (status === 'loading') {
return (
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="inline-flex h-16 items-center gap-2.5 border-l-2 border-transparent pb-2 pl-4 pr-2">
<div className="relative h-10 w-10 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 (
<div className="flex h-16 items-center justify-between border-l-2 border-transparent pb-2 pl-4 pr-2">
<div className="flex items-center gap-2.5">
<div className="flex h-16 items-center justify-between border-l-2 border-transparent pb-2 pl-4 pr-3">
<Link to={`/users/${db.account.pubkey}`} className="flex items-center gap-1.5">
<Image
src={user?.picture || user?.image}
alt={db.account.npub}
className="h-10 w-10 shrink-0 rounded-lg object-cover"
className="h-9 w-9 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col items-start gap-1.5">
<p className="max-w-[10rem] truncate font-bold leading-none text-white">
{user?.name || user?.display_name || user?.nip05}
<div className="flex w-full flex-1 flex-col items-start gap-0.5">
<p className="max-w-[10rem] truncate font-semibold leading-none text-white">
{user?.name || user?.display_name || user?.displayName}
</p>
<span className="max-w-[8rem] truncate text-sm leading-none text-white/50">
{displayNpub(db.account.pubkey, 16)}
<span className="max-w-[7rem] truncate text-sm leading-none text-white/50">
{user?.nip05 || displayNpub(db.account.pubkey, 12)}
</span>
</div>
</Link>
<div className="inline-flex divide-x divide-white/5 rounded-lg border-t border-white/10 bg-white/20">
<Link
to="/settings/"
className="inline-flex h-9 w-9 items-center justify-center hover:bg-white/10"
>
<SettingsIcon className="h-4 w-4 text-white" />
</Link>
<Logout />
</div>
<AccountMoreActions pubkey={db.account.pubkey} />
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { HorizontalDotsIcon } from '@shared/icons';
import { Logout } from '@shared/logout';
export function AccountMoreActions({ pubkey }: { pubkey: string }) {
const [open, setOpen] = useState(false);
@@ -18,7 +19,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-white/10 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/20 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
@@ -44,9 +45,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-white/10">
Logout
</button>
<Logout />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>

View File

@@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {

View File

@@ -2,23 +2,21 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function BannerUploader({
setBanner,
}: {
setBanner: Dispatch<
SetStateAction<{ url: undefined | string; error?: undefined | string }>
>;
setBanner: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadBanner = async () => {
setLoading(true);
const image = await upload(null);
if (image.url) {
setBanner(image);
setBanner(image.url);
}
setLoading(false);
};
@@ -27,13 +25,14 @@ export function BannerUploader({
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/40 hover:bg-black/50"
className="inline-flex h-full w-full flex-col items-center justify-center gap-1 bg-black/40 hover:bg-black/50"
>
{loading ? (
<LoaderIcon className="h-8 w-8 animate-spin text-white" />
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
) : (
<PlusIcon className="h-8 w-8 text-white" />
<PlusIcon className="h-6 w-6 text-white" />
)}
<p className="text-sm font-medium text-white/70">Add a banner image</p>
</button>
);
}

View File

@@ -1,32 +1,28 @@
import { message } from '@tauri-apps/api/dialog';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { Suggestion } from '@shared/composer';
import { CancelIcon, LoaderIcon, PlusCircleIcon } from '@shared/icons';
import { MediaUploader, MentionPopup } from '@shared/composer';
import { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes';
import { useComposer } from '@stores/composer';
import { useNostr } from '@utils/hooks/useNostr';
import { useImageUploader } from '@utils/hooks/useUploader';
import { sendNativeNotification } from '@utils/notification';
export function Composer() {
const { publish } = useNostr();
const [status, setStatus] = useState<null | 'loading' | 'done'>(null);
const [loading, setLoading] = useState<boolean>(false);
const [reply, clearReply] = useComposer((state) => [state.reply, state.clearReply]);
const expand = useComposer((state) => state.expand)
const upload = useImageUploader();
const { publish } = useNostr();
const expand = useComposer((state) => state.expand);
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -35,12 +31,6 @@ export function Composer() {
},
}),
Placeholder.configure({ placeholder: 'Type something...' }),
Mention.configure({
suggestion: Suggestion,
renderLabel({ node }) {
return `nostr:${nip19.npubEncode(node.attrs.id.pubkey)} `;
},
}),
Image.configure({
HTMLAttributes: {
class:
@@ -48,26 +38,22 @@ export function Composer() {
},
}),
],
content: '',
content: JSON.parse(localStorage.getItem('editor-content') || '{}'),
editorProps: {
attributes: {
class: 'h-full markdown break-all overflow-y-auto outline-none pr-2',
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem('editor-content', jsonContent);
},
});
const uploadImage = async (file?: string) => {
const image = await upload(file, true);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
}
};
const submit = async () => {
setStatus('loading');
try {
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
@@ -108,18 +94,19 @@ export function Composer() {
await publish({ content: serializedContent, kind: 1, tags });
// send native notifiation
await sendNativeNotification('Publish post successfully');
await sendNativeNotification('Post has been published successfully.');
// update state
setStatus('done');
setLoading(false);
// reset editor
editor.commands.clearContent();
// reset reply
if (reply.id) {
clearReply();
}
} catch {
setStatus(null);
console.log('failed to publish');
setLoading(false);
await message('Publishing post failed.', { title: 'Lume', type: 'error' });
}
};
@@ -136,7 +123,10 @@ export function Composer() {
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className={twMerge('scrollbar-hide markdown break-all max-h-[500px] overflow-y-auto outline-none pr-2', expand ? 'min-h-[500px]' : 'min-h-[120px]')}
className={twMerge(
'scrollbar-hide markdown max-h-[500px] overflow-y-auto break-all pr-2 outline-none',
expand ? 'min-h-[500px]' : reply.id ? 'min-h-min' : 'min-h-[120px]'
)}
/>
{reply.id && (
<div className="relative">
@@ -152,17 +142,18 @@ export function Composer() {
)}
</div>
</div>
<div className="flex items-center justify-between bg-white/5 rounded-b-xl p-2 border-t border-white/10">
<div className="flex items-center justify-between rounded-b-xl border-t border-white/10 bg-white/5 p-2">
<div className="inline-flex items-center gap-1">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<button
type="button"
onClick={() => uploadImage()}
className="ml-2 inline-flex h-10 w-10 items-center justify-center rounded-lg backdrop-blur-xl hover:bg-white/10"
onClick={() => submit()}
disabled={editor && editor.isEmpty}
className="inline-flex h-10 w-20 items-center justify-center rounded-lg bg-fuchsia-500 px-2 font-semibold hover:bg-fuchsia-600 disabled:opacity-50"
>
<PlusCircleIcon className="h-5 w-5 text-white" />
</button>
<button onClick={() => submit()} className="inline-flex items-center justify-center w-max px-8 rounded-lg font-bold h-10 bg-fuchsia-500 hover:bg-fuchsia-600">
{status === 'loading' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
) : (
'Post'
)}

View File

@@ -1,6 +1,8 @@
export * from './user';
export * from './modal';
export * from './composer';
export * from './mention/list';
export * from './mention/item';
export * from './mention/popup';
export * from './mention/suggestion';
export * from './mention/inlineList';
export * from './mediaUploader';

View File

@@ -0,0 +1,62 @@
import { message } from '@tauri-apps/api/dialog';
import { UnlistenFn, listen } from '@tauri-apps/api/event';
import { Editor } from '@tiptap/react';
import { useEffect, useState } from 'react';
import { MediaIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ editor }: { editor: Editor }) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async (file?: string) => {
try {
// start loading
setLoading(true);
const image = await upload(file, true);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
useEffect(() => {
let unlisten: UnlistenFn;
async function listenDnD() {
unlisten = await listen('tauri://file-drop', (event) => {
uploadToNostrBuild(event.payload[0]);
});
}
// start listen drag and drop event
listenDnD();
// clean up
return () => {
unlisten();
};
}, []);
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="ml-2 inline-flex h-10 w-max items-center justify-center gap-1.5 rounded-lg px-2 text-sm font-medium text-white/80 hover:bg-white/10 hover:backdrop-blur-xl"
>
<MediaIcon className="h-5 w-5 text-white/80" />
{loading ? 'Uploading...' : 'Add media'}
</button>
);
}

View File

@@ -0,0 +1,83 @@
import { type SuggestionProps } from '@tiptap/suggestion';
import {
ForwardedRef,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { MentionItem } from '@shared/composer';
export const MentionInlineList = forwardRef(
(props: SuggestionProps, ref: ForwardedRef<unknown>) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[250px] flex-col rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
{props.items.length ? (
props.items.map((item: string, index: number) => (
<button
className={twMerge(
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-white/10',
`${index === selectedIndex ? 'is-selected' : ''}`
)}
key={index}
onClick={() => selectItem(index)}
>
<MentionItem embed={item} />
</button>
))
) : (
<div>No result</div>
)}
</div>
);
}
);
MentionInlineList.displayName = 'MentionList';

View File

@@ -1,22 +1,36 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { Profile } from '@utils/types';
export function MentionItem({ profile }: { profile: Profile }) {
export function MentionItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
const { status, user } = useProfile(pubkey, embed);
if (status === 'loading') {
return (
<div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<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 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-white/10">
<Image
src={profile.picture || profile.image}
alt={profile.pubkey}
src={user.picture || user.image}
alt={pubkey}
className="shirnk-0 h-8 w-8 rounded-md object-cover"
/>
<div className="flex flex-col gap-px">
<h5 className="max-w-[15rem] text-sm font-medium leading-none text-white">
{profile.ident}
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-white">
{user.display_name || user.displayName || user.name}
</h5>
<span className="text-sm leading-none text-white/50">
{displayNpub(profile.pubkey, 16)}
{displayNpub(pubkey, 16)}
</span>
</div>
</div>

View File

@@ -1,75 +0,0 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { MentionItem } from '@shared/composer';
export const MentionList = forwardRef((props: any, ref: any) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[250px] flex-col rounded-xl border-t border-zinc-700/50 bg-zinc-800 px-3 py-3">
{props.items.length ? (
props.items.map((item: NDKUserProfile, index: number) => (
<button
className={twMerge(
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-zinc-700',
`${index === selectedIndex ? 'is-selected' : ''}`
)}
key={index}
onClick={() => selectItem(index)}
>
<MentionItem profile={item} />
</button>
))
) : (
<div>No result</div>
)}
</div>
);
});
MentionList.displayName = 'MentionList';

View File

@@ -0,0 +1,38 @@
import * as Popover from '@radix-ui/react-popover';
import { Editor } from '@tiptap/react';
import { nip19 } from 'nostr-tools';
import { useStorage } from '@libs/storage/provider';
import { MentionItem } from '@shared/composer';
import { MentionIcon } from '@shared/icons';
export function MentionPopup({ editor }: { editor: Editor }) {
const { db } = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg hover:bg-white/10 hover:backdrop-blur-xl"
>
<MentionIcon className="h-5 w-5 text-white/80" />
</button>
</Popover.Trigger>
<Popover.Content className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg bg-white/10 backdrop-blur-xl focus:outline-none">
<div className="flex flex-col gap-1 py-1">
{db.account.follows.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}>
<MentionItem pubkey={item} />
</button>
))}
</div>
</Popover.Content>
</Popover.Root>
);
}

View File

@@ -1,7 +1,7 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { MentionList } from '@shared/composer';
import { MentionInlineList } from '@shared/composer';
export const Suggestion = {
items: async ({ query }) => {
@@ -17,7 +17,7 @@ export const Suggestion = {
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
component = new ReactRenderer(MentionInlineList, {
props,
editor: props.editor,
});

View File

@@ -28,16 +28,14 @@ export function ComposerModal() {
<Dialog.Trigger asChild>
<button
type="button"
className="flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-2 text-white/80"
className="flex h-9 items-center gap-2 rounded-full border-t border-white/10 bg-white/20 px-4 text-sm font-semibold text-white/80 hover:bg-fuchsia-500 hover:text-white"
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<ComposeIcon className="h-4 w-4 text-white" />
</span>
New postr
New
<ComposeIcon className="h-4 w-4 text-white" />
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Portal>
<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={twMerge(

View File

@@ -1,6 +1,7 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ComposerUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
@@ -13,9 +14,7 @@ export function ComposerUser({ pubkey }: { pubkey: string }) {
className="h-10 w-10 shrink-0 rounded-lg"
/>
<h5 className="text-base font-semibold leading-none text-white">
{user?.nip05 || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
{user?.name || user?.display_name || user?.displayName || displayNpub(pubkey, 16)}
</h5>
</div>
);

28
src/shared/frame.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { HTMLProps, ReactNode, useCallback } from 'react';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
export function Frame({
children,
className,
lighter,
}: {
children: ReactNode;
className: HTMLProps<HTMLElement>['className'];
lighter?: boolean;
}) {
const { db } = useStorage();
const platformStyles = useCallback(() => {
switch (db.platform) {
case 'darwin':
if (lighter) return 'bg-black/80';
return 'bg-black/90';
default:
return 'bg-black';
}
}, []);
return <div className={twMerge(className, platformStyles())}>{children}</div>;
}

74
src/shared/icons/alby.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { SVGProps } from 'react';
export function AlbyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="400"
height="578"
fill="none"
viewBox="0 0 400 578"
{...props}
>
<path
fill="#000"
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
opacity="0.1"
></path>
<path
fill="#fff"
stroke="#000"
strokeWidth="15.077"
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
></path>
<path
fill="#FFDF6F"
stroke="#000"
strokeWidth="15"
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
></path>
<path
fill="#000"
stroke="#000"
strokeWidth="15.077"
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
></path>
<path
stroke="#000"
strokeLinecap="round"
strokeWidth="15.077"
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
></path>
<path
fill="#000"
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
<path
fill="#000"
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
<path
fill="#FFDF6F"
fillRule="evenodd"
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
clipRule="evenodd"
></path>
<path
fill="#000"
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
></path>
<path
fill="#000"
fillRule="evenodd"
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
clipRule="evenodd"
></path>
<path
fill="#fff"
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
></path>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function ArticleIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M16.25 12V4.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1v13a2.5 2.5 0 002.5 2.5H18.5M16.25 12v5.75a2.5 2.5 0 005 0V13a1 1 0 00-1-1h-4zm-9.5 3.75h5.5m-5.5-8h5.5v4.5h-5.5v-4.5z"
></path>
</svg>
);
}

20
src/shared/icons/dots.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { SVGProps } from 'react';
export function DotsPattern(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg {...props}>
<pattern
id="pattern-circles"
width="30"
height="30"
x="0"
y="0"
patternContentUnits="userSpaceOnUse"
patternUnits="userSpaceOnUse"
>
<circle cx="2" cy="2" r="1.626" fill="currentColor"></circle>
</pattern>
<rect width="100%" height="100%" x="0" y="0" fill="url(#pattern-circles)"></rect>
</svg>
);
}

View File

@@ -13,10 +13,9 @@ export function FileIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M8.75 6.75h6.5m-6.5 4h6.5m-6.5 4h2.5m-5.5 6.5h12.5a1 1 0 001-1V3.75a1 1 0 00-1-1H5.75a1 1 0 00-1 1v16.5a1 1 0 001 1z"
/>
d="M12.75 3.25v5a1 1 0 001 1h5m-10 4h3.5m-3.5 4h6.5m-9.5-14.5h6.586a1 1 0 01.707.293l5.914 5.914a1 1 0 01.293.707V20.25a1 1 0 01-1 1H5.75a1 1 0 01-1-1V3.75a1 1 0 011-1z"
></path>
</svg>
);
}

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