Compare commits
181 Commits
v4.0.0-alp
...
v4.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5255fa503 | ||
|
|
954a17b541 | ||
|
|
a55b31b0e6 | ||
|
|
bdf3ffd7bf | ||
|
|
07ce253f5b | ||
|
|
f3db010c74 | ||
|
|
dcf2791fe5 | ||
|
|
8fcf3551d8 | ||
|
|
2d987849d8 | ||
|
|
3b99926f3b | ||
|
|
113d69a4df | ||
|
|
5d12ba7216 | ||
|
|
72b59020b4 | ||
|
|
4c323b9daa | ||
|
|
72da83d648 | ||
|
|
783a4538a4 | ||
|
|
15e62cad11 | ||
|
|
c52b20ca80 | ||
|
|
04706a6d7c | ||
|
|
0755cbeb6c | ||
|
|
8eb01c8bbf | ||
|
|
ed4f89ff66 | ||
|
|
d9fe647f8e | ||
|
|
843c2d52e7 | ||
|
|
017a3676a4 | ||
|
|
fcb70c0e9a | ||
|
|
0fec21b9ce | ||
|
|
968b1ada94 | ||
|
|
5c9b599b1e | ||
|
|
717c3e17df | ||
|
|
a4540a0802 | ||
|
|
31bacc2646 | ||
|
|
6e5d0f0e76 | ||
|
|
f0712e5961 | ||
|
|
3fbd66dece | ||
|
|
1283432632 | ||
|
|
59eaaec903 | ||
|
|
4f0f210076 | ||
|
|
e4a317f038 | ||
|
|
9779d020c7 | ||
|
|
f8280ec8ee | ||
|
|
6c26f8967b | ||
|
|
e1424b851c | ||
|
|
d14e609579 | ||
|
|
8c0627ff27 | ||
|
|
18c133d096 | ||
|
|
0061ecea78 | ||
|
|
d01cf8319d | ||
|
|
843895d876 | ||
|
|
7c99ed39e4 | ||
|
|
71be59b2e9 | ||
|
|
1c20512ecc | ||
|
|
90342c552f | ||
|
|
b396c8a695 | ||
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 | ||
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
| 17766d29d6 | |||
| 3b13dfeed8 | |||
| 17ba79e01b | |||
| bafad544e9 | |||
| 89c36423ae | |||
| cd31b99559 | |||
| f3c52237fa | |||
| 413d8d82df | |||
| 2eb2010d43 | |||
| 94d400cab2 | |||
| 09b143cb08 | |||
| e3ede34108 | |||
| ed6aca41ea | |||
| 89f577fbef | |||
| a14aeaeb55 | |||
|
|
35cf0abda4 | ||
| 8a7b246315 | |||
|
|
d98c6d0709 | ||
| bda20e8fe8 | |||
| c342daecc8 | |||
| 5e6692cd6d | |||
| 420be77b5c | |||
| 999073f84c | |||
| 174b28f1a7 | |||
| 763cb10e85 | |||
| 89bb8d88f6 | |||
| 09aa2ecafc | |||
| 7271e9ea87 | |||
| a02e496b29 | |||
| cbbf5eaf50 | |||
| d3fa59d2b1 | |||
| aa23e39334 | |||
| c8e2018fd0 | |||
| 6e489f1c49 | |||
| a49b88ab35 | |||
| 31839531ea | |||
| ec0f3fabc0 | |||
| dd7155a3a6 | |||
| cb565ff35b | |||
| 5d59040224 | |||
| 7fabf949c6 | |||
| ea5120e2f0 | |||
| 14f07dfea8 | |||
|
|
1de48cc640 | ||
|
|
f72eb456e8 | ||
| 05b52564e0 | |||
| c8e014f33e | |||
| 46cc01e0ee | |||
| 16e6d234e5 | |||
| 3005d27403 |
65
.github/workflows/main.yml
vendored
@@ -1,53 +1,56 @@
|
|||||||
name: 'Publish'
|
name: "Publish"
|
||||||
on: workflow_dispatch
|
on: workflow_dispatch
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-tauri:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
include:
|
||||||
- platform: 'macos-latest'
|
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||||
args: '--target universal-apple-darwin'
|
args: "--target aarch64-apple-darwin"
|
||||||
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
|
args: "--target x86_64-apple-darwin"
|
||||||
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
|
args: "--target universal-apple-darwin"
|
||||||
- platform: 'ubuntu-22.04'
|
- platform: 'ubuntu-22.04'
|
||||||
args: ''
|
args: ''
|
||||||
- platform: 'windows-latest'
|
- platform: 'windows-latest'
|
||||||
args: '--target x86_64-pc-windows-msvc'
|
args: '--target x86_64-pc-windows-msvc'
|
||||||
runs-on: ${{ matrix.settings.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: setup node
|
|
||||||
uses: actions/setup-node@v3
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: "lts/*"
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
- name: Install PNPM
|
||||||
targets: aarch64-apple-darwin
|
|
||||||
- name: install dependencies (ubuntu only)
|
|
||||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 8.x.x
|
version: 8.x.x
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Setup node and cache for package data
|
|
||||||
uses: actions/setup-node@v3
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
cache: 'pnpm'
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
- name: Install dependencies (ubuntu only)
|
||||||
- uses: Swatinem/rust-cache@v2
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
with:
|
run: |
|
||||||
cache-on-failure: true
|
sudo apt-get update
|
||||||
- run: pnpm install
|
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@dev
|
- uses: tauri-apps/tauri-action@dev
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -62,9 +65,9 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'v__VERSION__'
|
releaseName: "v__VERSION__"
|
||||||
releaseBody: 'See the assets to download this version and install.'
|
releaseBody: "See the assets to download this version and install."
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.settings.args }}
|
args: ${{ matrix.args }}
|
||||||
includeDebug: false
|
includeDebug: false
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -35,3 +35,4 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|||||||
12
README.md
@@ -1,18 +1,14 @@
|
|||||||
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
|
|
||||||
|
|
||||||
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
|
|
||||||
|
|
||||||
--
|
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||||
|
|
||||||
Supported platform: macOS, Windows and Linux
|
Supported platform: macOS. Windows and Linux are coming soon.
|
||||||
|
|
||||||
|
Windows and Linux are availabel on v3 and below.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Lume Desktop</title>
|
<title>Lume Desktop</title>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden bg-white font-sans text-black antialiased dark:bg-black dark:text-white"
|
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||||
>
|
>
|
||||||
<div id="root" class="h-full w-full"></div>
|
<div id="root" class="h-full w-full"></div>
|
||||||
<script type="module" src="/src/app.tsx"></script>
|
<script type="module" src="/src/app.tsx"></script>
|
||||||
|
|||||||
@@ -9,46 +9,52 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
"@getalby/bitcoin-connect-react": "^3.5.3",
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
|
"@lume/system": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.24.1",
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.24.1",
|
"@tanstack/query-persist-client-core": "^5.51.9",
|
||||||
"@tanstack/react-router": "^1.18.1",
|
"@tanstack/react-query": "^5.51.9",
|
||||||
"i18next": "^23.10.0",
|
"@tanstack/react-router": "^1.45.4",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"embla-carousel-react": "^8.1.6",
|
||||||
"nostr-tools": "^2.3.1",
|
"i18next": "^23.12.1",
|
||||||
"react": "^18.2.0",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"minidenticons": "^4.2.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"nostr-tools": "^2.7.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^14.0.5",
|
"react-hook-form": "^7.52.1",
|
||||||
"slate": "^0.101.5",
|
"react-i18next": "^14.1.3",
|
||||||
"slate-react": "^0.101.6",
|
"react-string-replace": "^1.1.1",
|
||||||
"sonner": "^1.4.3",
|
"slate": "^0.103.0",
|
||||||
"virtua": "^0.27.5"
|
"slate-react": "^0.105.0",
|
||||||
|
"use-debounce": "^10.0.1",
|
||||||
|
"virtua": "^0.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@tanstack/router-devtools": "^1.18.1",
|
"@tanstack/router-devtools": "^1.45.4",
|
||||||
"@tanstack/router-vite-plugin": "^1.18.1",
|
"@tanstack/router-vite-plugin": "^1.45.3",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.6",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.3.4",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
"vite-tsconfig-paths": "^4.3.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/desktop2/public/404.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/desktop2/public/antenas.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/desktop2/public/antenas@2x.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
apps/desktop2/public/foryou.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/desktop2/public/foryou@2x.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
apps/desktop2/public/global.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/desktop2/public/global@2x.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
apps/desktop2/public/group.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/desktop2/public/group@2x.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB |
BIN
apps/desktop2/public/icon.jpeg
Normal file
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 951 KiB |
BIN
apps/desktop2/public/newsfeed.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop2/public/newsfeed@2x.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
apps/desktop2/public/poster_1.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
apps/desktop2/public/poster_2.jpeg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
apps/desktop2/public/poster_3.jpeg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
apps/desktop2/public/poster_4.jpeg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/desktop2/public/trending.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop2/public/trending@2x.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
@@ -1,6 +1,32 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.content-break {
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-toolbar {
|
||||||
|
box-shadow:
|
||||||
|
0 0 #0000,
|
||||||
|
0 0 #0000,
|
||||||
|
0 8px 24px 0 rgba(0, 0, 0, 0.2),
|
||||||
|
0 2px 8px 0 rgba(0, 0, 0, 0.08),
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-primary {
|
||||||
|
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Overide some default styles
|
||||||
|
*/
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -23,24 +49,62 @@ input::-ms-clear {
|
|||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
media-controller {
|
.spinner-leaf {
|
||||||
@apply w-full overflow-hidden rounded-xl;
|
position: absolute;
|
||||||
}
|
top: 0;
|
||||||
|
left: calc(50% - 12.5% / 2);
|
||||||
|
width: 12.5%;
|
||||||
|
height: 100%;
|
||||||
|
animation: spinner-leaf-fade 800ms linear infinite;
|
||||||
|
|
||||||
@layer utilities {
|
&::before {
|
||||||
.content-break {
|
content: "";
|
||||||
word-break: break-word;
|
display: block;
|
||||||
word-wrap: break-word;
|
width: 100%;
|
||||||
overflow-wrap: break-word;
|
height: 30%;
|
||||||
|
background-color: currentColor;
|
||||||
|
@apply rounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-toolbar {
|
&:where(:nth-child(1)) {
|
||||||
box-shadow:
|
transform: rotate(0deg);
|
||||||
0 0 #0000,
|
animation-delay: -800ms;
|
||||||
0 0 #0000,
|
}
|
||||||
0 8px 24px 0 rgba(0, 0, 0, 0.2),
|
&:where(:nth-child(2)) {
|
||||||
0 2px 8px 0 rgba(0, 0, 0, 0.08),
|
transform: rotate(45deg);
|
||||||
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
|
animation-delay: -700ms;
|
||||||
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
|
}
|
||||||
|
&:where(:nth-child(3)) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
animation-delay: -600ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(4)) {
|
||||||
|
transform: rotate(135deg);
|
||||||
|
animation-delay: -500ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(5)) {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
animation-delay: -400ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(6)) {
|
||||||
|
transform: rotate(225deg);
|
||||||
|
animation-delay: -300ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(7)) {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
animation-delay: -200ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(8)) {
|
||||||
|
transform: rotate(315deg);
|
||||||
|
animation-delay: -100ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-leaf-fade {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,20 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ArkProvider } from "./ark";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import React, { StrictMode } from "react";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { I18nextProvider } from "react-i18next";
|
|
||||||
import "./app.css";
|
|
||||||
import i18n from "./locale";
|
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import { locale, platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
import "./app.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient();
|
||||||
defaultOptions: {
|
const platform = type();
|
||||||
queries: {
|
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
|
||||||
storage: window.localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const platformName = await platform();
|
|
||||||
const osLocale = (await locale()).slice(0, 2);
|
|
||||||
|
|
||||||
// Set up a Router instance
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: { queryClient, platform },
|
||||||
ark: undefined!,
|
Wrap: ({ children }) => {
|
||||||
platform: platformName,
|
return (
|
||||||
locale: osLocale,
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
queryClient,
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,17 +25,8 @@ declare module "@tanstack/react-router" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function InnerApp() {
|
|
||||||
const ark = useArk();
|
|
||||||
return <RouterProvider router={router} context={{ ark }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return <RouterProvider router={router} />;
|
||||||
<ArkProvider>
|
|
||||||
<InnerApp />
|
|
||||||
</ArkProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
// biome-ignore lint/style/noNonNullAssertion: idk
|
||||||
@@ -67,25 +35,8 @@ const rootElement = document.getElementById("root")!;
|
|||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
|
||||||
<PersistQueryClientProvider
|
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{ persister }}
|
|
||||||
>
|
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
icons={{
|
|
||||||
success: <CheckCircleIcon className="size-5" />,
|
|
||||||
info: <InfoCircleIcon className="size-5" />,
|
|
||||||
error: <CancelCircleIcon className="size-5" />,
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
theme="system"
|
|
||||||
/>
|
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</StrictMode>,
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Ark, ArkContext } from "@lume/ark";
|
|
||||||
import { PropsWithChildren, useMemo } from "react";
|
|
||||||
|
|
||||||
export const ArkProvider = ({ children }: PropsWithChildren<object>) => {
|
|
||||||
const ark = useMemo(() => new Ark(), []);
|
|
||||||
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>;
|
|
||||||
};
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { Account } from "@lume/types";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
||||||
import { BackupDialog } from "./backup";
|
|
||||||
import { LoginDialog } from "./login";
|
|
||||||
|
|
||||||
export function Accounts() {
|
|
||||||
const ark = useArk();
|
|
||||||
const params = useParams({ strict: false });
|
|
||||||
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getAllAccounts() {
|
|
||||||
const data = await ark.get_all_accounts();
|
|
||||||
if (data) setAccounts(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllAccounts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-tauri-drag-region className="flex items-center gap-4">
|
|
||||||
{accounts
|
|
||||||
? accounts.map((account) =>
|
|
||||||
// @ts-ignore, useless
|
|
||||||
account.npub === params.account ? (
|
|
||||||
<Active key={account.npub} pubkey={account.npub} />
|
|
||||||
) : (
|
|
||||||
<Inactive key={account.npub} pubkey={account.npub} />
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inactive({ pubkey }: { pubkey: string }) {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
|
||||||
const select = await ark.load_selected_account(npub);
|
|
||||||
if (select)
|
|
||||||
navigate({ to: "/$account/home/local", params: { account: npub } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => changeAccount(pubkey)}>
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-8 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Active({ pubkey }: { pubkey: string }) {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// @ts-ignore, magic !!!
|
|
||||||
const { guest } = useSearch({ strict: false });
|
|
||||||
// @ts-ignore, magic !!!
|
|
||||||
const { account } = useParams({ strict: false });
|
|
||||||
|
|
||||||
if (guest) {
|
|
||||||
return (
|
|
||||||
<Popover.Root open={true}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<button type="button">
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
className="flex w-[280px] flex-col gap-4 rounded-xl bg-black p-5 text-neutral-100 focus:outline-none dark:bg-white dark:text-neutral-900 dark:shadow-none"
|
|
||||||
sideOffset={10}
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="mb-1 font-semibold">
|
|
||||||
You're using random account
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-600">
|
|
||||||
You can continue by claim and backup this account, or you can
|
|
||||||
import your own account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<BackupDialog />
|
|
||||||
<LoginDialog />
|
|
||||||
</div>
|
|
||||||
<Popover.Arrow className="fill-black dark:fill-white" />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
className="flex w-[220px] flex-col rounded-xl bg-black p-2 text-neutral-100 focus:outline-none dark:bg-white dark:text-neutral-900 dark:shadow-none"
|
|
||||||
sideOffset={10}
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
|
|
||||||
Add account
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onClick={() => ark.open_profile(account)}
|
|
||||||
className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100"
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
|
|
||||||
⌘+Shift+P
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onClick={() => navigate({ to: "/", search: { manually: true } })}
|
|
||||||
className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
|
|
||||||
⌘+Shift+L
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
43
apps/desktop2/src/components/avatarUploader.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export function AvatarUploader({
|
||||||
|
setPicture,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
setPicture: Dispatch<SetStateAction<string>>;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const uploadAvatar = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const image = await NostrQuery.upload();
|
||||||
|
setPicture(image);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
await message(String(e), { title: "Lume", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => uploadAvatar()}
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
>
|
||||||
|
{loading ? <Spinner className="size-4" /> : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function BackupDialog() {
|
|
||||||
// @ts-ignore, magic!!!
|
|
||||||
const { account } = useParams({ strict: false });
|
|
||||||
|
|
||||||
const [key, setKey] = useState(null);
|
|
||||||
const [passphase, setPassphase] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const encryptKey = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
|
||||||
npub: account,
|
|
||||||
password: passphase,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (encrypted) {
|
|
||||||
setKey(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-900 text-sm font-medium leading-tight text-neutral-100 hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Claim & Backup
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur dark:bg-white/30" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Dialog.Close className="absolute right-5 top-5 flex w-12 flex-col items-center justify-center gap-1 text-white">
|
|
||||||
<CancelIcon className="size-8" />
|
|
||||||
<span className="text-sm font-medium uppercase text-neutral-400 dark:text-neutral-600">
|
|
||||||
Esc
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
<div className="relative flex h-min w-full max-w-xl flex-col gap-8 rounded-xl bg-white p-5 dark:bg-black">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
This is your account key
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
It's use for login to Lume or other Nostr clients. You will lost
|
|
||||||
access to your account if you lose this key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">Set a passphase to secure your key</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
name="passphase"
|
|
||||||
type="password"
|
|
||||||
value={passphase}
|
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
|
||||||
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{key ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">
|
|
||||||
Copy this key and keep it in safe place
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="nsec"
|
|
||||||
type="text"
|
|
||||||
value={key}
|
|
||||||
readOnly
|
|
||||||
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{!key ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={encryptKey}
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
<div className="size-5" />
|
|
||||||
<div>Submit</div>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/$account/home/local"
|
|
||||||
params={{ account }}
|
|
||||||
search={{ guest: false }}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
I've safely store my account key
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function Balance({
|
|
||||||
recipient,
|
|
||||||
account,
|
|
||||||
}: {
|
|
||||||
recipient: string;
|
|
||||||
account: string;
|
|
||||||
}) {
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [balance, setBalance] = useState(0);
|
|
||||||
|
|
||||||
const ark = useArk();
|
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getBalance() {
|
|
||||||
const val = await ark.get_balance();
|
|
||||||
setBalance(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
getBalance();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-16 items-center justify-end px-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-end">
|
|
||||||
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
Your balance
|
|
||||||
</div>
|
|
||||||
<div className="font-medium leading-tight">
|
|
||||||
₿ {value.bitcoinFormatted}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<User.Provider pubkey={account}>
|
|
||||||
<User.Root>
|
|
||||||
<User.Avatar className="size-9 rounded-full" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
211
apps/desktop2/src/components/column.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { CheckIcon, HorizontalDotsIcon } from "@lume/icons";
|
||||||
|
import type { LumeColumn } from "@lume/types";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type WindowEvent = {
|
||||||
|
scroll: boolean;
|
||||||
|
resize: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Column = memo(function Column({
|
||||||
|
column,
|
||||||
|
account,
|
||||||
|
}: {
|
||||||
|
column: LumeColumn;
|
||||||
|
account: string;
|
||||||
|
}) {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const webviewLabel = `column-${account}_${column.label}`;
|
||||||
|
|
||||||
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
|
|
||||||
|
const repositionWebview = useCallback(async () => {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("reposition_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
x: newRect.x,
|
||||||
|
y: newRect.y,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resizeWebview = useCallback(async () => {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("resize_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
width: newRect.width,
|
||||||
|
height: newRect.height,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreated) return;
|
||||||
|
|
||||||
|
const unlisten = listen<WindowEvent>("child_webview", (data) => {
|
||||||
|
if (data.payload.scroll) repositionWebview();
|
||||||
|
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f());
|
||||||
|
};
|
||||||
|
}, [isCreated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!container?.current) return;
|
||||||
|
|
||||||
|
const rect = container.current.getBoundingClientRect();
|
||||||
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
|
|
||||||
|
const prop = {
|
||||||
|
label: webviewLabel,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// create new webview
|
||||||
|
invoke("create_column", { column: prop }).then(() => {
|
||||||
|
console.log("created: ", webviewLabel);
|
||||||
|
setIsCreated(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// close webview when unmounted
|
||||||
|
return () => {
|
||||||
|
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||||
|
console.log("closed: ", webviewLabel);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-[480px] shrink-0 p-2">
|
||||||
|
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/10">
|
||||||
|
<Header
|
||||||
|
label={column.label}
|
||||||
|
webview={webviewLabel}
|
||||||
|
name={column.name}
|
||||||
|
/>
|
||||||
|
<div ref={container} className="flex-1 w-full h-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
label,
|
||||||
|
webview,
|
||||||
|
name,
|
||||||
|
}: { label: string; webview: string; name: string }) {
|
||||||
|
const [title, setTitle] = useState(name);
|
||||||
|
const [isChanged, setIsChanged] = useState(false);
|
||||||
|
|
||||||
|
const saveNewTitle = async () => {
|
||||||
|
const mainWindow = getCurrentWindow();
|
||||||
|
await mainWindow.emit("columns", { type: "set_title", label, title });
|
||||||
|
|
||||||
|
// update search params
|
||||||
|
// @ts-ignore, hahaha
|
||||||
|
search.name = title;
|
||||||
|
|
||||||
|
// reset state
|
||||||
|
setIsChanged(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Reload",
|
||||||
|
action: async () => {
|
||||||
|
await invoke("reload_column", { label: webview });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Open in new window",
|
||||||
|
action: () => console.log("not implemented."),
|
||||||
|
}),
|
||||||
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Move left",
|
||||||
|
action: async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "move",
|
||||||
|
label,
|
||||||
|
direction: "left",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Move right",
|
||||||
|
action: async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "move",
|
||||||
|
label,
|
||||||
|
direction: "right",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Close",
|
||||||
|
action: async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "remove",
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (title.length !== name.length) setIsChanged(true);
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
|
||||||
|
<div className="size-7" />
|
||||||
|
<div className="flex items-center justify-center shrink-0 h-7">
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
onBlur={(e) => setTitle(e.currentTarget.textContent)}
|
||||||
|
className="text-sm font-medium focus:outline-none"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{isChanged ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveNewTitle()}
|
||||||
|
className="text-teal-500 hover:text-teal-600"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<HorizontalDotsIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/desktop2/src/components/conversation.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { ThreadIcon } from "@lume/icons";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
export const Conversation = memo(function Conversation({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
event: LumeEvent;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const thread = useMemo(() => event.thread, [event]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
|
||||||
|
<div className="flex items-center gap-2 px-3">
|
||||||
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
<ThreadIcon className="size-4" />
|
||||||
|
Thread
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
|
</div>
|
||||||
|
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-3 h-14">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function LoginDialog() {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [nsec, setNsec] = useState("");
|
|
||||||
const [passphase, setPassphase] = useState("");
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
try {
|
|
||||||
if (!nsec.length) {
|
|
||||||
return toast.info("You must enter a valid nsec or ncrypto");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nsec.startsWith("ncrypto") && !passphase.length) {
|
|
||||||
return toast.warning("You must provide a passphase for ncrypto key");
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = await ark.save_account(nsec, passphase);
|
|
||||||
|
|
||||||
if (save) {
|
|
||||||
navigate({ to: "/", search: { guest: false } });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-900 text-sm font-medium leading-tight text-neutral-100 hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Add account
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur dark:bg-white/30" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Dialog.Close className="absolute right-5 top-5 flex w-12 flex-col items-center justify-center gap-1 text-white">
|
|
||||||
<CancelIcon className="size-8" />
|
|
||||||
<span className="text-sm font-medium uppercase text-neutral-400 dark:text-neutral-600">
|
|
||||||
Esc
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
<div className="relative flex h-min w-full max-w-xl flex-col gap-8 rounded-xl bg-white p-5 dark:bg-black">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<h3 className="text-lg font-semibold">Add new account with</h3>
|
|
||||||
<div className="flex h-11 items-center overflow-hidden rounded-lg bg-neutral-100 p-1 dark:bg-neutral-900">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-full flex-1 rounded-md bg-white text-sm font-medium dark:bg-black"
|
|
||||||
>
|
|
||||||
nsec
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full flex-1 flex-col items-center justify-center rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
<span className="leading-tight">nsecBunker</span>
|
|
||||||
<span className="text-xs font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
coming soon
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full flex-1 flex-col items-center justify-center rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
<span className="leading-tight">Address</span>
|
|
||||||
<span className="text-xs font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
coming soon
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">
|
|
||||||
Enter sign in key start with nsec or ncrypto
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="nsec"
|
|
||||||
type="text"
|
|
||||||
placeholder="nsec or ncrypto..."
|
|
||||||
value={nsec}
|
|
||||||
onChange={(e) => setNsec(e.target.value)}
|
|
||||||
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">Passphase (optional)</label>
|
|
||||||
<input
|
|
||||||
name="passphase"
|
|
||||||
type="password"
|
|
||||||
value={passphase}
|
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
|
||||||
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={login}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
<div className="size-5" />
|
|
||||||
<div>Add account</div>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { PinIcon } from "@lume/icons";
|
import { VisitIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NotePin() {
|
export function NoteOpenThread() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -13,15 +12,15 @@ export function NotePin() {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
|
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<PinIcon className="size-4" />
|
<VisitIcon className="shrink-0 size-4" />
|
||||||
{t("note.buttons.pin")}
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
{t("note.buttons.pinTooltip")}
|
Open
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Portal>
|
</Tooltip.Portal>
|
||||||
37
apps/desktop2/src/components/note/buttons/reply.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { ReplyIcon } from "@lume/icons";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
|
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openEditor(event.id)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
large
|
||||||
|
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReplyIcon className="shrink-0 size-4" />
|
||||||
|
{large ? "Reply" : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
Reply
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/desktop2/src/components/note/buttons/repost.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { RepostIcon } from "@lume/icons";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
|
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isRepost, setIsRepost] = useState(false);
|
||||||
|
|
||||||
|
const repost = async () => {
|
||||||
|
if (isRepost) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// repost
|
||||||
|
await event.repost();
|
||||||
|
|
||||||
|
// update state
|
||||||
|
setLoading(false);
|
||||||
|
setIsRepost(true);
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
await message("Repost failed, try again later", {
|
||||||
|
title: "Lume",
|
||||||
|
kind: "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Quote",
|
||||||
|
action: async () => repost(),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Repost",
|
||||||
|
action: () => LumeWindow.openEditor(null, event.id),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!settings.display_repost_button) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
large
|
||||||
|
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
|
||||||
|
)}
|
||||||
|
{large ? "Repost" : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/desktop2/src/components/note/buttons/zap.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ZapIcon } from "@lume/icons";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
|
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
if (!settings.display_zap_button) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openZap(event.id)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
large
|
||||||
|
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ZapIcon className="size-4" />
|
||||||
|
{large ? "Zap" : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/desktop2/src/components/note/child.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEvent } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Note } from ".";
|
||||||
|
import { InfoIcon } from "@lume/icons";
|
||||||
|
import type { EventTag } from "@lume/types";
|
||||||
|
|
||||||
|
export function NoteChild({
|
||||||
|
event,
|
||||||
|
isRoot,
|
||||||
|
}: {
|
||||||
|
event: EventTag;
|
||||||
|
isRoot?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
|
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||||
|
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
|
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||||
|
<InfoIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
Event not found with your current relay set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/desktop2/src/components/note/content.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { Images } from "./preview/images";
|
||||||
|
import { Videos } from "./preview/videos";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContent({
|
||||||
|
quote = true,
|
||||||
|
mention = true,
|
||||||
|
clean,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
quote?: boolean;
|
||||||
|
mention?: boolean;
|
||||||
|
clean?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const warning = useMemo(() => event.warning, [event]);
|
||||||
|
const content = useMemo(() => {
|
||||||
|
try {
|
||||||
|
// Get parsed meta
|
||||||
|
const { content, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
|
let richContent: ReactNode[] | string = settings.display_media
|
||||||
|
? content
|
||||||
|
: event.content;
|
||||||
|
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||||
|
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (quote) {
|
||||||
|
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||||
|
<MentionNote key={event + index} eventId={event} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quote && clean) {
|
||||||
|
richContent = reactStringReplace(richContent, event, () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of mentions) {
|
||||||
|
if (mention) {
|
||||||
|
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||||
|
<MentionUser key={user + index} pubkey={user} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mention && clean) {
|
||||||
|
richContent = reactStringReplace(richContent, user, () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
richContent = reactStringReplace(
|
||||||
|
richContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, index) => (
|
||||||
|
<a
|
||||||
|
key={match + index}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return richContent;
|
||||||
|
} catch {
|
||||||
|
return event.content;
|
||||||
|
}
|
||||||
|
}, [event.content]);
|
||||||
|
|
||||||
|
const [blurred, setBlurred] = useState(() => warning?.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-2">
|
||||||
|
{blurred ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-lg">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
The content is hidden because the author
|
||||||
|
<br />
|
||||||
|
marked it with a warning for a reason:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-white">{warning}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBlurred(false)}
|
||||||
|
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
|
||||||
|
>
|
||||||
|
View anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
|
event.meta?.content.length > 500
|
||||||
|
? "max-h-[250px] gradient-mask-b-0"
|
||||||
|
: "",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
{settings.display_media ? (
|
||||||
|
<>
|
||||||
|
{event.meta?.images.length ? (
|
||||||
|
<Images urls={event.meta.images} />
|
||||||
|
) : null}
|
||||||
|
{event.meta?.videos.length ? (
|
||||||
|
<Videos urls={event.meta.videos} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/desktop2/src/components/note/contentLarge.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { ImagePreview } from "./preview/image";
|
||||||
|
import { VideoPreview } from "./preview/video";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContentLarge({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const content = useMemo(() => {
|
||||||
|
try {
|
||||||
|
// Get parsed meta
|
||||||
|
const { images, videos, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
|
let richContent: ReactNode[] | string = event.content;
|
||||||
|
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
richContent = reactStringReplace(richContent, regex, () => (
|
||||||
|
<Hashtag key={nanoid()} tag={hashtag} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
richContent = reactStringReplace(richContent, event, (match, i) => (
|
||||||
|
<MentionNote key={match + i} eventId={event} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mention of mentions) {
|
||||||
|
richContent = reactStringReplace(richContent, mention, (match, i) => (
|
||||||
|
<MentionUser key={match + i} pubkey={mention} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
richContent = reactStringReplace(richContent, image, (match, i) => (
|
||||||
|
<ImagePreview key={match + i} url={match} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const video of videos) {
|
||||||
|
richContent = reactStringReplace(richContent, video, (match, i) => (
|
||||||
|
<VideoPreview key={match + i} url={match} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
richContent = reactStringReplace(
|
||||||
|
richContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, i) => (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-500 line-clamp-1 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return richContent;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[parser]: ", e);
|
||||||
|
return event.content;
|
||||||
|
}
|
||||||
|
}, [event.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text leading-normal text-pretty content-break",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { NotePin } from "./buttons/pin";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
import { NoteReaction } from "./buttons/reaction";
|
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
import { NoteZap } from "./buttons/zap";
|
import { NoteZap } from "./buttons/zap";
|
||||||
import { NoteChild } from "./child";
|
import { NoteChild } from "./child";
|
||||||
import { NoteContent } from "./content";
|
import { NoteContent } from "./content";
|
||||||
|
import { NoteContentLarge } from "./contentLarge";
|
||||||
import { NoteMenu } from "./menu";
|
import { NoteMenu } from "./menu";
|
||||||
import { NoteProvider } from "./provider";
|
import { NoteProvider } from "./provider";
|
||||||
import { NoteRoot } from "./root";
|
import { NoteRoot } from "./root";
|
||||||
import { NoteThread } from "./thread";
|
|
||||||
import { NoteUser } from "./user";
|
import { NoteUser } from "./user";
|
||||||
|
|
||||||
export const Note = {
|
export const Note = {
|
||||||
@@ -18,10 +17,9 @@ export const Note = {
|
|||||||
Menu: NoteMenu,
|
Menu: NoteMenu,
|
||||||
Reply: NoteReply,
|
Reply: NoteReply,
|
||||||
Repost: NoteRepost,
|
Repost: NoteRepost,
|
||||||
Reaction: NoteReaction,
|
|
||||||
Content: NoteContent,
|
Content: NoteContent,
|
||||||
|
ContentLarge: NoteContentLarge,
|
||||||
Zap: NoteZap,
|
Zap: NoteZap,
|
||||||
Pin: NotePin,
|
Open: NoteOpenThread,
|
||||||
Child: NoteChild,
|
Child: NoteChild,
|
||||||
Thread: NoteThread,
|
|
||||||
};
|
};
|
||||||
10
apps/desktop2/src/components/note/mentions/hashtag.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
|
return (
|
||||||
|
<span className="leading-normal break-all cursor-default group text-start">
|
||||||
|
<span className="text-blue-500">#</span>
|
||||||
|
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||||
|
{tag.replace("#", "")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/desktop2/src/components/note/mentions/note.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { User } from "@/components/user";
|
||||||
|
import { LinkIcon } from "@lume/icons";
|
||||||
|
import { LumeWindow, useEvent } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
|
||||||
|
export function MentionNote({
|
||||||
|
eventId,
|
||||||
|
openable = true,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
openable?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
|
||||||
|
<p className="text-sm font-medium text-red-500">
|
||||||
|
Event not found with your current relay set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="flex items-center gap-2 h-8">
|
||||||
|
<User.Avatar className="rounded-full size-6" />
|
||||||
|
<div className="inline-flex items-center flex-1 gap-2">
|
||||||
|
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||||
|
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||||
|
<User.Time
|
||||||
|
time={data.created_at}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="select-text text-pretty line-clamp-3 content-break leading-normal">
|
||||||
|
{data.content}
|
||||||
|
</div>
|
||||||
|
{openable ? (
|
||||||
|
<div className="flex items-center justify-start mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
LumeWindow.openEvent(data);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
View post
|
||||||
|
<LinkIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/desktop2/src/components/note/mentions/user.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { LumeWindow, useProfile } from "@lume/system";
|
||||||
|
import { displayNpub } from "@lume/utils";
|
||||||
|
|
||||||
|
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||||
|
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openProfile(pubkey)}
|
||||||
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "@anon"
|
||||||
|
: isError
|
||||||
|
? displayNpub(pubkey, 16)
|
||||||
|
: `@${profile?.name || profile?.display_name || "anon"}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/desktop2/src/components/note/menu.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { HorizontalDotsIcon } from "@lume/icons";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
|
|
||||||
|
export function NoteMenu() {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Sharable Link",
|
||||||
|
action: async () => {
|
||||||
|
const eventId = await event.idAsBech32();
|
||||||
|
await writeText(`https://njump.me/${eventId}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Event ID",
|
||||||
|
action: async () => {
|
||||||
|
const eventId = await event.idAsBech32();
|
||||||
|
await writeText(eventId);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Public Key",
|
||||||
|
action: async () => {
|
||||||
|
const pubkey = await event.pubkeyAsBech32();
|
||||||
|
await writeText(pubkey);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Raw Event",
|
||||||
|
action: async () => {
|
||||||
|
event.meta = undefined;
|
||||||
|
const raw = JSON.stringify(event);
|
||||||
|
await writeText(raw);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
<HorizontalDotsIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/desktop2/src/components/note/preview/image.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function ImagePreview({ url }: { url: string }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (settings.image_resize_service.length) {
|
||||||
|
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
|
||||||
|
return newUrl;
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}, [settings.image_resize_service]);
|
||||||
|
|
||||||
|
if (!settings.display_media) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="max-h-[400px] max-w-[400px] h-auto w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/desktop2/src/components/note/preview/images.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export function Images({ urls }: { urls: string[] }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const [slidesInView, setSlidesInView] = useState([]);
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
|
dragFree: true,
|
||||||
|
align: "start",
|
||||||
|
watchSlides: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrls = useMemo(() => {
|
||||||
|
if (settings.image_resize_service.length) {
|
||||||
|
let newUrls: string[];
|
||||||
|
|
||||||
|
if (urls.length === 1) {
|
||||||
|
newUrls = urls.map(
|
||||||
|
(url) =>
|
||||||
|
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newUrls = urls.map(
|
||||||
|
(url) =>
|
||||||
|
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newUrls;
|
||||||
|
} else {
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}, [settings.image_resize_service]);
|
||||||
|
|
||||||
|
const scrollPrev = useCallback(() => {
|
||||||
|
if (emblaApi) emblaApi.scrollPrev();
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
const scrollNext = useCallback(() => {
|
||||||
|
if (emblaApi) emblaApi.scrollNext();
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
const updateSlidesInView = useCallback((emblaApi) => {
|
||||||
|
setSlidesInView((slidesInView) => {
|
||||||
|
if (slidesInView.length === emblaApi.slideNodes().length) {
|
||||||
|
emblaApi.off("slidesInView", updateSlidesInView);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inView = emblaApi
|
||||||
|
.slidesInView()
|
||||||
|
.filter((index) => !slidesInView.includes(index));
|
||||||
|
|
||||||
|
return slidesInView.concat(inView);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emblaApi && urls.length > 1) {
|
||||||
|
updateSlidesInView(emblaApi);
|
||||||
|
|
||||||
|
emblaApi.on("slidesInView", updateSlidesInView);
|
||||||
|
emblaApi.on("reInit", updateSlidesInView);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emblaApi?.off("slidesInView", updateSlidesInView);
|
||||||
|
emblaApi?.off("reInit", updateSlidesInView);
|
||||||
|
};
|
||||||
|
}, [emblaApi, updateSlidesInView]);
|
||||||
|
|
||||||
|
if (urls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 group">
|
||||||
|
<img
|
||||||
|
src={imageUrls[0]}
|
||||||
|
alt={urls[0]}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => urls[0]}
|
||||||
|
onKeyDown={() => urls[0]}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative px-3 overflow-hidden group">
|
||||||
|
<div ref={emblaRef} className="w-full h-[320px]">
|
||||||
|
<div className="flex w-full gap-2 scrollbar-none">
|
||||||
|
{imageUrls.map((url, index) => (
|
||||||
|
<LazyImage
|
||||||
|
/* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
|
||||||
|
key={url + index}
|
||||||
|
url={url}
|
||||||
|
inView={slidesInView.indexOf(index) > -1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!emblaApi?.canScrollPrev}
|
||||||
|
className={cn(
|
||||||
|
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
|
!emblaApi?.canScrollPrev ? "opacity-50" : "",
|
||||||
|
)}
|
||||||
|
onClick={() => scrollPrev()}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!emblaApi?.canScrollNext}
|
||||||
|
className={cn(
|
||||||
|
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
|
!emblaApi?.canScrollNext ? "opacity-50" : "",
|
||||||
|
)}
|
||||||
|
onClick={() => scrollNext()}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
|
const setLoaded = useCallback(() => {
|
||||||
|
if (inView) setHasLoaded(true);
|
||||||
|
}, [inView, setHasLoaded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
|
||||||
|
{!hasLoaded ? (
|
||||||
|
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
inView
|
||||||
|
? url
|
||||||
|
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
|
||||||
|
}
|
||||||
|
data-src={url}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
|
onLoad={setLoaded}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/desktop2/src/components/note/preview/video.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export function VideoPreview({ url }: { url: string }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
if (settings.display_media) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1">
|
||||||
|
<video
|
||||||
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={`${url}#t=0.1`} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/desktop2/src/components/note/preview/videos.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function Videos({ urls }: { urls: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="group px-3">
|
||||||
|
{urls.map((url) => (
|
||||||
|
<video
|
||||||
|
key={url}
|
||||||
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={`${urls[0]}#t=0.1`} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/desktop2/src/components/note/provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const NoteContext = createContext<LumeEvent>(null);
|
||||||
|
|
||||||
|
export function NoteProvider({
|
||||||
|
event,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
event: LumeEvent;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNoteContext() {
|
||||||
|
const context = useContext(NoteContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
16
apps/desktop2/src/components/note/root.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function NoteRoot({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("h-min w-full", className)} contentEditable={false}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/desktop2/src/components/note/user.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { User } from "../user";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteUser({ className }: { className?: string }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "View Profile",
|
||||||
|
action: () => LumeWindow.openProfile(event.pubkey),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Public Key",
|
||||||
|
action: async () => {
|
||||||
|
const pubkey = await event.pubkeyAsBech32();
|
||||||
|
await writeText(pubkey);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className={cn("flex items-start justify-between", className)}>
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<User.Avatar className="rounded-full size-8" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center w-full gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
|
<User.NIP05 />
|
||||||
|
</div>
|
||||||
|
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||||
|
<User.Time
|
||||||
|
time={event.created_at}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/desktop2/src/components/quote.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { QuoteIcon } from "@lume/icons";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
export const Quote = memo(function Quote({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
event: LumeEvent;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Note.Child event={event.quote} isRoot />
|
||||||
|
<div className="flex items-center gap-2 px-3">
|
||||||
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
<QuoteIcon className="size-4" />
|
||||||
|
Quote
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" quote={false} clean />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-3 h-14">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,113 +1,82 @@
|
|||||||
import { RepostIcon } from "@lume/icons";
|
import { Note } from "@/components/note";
|
||||||
import { Event } from "@lume/types";
|
import { User } from "@/components/user";
|
||||||
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { memo } from "react";
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { Note, User } from "@lume/ui";
|
|
||||||
|
|
||||||
export function RepostNote({
|
export const RepostNote = memo(function RepostNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const ark = useArk();
|
const { isLoading, isError, data } = useQuery({
|
||||||
|
queryKey: ["event", event.repostId],
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
data: repostEvent,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["repost", event.id],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (event.content.length > 50) {
|
const data = await NostrQuery.getRepostEvent(event);
|
||||||
const embed: Event = JSON.parse(event.content);
|
return data;
|
||||||
return embed;
|
} catch (e) {
|
||||||
}
|
throw new Error(e);
|
||||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
|
||||||
return await ark.get_event(id);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to get repost event");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !repostEvent) {
|
|
||||||
return (
|
|
||||||
<Note.Root className={className}>
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex h-14 gap-2 px-3">
|
|
||||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
|
||||||
<div className="inline-flex items-baseline gap-1">
|
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="mb-3 select-text px-3">
|
|
||||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
|
||||||
<p className="text-red-500">Failed to get event</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
|
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
{isLoading ? (
|
||||||
<User.Root className="flex gap-3">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<div className="inline-flex w-11 shrink-0 items-center justify-center">
|
<Spinner />
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Loading event...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center gap-2">
|
) : isError || !data ? (
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
<div className="flex items-center justify-center h-20">
|
||||||
<div className="inline-flex items-baseline gap-1">
|
Event not found within your current relay set
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</User.Root>
|
<Note.Provider event={data}>
|
||||||
</User.Provider>
|
<Note.Root>
|
||||||
<Note.Provider event={repostEvent}>
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<div className="flex gap-3">
|
<Note.Menu />
|
||||||
<div className="size-11 shrink-0" />
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<Note.Content className="px-3" />
|
||||||
<Note.Content />
|
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="inline-flex items-center gap-3">
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<Note.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
<div>
|
||||||
</div>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
</div>
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
Reposted by
|
||||||
|
</div>
|
||||||
|
<User.Avatar className="rounded-full size-6" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
)}
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
|
|
||||||
export function Suggest() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isLoading, isError, data } = useQuery({
|
|
||||||
queryKey: ["trending-users"],
|
|
||||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
|
||||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch trending users from nostr.band API.");
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
|
|
||||||
<div className="h-10 shrink-0 text-lg font-semibold">
|
|
||||||
Suggested Follows
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-44 w-full items-center justify-center">
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : isError ? (
|
|
||||||
<div className="flex h-44 w-full items-center justify-center">
|
|
||||||
{t("suggestion.error")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data?.profiles.map((item: { pubkey: string }) => (
|
|
||||||
<div key={item.pubkey} className="h-max w-full overflow-hidden py-5">
|
|
||||||
<User.Provider pubkey={item.pubkey}>
|
|
||||||
<User.Root>
|
|
||||||
<div className="flex h-full w-full flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-full" />
|
|
||||||
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
|
||||||
</div>
|
|
||||||
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" />
|
|
||||||
</div>
|
|
||||||
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,35 @@
|
|||||||
import { Event } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
export function TextNote({
|
export const TextNote = memo(function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
|
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<div className="flex gap-3">
|
<Note.Menu />
|
||||||
<div className="size-11 shrink-0" />
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<Note.Content className="px-3" />
|
||||||
<Note.Content className="mb-2" />
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
<Note.Thread />
|
<Note.Open />
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
15
apps/desktop2/src/components/toolbar.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ReactNode } from "@tanstack/react-router";
|
||||||
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export function Toolbar({ children }: { children: ReactNode }) {
|
||||||
|
const [domReady, setDomReady] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setDomReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return domReady
|
||||||
|
? createPortal(children, document.getElementById("toolbar"))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
12
apps/desktop2/src/components/user/about.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserAbout({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("content-break select-text", className)}>
|
||||||
|
{user.profile?.about?.trim() || "No bio"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/desktop2/src/components/user/avatar.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import * as Avatar from "@radix-ui/react-avatar";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { minidenticon } from "minidenticons";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserAvatar({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const picture = useMemo(() => {
|
||||||
|
if (
|
||||||
|
settings?.image_resize_service?.length &&
|
||||||
|
user.profile?.picture?.length
|
||||||
|
) {
|
||||||
|
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
return user.profile?.picture;
|
||||||
|
}
|
||||||
|
}, [user.profile?.picture]);
|
||||||
|
|
||||||
|
const fallback = useMemo(
|
||||||
|
() =>
|
||||||
|
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||||
|
minidenticon(user.pubkey, 60, 50),
|
||||||
|
)}`,
|
||||||
|
[user.pubkey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings && !settings.display_avatar) {
|
||||||
|
return (
|
||||||
|
<Avatar.Root
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar.Fallback delayMs={120}>
|
||||||
|
<img
|
||||||
|
src={fallback}
|
||||||
|
alt={user.pubkey}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar.Root
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar.Image
|
||||||
|
src={picture}
|
||||||
|
alt={user.pubkey}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback>
|
||||||
|
<img
|
||||||
|
src={fallback}
|
||||||
|
alt={user.pubkey}
|
||||||
|
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && !user.profile.banner) {
|
if (user && !user.profile?.banner) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||||
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={user.profile.banner}
|
src={user?.profile?.banner}
|
||||||
alt="banner"
|
alt="banner"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
60
apps/desktop2/src/components/user/followButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
|
export function UserFollowButton({
|
||||||
|
simple = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
simple?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
|
const toggleFollow = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||||
|
|
||||||
|
if (toggle) {
|
||||||
|
setFollowed((prev) => !prev);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||||
|
if (mounted) setFollowed(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => toggleFollow()}
|
||||||
|
className={cn("w-max", className)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : followed ? (
|
||||||
|
!simple ? (
|
||||||
|
"Unfollow"
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
"Follow"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/desktop2/src/components/user/name.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { cn, displayNpub } from "@lume/utils";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserName({
|
||||||
|
className,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
prefix?: string;
|
||||||
|
}) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("max-w-[12rem] truncate", className)}>
|
||||||
|
{prefix}
|
||||||
|
{user.profile?.display_name ||
|
||||||
|
user.profile?.name ||
|
||||||
|
displayNpub(user.pubkey, 16)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/desktop2/src/components/user/nip05.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { VerifiedIcon } from "@lume/icons";
|
||||||
|
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
|
|
||||||
|
export function UserNip05() {
|
||||||
|
const user = useUserContext();
|
||||||
|
const { isLoading, data: verified } = useQuery({
|
||||||
|
queryKey: ["nip05", user?.pubkey],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user.profile?.nip05?.length) return false;
|
||||||
|
|
||||||
|
const verify = await NostrQuery.verifyNip05(
|
||||||
|
user.pubkey,
|
||||||
|
user.profile?.nip05,
|
||||||
|
);
|
||||||
|
|
||||||
|
return verify;
|
||||||
|
},
|
||||||
|
enabled: !!user.profile?.nip05,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: false,
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: localStorage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 72, // 72 hours
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.profile?.nip05?.length) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
{!isLoading && verified ? (
|
||||||
|
<VerifiedIcon className="text-teal-500 size-4" />
|
||||||
|
) : null}
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm font-medium text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
{!user.profile?.nip05
|
||||||
|
? displayNpub(user.pubkey, 16)
|
||||||
|
: user.profile?.nip05.length > 50
|
||||||
|
? displayLongHandle(user.profile?.nip05)
|
||||||
|
: user.profile.nip05?.replace("_@", "")}
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/desktop2/src/components/user/provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useProfile } from "@lume/system";
|
||||||
|
import type { Metadata } from "@lume/types";
|
||||||
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const UserContext = createContext<{
|
||||||
|
pubkey: string;
|
||||||
|
profile: Metadata;
|
||||||
|
isError: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
|
export function UserProvider({
|
||||||
|
pubkey,
|
||||||
|
children,
|
||||||
|
embedProfile,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
children: ReactNode;
|
||||||
|
embedProfile?: string;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserContext() {
|
||||||
|
const context = useContext(UserContext);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function UserRoot({
|
export function UserRoot({
|
||||||
children,
|
children,
|
||||||
18
apps/desktop2/src/components/user/time.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { cn, formatCreatedAt } from "@lume/utils";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function UserTime({
|
||||||
|
time,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
time: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||||
|
{createdAt}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
apps/desktop2/src/routes/$account.home.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Column } from "@/components/column";
|
||||||
|
import { Toolbar } from "@/components/toolbar";
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/$account/home")({
|
||||||
|
loader: async () => {
|
||||||
|
const columns = await NostrQuery.getColumns();
|
||||||
|
return columns;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { account } = Route.useParams();
|
||||||
|
const initialColumnList = Route.useLoaderData();
|
||||||
|
|
||||||
|
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
|
watchDrag: false,
|
||||||
|
loop: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollPrev = useCallback(() => {
|
||||||
|
if (emblaApi) emblaApi.scrollPrev();
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
const scrollNext = useCallback(() => {
|
||||||
|
if (emblaApi) emblaApi.scrollNext();
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
const emitScrollEvent = useCallback(() => {
|
||||||
|
getCurrentWindow().emit("child_webview", { scroll: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emitResizeEvent = useCallback(() => {
|
||||||
|
getCurrentWindow().emit("child_webview", { resize: true, direction: "x" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openLumeStore = useCallback(async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "add",
|
||||||
|
column: {
|
||||||
|
label: "store",
|
||||||
|
name: "Column Gallery",
|
||||||
|
content: "/store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
|
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||||
|
setColumns((prev) => [column, ...prev]);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
|
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
const move = useDebouncedCallback(
|
||||||
|
(label: string, direction: "left" | "right") => {
|
||||||
|
const newCols = [...columns];
|
||||||
|
|
||||||
|
const col = newCols.find((el) => el.label === label);
|
||||||
|
const colIndex = newCols.findIndex((el) => el.label === label);
|
||||||
|
|
||||||
|
newCols.splice(colIndex, 1);
|
||||||
|
|
||||||
|
if (direction === "left") newCols.splice(colIndex - 1, 0, col);
|
||||||
|
if (direction === "right") newCols.splice(colIndex + 1, 0, col);
|
||||||
|
|
||||||
|
setColumns(newCols);
|
||||||
|
},
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
|
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||||
|
|
||||||
|
const updatedCol = Object.assign({}, columns[currentColIndex]);
|
||||||
|
updatedCol.name = title;
|
||||||
|
|
||||||
|
const newCols = columns.slice();
|
||||||
|
newCols[currentColIndex] = updatedCol;
|
||||||
|
|
||||||
|
setColumns(newCols);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
const reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||||
|
|
||||||
|
const handleKeyDown = useDebouncedCallback((event) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (emblaApi) emblaApi.scrollPrev();
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (emblaApi) emblaApi.scrollNext();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emblaApi) {
|
||||||
|
emblaApi.on("scroll", emitScrollEvent);
|
||||||
|
emblaApi.on("resize", emitResizeEvent);
|
||||||
|
emblaApi.on("slidesChanged", emitScrollEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emblaApi?.off("scroll", emitScrollEvent);
|
||||||
|
emblaApi?.off("resize", emitResizeEvent);
|
||||||
|
emblaApi?.off("slidesChanged", emitScrollEvent);
|
||||||
|
};
|
||||||
|
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (columns?.length) {
|
||||||
|
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||||
|
}
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColumns(initialColumnList);
|
||||||
|
}, [initialColumnList]);
|
||||||
|
|
||||||
|
// Listen for keyboard event
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
// Listen for columns event
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<ColumnEvent>("columns", (data) => {
|
||||||
|
if (data.payload.type === "reset") reset();
|
||||||
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
|
if (data.payload.type === "move")
|
||||||
|
move(data.payload.label, data.payload.direction);
|
||||||
|
if (data.payload.type === "set_title")
|
||||||
|
updateName(data.payload.label, data.payload.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="size-full">
|
||||||
|
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||||
|
<div className="flex size-full">
|
||||||
|
{columns?.map((column) => (
|
||||||
|
<Column
|
||||||
|
key={account + column.label}
|
||||||
|
column={column}
|
||||||
|
account={account}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="shrink-0 p-2 h-full w-[480px]">
|
||||||
|
<div className="size-full bg-black/5 dark:bg-white/5 rounded-xl flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openLumeStore()}
|
||||||
|
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-5" />
|
||||||
|
Add Column
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toolbar>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollPrev()}
|
||||||
|
className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollNext()}
|
||||||
|
className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,131 +1,233 @@
|
|||||||
|
import { User } from "@/components/user";
|
||||||
import {
|
import {
|
||||||
BellFilledIcon,
|
ChevronDownIcon,
|
||||||
BellIcon,
|
|
||||||
ComposeFilledIcon,
|
ComposeFilledIcon,
|
||||||
HomeFilledIcon,
|
PlusIcon,
|
||||||
HomeIcon,
|
SearchIcon,
|
||||||
HorizontalDotsIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
SpaceFilledIcon,
|
|
||||||
SpaceIcon,
|
|
||||||
} from "@lume/icons";
|
} from "@lume/icons";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Accounts } from "@/components/accounts";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { useArk } from "@lume/ark";
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
import { Box } from "@lume/ui";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { memo, useCallback, useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
component: App,
|
beforeLoad: async ({ params }) => {
|
||||||
|
const settings = await NostrQuery.getUserSettings();
|
||||||
|
const accounts = await NostrAccount.getAccounts();
|
||||||
|
const otherAccounts = accounts.filter(
|
||||||
|
(account) => account !== params.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { otherAccounts, settings };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function App() {
|
function Screen() {
|
||||||
const ark = useArk();
|
const { settings, platform } = Route.useRouteContext();
|
||||||
const context = Route.useRouteContext();
|
|
||||||
|
const openLumeStore = async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "add",
|
||||||
|
column: {
|
||||||
|
label: "store",
|
||||||
|
name: "Column Gallery",
|
||||||
|
content: "/store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex h-11 shrink-0 items-center justify-between px-3"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 shrink-0 items-center justify-between pr-4",
|
"flex-1 flex items-center gap-2",
|
||||||
context.platform === "macos" ? "pl-24" : "pl-4",
|
platform === "macos" ? "pl-[64px]" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Navigation />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Accounts />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor()}
|
onClick={() => openLumeStore()}
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-5" />
|
||||||
|
Column
|
||||||
|
</button>
|
||||||
|
<div id="toolbar" />
|
||||||
|
</div>
|
||||||
|
<div data-tauri-drag-region className="hidden md:flex md:flex-1">
|
||||||
|
<Search />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex-1 flex items-center justify-end gap-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openEditor()}
|
||||||
|
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
||||||
>
|
>
|
||||||
<ComposeFilledIcon className="size-4" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Accounts />
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => ark.open_settings()}
|
</div>
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600"
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1",
|
||||||
|
settings.vibrancy
|
||||||
|
? ""
|
||||||
|
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<HorizontalDotsIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Box>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Navigation() {
|
const Accounts = memo(function Accounts() {
|
||||||
// @ts-ignore, useless
|
const { otherAccounts } = Route.useRouteContext();
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(
|
||||||
|
async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "New Post",
|
||||||
|
action: () => LumeWindow.openEditor(),
|
||||||
|
}),
|
||||||
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "View Profile",
|
||||||
|
action: () => LumeWindow.openProfile(account),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Open Settings",
|
||||||
|
action: () => LumeWindow.openSettings(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
},
|
||||||
|
[account],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeAccount = useCallback(
|
||||||
|
async (npub: string) => {
|
||||||
|
// Change current account and update signer
|
||||||
|
const select = await NostrAccount.loadAccount(npub);
|
||||||
|
|
||||||
|
if (select) {
|
||||||
|
// Reset current columns
|
||||||
|
await getCurrentWindow().emit("columns", { type: "reset" });
|
||||||
|
|
||||||
|
// Redirect to new account
|
||||||
|
return navigate({
|
||||||
|
to: "/$account/home",
|
||||||
|
params: { account: npub },
|
||||||
|
resetScroll: true,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await message("Something wrong.", { title: "Accounts", kind: "error" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[otherAccounts],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-tauri-drag-region className="hidden md:flex items-center gap-3">
|
||||||
data-tauri-drag-region
|
{otherAccounts.map((npub) => (
|
||||||
className="flex h-full flex-1 items-center gap-2"
|
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
|
||||||
|
<User.Provider pubkey={npub}>
|
||||||
|
<User.Root className="shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto hover:ring-1 hover:ring-blue-500">
|
||||||
|
<User.Avatar className="rounded-full size-8" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Link to="/$account/home/local" params={{ account }}>
|
<User.Provider pubkey={account}>
|
||||||
{({ isActive }) => (
|
<User.Root className="shrink-0 rounded-full">
|
||||||
<div
|
<User.Avatar className="rounded-full size-8" />
|
||||||
className={cn(
|
</User.Root>
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3",
|
</User.Provider>
|
||||||
isActive
|
<ChevronDownIcon className="size-3" />
|
||||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
</button>
|
||||||
: "hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<HomeFilledIcon className="size-5" />
|
|
||||||
) : (
|
|
||||||
<HomeIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">Home</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/$account/space" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
: "hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<SpaceFilledIcon className="size-5" />
|
|
||||||
) : (
|
|
||||||
<SpaceIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">Space</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/$account/activity" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
: "hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<BellFilledIcon className="size-5" />
|
|
||||||
) : (
|
|
||||||
<BellIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">Activity</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const Search = memo(function Search() {
|
||||||
|
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Notes",
|
||||||
|
action: () => setSearchType("notes"),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Users",
|
||||||
|
action: () => setSearchType("users"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10 text-black/50 dark:text-white/50"
|
||||||
|
>
|
||||||
|
{searchType}
|
||||||
|
<ChevronDownIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
LumeWindow.openSearch(searchType, query);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
<SearchIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/activity")({
|
|
||||||
component: Activity,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Activity() {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
|
||||||
<p>Activity</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { GlobalIcon, LoaderIcon, LocalIcon, RefreshIcon } from "@lume/icons";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const queryKey = `${window.location.pathname.split("/").at(-1)}_newsfeed`;
|
|
||||||
await queryClient.refetchQueries({ queryKey: [queryKey, account] });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-full max-w-xl shrink-0 items-center justify-between border-b border-neutral-100 dark:border-neutral-900">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link to="/$account/home/local">
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm leading-tight hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-100 font-semibold text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
|
||||||
: "text-neutral-600 dark:text-neutral-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LocalIcon className="size-4" />
|
|
||||||
Local
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/$account/home/global">
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm leading-tight hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-100 font-semibold text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
|
||||||
: "text-neutral-600 dark:text-neutral-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GlobalIcon className="size-4" />
|
|
||||||
Global
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refresh}
|
|
||||||
className="text-neutral-700 hover:text-blue-500 dark:text-neutral-300"
|
|
||||||
>
|
|
||||||
<RefreshIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
|
||||||
import { Event, Kind } from "@lume/types";
|
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/home/global")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const ark = useArk();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
hasNextPage,
|
|
||||||
isLoading,
|
|
||||||
isRefetching,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["global_newsfeed", account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events(
|
|
||||||
"global",
|
|
||||||
FETCH_LIMIT,
|
|
||||||
pageParam,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
if (!lastEvent) return;
|
|
||||||
return lastEvent.created_at - 1;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} />;
|
|
||||||
default:
|
|
||||||
return <TextNote key={event.id} event={event} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
{isLoading || isRefetching ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
{data.map((item) => renderItem(item))}
|
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
<div className="flex h-20 items-center justify-center">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
|
||||||
import { Event, Kind } from "@lume/types";
|
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/home/local")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const ark = useArk();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
hasNextPage,
|
|
||||||
isLoading,
|
|
||||||
isRefetching,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["local_newsfeed", account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events(
|
|
||||||
"local",
|
|
||||||
FETCH_LIMIT,
|
|
||||||
pageParam,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
if (!lastEvent) return;
|
|
||||||
return lastEvent.created_at - 1;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} />;
|
|
||||||
default:
|
|
||||||
return <TextNote key={event.id} event={event} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
{isLoading || isRefetching ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : !data.length ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
|
|
||||||
<InfoIcon className="size-6" />
|
|
||||||
<p>
|
|
||||||
Empty newsfeed. Or you view the{" "}
|
|
||||||
<Link
|
|
||||||
to="/$account/home/global"
|
|
||||||
className="text-blue-500 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
Global Newsfeed
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Suggest />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
{data.map((item) => renderItem(item))}
|
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
<div className="flex h-20 items-center justify-center">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/space")({
|
|
||||||
component: Space,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Space() {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
|
||||||
<p>Space</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,22 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { Spinner } from "@lume/ui";
|
||||||
import {
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
Outlet,
|
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
ScrollRestoration,
|
import type { OsType } from "@tauri-apps/plugin-os";
|
||||||
createRootRouteWithContext,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
import { type Ark } from "@lume/ark";
|
|
||||||
import { type QueryClient } from "@tanstack/react-query";
|
|
||||||
import { type Platform } from "@tauri-apps/plugin-os";
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
ark: Ark;
|
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
platform: Platform;
|
platform: OsType;
|
||||||
locale: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: () => (
|
component: () => <Outlet />,
|
||||||
<>
|
|
||||||
<ScrollRestoration />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
pendingComponent: Pending,
|
pendingComponent: Pending,
|
||||||
wrapInSuspense: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function Pending() {
|
function Pending() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
16
apps/desktop2/src/routes/auth.lazy.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Container } from "@lume/ui";
|
||||||
|
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/auth")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
return (
|
||||||
|
<Container withDrag>
|
||||||
|
<div className="max-w-sm mx-auto size-full">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
apps/desktop2/src/routes/auth/$account.backup.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { CheckIcon } from "@lume/icons";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { displayNsec } from "@lume/utils";
|
||||||
|
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/auth/$account/backup")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { account } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [key, setKey] = useState(null);
|
||||||
|
const [passphase, setPassphase] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState({ c1: false, c2: false });
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
if (key) {
|
||||||
|
if (!confirm.c1 || !confirm.c2) {
|
||||||
|
return await message("You need to confirm before continue", {
|
||||||
|
title: "Backup",
|
||||||
|
kind: "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate({ to: "/", replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
invoke("get_encrypted_key", {
|
||||||
|
npub: account,
|
||||||
|
password: passphase,
|
||||||
|
}).then((encrypted: string) => {
|
||||||
|
// update state
|
||||||
|
setKey(encrypted);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Backup",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyKey = async () => {
|
||||||
|
try {
|
||||||
|
await writeText(key);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (e) {
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Backup",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
|
<div className="flex flex-col text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||||
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
|
It's use for login to Lume or other Nostr clients. You will lost
|
||||||
|
access to your account if you lose this key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="passphase" className="font-medium">
|
||||||
|
Set a passphase to secure your key
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
name="passphase"
|
||||||
|
type="password"
|
||||||
|
value={passphase}
|
||||||
|
onChange={(e) => setPassphase(e.target.value)}
|
||||||
|
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{key ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="nsec" className="font-medium">
|
||||||
|
Copy this key and keep it in safe place
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
name="nsec"
|
||||||
|
type="text"
|
||||||
|
value={displayNsec(key, 36)}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyKey()}
|
||||||
|
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="font-medium">Before you continue:</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c1}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||||
|
}
|
||||||
|
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
id="confirm1"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
|
htmlFor="confirm1"
|
||||||
|
>
|
||||||
|
I will make sure keep it safe and not sharing with anyone.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c2}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||||
|
}
|
||||||
|
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
id="confirm2"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
|
htmlFor="confirm2"
|
||||||
|
>
|
||||||
|
I understand I cannot recover private key.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Spinner /> : "Continue"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/desktop2/src/routes/auth/create-profile.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import type { Metadata } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/auth/create-profile")({
|
||||||
|
loader: async () => {
|
||||||
|
const account = await NostrAccount.createAccount();
|
||||||
|
return account;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const account = Route.useLoaderData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const [picture, setPicture] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (data: {
|
||||||
|
name: string;
|
||||||
|
about: string;
|
||||||
|
website: string;
|
||||||
|
}) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save account keys
|
||||||
|
const save = await NostrAccount.saveAccount(account.nsec);
|
||||||
|
|
||||||
|
// Then create profile
|
||||||
|
if (save) {
|
||||||
|
const profile: Metadata = { ...data, picture };
|
||||||
|
const eventId = await NostrAccount.createProfile(profile);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
navigate({
|
||||||
|
to: "/auth/$account/backup",
|
||||||
|
params: { account: account.npub },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
await message(String(e), { title: "Create Profile", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center size-full gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="w-full mb-0">
|
||||||
|
<div className="flex flex-col gap-3 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
||||||
|
<div className="self-center relative rounded-full size-20 bg-neutral-200 dark:bg-white/70 my-3">
|
||||||
|
{picture ? (
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt="avatar"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarUploader
|
||||||
|
setPicture={setPicture}
|
||||||
|
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-8" />
|
||||||
|
</AvatarUploader>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="display_name" className="font-medium">
|
||||||
|
Display Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("display_name", { required: true, minLength: 1 })}
|
||||||
|
placeholder="e.g. Alice in Nostrland"
|
||||||
|
spellCheck={false}
|
||||||
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="name" className="font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("name")}
|
||||||
|
placeholder="e.g. alice"
|
||||||
|
spellCheck={false}
|
||||||
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="about" className="font-medium">
|
||||||
|
Bio
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register("about")}
|
||||||
|
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="website" className="font-medium">
|
||||||
|
Website
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
{...register("website")}
|
||||||
|
placeholder="e.g. https://alice.me"
|
||||||
|
spellCheck={false}
|
||||||
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Spinner /> : "Continue"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/create/")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [method, setMethod] = useState<"self" | "managed">("self");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (method === "self") {
|
|
||||||
navigate({ to: "/auth/create/self" });
|
|
||||||
} else {
|
|
||||||
navigate({ to: "/auth/create/managed" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
|
|
||||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signup.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMethod("self")}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-start rounded-xl bg-neutral-100 px-4 py-3.5 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800",
|
|
||||||
method === "self"
|
|
||||||
? "ring-1 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-black"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="font-semibold">{t("signup.selfManageMethod")}</p>
|
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signup.selfManageMethodDescription")}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMethod("managed")}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-start rounded-xl bg-neutral-100 px-4 py-3.5 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800",
|
|
||||||
method === "managed"
|
|
||||||
? "ring-1 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-black"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="font-semibold">{t("signup.providerMethod")}</p>
|
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signup.providerMethodDescription")}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={next}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.continue")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{method === "managed" ? (
|
|
||||||
<div className="flex flex-col gap-1.5 rounded-xl border border-red-200 bg-red-100 p-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900 dark:text-red-200">
|
|
||||||
<p className="font-semibold text-red-900 dark:text-red-100">
|
|
||||||
Attention:
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You're chosing Managed by Provider, this feature still in
|
|
||||||
"Beta".
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Some functions still missing or not work as expected, you
|
|
||||||
shouldn't create your main account with this method
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://github.com/kind-0/nsecbunkerd/blob/master/OAUTH-LIKE-FLOW.md"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500"
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute('/auth/create/managed')({
|
|
||||||
component: () => <div>Hello /auth/create/managed!</div>
|
|
||||||
})
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { CheckIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { Keys } from "@lume/types";
|
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/create/self")({
|
|
||||||
component: Create,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Create() {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showKey, setShowKey] = useState(false);
|
|
||||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
|
||||||
const [keys, setKeys] = useState<Keys>(null);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await ark.save_account(keys);
|
|
||||||
navigate({
|
|
||||||
to: "/$account/home/local",
|
|
||||||
params: { account: keys.npub },
|
|
||||||
search: { onboarding: true },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function genKeys() {
|
|
||||||
const res = await ark.create_keys();
|
|
||||||
setKeys(res);
|
|
||||||
}
|
|
||||||
genKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-semibold">
|
|
||||||
{t("signupWithSelfManage.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signupWithSelfManage.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mb-0 flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
{keys ? (
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={keys.nsec}
|
|
||||||
type={showKey ? "text" : "password"}
|
|
||||||
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-14 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowKey((state) => !state)}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{showKey ? (
|
|
||||||
<EyeOnIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOffIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c1}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
|
||||||
}
|
|
||||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm1"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm text-neutral-700 dark:text-neutral-400"
|
|
||||||
htmlFor="confirm1"
|
|
||||||
>
|
|
||||||
{t("signupWithSelfManage.confirm1")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c3}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
|
||||||
}
|
|
||||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm3"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm text-neutral-700 dark:text-neutral-400"
|
|
||||||
htmlFor="confirm3"
|
|
||||||
>
|
|
||||||
{t("signupWithSelfManage.confirm3")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c2}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
|
||||||
}
|
|
||||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm2"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm text-neutral-700 dark:text-neutral-400"
|
|
||||||
htmlFor="confirm2"
|
|
||||||
>
|
|
||||||
{t("signupWithSelfManage.confirm2")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={!confirm.c1 || !confirm.c2 || !confirm.c3}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("signupWithSelfManage.button")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +1,90 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { NostrAccount } from "@lume/system";
|
||||||
import { LoaderIcon } from "@lume/icons";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/import")({
|
export const Route = createLazyFileRoute("/auth/import")({
|
||||||
component: Import,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Import() {
|
function Screen() {
|
||||||
const ark = useArk();
|
const navigate = Route.useNavigate();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!key.startsWith("nsec1")) return;
|
if (!key.startsWith("nsec1")) {
|
||||||
if (key.length < 30) return;
|
return await message(
|
||||||
|
"You need to enter a valid private key starts with nsec or ncryptsec",
|
||||||
setLoading(true);
|
{ title: "Import Key", kind: "info" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const npub: string = await invoke("get_public_key", { nsec: key });
|
setLoading(true);
|
||||||
await ark.save_account({
|
|
||||||
npub,
|
const npub = await NostrAccount.saveAccount(key, password);
|
||||||
nsec: key,
|
|
||||||
});
|
if (npub) {
|
||||||
navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/$account/home/local",
|
}
|
||||||
params: { account: npub },
|
|
||||||
search: { onboarding: true },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(e);
|
await message(String(e), { title: "Import Key", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNip05 = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/.test(key);
|
|
||||||
const isNip49 = key.startsWith("ncryptsec");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex flex-col items-center justify-center size-full gap-4">
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
<div className="text-center">
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||||
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
|
|
||||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("login.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col w-full">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="key"
|
||||||
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
Private Key
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
value={key}
|
name="key"
|
||||||
type="text"
|
type="text"
|
||||||
|
placeholder="nsec or ncryptsec..."
|
||||||
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={(e) => setKey(e.target.value)}
|
||||||
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-10 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isNip05 || isNip49 ? (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label
|
<label
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
Password *
|
Password (Optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={password}
|
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-10 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => submit()}
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? <Spinner /> : "Login"}
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Import"
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
apps/desktop2/src/routes/auth/remote.lazy.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/auth/remote")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!uri.startsWith("bunker://")) {
|
||||||
|
return await message(
|
||||||
|
"You need to enter a valid Connect URI starts with bunker://",
|
||||||
|
{ title: "Nostr Connect", kind: "info" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||||
|
|
||||||
|
if (remoteAccount?.length) {
|
||||||
|
navigate({ to: "/", replace: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
await message(String(e), { title: "Nostr Connect", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center size-full gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="flex flex-col gap-1 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
||||||
|
<label
|
||||||
|
htmlFor="uri"
|
||||||
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
Connect URI
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="uri"
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={uri}
|
||||||
|
onChange={(e) => setUri(e.target.value)}
|
||||||
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Spinner /> : "Login"}
|
||||||
|
</button>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
|
||||||
|
Waiting confirmation...
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/desktop2/src/routes/bootstrap-relays.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { Relay } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/bootstrap-relays")({
|
||||||
|
loader: async () => {
|
||||||
|
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
|
||||||
|
return bootstrapRelays;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const bootstrapRelays = Route.useLoaderData();
|
||||||
|
const { register, reset, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const [relays, setRelays] = useState<Relay[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const removeRelay = (url: string) => {
|
||||||
|
setRelays((prev) => prev.filter((relay) => relay.url !== url));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: { url: string; purpose: string }) => {
|
||||||
|
try {
|
||||||
|
if (!data.url.startsWith("wss://") || !data.url.startsWith("ws://")) {
|
||||||
|
return await message("Relay must be starts with wss:// or ws://", {
|
||||||
|
title: "Bootstrap Relays",
|
||||||
|
kind: "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const relay: Relay = { url: data.url, purpose: data.purpose };
|
||||||
|
setRelays((prev) => [...prev, relay]);
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await NostrQuery.saveBootstrapRelays(relays);
|
||||||
|
} catch (e) {
|
||||||
|
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRelays(bootstrapRelays);
|
||||||
|
}, [bootstrapRelays]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="relative flex flex-col items-center justify-between w-full h-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="absolute top-0 left-0 h-14 w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex items-end justify-center flex-1 w-full px-4 pb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold">Customize Bootstrap Relays</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center flex-1 w-full">
|
||||||
|
<div className="flex flex-col w-full max-w-sm mx-auto p-3 overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
||||||
|
{relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay.url}
|
||||||
|
className="flex items-center justify-between h-11"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||||
|
{relay.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{relay.purpose?.length ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{relay.purpose}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRelay(relay.url)}
|
||||||
|
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<CancelIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex items-center w-full gap-2 mb-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
|
||||||
|
<input
|
||||||
|
{...register("url", {
|
||||||
|
required: true,
|
||||||
|
minLength: 1,
|
||||||
|
})}
|
||||||
|
name="url"
|
||||||
|
placeholder="wss://..."
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
{...register("purpose")}
|
||||||
|
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
|
||||||
|
>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="write">Write</option>
|
||||||
|
<option value="">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-7" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-sm mx-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => save()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Save & Relaunch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
apps/desktop2/src/routes/create-group.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { User } from "@/components/user";
|
||||||
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-group")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: async () => {
|
||||||
|
const contacts = await NostrAccount.getContactList();
|
||||||
|
return contacts;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [users, setUsers] = useState<string[]>([
|
||||||
|
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||||
|
]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const contacts = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleUser = (pubkey: string) => {
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.includes(pubkey)
|
||||||
|
? prev.filter((i) => i !== pubkey)
|
||||||
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = () => {
|
||||||
|
if (!npub.startsWith("npub1")) return;
|
||||||
|
if (users.includes(npub)) return;
|
||||||
|
|
||||||
|
setUsers((prev) => [...prev, npub]);
|
||||||
|
setNpub("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const key = `lume_group_${search.label}`;
|
||||||
|
const createGroup = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
|
JSON.stringify(users),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createGroup) {
|
||||||
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
await message(String(e), { title: "Create Group", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="font-serif text-2xl font-medium">
|
||||||
|
Focus feeds for people you like
|
||||||
|
</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Add some people for custom feeds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||||
|
<div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter a name for this group"
|
||||||
|
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center w-full gap-3">
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
name="npub"
|
||||||
|
value={npub}
|
||||||
|
onChange={(e) => setNpub(e.target.value)}
|
||||||
|
placeholder="npub1..."
|
||||||
|
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addUser()}
|
||||||
|
className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold">Added</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{users.length ? (
|
||||||
|
users.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleUser(item)}
|
||||||
|
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="rounded-full size-8" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div>
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
|
||||||
|
Empty.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold">Contacts</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{contacts.length ? (
|
||||||
|
contacts.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleUser(item)}
|
||||||
|
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="rounded-full size-8" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
|
||||||
|
<p>
|
||||||
|
Find more user at{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.nostr.directory/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-blue-600 after:content-['_↗']"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Nostr Directory
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || users.length < 1}
|
||||||
|
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/desktop2/src/routes/create-newsfeed.f2f.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const { redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!npub.startsWith("npub1")) {
|
||||||
|
return await message("You must enter a valid npub.", {
|
||||||
|
title: "Create Newsfeed",
|
||||||
|
kind: "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const sync = await NostrAccount.f2f(npub);
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
return navigate({ to: redirect });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Create Newsfeed",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
|
<div className="flex flex-col justify-between h-full">
|
||||||
|
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
|
||||||
|
<p className="font-semibold text-neutral-500">
|
||||||
|
You already have a friend on Nostr?
|
||||||
|
</p>
|
||||||
|
<p>Instead of building the timeline by yourself.</p>
|
||||||
|
<p className="font-semibold text-neutral-500">
|
||||||
|
Just enter your friend's{" "}
|
||||||
|
<span className="text-blue-500">npub.</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You will have the same experience as your friend. Of course, you
|
||||||
|
always can edit your network later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="npub" className="text-sm font-medium">
|
||||||
|
NPUB
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="npub"
|
||||||
|
placeholder="npub1..."
|
||||||
|
value={npub}
|
||||||
|
onChange={(e) => setNpub(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="px-3 bg-transparent border rounded-lg h-11 border-neutral-200 dark:border-neutral-800 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
className="inline-flex items-center justify-center w-full text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/desktop2/src/routes/create-newsfeed.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const search = Route.useSearch();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="font-serif text-2xl font-medium">
|
||||||
|
Build up your timeline.
|
||||||
|
</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Follow some people to keep up to date with them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||||
|
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
|
||||||
|
<Link
|
||||||
|
to="/create-newsfeed/users"
|
||||||
|
search={search}
|
||||||
|
className="flex-1 h-8"
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium rounded-md h-full flex items-center justify-center",
|
||||||
|
isActive
|
||||||
|
? "bg-white dark:bg-white/20 shadow"
|
||||||
|
: "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/create-newsfeed/f2f"
|
||||||
|
search={search}
|
||||||
|
className="flex-1 h-8"
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md h-full flex items-center justify-center",
|
||||||
|
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Friend to Friend
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
apps/desktop2/src/routes/create-newsfeed.users.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { User } from "@/components/user";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Await, defer } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed/users")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: async ({ abortController }) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: defer(
|
||||||
|
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||||
|
signal: abortController.signal,
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
const { redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [follows, setFollows] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleFollow = (pubkey: string) => {
|
||||||
|
setFollows((prev) =>
|
||||||
|
prev.includes(pubkey)
|
||||||
|
? prev.filter((i) => i !== pubkey)
|
||||||
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const newContactList = await NostrAccount.setContactList(follows);
|
||||||
|
|
||||||
|
if (newContactList) {
|
||||||
|
return navigate({ to: redirect });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Create Group",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center w-full gap-3">
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(users) =>
|
||||||
|
users.profiles.map((item: { pubkey: string }) => (
|
||||||
|
<div
|
||||||
|
key={item.pubkey}
|
||||||
|
className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<div className="flex flex-col w-full h-full gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="rounded-full size-7" />
|
||||||
|
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFollow(item.pubkey)}
|
||||||
|
className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{follows.includes(item.pubkey)
|
||||||
|
? "Unfollow"
|
||||||
|
: "Follow"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || follows.length < 1}
|
||||||
|
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/desktop2/src/routes/create-topic.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { CheckCircleIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { TOPICS } from "@lume/utils";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type Topic = {
|
||||||
|
title: string;
|
||||||
|
content: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-topic")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleTopic = (topic: Topic) => {
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.find((item) => item.title === topic.title)
|
||||||
|
? prev.filter((i) => i.title !== topic.title)
|
||||||
|
: [...prev, topic],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const key = `lume_topic_${search.label}`;
|
||||||
|
const createTopic = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
|
JSON.stringify(topics),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createTopic) {
|
||||||
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Create Topic",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="font-serif text-2xl font-medium">
|
||||||
|
What are your interests?
|
||||||
|
</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Add some topics you want to focus on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||||
|
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||||
|
<span className="text-sm font-medium">Added: {topics.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center w-full gap-3">
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{TOPICS.map((topic) => (
|
||||||
|
<button
|
||||||
|
key={topic.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTopic(topic)}
|
||||||
|
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<div>{topic.icon}</div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
<span>{topic.title}</span>
|
||||||
|
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
|
||||||
|
{topic.content.length} hashtags
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topics.find((item) => item.title === topic.title) ? (
|
||||||
|
<CheckCircleIcon className="text-teal-500 size-4" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || topics.length < 1}
|
||||||
|
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
import { NostrQuery } from "@lume/system";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { insertImage, isImagePath } from "@lume/utils";
|
||||||
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
|
||||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton() {
|
||||||
const ark = useArk();
|
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const upload = async () => {
|
||||||
try {
|
try {
|
||||||
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const image = await ark.upload();
|
const image = await NostrQuery.upload();
|
||||||
|
|
||||||
if (image) {
|
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
|
||||||
|
|
||||||
|
// reset loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(`Upload failed, error: ${e}`);
|
await message(String(e), { title: "Upload", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
let unlisten: UnlistenFn = undefined;
|
let unlisten: UnlistenFn = undefined;
|
||||||
|
|
||||||
async function listenFileDrop() {
|
async function listenFileDrop() {
|
||||||
const window = getCurrent();
|
const window = getCurrentWindow();
|
||||||
if (!unlisten) {
|
if (!unlisten) {
|
||||||
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
||||||
// @ts-ignore, lfg !!!
|
// @ts-ignore, lfg !!!
|
||||||
@@ -44,7 +42,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
// upload all images
|
// upload all images
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (isImagePath(item)) {
|
if (isImagePath(item)) {
|
||||||
const image = await ark.upload(item);
|
const image = await NostrQuery.upload(item);
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,15 +62,16 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => uploadToNostrBuild()}
|
onClick={() => upload()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<AddMediaIcon className="size-5" />
|
<AddMediaIcon className="size-4" />
|
||||||
)}
|
)}
|
||||||
|
Add media
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/desktop2/src/routes/editor/-components/pow.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { PowIcon } from "@lume/icons";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export function PowButton({
|
||||||
|
setDifficulty,
|
||||||
|
}: {
|
||||||
|
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||||
|
}
|
||||||
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<PowIcon className="size-4" />
|
||||||
|
PoW
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/desktop2/src/routes/editor/-components/warning.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NsfwIcon } from "@lume/icons";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export function WarningButton({
|
||||||
|
setWarning,
|
||||||
|
}: {
|
||||||
|
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||||
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<NsfwIcon className="size-4" />
|
||||||
|
Mark as sensitive
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||