Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab34de1ee | ||
|
|
9730837e00 | ||
|
|
ec34255df1 | ||
|
|
a262217ab2 | ||
|
|
c5d06a2492 | ||
|
|
c93edde7d2 | ||
|
|
5103126001 | ||
|
|
b0c49c5141 | ||
|
|
2ed2cb3afd | ||
|
|
2bcda1f2ef | ||
|
|
ca20bbd298 | ||
|
|
c6da06cd4d | ||
|
|
50bf6c04c1 | ||
|
|
490417771c | ||
|
|
0cb491eaf9 | ||
|
|
ece6bcc125 | ||
|
|
4b79e559d2 | ||
|
|
322e510db2 | ||
|
|
4e279f127d | ||
|
|
5655a8136d | ||
|
|
d80534c51f | ||
|
|
0b97248fb8 | ||
|
|
f54f448ecb | ||
|
|
bd1f2b899d | ||
|
|
efd3c83193 | ||
|
|
85fa1e2359 | ||
|
|
cd6ba5884f | ||
|
|
bfed56ba13 | ||
|
|
d1018ba8d1 | ||
|
|
a42542c16e | ||
|
|
26a6ec954c | ||
|
|
f346282c3c | ||
|
|
f3e875eeea | ||
|
|
6ad8ffddf0 | ||
|
|
d37e2a3c80 | ||
|
|
18e1ac0e6c | ||
|
|
aa4f21a869 | ||
|
|
bbc052eebc | ||
|
|
c201b5816c | ||
|
|
043cabfd4e | ||
|
|
9b02ab5842 | ||
|
|
618a45d349 | ||
|
|
11dcef4e87 | ||
|
|
d87371aec4 | ||
|
|
cfb017f70b | ||
|
|
9ba95301db | ||
|
|
0518389f50 | ||
|
|
eb6e3e52df | ||
|
|
1e95a2fd95 | ||
|
|
83d24351cd | ||
|
|
470dc1c759 | ||
|
|
42b780ce6a | ||
|
|
5ab2b1ae31 | ||
|
|
055d73c829 | ||
|
|
469296790e | ||
|
|
c032dbea1a | ||
|
|
172566028b | ||
|
|
cc7de41bfd | ||
|
|
ba9c81a10a | ||
|
|
e158f2e4d7 | ||
|
|
62bd689031 | ||
|
|
cb6006f596 | ||
|
|
adad048873 | ||
|
|
790fee6c05 | ||
|
|
9f5b14956a | ||
|
|
e04497d841 | ||
|
|
106c627ec4 | ||
|
|
c40762cc04 | ||
|
|
d2b5ae0507 | ||
|
|
8c6aea8050 | ||
|
|
090a815f99 | ||
|
|
d841163ba7 | ||
|
|
8398ae80d3 | ||
|
|
fe60f75e96 | ||
|
|
e098743d43 | ||
|
|
0c19ada1ab | ||
|
|
09db39fce1 | ||
|
|
f0fc89724d | ||
|
|
afa9327bb7 | ||
|
|
5c3644f977 | ||
|
|
0a8eed9a46 | ||
|
|
bacfaed48a | ||
|
|
3d5085785b | ||
|
|
9152c3e122 | ||
|
|
a5574bef6c | ||
|
|
2c7f3685b6 | ||
|
|
be0abc4075 | ||
|
|
dafe35cd1f | ||
|
|
b23903240b | ||
|
|
872a6cee36 | ||
|
|
ac7ce726c5 | ||
|
|
e5e290c0c3 | ||
|
|
2eab6f04c7 |
@@ -23,4 +23,5 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
src/router.gen.ts
|
src/routes.gen.ts
|
||||||
|
src/commands.gen.ts
|
||||||
|
|||||||
@@ -1,66 +1 @@
|
|||||||
## Introduction
|
### Lume will be rewritten in Rust
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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 are coming soon.
|
|
||||||
|
|
||||||
Windows and Linux are availabel on v3 and below.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node.js >= 18: https://nodejs.org/en
|
|
||||||
|
|
||||||
- Rust: https://rustup.rs/
|
|
||||||
|
|
||||||
- PNPM: https://pnpm.io
|
|
||||||
|
|
||||||
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
|
|
||||||
|
|
||||||
## Develop
|
|
||||||
|
|
||||||
Clone project
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone https://github.com/lumehq/lume.git && cd lume
|
|
||||||
```
|
|
||||||
|
|
||||||
Install packages
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Run dev build
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm tauri dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate production build
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nix
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
1. [Install Nix](https://zero-to-flakes.com/install)
|
|
||||||
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
|
|
||||||
|
|
||||||
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
|
||||||
|
|||||||
@@ -4,10 +4,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [
|
"ignore": ["./src/routes.gen.ts", "./src/commands.gen.ts"]
|
||||||
"./src/routes.gen.ts",
|
|
||||||
"./src/commands.gen.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Developing
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20: https://nodejs.org/
|
||||||
|
|
||||||
|
- Rust: https://rustup.rs/
|
||||||
|
|
||||||
|
- PNPM: https://pnpm.io/
|
||||||
|
|
||||||
|
- Tauri: https://tauri.app/guides/prerequisites/
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
Clone project
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/lumehq/lume.git && cd lume
|
||||||
|
```
|
||||||
|
|
||||||
|
Install required dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run dev
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Build
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm tauri build
|
||||||
|
```
|
||||||
@@ -10,76 +10,73 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/bitcoin-connect-react": "^3.6.2",
|
"@getalby/bitcoin-connect-react": "^3.6.3",
|
||||||
"@phosphor-icons/react": "^2.1.7",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.1",
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@tanstack/query-persist-client-core": "^5.51.21",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@tanstack/react-query": "^5.51.23",
|
"@tanstack/query-broadcast-client-experimental": "^5.62.16",
|
||||||
"@tanstack/react-router": "^1.48.1",
|
"@tanstack/query-persist-client-core": "^5.62.16",
|
||||||
"@tanstack/react-store": "^0.5.5",
|
"@tanstack/react-query": "^5.63.0",
|
||||||
"@tanstack/store": "^0.5.5",
|
"@tanstack/react-router": "^1.95.3",
|
||||||
"@tauri-apps/api": "2.0.0-rc.1",
|
"@tauri-apps/api": "^2.2.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.0",
|
"@tauri-apps/plugin-clipboard-manager": "^2.2.0",
|
||||||
"@tauri-apps/plugin-dialog": "2.0.0-rc.0",
|
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||||
"@tauri-apps/plugin-fs": "2.0.0-rc.1",
|
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||||
"@tauri-apps/plugin-http": "2.0.0-rc.1",
|
"@tauri-apps/plugin-http": "^2.2.0",
|
||||||
"@tauri-apps/plugin-os": "2.0.0-rc.0",
|
"@tauri-apps/plugin-os": "^2.2.0",
|
||||||
"@tauri-apps/plugin-process": "2.0.0-rc.0",
|
"@tauri-apps/plugin-process": "^2.2.0",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-rc.0",
|
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||||
"@tauri-apps/plugin-store": "2.0.0-rc.0",
|
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
|
||||||
"@tauri-apps/plugin-updater": "2.0.0-rc.0",
|
"@tauri-apps/plugin-updater": "^2.3.1",
|
||||||
"@tauri-apps/plugin-upload": "2.0.0-rc.0",
|
"@tauri-apps/plugin-upload": "^2.2.1",
|
||||||
"@tauri-apps/plugin-window-state": "2.0.0-rc.0",
|
"@tauri-apps/plugin-window-state": "^2.2.0",
|
||||||
"bitcoin-units": "^1.0.0",
|
"bitcoin-units": "^1.0.0",
|
||||||
"boring-avatars": "^1.10.2",
|
"dayjs": "^1.11.13",
|
||||||
"dayjs": "^1.11.12",
|
"embla-carousel-react": "^8.5.2",
|
||||||
"embla-carousel-react": "^8.1.8",
|
"i18next": "^23.16.8",
|
||||||
"i18next": "^23.13.0",
|
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"light-bolt11-decoder": "^3.1.1",
|
"light-bolt11-decoder": "^3.2.0",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.9",
|
||||||
"nostr-tools": "^2.7.2",
|
"nostr-tools": "^2.10.4",
|
||||||
"react": "19.0.0-rc-d025ddd3-20240722",
|
"react": "19.0.0-rc-cae764ce-20241025",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.9.0",
|
||||||
"react-dom": "19.0.0-rc-d025ddd3-20240722",
|
"react-dom": "19.0.0-rc-cae764ce-20241025",
|
||||||
"react-hook-form": "^7.52.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.4.0",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"slate": "^0.103.0",
|
"rich-textarea": "^0.26.4",
|
||||||
"slate-react": "^0.107.1",
|
"use-debounce": "^10.0.4",
|
||||||
"use-debounce": "^10.0.3",
|
"virtua": "^0.34.2"
|
||||||
"virtua": "^0.33.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.8.3",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@evilmartians/harmony": "^1.2.0",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/typography": "^0.5.14",
|
"@tanstack/router-devtools": "^1.95.3",
|
||||||
"@tanstack/router-devtools": "^1.48.1",
|
"@tanstack/router-plugin": "^1.95.3",
|
||||||
"@tanstack/router-plugin": "^1.47.0",
|
"@tauri-apps/cli": "^2.2.3",
|
||||||
"@tauri-apps/cli": "2.0.0-rc.4",
|
|
||||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
|
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.49",
|
||||||
"tailwind-gradient-mask-image": "^1.2.0",
|
"tailwind-gradient-mask-image": "^1.2.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
"tailwind-scrollbar": "^3.1.0",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-content-visibility": "^0.2.0",
|
"tailwindcss-content-visibility": "^1.0.2",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^5.4.1",
|
"vite": "^5.4.11",
|
||||||
"vite-tsconfig-paths": "^5.0.1"
|
"vite-tsconfig-paths": "5.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "npm:types-react@rc",
|
"@types/react": "npm:types-react@rc",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 104 KiB |
@@ -5,3 +5,6 @@
|
|||||||
# Generated by Tauri
|
# Generated by Tauri
|
||||||
# will have schema files for capabilities auto-completion
|
# will have schema files for capabilities auto-completion
|
||||||
/gen/schemas
|
/gen/schemas
|
||||||
|
|
||||||
|
# Config
|
||||||
|
.cargo
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "Lume"
|
name = "Lume"
|
||||||
version = "4.0.0"
|
version = "24.11.0"
|
||||||
description = "nostr client"
|
description = "nostr client"
|
||||||
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
|
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
|
||||||
repository = "https://github.com/lumehq/lume"
|
repository = "https://github.com/lumehq/lume"
|
||||||
@@ -8,54 +8,53 @@ edition = "2021"
|
|||||||
rust-version = "1.70"
|
rust-version = "1.70"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.0.0-rc", features = [] }
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
tauri = { version = "2.0.0", features = [ "protocol-asset",
|
||||||
"sqlite",
|
"unstable",
|
||||||
|
"tray-icon",
|
||||||
|
"macos-private-api"
|
||||||
] }
|
] }
|
||||||
|
tauri-plugin-window-state = "2.0.0"
|
||||||
|
tauri-plugin-clipboard-manager = "2.0.0"
|
||||||
|
tauri-plugin-dialog = "2.0.0"
|
||||||
|
tauri-plugin-fs = "2.0.0"
|
||||||
|
tauri-plugin-http = "2.0.0"
|
||||||
|
tauri-plugin-notification = "2.0.0"
|
||||||
|
tauri-plugin-os = "2.0.0"
|
||||||
|
tauri-plugin-process = "2.0.0"
|
||||||
|
tauri-plugin-shell = "2.0.0"
|
||||||
|
tauri-plugin-updater = "2.0.0"
|
||||||
|
tauri-plugin-upload = "2.0.0"
|
||||||
|
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "8c67d44" }
|
||||||
|
tauri-plugin-prevent-default = "0.6"
|
||||||
|
tauri-plugin-theme = "2.1.2"
|
||||||
|
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum" }
|
||||||
|
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
|
||||||
|
|
||||||
|
specta = "^2.0.0-rc.20"
|
||||||
|
specta-typescript = "0.0.7"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "2.0.0-rc", features = [
|
|
||||||
"unstable",
|
|
||||||
"tray-icon",
|
|
||||||
"macos-private-api",
|
|
||||||
"protocol-asset",
|
|
||||||
] }
|
|
||||||
tauri-plugin-window-state = "2.0.0-rc"
|
|
||||||
tauri-plugin-clipboard-manager = "2.0.0-rc"
|
|
||||||
tauri-plugin-dialog = "2.0.0-rc"
|
|
||||||
tauri-plugin-fs = "2.0.0-rc"
|
|
||||||
tauri-plugin-http = "2.0.0-rc"
|
|
||||||
tauri-plugin-notification = "2.0.0-rc"
|
|
||||||
tauri-plugin-os = "2.0.0-rc"
|
|
||||||
tauri-plugin-process = "2.0.0-rc"
|
|
||||||
tauri-plugin-shell = "2.0.0-rc"
|
|
||||||
tauri-plugin-updater = "2.0.0-rc"
|
|
||||||
tauri-plugin-upload = "2.0.0-rc"
|
|
||||||
tauri-plugin-store = "2.0.0-rc"
|
|
||||||
tauri-plugin-theme = "0.4.1"
|
|
||||||
tauri-plugin-decorum = "1.0.0"
|
|
||||||
tauri-plugin-prevent-default = "0.4"
|
|
||||||
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
|
|
||||||
specta = "^2.0.0-rc.20"
|
|
||||||
specta-typescript = "0.0.7"
|
|
||||||
reqwest = "0.12.4"
|
reqwest = "0.12.4"
|
||||||
url = "2.5.0"
|
url = "2.5.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
regex = "1.10.4"
|
regex = "1.10.4"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
||||||
keyring-search = "1.2.0"
|
keyring-search = { git = "https://github.com/reyamir/keyring-search" }
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
|
async-trait = "0.1.83"
|
||||||
|
webbrowser = "1.0.2"
|
||||||
|
|
||||||
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "webln", "all-nips"] }
|
||||||
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
cocoa = "0.25.0"
|
|
||||||
objc = "0.2.7"
|
|
||||||
rand = "0.8.5"
|
|
||||||
monitor = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
|
||||||
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||||
|
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "desktop-capability",
|
|
||||||
"description": "Capability for the column",
|
|
||||||
"platforms": [
|
|
||||||
"linux",
|
|
||||||
"macOS",
|
|
||||||
"windows"
|
|
||||||
],
|
|
||||||
"windows": [
|
|
||||||
"column-*"
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
"core:resources:default",
|
|
||||||
"core:tray:default",
|
|
||||||
"os:allow-locale",
|
|
||||||
"os:allow-os-type",
|
|
||||||
"clipboard-manager:allow-write-text",
|
|
||||||
"dialog:allow-open",
|
|
||||||
"dialog:allow-ask",
|
|
||||||
"dialog:allow-message",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"core:menu:default",
|
|
||||||
"core:menu:allow-new",
|
|
||||||
"core:menu:allow-popup",
|
|
||||||
"http:default",
|
|
||||||
"shell:allow-open",
|
|
||||||
"store:allow-get",
|
|
||||||
"store:allow-set",
|
|
||||||
"store:allow-delete",
|
|
||||||
{
|
|
||||||
"identifier": "http:default",
|
|
||||||
"allow": [
|
|
||||||
{
|
|
||||||
"url": "http://**/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://**/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "fs:allow-read-text-file",
|
|
||||||
"allow": [
|
|
||||||
{
|
|
||||||
"path": "$RESOURCE/locales/*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "$RESOURCE/resources/*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "desktop-capability",
|
"identifier": "window",
|
||||||
"description": "Capability for the desktop",
|
"description": "Capability for the desktop",
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"macOS",
|
"macOS",
|
||||||
"windows"
|
"windows"
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main",
|
"*"
|
||||||
"panel",
|
|
||||||
"settings",
|
|
||||||
"search-*",
|
|
||||||
"zap-*",
|
|
||||||
"event-*",
|
|
||||||
"user-*",
|
|
||||||
"editor-*"
|
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:path:default",
|
"core:path:default",
|
||||||
@@ -24,6 +17,23 @@
|
|||||||
"core:resources:default",
|
"core:resources:default",
|
||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"core:tray:default",
|
"core:tray:default",
|
||||||
|
"core:window:allow-create",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-center",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-create-webview",
|
||||||
|
"core:webview:allow-set-webview-size",
|
||||||
|
"core:webview:allow-set-webview-position",
|
||||||
|
"core:webview:allow-webview-close",
|
||||||
|
"core:menu:allow-new",
|
||||||
|
"core:menu:allow-popup",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:default",
|
"notification:default",
|
||||||
@@ -33,39 +43,17 @@
|
|||||||
"updater:default",
|
"updater:default",
|
||||||
"updater:allow-check",
|
"updater:allow-check",
|
||||||
"updater:allow-download-and-install",
|
"updater:allow-download-and-install",
|
||||||
"core:window:allow-create",
|
|
||||||
"core:window:allow-close",
|
|
||||||
"core:window:allow-destroy",
|
|
||||||
"core:window:allow-set-focus",
|
|
||||||
"core:window:allow-center",
|
|
||||||
"core:window:allow-minimize",
|
|
||||||
"core:window:allow-maximize",
|
|
||||||
"core:window:allow-set-size",
|
|
||||||
"core:window:allow-set-focus",
|
|
||||||
"core:window:allow-start-dragging",
|
|
||||||
"decorum:allow-show-snap-overlay",
|
"decorum:allow-show-snap-overlay",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"clipboard-manager:allow-read-text",
|
"clipboard-manager:allow-read-text",
|
||||||
"core:webview:allow-create-webview-window",
|
"dialog:default",
|
||||||
"core:webview:allow-create-webview",
|
|
||||||
"core:webview:allow-set-webview-size",
|
|
||||||
"core:webview:allow-set-webview-position",
|
|
||||||
"core:webview:allow-webview-close",
|
|
||||||
"dialog:allow-open",
|
|
||||||
"dialog:allow-ask",
|
|
||||||
"dialog:allow-message",
|
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"process:allow-exit",
|
"process:allow-exit",
|
||||||
"fs:allow-read-file",
|
"fs:allow-read-file",
|
||||||
"theme:allow-set-theme",
|
|
||||||
"theme:allow-get-theme",
|
|
||||||
"core:menu:allow-new",
|
|
||||||
"core:menu:allow-popup",
|
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"store:allow-get",
|
"store:default",
|
||||||
"store:allow-set",
|
|
||||||
"store:allow-delete",
|
|
||||||
"prevent-default:default",
|
"prevent-default:default",
|
||||||
|
"theme:default",
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-set-focus","core:window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete","prevent-default:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
|
{"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","core:menu:allow-new","core:menu:allow-popup","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:default","process:allow-restart","process:allow-exit","fs:allow-read-file","shell:allow-open","store:default","prevent-default:default","theme:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 620 B |
@@ -1,83 +1,37 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"default": true,
|
"default": true,
|
||||||
"official": true,
|
|
||||||
"label": "onboarding",
|
"label": "onboarding",
|
||||||
"name": "Onboarding",
|
"name": "Onboarding",
|
||||||
"description": "Tips for Mastering Lume.",
|
"description": "Tips for Mastering Lume.",
|
||||||
"url": "/columns/onboarding",
|
"url": "/columns/onboarding"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": true,
|
"default": true,
|
||||||
"official": true,
|
"label": "launchpad",
|
||||||
"label": "columns_gallery",
|
"name": "Launchpad",
|
||||||
"name": "Columns Gallery",
|
|
||||||
"description": "Expand your experiences.",
|
"description": "Expand your experiences.",
|
||||||
"url": "/columns/gallery",
|
"url": "/columns/launchpad"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": false,
|
"default": false,
|
||||||
"official": true,
|
|
||||||
"label": "local_feeds",
|
|
||||||
"name": "Local Feeds",
|
|
||||||
"description": "All notes from your follows.",
|
|
||||||
"url": "/columns/newsfeed",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "notification",
|
|
||||||
"name": "Notification",
|
|
||||||
"description": "All things around you.",
|
|
||||||
"url": "/columns/notification",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "search",
|
"label": "search",
|
||||||
"name": "Search",
|
"name": "Search",
|
||||||
"description": "Find anything.",
|
"description": "Find anything.",
|
||||||
"url": "/columns/search",
|
"url": "/columns/search"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": false,
|
"default": false,
|
||||||
"official": true,
|
|
||||||
"label": "stories",
|
|
||||||
"name": "Stories",
|
|
||||||
"description": "Keep up to date with your follows.",
|
|
||||||
"url": "/columns/stories",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "global_feeds",
|
"label": "global_feeds",
|
||||||
"name": "Global Feeds",
|
"name": "Global Feeds",
|
||||||
"description": "Discover all global notes.",
|
"description": "All global notes from all connected relays.",
|
||||||
"url": "/columns/global",
|
"url": "/columns/global"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": false,
|
"default": false,
|
||||||
"official": true,
|
|
||||||
"label": "group_feeds",
|
|
||||||
"name": "Group",
|
|
||||||
"description": "Custom feeds for group of people.",
|
|
||||||
"url": "/columns/group",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "trending",
|
"label": "trending",
|
||||||
"name": "Trending",
|
"name": "Trending",
|
||||||
"description": "Discover all trending notes.",
|
"description": "Discover all trending notes.",
|
||||||
"url": "/columns/trending",
|
"url": "/columns/trending"
|
||||||
"picture": ""
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
wss://relay.damus.io,
|
wss://relay.damus.io,
|
||||||
wss://relay.nostr.net,
|
wss://relay.primal.net,
|
||||||
wss://purplepag.es/,
|
wss://nostr.fmt.wiz.biz,
|
||||||
wss://directory.yabu.me/,
|
wss://directory.yabu.me,
|
||||||
|
wss://purplepag.es,
|
||||||
|
|||||||
@@ -1,120 +1,92 @@
|
|||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use keyring_search::{Limit, List, Search};
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{collections::HashSet, str::FromStr, time::Duration};
|
use std::{str::FromStr, time::Duration};
|
||||||
use tauri::{Emitter, Manager, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::{
|
use crate::{common::get_all_accounts, Nostr, Settings};
|
||||||
common::{get_user_settings, init_nip65},
|
|
||||||
Nostr, NEWSFEED_NEG_LIMIT, NOTIFICATION_NEG_LIMIT, NOTIFICATION_SUB_ID,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
struct Account {
|
struct Account {
|
||||||
password: String,
|
secret_key: String,
|
||||||
nostr_connect: Option<String>,
|
nostr_connect: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct AuthHandler;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AuthUrlHandler for AuthHandler {
|
||||||
|
async fn on_auth_url(&self, auth_url: Url) -> Result<()> {
|
||||||
|
webbrowser::open(auth_url.as_str())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn get_accounts() -> Vec<String> {
|
pub fn get_accounts() -> Vec<String> {
|
||||||
let search = Search::new().expect("Unexpected.");
|
get_all_accounts()
|
||||||
let results = search.by_service("Lume Secret Storage");
|
|
||||||
let list = List::list_credentials(&results, Limit::All);
|
|
||||||
let accounts: HashSet<String> = list
|
|
||||||
.split_whitespace()
|
|
||||||
.filter(|v| v.starts_with("npub1"))
|
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
accounts.into_iter().collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn create_account(
|
pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
name: String,
|
let client = &state.client;
|
||||||
about: String,
|
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
picture: String,
|
let npub = public_key.to_bech32().map_err(|e| e.to_string())?;
|
||||||
password: String,
|
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Set empty password
|
||||||
|
keyring.set_password("").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Get user's profile
|
||||||
|
let _ = client
|
||||||
|
.fetch_metadata(public_key, Some(Duration::from_secs(4)))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(npub)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn import_account(
|
||||||
|
key: String,
|
||||||
|
password: Option<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keys = Keys::generate();
|
|
||||||
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
|
|
||||||
let secret_key = keys.secret_key().map_err(|e| e.to_string())?;
|
|
||||||
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
// Save account
|
// Create secret key
|
||||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
let secret_key = if let Some(pw) = password {
|
||||||
let account = Account {
|
|
||||||
password: enc_bech32,
|
|
||||||
nostr_connect: None,
|
|
||||||
};
|
|
||||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
|
||||||
let _ = keyring.set_password(&j);
|
|
||||||
|
|
||||||
let signer = NostrSigner::Keys(keys);
|
|
||||||
|
|
||||||
// Update signer
|
|
||||||
client.set_signer(Some(signer)).await;
|
|
||||||
|
|
||||||
let mut metadata = Metadata::new()
|
|
||||||
.display_name(name.clone())
|
|
||||||
.name(name.to_lowercase())
|
|
||||||
.about(about);
|
|
||||||
|
|
||||||
if let Ok(url) = Url::parse(&picture) {
|
|
||||||
metadata = metadata.picture(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
match client.set_metadata(&metadata).await {
|
|
||||||
Ok(_) => Ok(npub),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn import_account(key: String, password: String) -> Result<String, String> {
|
|
||||||
let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
|
|
||||||
true => {
|
|
||||||
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
enc.to_secret_key(pw).map_err(|err| err.to_string())?
|
||||||
let secret_key = enc.to_secret_key(password).map_err(|err| err.to_string())?;
|
} else {
|
||||||
let keys = Keys::new(secret_key);
|
SecretKey::from_str(&key).map_err(|err| err.to_string())?
|
||||||
let npub = keys.public_key().to_bech32().unwrap();
|
|
||||||
|
|
||||||
(npub, enc_bech32)
|
|
||||||
}
|
|
||||||
false => {
|
|
||||||
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
|
||||||
let keys = Keys::new(secret_key.clone());
|
|
||||||
let npub = keys.public_key().to_bech32().unwrap();
|
|
||||||
|
|
||||||
let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
(npub, enc_bech32)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
let hex = secret_key.to_secret_hex();
|
||||||
|
let keys = Keys::new(secret_key);
|
||||||
|
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
let npub = public_key.to_bech32().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let account = Account {
|
let account = Account {
|
||||||
password: enc_bech32,
|
secret_key: hex,
|
||||||
nostr_connect: None,
|
nostr_connect: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save secret key to keyring
|
||||||
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||||
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Update signer
|
||||||
|
client.set_signer(keys).await;
|
||||||
|
|
||||||
Ok(npub)
|
Ok(npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,46 +94,50 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let bunker_uri = NostrConnectURI::parse(&uri).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match NostrConnectURI::parse(uri.clone()) {
|
|
||||||
Ok(bunker_uri) => {
|
|
||||||
// Local user
|
// Local user
|
||||||
let app_keys = Keys::generate();
|
let app_keys = Keys::generate();
|
||||||
let app_secret = app_keys.secret_key().unwrap().to_string();
|
let app_secret = app_keys.secret_key().to_secret_hex();
|
||||||
|
|
||||||
// Get remote user
|
// Get remote user
|
||||||
let remote_user = bunker_uri.signer_public_key().unwrap();
|
let remote_user = bunker_uri.remote_signer_public_key().unwrap();
|
||||||
let remote_npub = remote_user.to_bech32().unwrap();
|
let remote_npub = remote_user.to_bech32().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None).await {
|
// Init nostr connect
|
||||||
Ok(signer) => {
|
let mut nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
|
||||||
let mut url = Url::parse(&uri).unwrap();
|
.map_err(|err| err.to_string())?;
|
||||||
let query: Vec<(String, String)> = url
|
|
||||||
.query_pairs()
|
// Handle auth url
|
||||||
.filter(|(name, _)| name != "secret")
|
nostr_connect.auth_url_handler(AuthHandler);
|
||||||
.map(|(name, value)| (name.into_owned(), value.into_owned()))
|
|
||||||
.collect();
|
let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
|
||||||
url.query_pairs_mut().clear().extend_pairs(&query);
|
|
||||||
|
let reuse_bunker = nostr_connect
|
||||||
|
.bunker_uri()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let mut reuse_uri = reuse_bunker.to_string();
|
||||||
|
|
||||||
|
if let Some(secret) = reuse_bunker.secret() {
|
||||||
|
let replace = format!("&secret={}", secret);
|
||||||
|
reuse_uri = reuse_uri.replace(replace.as_str(), "");
|
||||||
|
}
|
||||||
|
|
||||||
let key = format!("{}_nostrconnect", remote_npub);
|
|
||||||
let keyring = Entry::new("Lume Secret Storage", &key).unwrap();
|
|
||||||
let account = Account {
|
let account = Account {
|
||||||
password: app_secret,
|
secret_key: app_secret,
|
||||||
nostr_connect: Some(url.to_string()),
|
nostr_connect: Some(reuse_uri),
|
||||||
};
|
};
|
||||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
|
||||||
let _ = keyring.set_password(&j);
|
// Save secret key to keyring
|
||||||
|
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||||
|
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Update signer
|
// Update signer
|
||||||
let _ = client.set_signer(Some(signer.into())).await;
|
let _ = client.set_signer(nostr_connect).await;
|
||||||
|
|
||||||
Ok(remote_npub)
|
Ok(remote_npub)
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -175,9 +151,9 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
|
|||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||||
let account = Account {
|
let account = Account {
|
||||||
password: enc_bech32,
|
secret_key: enc_bech32,
|
||||||
nostr_connect: None,
|
nostr_connect: None,
|
||||||
};
|
};
|
||||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||||
@@ -189,16 +165,17 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn get_private_key(id: String) -> Result<String, String> {
|
pub fn get_private_key(id: String) -> Result<String, String> {
|
||||||
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||||
let password = keyring.get_password().map_err(|e| e.to_string())?;
|
let password = keyring.get_password().map_err(|e| e.to_string())?;
|
||||||
|
let account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(password)
|
Ok(account.secret_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn delete_account(id: String) -> Result<(), String> {
|
pub fn delete_account(id: String) -> Result<(), String> {
|
||||||
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||||
let _ = keyring.delete_credential();
|
let _ = keyring.delete_credential();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -206,135 +183,91 @@ pub fn delete_account(id: String) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn login(
|
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
account: String,
|
let client = &state.client;
|
||||||
password: String,
|
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match client.signer().await {
|
||||||
|
Ok(signer) => {
|
||||||
|
let signer_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
|
||||||
|
let is_match = signer_key == public_key;
|
||||||
|
|
||||||
|
Ok(is_match)
|
||||||
|
}
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn set_signer(
|
||||||
|
id: String,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
handle: tauri::AppHandle,
|
handle: tauri::AppHandle,
|
||||||
) -> Result<String, String> {
|
) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keyring = Entry::new("Lume Secret Storage", &account).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let account = match keyring.get_password() {
|
let account = match keyring.get_password() {
|
||||||
Ok(pw) => {
|
Ok(pw) => {
|
||||||
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
|
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if account.secret_key.is_empty() {
|
||||||
|
return Err("Watch Only account".into());
|
||||||
|
};
|
||||||
|
|
||||||
account
|
account
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.to_string()),
|
Err(e) => return Err(e.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let public_key = match account.nostr_connect {
|
match account.nostr_connect {
|
||||||
None => {
|
None => {
|
||||||
let ncryptsec =
|
let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||||
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
|
|
||||||
let secret_key = ncryptsec
|
|
||||||
.to_secret_key(password)
|
|
||||||
.map_err(|_| "Wrong password.")?;
|
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
let public_key = keys.public_key().to_bech32().unwrap();
|
|
||||||
let signer = NostrSigner::Keys(keys);
|
|
||||||
|
|
||||||
// Update signer
|
// Update signer
|
||||||
client.set_signer(Some(signer)).await;
|
client.set_signer(keys).await;
|
||||||
|
// Emit to front-end
|
||||||
|
handle.emit("signer-updated", ()).unwrap();
|
||||||
|
|
||||||
public_key
|
Ok(())
|
||||||
}
|
}
|
||||||
Some(bunker) => {
|
Some(bunker) => {
|
||||||
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
|
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
|
||||||
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap();
|
let app_keys = Keys::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||||
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None).await {
|
match NostrConnect::new(uri, app_keys, Duration::from_secs(120), None) {
|
||||||
Ok(signer) => {
|
Ok(mut signer) => {
|
||||||
|
// Handle auth url
|
||||||
|
signer.auth_url_handler(AuthHandler);
|
||||||
// Update signer
|
// Update signer
|
||||||
client.set_signer(Some(signer.into())).await;
|
client.set_signer(signer).await;
|
||||||
public_key
|
// Emit to front-end
|
||||||
|
handle.emit("signer-updated", ()).unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to user's relay (NIP-65)
|
|
||||||
init_nip65(client).await;
|
|
||||||
|
|
||||||
// Get user's contact list
|
|
||||||
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
|
|
||||||
let mut contacts_state = state.contact_list.lock().await;
|
|
||||||
*contacts_state = contacts;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get user's settings
|
|
||||||
if let Ok(settings) = get_user_settings(client).await {
|
|
||||||
let mut settings_state = state.settings.lock().await;
|
|
||||||
*settings_state = settings;
|
|
||||||
};
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let state = handle.state::<Nostr>();
|
|
||||||
let client = &state.client;
|
|
||||||
let contact_list = state.contact_list.lock().await;
|
|
||||||
|
|
||||||
let signer = client.signer().await.unwrap();
|
|
||||||
let public_key = signer.public_key().await.unwrap();
|
|
||||||
|
|
||||||
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
|
||||||
|
|
||||||
if !contact_list.is_empty() {
|
|
||||||
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
|
|
||||||
let sync = Filter::new()
|
|
||||||
.authors(authors.clone())
|
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
|
||||||
.limit(NEWSFEED_NEG_LIMIT);
|
|
||||||
|
|
||||||
if client
|
|
||||||
.reconcile(sync, NegentropyOptions::default())
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
handle.emit("newsfeed_synchronized", ()).unwrap();
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
drop(contact_list);
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
|
||||||
|
let settings = state.settings.read().await.clone();
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
let sync = Filter::new()
|
#[tauri::command]
|
||||||
.pubkey(public_key)
|
#[specta::specta]
|
||||||
.kinds(vec![
|
pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
Kind::TextNote,
|
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
|
||||||
Kind::Repost,
|
let mut settings = state.settings.write().await;
|
||||||
Kind::Reaction,
|
// Update state
|
||||||
Kind::ZapReceipt,
|
settings.clone_from(&parsed);
|
||||||
])
|
|
||||||
.limit(NOTIFICATION_NEG_LIMIT);
|
|
||||||
|
|
||||||
// Sync notification with negentropy
|
Ok(())
|
||||||
if client
|
|
||||||
.reconcile(sync, NegentropyOptions::default())
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
handle.emit("notification_synchronized", ()).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let notification = Filter::new()
|
|
||||||
.pubkey(public_key)
|
|
||||||
.kinds(vec![
|
|
||||||
Kind::TextNote,
|
|
||||||
Kind::Repost,
|
|
||||||
Kind::Reaction,
|
|
||||||
Kind::ZapReceipt,
|
|
||||||
])
|
|
||||||
.since(Timestamp::now());
|
|
||||||
|
|
||||||
// Subscribing for new notification...
|
|
||||||
if let Err(e) = client
|
|
||||||
.subscribe_with_id(notification_id, vec![notification], None)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Error: {}", e)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(public_key)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,55 +4,40 @@ use serde::{Deserialize, Serialize};
|
|||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{str::FromStr, time::Duration};
|
use std::{str::FromStr, time::Duration};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tauri_specta::Event;
|
|
||||||
|
|
||||||
use crate::{common::get_latest_event, NewSettings, Nostr, Settings};
|
use crate::{common::process_event, Nostr, RichEvent, FETCH_LIMIT};
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||||
pub struct Profile {
|
pub struct Mention {
|
||||||
name: String,
|
pubkey: String,
|
||||||
|
avatar: String,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
about: Option<String>,
|
name: String,
|
||||||
picture: String,
|
|
||||||
banner: Option<String>,
|
|
||||||
nip05: Option<String>,
|
|
||||||
lud16: Option<String>,
|
|
||||||
website: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn get_profile(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let public_key: PublicKey = match id {
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
Some(user_id) => PublicKey::parse(&user_id).map_err(|e| e.to_string())?,
|
|
||||||
None => client.signer().await.unwrap().public_key().await.unwrap(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
match client
|
let events = client
|
||||||
.get_events_of(
|
.database()
|
||||||
vec![filter],
|
.query(vec![filter])
|
||||||
EventSource::both(Some(Duration::from_secs(3))),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = events.first() {
|
match events.first() {
|
||||||
if let Ok(metadata) = Metadata::from_json(&event.content) {
|
Some(event) => match Metadata::from_json(&event.content) {
|
||||||
Ok(metadata.as_json())
|
Ok(metadata) => Ok(metadata.as_json()),
|
||||||
} else {
|
|
||||||
Err("Parse metadata failed".into())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(Metadata::new().as_json())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
None => Err("Metadata not found".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +56,6 @@ pub async fn set_contact_list(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Update local state
|
|
||||||
state.contact_list.lock().await.clone_from(&contact_list);
|
|
||||||
|
|
||||||
match client.set_contact_list(contact_list).await {
|
match client.set_contact_list(contact_list).await {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
@@ -82,52 +64,26 @@ pub async fn set_contact_list(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match client.get_contact_list(Some(Duration::from_secs(10))).await {
|
let contact_list = client
|
||||||
Ok(contact_list) => {
|
.database()
|
||||||
if !contact_list.is_empty() {
|
.contacts_public_keys(public_key)
|
||||||
let list = contact_list
|
.await
|
||||||
.into_iter()
|
.map_err(|e| e.to_string())?;
|
||||||
.map(|f| f.public_key.to_hex())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(list)
|
let pubkeys: Vec<String> = contact_list.into_iter().map(|pk| pk.to_hex()).collect();
|
||||||
} else {
|
|
||||||
Err("Empty.".into())
|
Ok(pubkeys)
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn set_profile(new_profile: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let mut metadata = Metadata::new()
|
let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
|
||||||
.name(profile.name)
|
|
||||||
.display_name(profile.display_name)
|
|
||||||
.about(profile.about.unwrap_or_default())
|
|
||||||
.nip05(profile.nip05.unwrap_or_default())
|
|
||||||
.lud16(profile.lud16.unwrap_or_default());
|
|
||||||
|
|
||||||
if let Ok(url) = Url::parse(&profile.picture) {
|
|
||||||
metadata = metadata.picture(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(b) = profile.banner {
|
|
||||||
if let Ok(url) = Url::parse(&b) {
|
|
||||||
metadata = metadata.banner(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(w) = profile.website {
|
|
||||||
if let Ok(url) = Url::parse(&w) {
|
|
||||||
metadata = metadata.website(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match client.set_metadata(&metadata).await {
|
match client.set_metadata(&metadata).await {
|
||||||
Ok(id) => Ok(id.to_string()),
|
Ok(id) => Ok(id.to_string()),
|
||||||
@@ -137,21 +93,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn is_contact_list_empty(state: State<'_, Nostr>) -> Result<bool, ()> {
|
pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
Ok(state.contact_list.lock().await.is_empty())
|
let client = &state.client;
|
||||||
}
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
#[tauri::command]
|
match client.database().contacts_public_keys(public_key).await {
|
||||||
#[specta::specta]
|
Ok(public_keys) => Ok(public_keys.iter().any(|i| i == &public_key)),
|
||||||
pub async fn check_contact(hex: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
Err(_) => Ok(false),
|
||||||
let contact_list = state.contact_list.lock().await;
|
|
||||||
|
|
||||||
match PublicKey::parse(&hex) {
|
|
||||||
Ok(public_key) => match contact_list.iter().position(|x| x.public_key == public_key) {
|
|
||||||
Some(_) => Ok(true),
|
|
||||||
None => Ok(false),
|
|
||||||
},
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,9 +129,6 @@ pub async fn toggle_contact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state
|
|
||||||
state.contact_list.lock().await.clone_from(&contact_list);
|
|
||||||
|
|
||||||
// Publish
|
// Publish
|
||||||
match client.set_contact_list(contact_list).await {
|
match client.set_contact_list(contact_list).await {
|
||||||
Ok(event_id) => Ok(event_id.to_string()),
|
Ok(event_id) => Ok(event_id.to_string()),
|
||||||
@@ -196,57 +141,303 @@ pub async fn toggle_contact(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn set_lume_store(
|
pub async fn set_group(
|
||||||
key: String,
|
title: String,
|
||||||
content: String,
|
description: Option<String>,
|
||||||
|
image: Option<String>,
|
||||||
|
users: Vec<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
let public_keys: Vec<PublicKey> = users
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
.iter()
|
||||||
|
.filter_map(|u| {
|
||||||
|
if let Ok(pk) = PublicKey::from_str(u) {
|
||||||
|
Some(pk)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let label = title.to_lowercase().replace(" ", "-");
|
||||||
|
let mut tags: Vec<Tag> = vec![Tag::title(title)];
|
||||||
|
|
||||||
let encrypted = signer
|
if let Some(desc) = description {
|
||||||
.nip44_encrypt(public_key, content)
|
tags.push(Tag::description(desc))
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(img) = image {
|
||||||
|
let url = UncheckedUrl::new(img);
|
||||||
|
tags.push(Tag::image(url, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = EventBuilder::follow_set(label, public_keys.clone()).add_tags(tags);
|
||||||
|
|
||||||
|
// Sign event
|
||||||
|
let event = client
|
||||||
|
.sign_event_builder(builder)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let tag = Tag::identifier(key);
|
|
||||||
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
|
|
||||||
|
|
||||||
match client.send_event_builder(builder).await {
|
match client.send_event(event).await {
|
||||||
Ok(event_id) => Ok(event_id.to_string()),
|
Ok(output) => Ok(output.to_hex()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_lume_store(key: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
|
||||||
|
match client.database().event_by_id(&event_id).await {
|
||||||
|
Ok(event) => {
|
||||||
|
if let Some(ev) = event {
|
||||||
|
Ok(ev.as_json())
|
||||||
|
} else {
|
||||||
|
Err("Event not found".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_newsfeeds(
|
||||||
|
id: String,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let groups = Filter::new().kind(Kind::FollowSet).author(public_key);
|
||||||
|
let contacts = Filter::new()
|
||||||
|
.kind(Kind::ContactList)
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let mut remote_events = Events::new(&[groups.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![groups], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
remote_events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let contact_events = client
|
||||||
|
.fetch_events(vec![contacts], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let events = remote_events.merge(contact_events);
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_local_newsfeeds(
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::FollowSet)
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn set_interest(
|
||||||
|
title: String,
|
||||||
|
description: Option<String>,
|
||||||
|
image: Option<String>,
|
||||||
|
hashtags: Vec<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let label = title.to_lowercase().replace(" ", "-");
|
||||||
|
let mut tags: Vec<Tag> = vec![Tag::title(title)];
|
||||||
|
|
||||||
|
if let Some(desc) = description {
|
||||||
|
tags.push(Tag::description(desc))
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(img) = image {
|
||||||
|
let url = UncheckedUrl::new(img);
|
||||||
|
tags.push(Tag::image(url, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = EventBuilder::interest_set(label, hashtags.clone()).add_tags(tags);
|
||||||
|
|
||||||
|
// Sign event
|
||||||
|
let event = client
|
||||||
|
.sign_event_builder(builder)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
match client.send_event(event).await {
|
||||||
|
Ok(output) => Ok(output.to_hex()),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match client.database().event_by_id(&event_id).await {
|
||||||
|
Ok(event) => {
|
||||||
|
if let Some(ev) = event {
|
||||||
|
Ok(ev.as_json())
|
||||||
|
} else {
|
||||||
|
Err("Event not found".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_interests(
|
||||||
|
id: String,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::InterestSet, Kind::Interests])
|
||||||
|
.author(public_key);
|
||||||
|
|
||||||
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_local_interests(
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::Interests, Kind::InterestSet])
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_relay_list(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::RelayList)
|
||||||
.identifier(key)
|
.limit(1);
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
match client
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
.get_events_of(vec![filter], EventSource::Database)
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = get_latest_event(&events) {
|
while let Some(event) = rx.next().await {
|
||||||
match signer.nip44_decrypt(public_key, event.content()).await {
|
events.insert(event);
|
||||||
Ok(decrypted) => Ok(decrypted),
|
|
||||||
Err(_) => Err(event.content.to_string()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(event) = events.first() {
|
||||||
|
Ok(event.as_json())
|
||||||
} else {
|
} else {
|
||||||
Err("Not found".into())
|
Err("Relay list not found".into())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let data: Vec<Mention> = events
|
||||||
|
.iter()
|
||||||
|
.map(|event| {
|
||||||
|
let pubkey = event.pubkey.to_bech32().unwrap();
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new());
|
||||||
|
|
||||||
|
Mention {
|
||||||
|
pubkey,
|
||||||
|
avatar: metadata.picture.unwrap_or_else(|| "".to_string()),
|
||||||
|
display_name: metadata.display_name.unwrap_or_else(|| "".to_string()),
|
||||||
|
name: metadata.name.unwrap_or_else(|| "".to_string()),
|
||||||
}
|
}
|
||||||
Err(err) => Err(err.to_string()),
|
})
|
||||||
}
|
.collect();
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -256,7 +447,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
|
|||||||
|
|
||||||
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
|
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
|
||||||
let nwc = NWC::new(nwc_uri);
|
let nwc = NWC::new(nwc_uri);
|
||||||
let keyring = Entry::new("Lume Secret", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
let keyring =
|
||||||
|
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
keyring.set_password(uri).map_err(|e| e.to_string())?;
|
keyring.set_password(uri).map_err(|e| e.to_string())?;
|
||||||
client.set_zapper(nwc).await;
|
client.set_zapper(nwc).await;
|
||||||
|
|
||||||
@@ -268,37 +461,32 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
|
if client.zapper().await.is_err() {
|
||||||
let keyring =
|
let keyring =
|
||||||
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match keyring.get_password() {
|
match keyring.get_password() {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
||||||
let nwc = NWC::new(uri);
|
let nwc = NWC::new(uri);
|
||||||
|
|
||||||
// Get current balance
|
|
||||||
let balance = nwc.get_balance().await;
|
|
||||||
|
|
||||||
// Update zapper
|
|
||||||
client.set_zapper(nwc).await;
|
client.set_zapper(nwc).await;
|
||||||
|
}
|
||||||
|
Err(_) => return Err("Wallet not found.".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match balance {
|
Ok(())
|
||||||
Ok(val) => Ok(val.to_string()),
|
|
||||||
Err(_) => Err("Get balance failed.".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => Err("NWC not found.".into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keyring =
|
let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||||
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match keyring.delete_credential() {
|
match keyring.delete_credential() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -312,52 +500,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn zap_profile(
|
pub async fn zap_profile(
|
||||||
id: &str,
|
id: String,
|
||||||
amount: &str,
|
amount: String,
|
||||||
message: &str,
|
message: Option<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
|
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let details = ZapDetails::new(ZapType::Private).message(message);
|
|
||||||
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
||||||
|
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||||
|
|
||||||
if client.zap(public_key, num, Some(details)).await.is_ok() {
|
match client.zap(public_key, num, details).await {
|
||||||
Ok(true)
|
Ok(()) => Ok(()),
|
||||||
} else {
|
Err(e) => Err(e.to_string()),
|
||||||
Err("Zap profile failed".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn zap_event(
|
pub async fn zap_event(
|
||||||
id: &str,
|
id: String,
|
||||||
amount: &str,
|
amount: String,
|
||||||
message: &str,
|
message: Option<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let event_id = match Nip19::from_bech32(id) {
|
|
||||||
Ok(val) => match val {
|
|
||||||
Nip19::EventId(id) => id,
|
|
||||||
Nip19::Event(event) => event.event_id,
|
|
||||||
_ => return Err("Event ID is invalid.".into()),
|
|
||||||
},
|
|
||||||
Err(_) => match EventId::from_hex(id) {
|
|
||||||
Ok(val) => val,
|
|
||||||
Err(_) => return Err("Event ID is invalid.".into()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let details = ZapDetails::new(ZapType::Private).message(message);
|
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
||||||
|
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||||
|
|
||||||
if client.zap(event_id, num, Some(details)).await.is_ok() {
|
match client.zap(event_id, num, details).await {
|
||||||
Ok(true)
|
Ok(()) => Ok(()),
|
||||||
} else {
|
Err(e) => Err(e.to_string()),
|
||||||
Err("Zap event failed".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,14 +551,11 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Ok(contact_list_events) = client
|
if let Ok(contact_list_events) = client
|
||||||
.get_events_of(
|
.fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
|
||||||
vec![contact_list_filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
for event in contact_list_events.into_iter() {
|
for event in contact_list_events.into_iter() {
|
||||||
for tag in event.into_iter_tags() {
|
for tag in event.tags.into_iter() {
|
||||||
if let Some(TagStandard::PublicKey {
|
if let Some(TagStandard::PublicKey {
|
||||||
public_key,
|
public_key,
|
||||||
relay_url,
|
relay_url,
|
||||||
@@ -405,82 +578,12 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_following(
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
public_key: &str,
|
|
||||||
) -> Result<Vec<String>, String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let public_key = PublicKey::parse(public_key).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let filter = Filter::new().kind(Kind::ContactList).author(public_key);
|
|
||||||
let events = match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => events,
|
|
||||||
Err(err) => return Err(err.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ret: Vec<String> = vec![];
|
|
||||||
if let Some(latest_event) = events.iter().max_by_key(|event| event.created_at()) {
|
|
||||||
ret.extend(latest_event.tags().iter().filter_map(|tag| {
|
|
||||||
if let Some(TagStandard::PublicKey {
|
|
||||||
uppercase: false, ..
|
|
||||||
}) = <nostr_sdk::Tag as Clone>::clone(tag).to_standardized()
|
|
||||||
{
|
|
||||||
tag.content().map(String::from)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_followers(
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
public_key: &str,
|
|
||||||
) -> Result<Vec<String>, String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let public_key = PublicKey::parse(public_key).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let filter = Filter::new().kind(Kind::ContactList).custom_tag(
|
|
||||||
SingleLetterTag::lowercase(Alphabet::P),
|
|
||||||
vec![public_key.to_hex()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let events = match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => events,
|
|
||||||
Err(err) => return Err(err.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ret: Vec<String> = events
|
|
||||||
.into_iter()
|
|
||||||
.map(|event| event.author().to_hex())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(ret)
|
|
||||||
// TODO: get more than 500 events
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match client.signer().await {
|
|
||||||
Ok(signer) => {
|
|
||||||
let public_key = signer.public_key().await.unwrap();
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.pubkey(public_key)
|
.pubkey(public_key)
|
||||||
.kinds(vec![
|
.kinds(vec![
|
||||||
@@ -489,59 +592,22 @@ pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, S
|
|||||||
Kind::Reaction,
|
Kind::Reaction,
|
||||||
Kind::ZapReceipt,
|
Kind::ZapReceipt,
|
||||||
])
|
])
|
||||||
.limit(200);
|
.limit(500);
|
||||||
|
|
||||||
match client
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
.database()
|
|
||||||
.query(vec![filter], Order::default())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
let mut rx = client
|
||||||
#[specta::specta]
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
pub async fn get_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
|
|
||||||
Ok(state.settings.lock().await.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn set_settings(
|
|
||||||
settings: &str,
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let ident = "lume_v4:settings";
|
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
|
||||||
let encrypted = signer
|
|
||||||
.nip44_encrypt(public_key, settings)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let tag = Tag::identifier(ident);
|
|
||||||
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
|
|
||||||
|
|
||||||
match client.send_event_builder(builder).await {
|
while let Some(event) = rx.next().await {
|
||||||
Ok(_) => {
|
events.insert(event);
|
||||||
let parsed: Settings = serde_json::from_str(settings).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
state.settings.lock().await.clone_from(&parsed);
|
|
||||||
|
|
||||||
// Emit new changes to frontend
|
|
||||||
NewSettings(parsed).emit(&handle).unwrap();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ pub mod account;
|
|||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod sync;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
use crate::Nostr;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{
|
use std::str::FromStr;
|
||||||
fs::OpenOptions,
|
use tauri::State;
|
||||||
io::{self, BufRead, Write},
|
|
||||||
time::Duration,
|
use crate::{Nostr, FETCH_LIMIT};
|
||||||
};
|
|
||||||
use tauri::{path::BaseDirectory, Manager, State};
|
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
#[derive(Serialize, Type)]
|
||||||
pub struct Relays {
|
pub struct Relays {
|
||||||
@@ -19,149 +16,67 @@ pub struct Relays {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
|
pub async fn get_all_relays(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let relays = client.pool().all_relays().await;
|
||||||
|
let v: Vec<String> = relays.iter().map(|item| item.0.to_string()).collect();
|
||||||
|
|
||||||
let connected_relays = client
|
Ok(v)
|
||||||
.relays()
|
|
||||||
.await
|
|
||||||
.into_keys()
|
|
||||||
.map(|url| url.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::RelayList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::Relays {
|
|
||||||
timeout: Some(Duration::from_secs(5)),
|
|
||||||
specific_relays: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let read = nip65_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, meta)| {
|
|
||||||
if let Some(RelayMetadata::Read) = meta {
|
|
||||||
Some(url.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let write = nip65_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, meta)| {
|
|
||||||
if let Some(RelayMetadata::Write) = meta {
|
|
||||||
Some(url.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let both = nip65_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, meta)| {
|
|
||||||
if meta.is_none() {
|
|
||||||
Some(url.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Relays {
|
|
||||||
connected: connected_relays,
|
|
||||||
read: Some(read),
|
|
||||||
write: Some(write),
|
|
||||||
both: Some(both),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(Relays {
|
|
||||||
connected: connected_relays,
|
|
||||||
read: None,
|
|
||||||
write: None,
|
|
||||||
both: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
pub async fn get_all_relay_lists(
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
|
|
||||||
if status {
|
let as_of = match until {
|
||||||
println!("Connecting to relay: {}", relay);
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
client
|
None => Timestamp::now(),
|
||||||
.connect_relay(relay)
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
|
||||||
|
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn is_relay_connected(relay: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let status = client.add_relay(&relay).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn remove_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
pub async fn connect_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
client
|
let _ = client.add_relay(&relay).await;
|
||||||
.remove_relay(relay)
|
let _ = client.connect_relay(&relay).await;
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
Ok(())
|
||||||
client
|
|
||||||
.disconnect_relay(relay)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let relays_path = app
|
let client = &state.client;
|
||||||
.path()
|
let _ = client.force_remove_relay(relay).await;
|
||||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let file = std::fs::File::open(relays_path).map_err(|e| e.to_string())?;
|
Ok(())
|
||||||
let reader = io::BufReader::new(file);
|
|
||||||
|
|
||||||
reader
|
|
||||||
.lines()
|
|
||||||
.collect::<Result<Vec<String>, io::Error>>()
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
let relays_path = app
|
|
||||||
.path()
|
|
||||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.open(relays_path)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
file.write_all(relays.as_bytes()).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::Nostr;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn sync_all(
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
reader: tauri::ipc::Channel<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
// Create a filter for get all public keys
|
||||||
|
let filter = Filter::new().kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::FollowSet,
|
||||||
|
Kind::ContactList,
|
||||||
|
Kind::MuteList,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let public_keys: Vec<PublicKey> = events
|
||||||
|
.iter()
|
||||||
|
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
let opts = SyncOptions::default().progress(tx);
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
while rx.changed().await.is_ok() {
|
||||||
|
let progress = *rx.borrow_and_update();
|
||||||
|
|
||||||
|
if progress.total > 0 {
|
||||||
|
reader.send(progress.percentage() * 100.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for chunk in public_keys.chunks(200) {
|
||||||
|
let authors = chunk.to_owned();
|
||||||
|
let filter = Filter::new().authors(authors).kinds(vec![
|
||||||
|
Kind::Metadata,
|
||||||
|
Kind::ContactList,
|
||||||
|
Kind::FollowSet,
|
||||||
|
Kind::Interests,
|
||||||
|
Kind::InterestSet,
|
||||||
|
Kind::EventDeletion,
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::Comment,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let _ = client
|
||||||
|
.sync(filter, &opts)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::utils::config::WindowEffectsConfig;
|
use std::path::PathBuf;
|
||||||
use tauri::window::Effect;
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::TitleBarStyle;
|
use tauri::TitleBarStyle;
|
||||||
use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl};
|
use tauri::{
|
||||||
use tauri::{WebviewBuilder, WebviewWindowBuilder};
|
utils::config::WindowEffectsConfig, webview::PageLoadEvent, window::Effect, LogicalPosition,
|
||||||
|
LogicalSize, Manager, WebviewBuilder, WebviewUrl, WebviewWindowBuilder, Window,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
use tauri_plugin_decorum::WebviewWindowExt;
|
use tauri_plugin_decorum::WebviewWindowExt;
|
||||||
|
|
||||||
|
use crate::common::get_last_segment;
|
||||||
|
use crate::Nostr;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type)]
|
#[derive(Serialize, Deserialize, Type)]
|
||||||
pub struct Window {
|
pub struct NewWindow {
|
||||||
label: String,
|
label: String,
|
||||||
title: String,
|
title: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -22,6 +26,7 @@ pub struct Window {
|
|||||||
maximizable: bool,
|
maximizable: bool,
|
||||||
minimizable: bool,
|
minimizable: bool,
|
||||||
hidden_title: bool,
|
hidden_title: bool,
|
||||||
|
closable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type)]
|
#[derive(Serialize, Deserialize, Type)]
|
||||||
@@ -34,94 +39,162 @@ pub struct Column {
|
|||||||
height: f32,
|
height: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn create_column(column: Column, app_handle: tauri::AppHandle) -> Result<String, String> {
|
pub async fn create_column(
|
||||||
match app_handle.get_window("main") {
|
column: Column,
|
||||||
Some(main_window) => match app_handle.get_webview(&column.label) {
|
app_handle: tauri::AppHandle,
|
||||||
Some(_) => Ok(column.label),
|
main_window: Window,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
match app_handle.get_webview(&column.label) {
|
||||||
|
Some(webview) => {
|
||||||
|
if let Err(e) = webview.set_size(LogicalSize::new(column.width, column.height)) {
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(column.label)
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
let path = PathBuf::from(column.url);
|
let path = PathBuf::from(column.url);
|
||||||
let webview_url = WebviewUrl::App(path);
|
let webview_url = WebviewUrl::App(path);
|
||||||
|
|
||||||
let builder = WebviewBuilder::new(column.label, webview_url)
|
let builder = WebviewBuilder::new(column.label, webview_url)
|
||||||
.incognito(true)
|
.incognito(true)
|
||||||
.transparent(true);
|
.transparent(true)
|
||||||
|
.on_page_load(|webview, payload| match payload.event() {
|
||||||
|
PageLoadEvent::Started => {
|
||||||
|
if let Ok(id) = get_last_segment(payload.url()) {
|
||||||
|
if let Ok(public_key) = PublicKey::parse(&id) {
|
||||||
|
let is_newsfeed = payload.url().to_string().contains("newsfeed");
|
||||||
|
|
||||||
if let Ok(webview) = main_window.add_child(
|
if is_newsfeed {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = webview.state::<Nostr>();
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
if let Ok(contact_list) =
|
||||||
|
client.database().contacts_public_keys(public_key).await
|
||||||
|
{
|
||||||
|
let subscription_id =
|
||||||
|
SubscriptionId::new(webview.label());
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(contact_list)
|
||||||
|
.kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::EventDeletion,
|
||||||
|
])
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
if let Err(e) = client
|
||||||
|
.subscribe_with_id(
|
||||||
|
subscription_id,
|
||||||
|
vec![filter],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
println!("Subscription error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if let Ok(event_id) = EventId::parse(&id) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = webview.state::<Nostr>();
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let subscription_id = SubscriptionId::new(webview.label());
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.custom_tag(
|
||||||
|
SingleLetterTag::uppercase(Alphabet::E),
|
||||||
|
[event_id],
|
||||||
|
)
|
||||||
|
.kind(Kind::Comment)
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
if let Err(e) = client
|
||||||
|
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
println!("Subscription error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PageLoadEvent::Finished => {
|
||||||
|
println!("{} finished loading", payload.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match main_window.add_child(
|
||||||
builder,
|
builder,
|
||||||
LogicalPosition::new(column.x, column.y),
|
LogicalPosition::new(column.x, column.y),
|
||||||
LogicalSize::new(column.width, column.height),
|
LogicalSize::new(column.width, column.height),
|
||||||
) {
|
) {
|
||||||
Ok(webview.label().into())
|
Ok(webview) => Ok(webview.label().into()),
|
||||||
} else {
|
Err(e) => Err(e.to_string()),
|
||||||
Err("Create webview failed".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
None => Err("Main window not found".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.close().is_ok()),
|
|
||||||
None => Err("Not found.".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn reposition_column(
|
|
||||||
label: String,
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.set_position(LogicalPosition::new(x, y)).is_ok()),
|
|
||||||
None => Err("Not found".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn resize_column(
|
|
||||||
label: String,
|
|
||||||
width: f32,
|
|
||||||
height: f32,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.set_size(LogicalSize::new(width, height)).is_ok()),
|
|
||||||
None => Err("Not found".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.eval("window.location.reload()").is_ok()),
|
|
||||||
None => Err("Not found".into()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
#[cfg(target_os = "macos")]
|
pub async fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||||
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
|
match app_handle.get_webview(&label) {
|
||||||
if let Some(window) = app_handle.get_window(&window.label) {
|
Some(webview) => Ok(webview.close().is_ok()),
|
||||||
if window.is_visible().unwrap_or_default() {
|
None => Err(format!("Cannot close, column not found: {}", label)),
|
||||||
let _ = window.set_focus();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn close_all_columns(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let mut webviews = app_handle.webviews();
|
||||||
|
webviews.remove("main");
|
||||||
|
|
||||||
|
for webview in webviews.values() {
|
||||||
|
webview.close().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
match app_handle.get_webview(&label) {
|
||||||
|
Some(webview) => {
|
||||||
|
webview.eval("location.reload(true)").unwrap();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err("Cannot reload, column not found.".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn open_window(window: NewWindow, app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
if let Some(current_window) = app_handle.get_window(&window.label) {
|
||||||
|
if current_window.is_visible().unwrap_or_default() {
|
||||||
|
let _ = current_window.set_focus();
|
||||||
} else {
|
} else {
|
||||||
let _ = window.show();
|
let _ = current_window.show();
|
||||||
let _ = window.set_focus();
|
let _ = current_window.set_focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ok(current_window.label().to_string())
|
||||||
} else {
|
} else {
|
||||||
let window = WebviewWindowBuilder::new(
|
#[cfg(target_os = "macos")]
|
||||||
|
let new_window = WebviewWindowBuilder::new(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&window.label,
|
&window.label,
|
||||||
WebviewUrl::App(PathBuf::from(window.url)),
|
WebviewUrl::App(PathBuf::from(window.url)),
|
||||||
@@ -129,11 +202,12 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
.title(&window.title)
|
.title(&window.title)
|
||||||
.min_inner_size(window.width, window.height)
|
.min_inner_size(window.width, window.height)
|
||||||
.inner_size(window.width, window.height)
|
.inner_size(window.width, window.height)
|
||||||
.hidden_title(true)
|
.hidden_title(window.hidden_title)
|
||||||
.title_bar_style(TitleBarStyle::Overlay)
|
.title_bar_style(TitleBarStyle::Overlay)
|
||||||
.minimizable(window.minimizable)
|
.minimizable(window.minimizable)
|
||||||
.maximizable(window.maximizable)
|
.maximizable(window.maximizable)
|
||||||
.transparent(true)
|
.transparent(true)
|
||||||
|
.closable(window.closable)
|
||||||
.effects(WindowEffectsConfig {
|
.effects(WindowEffectsConfig {
|
||||||
state: None,
|
state: None,
|
||||||
effects: vec![Effect::UnderWindowBackground],
|
effects: vec![Effect::UnderWindowBackground],
|
||||||
@@ -143,26 +217,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Restore native border
|
#[cfg(target_os = "windows")]
|
||||||
window.add_border(None);
|
let new_window = WebviewWindowBuilder::new(
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
#[specta::specta]
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
if let Some(window) = app_handle.get_window(&window.label) {
|
|
||||||
if window.is_visible().unwrap_or_default() {
|
|
||||||
let _ = window.set_focus();
|
|
||||||
} else {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let window = WebviewWindowBuilder::new(
|
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&window.label,
|
&window.label,
|
||||||
WebviewUrl::App(PathBuf::from(window.url)),
|
WebviewUrl::App(PathBuf::from(window.url)),
|
||||||
@@ -174,65 +230,18 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
.maximizable(window.maximizable)
|
.maximizable(window.maximizable)
|
||||||
.transparent(true)
|
.transparent(true)
|
||||||
.decorations(false)
|
.decorations(false)
|
||||||
.effects(WindowEffectsConfig {
|
.closable(window.closable)
|
||||||
state: None,
|
|
||||||
effects: vec![Effect::Mica],
|
|
||||||
radius: None,
|
|
||||||
color: None,
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Set decoration
|
|
||||||
window.create_overlay_titlebar().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn reopen_lume(app: tauri::AppHandle) {
|
|
||||||
if let Some(window) = app.get_window("main") {
|
|
||||||
if window.is_visible().unwrap_or_default() {
|
|
||||||
let _ = window.set_focus();
|
|
||||||
} else {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let window =
|
|
||||||
WebviewWindowBuilder::from_config(&app, app.config().app.windows.first().unwrap())
|
|
||||||
.unwrap()
|
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Set decoration
|
// Set decoration
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
window.create_overlay_titlebar().unwrap();
|
new_window.create_overlay_titlebar().unwrap();
|
||||||
|
|
||||||
// Restore native border
|
// Restore native border
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
window.add_border(None);
|
new_window.add_border(None);
|
||||||
|
|
||||||
// Set a custom inset to the traffic lights
|
Ok(new_window.label().to_string())
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
window.set_traffic_lights_inset(7.0, 13.0).unwrap();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let win = window.clone();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
window.on_window_event(move |event| {
|
|
||||||
if let tauri::WindowEvent::ThemeChanged(_) = event {
|
|
||||||
win.set_traffic_lights_inset(7.0, 13.0).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn quit() {
|
|
||||||
std::process::exit(0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
|
use futures::future::join_all;
|
||||||
|
use keyring_search::{Limit, List, Search};
|
||||||
use linkify::LinkFinder;
|
use linkify::LinkFinder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use reqwest::Client as ReqClient;
|
use reqwest::Client as ReqClient;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::collections::HashSet;
|
use std::{collections::HashSet, str::FromStr};
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::Settings;
|
use crate::RichEvent;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Type)]
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
pub struct Meta {
|
pub struct Meta {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub images: Vec<String>,
|
pub images: Vec<String>,
|
||||||
pub videos: Vec<String>,
|
|
||||||
pub events: Vec<String>,
|
pub events: Vec<String>,
|
||||||
pub mentions: Vec<String>,
|
pub mentions: Vec<String>,
|
||||||
pub hashtags: Vec<String>,
|
pub hashtags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||||
|
// const VIDEOS: [&str; 6] = ["mp4", "avi", "mov", "mkv", "wmv", "webm"];
|
||||||
|
|
||||||
const NOSTR_EVENTS: [&str; 10] = [
|
const NOSTR_EVENTS: [&str; 10] = [
|
||||||
"@nevent1",
|
"@nevent1",
|
||||||
"@note1",
|
"@note1",
|
||||||
@@ -31,60 +33,200 @@ const NOSTR_EVENTS: [&str; 10] = [
|
|||||||
"Nostr:note1",
|
"Nostr:note1",
|
||||||
"Nostr:nevent1",
|
"Nostr:nevent1",
|
||||||
];
|
];
|
||||||
const NOSTR_MENTIONS: [&str; 10] = [
|
|
||||||
|
const NOSTR_MENTIONS: [&str; 8] = [
|
||||||
"@npub1",
|
"@npub1",
|
||||||
"nostr:npub1",
|
"nostr:npub1",
|
||||||
"nostr:nprofile1",
|
"nostr:nprofile1",
|
||||||
"nostr:naddr1",
|
|
||||||
"npub1",
|
"npub1",
|
||||||
"nprofile1",
|
"nprofile1",
|
||||||
"naddr1",
|
|
||||||
"Nostr:npub1",
|
"Nostr:npub1",
|
||||||
"Nostr:nprofile1",
|
"Nostr:nprofile1",
|
||||||
"Nostr:naddr1",
|
"Nostr:naddr1",
|
||||||
];
|
];
|
||||||
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
|
||||||
const VIDEOS: [&str; 5] = ["mp4", "mov", "avi", "webm", "mkv"];
|
|
||||||
|
|
||||||
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
|
pub fn get_latest_event(events: &Events) -> Option<&Event> {
|
||||||
events.iter().next()
|
events.iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn filter_converstation(events: Vec<Event>) -> Vec<Event> {
|
pub fn create_tags(content: &str) -> Vec<Tag> {
|
||||||
|
let mut tags: Vec<Tag> = vec![];
|
||||||
|
let mut tag_set: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
// Get words
|
||||||
|
let words: Vec<_> = content.split_whitespace().collect();
|
||||||
|
|
||||||
|
// Get hashtags
|
||||||
|
let hashtags = words
|
||||||
|
.iter()
|
||||||
|
.filter(|&&word| word.starts_with('#'))
|
||||||
|
.map(|&s| s.to_string().replace("#", "").to_lowercase())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Get mentions
|
||||||
|
let mentions = words
|
||||||
|
.iter()
|
||||||
|
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
|
||||||
|
.map(|&s| s.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for mention in mentions {
|
||||||
|
let entity = mention.replace("nostr:", "").replace('@', "");
|
||||||
|
|
||||||
|
if !tag_set.contains(&entity) {
|
||||||
|
if entity.starts_with("npub") {
|
||||||
|
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
||||||
|
let tag = Tag::public_key(public_key);
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.starts_with("nprofile") {
|
||||||
|
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
||||||
|
let tag = Tag::public_key(public_key);
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.starts_with("note") {
|
||||||
|
if let Ok(event_id) = EventId::from_bech32(&entity) {
|
||||||
|
let tag = Tag::from_standardized(TagStandard::Quote {
|
||||||
|
event_id,
|
||||||
|
relay_url: None,
|
||||||
|
public_key: None,
|
||||||
|
});
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.starts_with("nevent") {
|
||||||
|
if let Ok(event) = Nip19Event::from_bech32(&entity) {
|
||||||
|
let relay_url = event.relays.first().and_then(|i| Url::parse(i).ok());
|
||||||
|
let tag = Tag::from_standardized(TagStandard::Quote {
|
||||||
|
event_id: event.event_id,
|
||||||
|
relay_url,
|
||||||
|
public_key: event.author,
|
||||||
|
});
|
||||||
|
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_set.insert(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for hashtag in hashtags {
|
||||||
|
if !tag_set.contains(&hashtag) {
|
||||||
|
let tag = Tag::hashtag(hashtag.clone());
|
||||||
|
tags.push(tag);
|
||||||
|
tag_set.insert(hashtag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_accounts() -> Vec<String> {
|
||||||
|
let search = Search::new().expect("Unexpected.");
|
||||||
|
let results = search.by_service("Lume Safe Storage");
|
||||||
|
let list = List::list_credentials(&results, Limit::All);
|
||||||
|
let accounts: HashSet<String> = list
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
accounts.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_last_segment(url: &Url) -> Result<String, String> {
|
||||||
|
url.path_segments()
|
||||||
|
.ok_or("No segments".to_string())?
|
||||||
|
.last()
|
||||||
|
.ok_or("No items".into())
|
||||||
|
.map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_event(client: &Client, events: Events, is_reply: bool) -> Vec<RichEvent> {
|
||||||
|
// Remove event thread if event is TextNote
|
||||||
|
let events: Vec<Event> = if !is_reply {
|
||||||
events
|
events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|ev| {
|
.filter_map(|ev| {
|
||||||
let tags = ev.get_tags_content(TagKind::SingleLetter(SingleLetterTag::lowercase(
|
if ev.kind == Kind::TextNote {
|
||||||
Alphabet::E,
|
let tags = ev
|
||||||
)));
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.is_reply() || t.is_root())
|
||||||
|
.filter_map(|t| t.content())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
if tags.is_empty() {
|
if tags.is_empty() {
|
||||||
Some(ev)
|
Some(ev)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
} else {
|
||||||
.collect::<Vec<Event>>()
|
Some(ev)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dedup_event(events: &[Event]) -> Vec<Event> {
|
|
||||||
let mut seen_ids = HashSet::new();
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.filter(|&event| {
|
|
||||||
let e = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E));
|
|
||||||
let e_tags: Vec<&Tag> = event.tags.iter().filter(|el| el.kind() == e).collect();
|
|
||||||
let ids: Vec<&str> = e_tags.iter().filter_map(|tag| tag.content()).collect();
|
|
||||||
let is_dup = ids.iter().any(|id| seen_ids.contains(*id));
|
|
||||||
|
|
||||||
for id in &ids {
|
|
||||||
seen_ids.insert(*id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
!is_dup
|
|
||||||
})
|
})
|
||||||
.cloned()
|
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
events.into_iter().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get deletion request by event's authors
|
||||||
|
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
|
||||||
|
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
|
||||||
|
|
||||||
|
let mut final_events: Vec<Event> = events.clone();
|
||||||
|
|
||||||
|
if let Ok(requests) = client.database().query(vec![filter]).await {
|
||||||
|
if !requests.is_empty() {
|
||||||
|
let ids: Vec<&str> = requests
|
||||||
|
.iter()
|
||||||
|
.flat_map(|event| {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.kind() == TagKind::e())
|
||||||
|
.filter_map(|t| t.content())
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Remove event if event is deleted by author
|
||||||
|
final_events = events
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ev| {
|
||||||
|
if ids.iter().any(|&i| i == ev.id.to_hex()) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert raw event to rich event
|
||||||
|
let futures = final_events.iter().map(|ev| async move {
|
||||||
|
let raw = ev.as_json();
|
||||||
|
let parsed = if ev.kind == Kind::TextNote {
|
||||||
|
Some(parse_event(&ev.content).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
RichEvent { raw, parsed }
|
||||||
|
});
|
||||||
|
|
||||||
|
join_all(futures).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn parse_event(content: &str) -> Meta {
|
pub async fn parse_event(content: &str) -> Meta {
|
||||||
@@ -115,7 +257,6 @@ pub async fn parse_event(content: &str) -> Meta {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut images = Vec::new();
|
let mut images = Vec::new();
|
||||||
let mut videos = Vec::new();
|
|
||||||
let mut text = content.to_string();
|
let mut text = content.to_string();
|
||||||
|
|
||||||
if !urls.is_empty() {
|
if !urls.is_empty() {
|
||||||
@@ -135,12 +276,6 @@ pub async fn parse_event(content: &str) -> Meta {
|
|||||||
// Process the next item.
|
// Process the next item.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if VIDEOS.contains(&ext) {
|
|
||||||
text = text.replace(url_str, "");
|
|
||||||
videos.push(url_str.to_string());
|
|
||||||
// Process the next item.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the content type of URL via HEAD request
|
// Check the content type of URL via HEAD request
|
||||||
@@ -167,212 +302,5 @@ pub async fn parse_event(content: &str) -> Meta {
|
|||||||
mentions,
|
mentions,
|
||||||
hashtags,
|
hashtags,
|
||||||
images,
|
images,
|
||||||
videos,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_event_tags(content: &str) -> Vec<Tag> {
|
|
||||||
let mut tags: Vec<Tag> = vec![];
|
|
||||||
let mut tag_set: HashSet<String> = HashSet::new();
|
|
||||||
|
|
||||||
// Get words
|
|
||||||
let words: Vec<_> = content.split_whitespace().collect();
|
|
||||||
|
|
||||||
// Get mentions
|
|
||||||
let mentions = words
|
|
||||||
.iter()
|
|
||||||
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
|
|
||||||
.map(|&s| s.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// Get hashtags
|
|
||||||
let hashtags = words
|
|
||||||
.iter()
|
|
||||||
.filter(|&&word| word.starts_with('#'))
|
|
||||||
.map(|&s| s.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
for mention in mentions {
|
|
||||||
let entity = mention.replace("nostr:", "").replace('@', "");
|
|
||||||
|
|
||||||
if !tag_set.contains(&entity) {
|
|
||||||
if entity.starts_with("npub") {
|
|
||||||
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
|
||||||
let tag = Tag::public_key(public_key);
|
|
||||||
tags.push(tag);
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if entity.starts_with("nprofile") {
|
|
||||||
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
|
||||||
let tag = Tag::public_key(public_key);
|
|
||||||
tags.push(tag);
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if entity.starts_with("note") {
|
|
||||||
if let Ok(event_id) = EventId::from_bech32(&entity) {
|
|
||||||
let hex = event_id.to_hex();
|
|
||||||
let tag = Tag::parse(&["e", &hex, "", "mention"]).unwrap();
|
|
||||||
tags.push(tag);
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if entity.starts_with("nevent") {
|
|
||||||
if let Ok(event) = Nip19Event::from_bech32(&entity) {
|
|
||||||
let hex = event.event_id.to_hex();
|
|
||||||
let relay = event.clone().relays.into_iter().next().unwrap_or("".into());
|
|
||||||
let tag = Tag::parse(&["e", &hex, &relay, "mention"]).unwrap();
|
|
||||||
|
|
||||||
if let Some(author) = event.author {
|
|
||||||
let tag = Tag::public_key(author);
|
|
||||||
tags.push(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
tags.push(tag);
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tag_set.insert(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for hashtag in hashtags {
|
|
||||||
if !tag_set.contains(&hashtag) {
|
|
||||||
let tag = Tag::hashtag(hashtag.clone());
|
|
||||||
tags.push(tag);
|
|
||||||
tag_set.insert(hashtag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tags
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn init_nip65(client: &Client) {
|
|
||||||
let signer = match client.signer().await {
|
|
||||||
Ok(signer) => signer,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to get signer: {:?}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let public_key = match signer.public_key().await {
|
|
||||||
Ok(public_key) => public_key,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to get public key: {:?}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::RelayList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if let Ok(events) = client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let relay_list = nip65::extract_relay_list(event);
|
|
||||||
for (url, metadata) in relay_list {
|
|
||||||
let opts = match metadata {
|
|
||||||
Some(RelayMetadata::Read) => RelayOptions::new().read(true).write(false),
|
|
||||||
Some(_) => RelayOptions::new().write(true).read(false),
|
|
||||||
None => RelayOptions::default(),
|
|
||||||
};
|
|
||||||
if let Err(e) = client.add_relay_with_opts(&url.to_string(), opts).await {
|
|
||||||
eprintln!("Failed to add relay {}: {:?}", url, e);
|
|
||||||
}
|
|
||||||
if let Err(e) = client.connect_relay(url.to_string()).await {
|
|
||||||
eprintln!("Failed to connect to relay {}: {:?}", url, e);
|
|
||||||
} else {
|
|
||||||
println!("Connecting to relay: {} - {:?}", url, metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("Failed to get events for RelayList.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_settings(client: &Client) -> Result<Settings, String> {
|
|
||||||
let ident = "lume_v4:settings";
|
|
||||||
let signer = client
|
|
||||||
.signer()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to get signer: {:?}", e))?;
|
|
||||||
let public_key = signer
|
|
||||||
.public_key()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to get public key: {:?}", e))?;
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(ident)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let content = event.content();
|
|
||||||
match signer.nip44_decrypt(public_key, content).await {
|
|
||||||
Ok(decrypted) => match serde_json::from_str(&decrypted) {
|
|
||||||
Ok(parsed) => Ok(parsed),
|
|
||||||
Err(_) => Err("Could not parse settings payload".into()),
|
|
||||||
},
|
|
||||||
Err(e) => Err(format!("Failed to decrypt settings content: {:?}", e)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err("Settings not found.".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!(
|
|
||||||
"Failed to get events for ApplicationSpecificData: {:?}",
|
|
||||||
e
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_parse_event() {
|
|
||||||
let content = "Check this image: https://example.com/image.jpg #cool @npub1";
|
|
||||||
let meta = parse_event(content).await;
|
|
||||||
|
|
||||||
assert_eq!(meta.content, "Check this image: #cool @npub1");
|
|
||||||
assert_eq!(meta.images, vec!["https://example.com/image.jpg"]);
|
|
||||||
assert_eq!(meta.videos, Vec::<String>::new());
|
|
||||||
assert_eq!(meta.hashtags, vec!["#cool"]);
|
|
||||||
assert_eq!(meta.mentions, vec!["@npub1"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_parse_video() {
|
|
||||||
let content = "Check this video: https://example.com/video.mp4 #cool @npub1";
|
|
||||||
let meta = parse_event(content).await;
|
|
||||||
|
|
||||||
assert_eq!(meta.content, "Check this video: #cool @npub1");
|
|
||||||
assert_eq!(meta.images, Vec::<String>::new());
|
|
||||||
assert_eq!(meta.videos, vec!["https://example.com/video.mp4"]);
|
|
||||||
assert_eq!(meta.hashtags, vec!["#cool"]);
|
|
||||||
assert_eq!(meta.mentions, vec!["@npub1"]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,109 +5,108 @@
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||||
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
|
use commands::{account::*, event::*, metadata::*, relay::*, sync::*, window::*};
|
||||||
use common::parse_event;
|
use common::{get_all_accounts, parse_event};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use specta_typescript::Typescript;
|
use specta_typescript::Typescript;
|
||||||
use std::{
|
use std::{collections::HashSet, fs, str::FromStr, time::Duration};
|
||||||
fs,
|
use tauri::{
|
||||||
io::{self, BufRead},
|
menu::{Menu, MenuItem},
|
||||||
str::FromStr,
|
Emitter, EventTarget, Listener, Manager, WebviewWindowBuilder,
|
||||||
time::Duration,
|
|
||||||
};
|
};
|
||||||
use tauri::{path::BaseDirectory, Emitter, EventTarget, Manager};
|
|
||||||
use tauri_plugin_decorum::WebviewWindowExt;
|
use tauri_plugin_decorum::WebviewWindowExt;
|
||||||
use tauri_plugin_notification::{NotificationExt, PermissionState};
|
use tauri_plugin_notification::{NotificationExt, PermissionState};
|
||||||
use tauri_specta::{collect_commands, collect_events, Builder, Event as TauriEvent};
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::sync::Mutex;
|
use tauri_specta::{collect_commands, Builder};
|
||||||
|
use tokio::{sync::RwLock, time::sleep};
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
|
||||||
pub struct Nostr {
|
pub struct Nostr {
|
||||||
client: Client,
|
client: Client,
|
||||||
contact_list: Mutex<Vec<Contact>>,
|
queue: RwLock<HashSet<PublicKey>>,
|
||||||
settings: Mutex<Settings>,
|
settings: RwLock<Settings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Payload {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
proxy: Option<String>,
|
resize_service: bool,
|
||||||
image_resize_service: Option<String>,
|
|
||||||
use_relay_hint: bool,
|
|
||||||
content_warning: bool,
|
content_warning: bool,
|
||||||
display_avatar: bool,
|
display_avatar: bool,
|
||||||
display_zap_button: bool,
|
display_zap_button: bool,
|
||||||
display_repost_button: bool,
|
display_repost_button: bool,
|
||||||
display_media: bool,
|
display_media: bool,
|
||||||
transparent: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
proxy: None,
|
|
||||||
image_resize_service: Some("https://wsrv.nl".to_string()),
|
|
||||||
use_relay_hint: true,
|
|
||||||
content_warning: true,
|
content_warning: true,
|
||||||
|
resize_service: true,
|
||||||
display_avatar: true,
|
display_avatar: true,
|
||||||
display_zap_button: true,
|
display_zap_button: true,
|
||||||
display_repost_button: true,
|
display_repost_button: true,
|
||||||
display_media: true,
|
display_media: true,
|
||||||
transparent: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type)]
|
pub const DEFAULT_DIFFICULTY: u8 = 0;
|
||||||
enum SubKind {
|
pub const FETCH_LIMIT: usize = 50;
|
||||||
Subscribe,
|
pub const QUEUE_DELAY: u64 = 150;
|
||||||
Unsubscribe,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type, TauriEvent)]
|
|
||||||
struct Subscription {
|
|
||||||
label: String,
|
|
||||||
kind: SubKind,
|
|
||||||
event_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
|
|
||||||
struct NewSettings(Settings);
|
|
||||||
|
|
||||||
pub const FETCH_LIMIT: usize = 44;
|
|
||||||
pub const NEWSFEED_NEG_LIMIT: usize = 256;
|
|
||||||
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
|
|
||||||
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
|
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
|
||||||
|
// Will be removed when almost relays support negentropy
|
||||||
|
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://nostr.fmt.wiz.biz",
|
||||||
|
"wss://directory.yabu.me",
|
||||||
|
"wss://purplepag.es",
|
||||||
|
];
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let builder = Builder::<tauri::Wry>::new()
|
tracing_subscriber::fmt::init();
|
||||||
// Then register them (separated by a comma)
|
|
||||||
.commands(collect_commands![
|
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||||
get_relays,
|
sync_all,
|
||||||
|
get_all_relays,
|
||||||
|
get_all_relay_lists,
|
||||||
|
is_relay_connected,
|
||||||
connect_relay,
|
connect_relay,
|
||||||
remove_relay,
|
remove_relay,
|
||||||
get_bootstrap_relays,
|
|
||||||
save_bootstrap_relays,
|
|
||||||
get_accounts,
|
get_accounts,
|
||||||
create_account,
|
watch_account,
|
||||||
import_account,
|
import_account,
|
||||||
connect_account,
|
connect_account,
|
||||||
get_private_key,
|
get_private_key,
|
||||||
delete_account,
|
delete_account,
|
||||||
reset_password,
|
reset_password,
|
||||||
login,
|
has_signer,
|
||||||
|
set_signer,
|
||||||
get_profile,
|
get_profile,
|
||||||
set_profile,
|
set_profile,
|
||||||
get_contact_list,
|
get_contact_list,
|
||||||
set_contact_list,
|
set_contact_list,
|
||||||
is_contact_list_empty,
|
is_contact,
|
||||||
check_contact,
|
|
||||||
toggle_contact,
|
toggle_contact,
|
||||||
get_lume_store,
|
get_all_profiles,
|
||||||
set_lume_store,
|
set_group,
|
||||||
|
get_group,
|
||||||
|
get_all_newsfeeds,
|
||||||
|
get_all_local_newsfeeds,
|
||||||
|
set_interest,
|
||||||
|
get_interest,
|
||||||
|
get_all_interests,
|
||||||
|
get_all_local_interests,
|
||||||
|
get_relay_list,
|
||||||
set_wallet,
|
set_wallet,
|
||||||
load_wallet,
|
load_wallet,
|
||||||
remove_wallet,
|
remove_wallet,
|
||||||
@@ -115,59 +114,62 @@ fn main() {
|
|||||||
zap_event,
|
zap_event,
|
||||||
copy_friend,
|
copy_friend,
|
||||||
get_notifications,
|
get_notifications,
|
||||||
get_settings,
|
|
||||||
set_settings,
|
|
||||||
verify_nip05,
|
verify_nip05,
|
||||||
get_event_meta,
|
get_meta_from_event,
|
||||||
get_event,
|
get_event,
|
||||||
get_event_from,
|
|
||||||
get_replies,
|
get_replies,
|
||||||
subscribe_to,
|
get_all_events_by_author,
|
||||||
get_events_by,
|
get_all_events_by_authors,
|
||||||
get_events_from_contacts,
|
get_all_events_by_hashtags,
|
||||||
get_group_events,
|
get_all_events_from,
|
||||||
|
get_all_events_by_kind,
|
||||||
|
get_all_providers,
|
||||||
|
request_events_from_provider,
|
||||||
|
get_all_events_by_request,
|
||||||
|
get_local_events,
|
||||||
get_global_events,
|
get_global_events,
|
||||||
get_hashtag_events,
|
|
||||||
search,
|
search,
|
||||||
publish,
|
publish,
|
||||||
reply,
|
reply,
|
||||||
repost,
|
repost,
|
||||||
|
is_reposted,
|
||||||
|
request_delete,
|
||||||
|
is_deleted_event,
|
||||||
event_to_bech32,
|
event_to_bech32,
|
||||||
user_to_bech32,
|
user_to_bech32,
|
||||||
create_column,
|
create_column,
|
||||||
close_column,
|
|
||||||
reposition_column,
|
|
||||||
resize_column,
|
|
||||||
reload_column,
|
reload_column,
|
||||||
|
close_column,
|
||||||
|
close_all_columns,
|
||||||
open_window,
|
open_window,
|
||||||
reopen_lume,
|
get_app_settings,
|
||||||
quit
|
set_app_settings,
|
||||||
])
|
]);
|
||||||
.events(collect_events![Subscription, NewSettings]);
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
builder
|
builder
|
||||||
.export(Typescript::default(), "../src/commands.gen.ts")
|
.export(Typescript::default(), "../src/commands.gen.ts")
|
||||||
.expect("Failed to export typescript bindings");
|
.expect("Failed to export typescript bindings");
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let tauri_builder = tauri::Builder::default().plugin(tauri_nspanel::init());
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let tauri_builder = tauri::Builder::default();
|
let tauri_builder = tauri::Builder::default();
|
||||||
|
|
||||||
let mut ctx = tauri::generate_context!();
|
let mut ctx = tauri::generate_context!();
|
||||||
|
|
||||||
tauri_builder
|
tauri_builder
|
||||||
.invoke_handler(builder.invoke_handler())
|
.invoke_handler(builder.invoke_handler())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
builder.mount_events(app);
|
|
||||||
|
|
||||||
let handle = app.handle();
|
let handle = app.handle();
|
||||||
let handle_clone = handle.clone();
|
let handle_clone = handle.clone();
|
||||||
let handle_clone_child = handle_clone.clone();
|
let handle_clone_child = handle_clone.clone();
|
||||||
|
let handle_clone_event = handle_clone_child.clone();
|
||||||
let main_window = app.get_webview_window("main").unwrap();
|
let main_window = app.get_webview_window("main").unwrap();
|
||||||
|
|
||||||
|
let config_dir = handle
|
||||||
|
.path()
|
||||||
|
.app_config_dir()
|
||||||
|
.expect("Error: app config directory not found.");
|
||||||
|
|
||||||
|
let _ = fs::create_dir_all(&config_dir);
|
||||||
|
|
||||||
// Set custom decoration for Windows
|
// Set custom decoration for Windows
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
main_window.create_overlay_titlebar().unwrap();
|
main_window.create_overlay_titlebar().unwrap();
|
||||||
@@ -180,35 +182,63 @@ fn main() {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
// Setup tray menu item
|
||||||
let win = main_window.clone();
|
let open_i = MenuItem::with_id(app, "open", "Open Lume", true, None::<&str>)?;
|
||||||
|
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
// Create tray menu
|
||||||
|
let menu = Menu::with_items(app, &[&open_i, &quit_i])?;
|
||||||
|
// Get main tray
|
||||||
|
let tray = app.tray_by_id("main").unwrap();
|
||||||
|
// Set menu
|
||||||
|
tray.set_menu(Some(menu)).unwrap();
|
||||||
|
// Listen to tray events
|
||||||
|
tray.on_menu_event(|handle, event| match event.id().as_ref() {
|
||||||
|
"open" => {
|
||||||
|
if let Some(window) = handle.get_window("main") {
|
||||||
|
if window.is_visible().unwrap_or_default() {
|
||||||
|
let _ = window.set_focus();
|
||||||
|
} else {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let window = WebviewWindowBuilder::from_config(
|
||||||
|
handle,
|
||||||
|
handle.config().app.windows.first().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Set decoration
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
window.create_overlay_titlebar().unwrap();
|
||||||
|
|
||||||
|
// Restore native border
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
main_window.on_window_event(move |event| {
|
window.add_border(None);
|
||||||
if let tauri::WindowEvent::ThemeChanged(_) = event {
|
|
||||||
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
// Set a custom inset to the traffic lights
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = tauri::async_runtime::block_on(async move {
|
let client = tauri::async_runtime::block_on(async move {
|
||||||
// Create data folder if not exist
|
|
||||||
let dir = handle
|
|
||||||
.path()
|
|
||||||
.app_config_dir()
|
|
||||||
.expect("App config directory not found.");
|
|
||||||
let _ = fs::create_dir_all(dir.clone());
|
|
||||||
|
|
||||||
// Setup database
|
// Setup database
|
||||||
let database = SQLiteDatabase::open(dir.join("nostr.db"))
|
let database = NostrLMDB::open(config_dir.join("nostr"))
|
||||||
.await
|
.expect("Error: cannot create database.");
|
||||||
.expect("Database error.");
|
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
let opts = Options::new()
|
let opts = Options::new()
|
||||||
.max_avg_latency(Duration::from_millis(500))
|
.gossip(true)
|
||||||
.automatic_authentication(true)
|
.max_avg_latency(Duration::from_secs(2))
|
||||||
.connection_timeout(Some(Duration::from_secs(5)))
|
.timeout(Duration::from_secs(10));
|
||||||
.timeout(Duration::from_secs(20));
|
|
||||||
|
|
||||||
// Setup nostr client
|
// Setup nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
@@ -216,7 +246,7 @@ fn main() {
|
|||||||
.opts(opts)
|
.opts(opts)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Get bootstrap relays
|
/* Get bootstrap relays
|
||||||
if let Ok(path) = handle
|
if let Ok(path) = handle
|
||||||
.path()
|
.path()
|
||||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
||||||
@@ -234,7 +264,7 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
RelayOptions::new().write(true).read(false)
|
RelayOptions::new().write(true).read(false)
|
||||||
};
|
};
|
||||||
let _ = client.add_relay_with_opts(relay, opts).await;
|
let _ = client.pool().add_relay(relay, opts).await;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = client.add_relay(relay).await;
|
let _ = client.add_relay(relay).await;
|
||||||
@@ -243,6 +273,13 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
for relay in BOOTSTRAP_RELAYS {
|
||||||
|
let _ = client.add_relay(relay).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
client.connect().await;
|
client.connect().await;
|
||||||
@@ -250,58 +287,209 @@ fn main() {
|
|||||||
client
|
client
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load app settings
|
||||||
|
let store = app.store(".data")?;
|
||||||
|
|
||||||
|
// Parse app settings if exist
|
||||||
|
let settings = if let Some(data) = store.get("tanstack-query-[\"settings\"]") {
|
||||||
|
if let Some(str) = data.as_str() {
|
||||||
|
let v: Value = serde_json::from_str(str).unwrap();
|
||||||
|
let data = v["state"]["data"].clone();
|
||||||
|
let parse: Settings = serde_json::from_value(data).unwrap();
|
||||||
|
|
||||||
|
RwLock::new(parse)
|
||||||
|
} else {
|
||||||
|
RwLock::new(Settings::default())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RwLock::new(Settings::default())
|
||||||
|
};
|
||||||
|
|
||||||
// Create global state
|
// Create global state
|
||||||
app.manage(Nostr {
|
app.manage(Nostr {
|
||||||
client,
|
client,
|
||||||
contact_list: Mutex::new(vec![]),
|
settings,
|
||||||
settings: Mutex::new(Settings::default()),
|
queue: RwLock::new(HashSet::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
Subscription::listen_any(app, move |event| {
|
// Listen for request metadata
|
||||||
let handle = handle_clone_child.to_owned();
|
app.listen_any("request_metadata", move |event| {
|
||||||
let payload = event.payload;
|
let payload = event.payload();
|
||||||
|
let parsed_payload: Payload = serde_json::from_str(payload).expect("Parse failed");
|
||||||
|
let handle = handle_clone_child.clone();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let state = handle.state::<Nostr>();
|
let state = handle.state::<Nostr>();
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
match payload.kind {
|
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
|
||||||
SubKind::Subscribe => {
|
let mut write_queue = state.queue.write().await;
|
||||||
let subscription_id = SubscriptionId::new(payload.label);
|
write_queue.insert(public_key);
|
||||||
|
|
||||||
let filter = if let Some(id) = payload.event_id {
|
|
||||||
let event_id = EventId::from_str(&id).unwrap();
|
|
||||||
|
|
||||||
Filter::new().event(event_id).since(Timestamp::now())
|
|
||||||
} else {
|
|
||||||
let contact_list = state.contact_list.lock().await;
|
|
||||||
let authors: Vec<PublicKey> =
|
|
||||||
contact_list.iter().map(|f| f.public_key).collect();
|
|
||||||
|
|
||||||
Filter::new()
|
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
|
||||||
.authors(authors)
|
|
||||||
.since(Timestamp::now())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wait for [QUEUE_DELAY]
|
||||||
|
sleep(Duration::from_millis(QUEUE_DELAY)).await;
|
||||||
|
|
||||||
|
let read_queue = state.queue.read().await;
|
||||||
|
|
||||||
|
if !read_queue.is_empty() {
|
||||||
|
let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(authors)
|
||||||
|
.kind(Kind::Metadata)
|
||||||
|
.limit(200);
|
||||||
|
|
||||||
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
|
||||||
|
|
||||||
|
// Drop queue, you don't need it at this time anymore
|
||||||
|
drop(read_queue);
|
||||||
|
// Clear queue
|
||||||
|
let mut write_queue = state.queue.write().await;
|
||||||
|
write_queue.clear();
|
||||||
|
|
||||||
|
if let Err(e) = client.subscribe(vec![filter], Some(opts)).await {
|
||||||
|
println!("Subscribe error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run a thread for negentropy
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = handle_clone_event.state::<Nostr>();
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
// Use default sync options
|
||||||
|
let opts = SyncOptions::default();
|
||||||
|
|
||||||
|
// Set interval
|
||||||
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
|
||||||
|
// Skip the first tick
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
let accounts = get_all_accounts();
|
||||||
|
let public_keys: Vec<PublicKey> = accounts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|acc| PublicKey::from_str(acc).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !public_keys.is_empty() {
|
||||||
|
// Create filter for notification
|
||||||
|
//
|
||||||
|
let filter = Filter::new().pubkeys(public_keys.clone()).kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::Reaction,
|
||||||
|
Kind::ZapReceipt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync notification
|
||||||
|
//
|
||||||
|
if let Ok(output) = client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||||
|
{
|
||||||
|
println!("Received: {}", output.received.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter for contact list
|
||||||
|
//
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(public_keys)
|
||||||
|
.kinds(vec![Kind::ContactList, Kind::FollowSet]);
|
||||||
|
|
||||||
|
// Sync events for contact list
|
||||||
|
//
|
||||||
|
if let Ok(events) = client.database().query(vec![filter]).await {
|
||||||
|
// Get unique public keys
|
||||||
|
let public_keys: HashSet<PublicKey> = events
|
||||||
|
.iter()
|
||||||
|
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Convert to vector
|
||||||
|
let public_keys: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||||
|
|
||||||
|
for chunk in public_keys.chunks(1000) {
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authors = chunk.to_owned();
|
||||||
|
|
||||||
|
// Create filter for metadata
|
||||||
|
//
|
||||||
|
let filter = Filter::new().authors(authors.clone()).kinds(vec![
|
||||||
|
Kind::Metadata,
|
||||||
|
Kind::FollowSet,
|
||||||
|
Kind::Interests,
|
||||||
|
Kind::InterestSet,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync metadata
|
||||||
|
//
|
||||||
|
if let Ok(output) =
|
||||||
|
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||||
|
{
|
||||||
|
println!("Received: {}", output.received.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter for text note
|
||||||
|
//
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(authors)
|
||||||
|
.kinds(vec![Kind::TextNote, Kind::Repost, Kind::EventDeletion])
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
// Sync text note
|
||||||
|
//
|
||||||
|
if let Ok(output) =
|
||||||
|
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||||
|
{
|
||||||
|
println!("Received: {}", output.received.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run a thread for handle notification
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = handle_clone.state::<Nostr>();
|
||||||
|
let client = &state.client;
|
||||||
|
let accounts = get_all_accounts();
|
||||||
|
|
||||||
|
if !accounts.is_empty() {
|
||||||
|
let subscription_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||||
|
|
||||||
|
let public_keys: Vec<PublicKey> = accounts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|acc| PublicKey::from_str(acc).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.pubkeys(public_keys)
|
||||||
|
.kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::Reaction,
|
||||||
|
Kind::ZapReceipt,
|
||||||
|
Kind::Comment,
|
||||||
|
])
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
// Subscribe for new notification
|
||||||
if let Err(e) = client
|
if let Err(e) = client
|
||||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
println!("Subscription error: {}", e)
|
println!("Subscribe error: {}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SubKind::Unsubscribe => {
|
|
||||||
let subscription_id = SubscriptionId::new(payload.label);
|
|
||||||
client.unsubscribe(subscription_id).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let state = handle_clone.state::<Nostr>();
|
|
||||||
let client = &state.client;
|
|
||||||
|
|
||||||
let allow_notification = match handle_clone.notification().request_permission() {
|
let allow_notification = match handle_clone.notification().request_permission() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -316,49 +504,60 @@ fn main() {
|
|||||||
|
|
||||||
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||||
|
|
||||||
client
|
let _ = client
|
||||||
.handle_notifications(|notification| async {
|
.handle_notifications(|notification| async {
|
||||||
|
#[allow(clippy::collapsible_match)]
|
||||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||||
if let RelayMessage::Event {
|
if let RelayMessage::Event {
|
||||||
subscription_id,
|
|
||||||
event,
|
event,
|
||||||
|
subscription_id,
|
||||||
|
..
|
||||||
} = message
|
} = message
|
||||||
{
|
{
|
||||||
// Handle events from notification subscription
|
|
||||||
if subscription_id == notification_id {
|
if subscription_id == notification_id {
|
||||||
// Send native notification
|
// Send native notification
|
||||||
if allow_notification {
|
if allow_notification {
|
||||||
let author = client
|
let author = client
|
||||||
.metadata(event.pubkey)
|
.database()
|
||||||
|
.profile(event.pubkey)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Metadata::new());
|
.unwrap_or_else(|_| {
|
||||||
|
DatabaseProfile::new(event.pubkey, Metadata::new())
|
||||||
|
});
|
||||||
|
|
||||||
send_notification(&event, author, &handle_clone);
|
send_event_notification(
|
||||||
|
&event,
|
||||||
|
author.metadata(),
|
||||||
|
&handle_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else if event.kind == Kind::Metadata {
|
||||||
|
if let Err(e) = handle_clone.emit("metadata", event.as_json()) {
|
||||||
|
println!("Emit error: {}", e)
|
||||||
}
|
}
|
||||||
|
} else if event.kind == Kind::Comment {
|
||||||
let label = subscription_id.to_string();
|
let payload = RichEvent {
|
||||||
let raw = event.as_json();
|
raw: event.as_json(),
|
||||||
let parsed = if event.kind == Kind::TextNote {
|
parsed: if event.kind == Kind::TextNote {
|
||||||
Some(parse_event(&event.content).await)
|
Some(parse_event(&event.content).await)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
handle_clone
|
if let Err(e) = handle_clone.emit_to(
|
||||||
.emit_to(
|
EventTarget::labeled(subscription_id.to_string()),
|
||||||
EventTarget::labeled(label),
|
"comment",
|
||||||
"event",
|
payload,
|
||||||
RichEvent { raw, parsed },
|
) {
|
||||||
)
|
println!("Emit error: {}", e)
|
||||||
.unwrap();
|
}
|
||||||
} else {
|
}
|
||||||
println!("new message: {}", message.as_json())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -377,8 +576,14 @@ fn main() {
|
|||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_upload::init())
|
.plugin(tauri_plugin_upload::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.run(ctx)
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.expect("error while running tauri application");
|
.build(ctx)
|
||||||
|
.expect("error while running tauri application")
|
||||||
|
.run(|_app_handle, event| {
|
||||||
|
if let tauri::RunEvent::ExitRequested { api, .. } = event {
|
||||||
|
api.prevent_exit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -395,13 +600,13 @@ fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
|||||||
tauri_plugin_prevent_default::Builder::new().build()
|
tauri_plugin_prevent_default::Builder::new().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
|
fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
|
||||||
match event.kind() {
|
match event.kind {
|
||||||
Kind::TextNote => {
|
Kind::TextNote => {
|
||||||
if let Err(e) = handle
|
if let Err(e) = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.body("Mentioned you in a thread.")
|
.body("You're mentioned in a thread.")
|
||||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||||
.show()
|
.show()
|
||||||
{
|
{
|
||||||
@@ -412,7 +617,7 @@ fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle)
|
|||||||
if let Err(e) = handle
|
if let Err(e) = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.body("Reposted your note.")
|
.body("Your note has been reposted.")
|
||||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||||
.show()
|
.show()
|
||||||
{
|
{
|
||||||
@@ -423,7 +628,7 @@ fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle)
|
|||||||
if let Err(e) = handle
|
if let Err(e) = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.body("Zapped you.")
|
.body("You've received zap.")
|
||||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||||
.show()
|
.show()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Lume",
|
"productName": "Lume",
|
||||||
"version": "4.1.1",
|
"version": "24.11.8",
|
||||||
"identifier": "nu.lume.Lume",
|
"identifier": "nu.lume.Lume",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -30,6 +30,13 @@
|
|||||||
"$RESOURCE/*"
|
"$RESOURCE/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"trayIcon": {
|
||||||
|
"id": "main",
|
||||||
|
"iconAsTemplate": true,
|
||||||
|
"menuOnLeftClick": true,
|
||||||
|
"tooltip": "Lume",
|
||||||
|
"iconPath": "./icons/tray.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -39,10 +46,7 @@
|
|||||||
"targets": "all",
|
"targets": "all",
|
||||||
"active": true,
|
"active": true,
|
||||||
"category": "SocialNetworking",
|
"category": "SocialNetworking",
|
||||||
"resources": [
|
"resources": ["resources/*", "locales/*"],
|
||||||
"resources/*",
|
|
||||||
"locales/*"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@@ -13,9 +13,7 @@
|
|||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"windowEffects": {
|
"windowEffects": {
|
||||||
"effects": [
|
"effects": ["underWindowBackground"]
|
||||||
"underWindowBackground"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@@ -10,12 +10,7 @@
|
|||||||
"minWidth": 480,
|
"minWidth": 480,
|
||||||
"minHeight": 700,
|
"minHeight": 700,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"decorations": false,
|
"decorations": false
|
||||||
"windowEffects": {
|
|
||||||
"effects": [
|
|
||||||
"mica"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,17 @@
|
|||||||
import {
|
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
||||||
type PersistedQuery,
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
experimental_createPersister,
|
|
||||||
} from "@tanstack/query-persist-client-core";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
|
import { Store } from "@tauri-apps/plugin-store";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { newQueryStorage } from "./commons";
|
import { newQueryStorage } from "./commons";
|
||||||
import { routeTree } from "./routes.gen"; // auto generated file
|
|
||||||
import type { LumeEvent } from "./system";
|
import type { LumeEvent } from "./system";
|
||||||
import "./app.css";
|
|
||||||
import { Store } from "@tauri-apps/plugin-store";
|
|
||||||
|
|
||||||
const platform = type();
|
import { routeTree } from "./routes.gen"; // auto generated file
|
||||||
const tauriStore = new Store(".lume.dat");
|
import "./app.css"; // global styles
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
gcTime: 1000 * 30,
|
|
||||||
persister: experimental_createPersister<PersistedQuery>({
|
|
||||||
storage: newQueryStorage(tauriStore),
|
|
||||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
routeTree,
|
|
||||||
context: { queryClient, platform },
|
|
||||||
Wrap: ({ children }) => {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register things for typesafety
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
router: typeof router;
|
||||||
@@ -47,18 +21,43 @@ declare module "@tanstack/react-router" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
const platform = type();
|
||||||
return <RouterProvider router={router} />;
|
// @ts-ignore, won't fix
|
||||||
}
|
const store = await Store.load(".data", { autoSave: 300 });
|
||||||
|
const storage = newQueryStorage(store);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 1000 * 20, // 20 seconds
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: storage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 6, // 6 hours
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
// Make sure all webviews use same query client
|
||||||
const rootElement = document.getElementById("root")!;
|
broadcastQueryClient({
|
||||||
|
queryClient,
|
||||||
|
broadcastChannel: "lume",
|
||||||
|
});
|
||||||
|
|
||||||
if (!rootElement.innerHTML) {
|
const router = createRouter({
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
routeTree,
|
||||||
root.render(
|
context: { store, queryClient, platform },
|
||||||
<StrictMode>
|
Wrap: ({ children }) => {
|
||||||
<App />
|
return (
|
||||||
</StrictMode>,
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
const root = ReactDOM.createRoot(rootElement as unknown as HTMLElement);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,15 +5,39 @@
|
|||||||
|
|
||||||
|
|
||||||
export const commands = {
|
export const commands = {
|
||||||
async getRelays() : Promise<Result<Relays, string>> {
|
async syncAll(reader: TAURI_CHANNEL<number>) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
|
return { status: "ok", data: await TAURI_INVOKE("sync_all", { reader }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async connectRelay(relay: string) : Promise<Result<boolean, string>> {
|
async getAllRelays() : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_relays") };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllRelayLists(until: string | null) : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_relay_lists", { until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isRelayConnected(relay: string) : Promise<Result<boolean, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("is_relay_connected", { relay }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async connectRelay(relay: string) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
|
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -21,7 +45,7 @@ async connectRelay(relay: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async removeRelay(relay: string) : Promise<Result<boolean, string>> {
|
async removeRelay(relay: string) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
|
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -29,34 +53,18 @@ async removeRelay(relay: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getBootstrapRelays() : Promise<Result<string[], string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getAccounts() : Promise<string[]> {
|
async getAccounts() : Promise<string[]> {
|
||||||
return await TAURI_INVOKE("get_accounts");
|
return await TAURI_INVOKE("get_accounts");
|
||||||
},
|
},
|
||||||
async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> {
|
async watchAccount(id: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) };
|
return { status: "ok", data: await TAURI_INVOKE("watch_account", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async importAccount(key: string, password: string) : Promise<Result<string, string>> {
|
async importAccount(key: string, password: string | null) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
|
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,15 +104,23 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async login(account: string, password: string) : Promise<Result<string, string>> {
|
async hasSigner(id: string) : Promise<Result<boolean, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
|
return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getProfile(id: string | null) : Promise<Result<string, string>> {
|
async setSigner(id: string) : Promise<Result<null, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("set_signer", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getProfile(id: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -112,17 +128,17 @@ async getProfile(id: string | null) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setProfile(profile: Profile) : Promise<Result<string, string>> {
|
async setProfile(newProfile: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) };
|
return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getContactList() : Promise<Result<string[], string>> {
|
async getContactList(id: string) : Promise<Result<string[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
|
return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -136,17 +152,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async isContactListEmpty() : Promise<Result<boolean, null>> {
|
async isContact(id: string) : Promise<Result<boolean, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
|
return { status: "ok", data: await TAURI_INVOKE("is_contact", { id }) };
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async checkContact(hex: string) : Promise<Result<boolean, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -160,17 +168,81 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getLumeStore(key: string) : Promise<Result<string, string>> {
|
async getAllProfiles() : Promise<Result<Mention[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setLumeStore(key: string, content: string) : Promise<Result<string, string>> {
|
async setGroup(title: string, description: string | null, image: string | null, users: string[]) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("set_lume_store", { key, content }) };
|
return { status: "ok", data: await TAURI_INVOKE("set_group", { title, description, image, users }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getGroup(id: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_group", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllNewsfeeds(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_newsfeeds", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllLocalNewsfeeds(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_local_newsfeeds", { until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setInterest(title: string, description: string | null, image: string | null, hashtags: string[]) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("set_interest", { title, description, image, hashtags }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getInterest(id: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_interest", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllInterests(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_interests", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllLocalInterests(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_local_interests", { until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getRelayList(id: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_relay_list", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -184,7 +256,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadWallet() : Promise<Result<string, string>> {
|
async loadWallet() : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
|
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -200,7 +272,7 @@ async removeWallet() : Promise<Result<null, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async zapProfile(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
|
async zapProfile(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
|
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -208,7 +280,7 @@ async zapProfile(id: string, amount: string, message: string) : Promise<Result<b
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async zapEvent(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
|
async zapEvent(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
|
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -224,25 +296,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getNotifications() : Promise<Result<string[], string>> {
|
async getNotifications(id: string) : Promise<Result<string[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_notifications") };
|
return { status: "ok", data: await TAURI_INVOKE("get_notifications", { id }) };
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getSettings() : Promise<Result<Settings, null>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_settings") };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async setSettings(settings: string) : Promise<Result<null, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("set_settings", { settings }) };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -256,9 +312,9 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
|
async getMetaFromEvent(content: string) : Promise<Result<Meta, null>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_meta_from_event", { content }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -272,14 +328,6 @@ async getEvent(id: string) : Promise<Result<RichEvent, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getEventFrom(id: string, relayHint: string) : Promise<Result<RichEvent, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_event_from", { id, relayHint }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
||||||
@@ -288,33 +336,73 @@ async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async subscribeTo(id: string) : Promise<Result<null, string>> {
|
async getAllEventsByAuthor(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("subscribe_to", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getEventsBy(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
|
async getAllEventsByAuthors(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, limit }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_authors", { publicKeys, until }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getEventsFromContacts(until: string | null) : Promise<Result<RichEvent[], string>> {
|
async getAllEventsByHashtags(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_events_from_contacts", { until }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_hashtags", { hashtags, until }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
|
async getAllEventsFrom(url: string, until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_from", { url, until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllEventsByKind(kind: number, until: string | null) : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_kind", { kind, until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllProviders() : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_providers") };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async requestEventsFromProvider(provider: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("request_events_from_provider", { provider }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllEventsByRequest(id: string, provider: string) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_request", { id, provider }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -328,17 +416,9 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
|
async search(query: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
|
return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async search(query: string, until: string | null) : Promise<Result<RichEvent[], string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("search", { query, until }) };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -352,9 +432,9 @@ async publish(content: string, warning: string | null, difficulty: number | null
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async reply(content: string, to: string, root: string | null) : Promise<Result<string, string>> {
|
async reply(content: string, to: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
|
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -368,6 +448,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async isReposted(id: string) : Promise<Result<boolean, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("is_reposted", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async requestDelete(id: string) : Promise<Result<null, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
async eventToBech32(id: string) : Promise<Result<string, string>> {
|
async eventToBech32(id: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
|
||||||
@@ -392,6 +496,14 @@ async createColumn(column: Column) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async reloadColumn(label: string) : Promise<Result<null, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
|
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
|
||||||
@@ -400,31 +512,15 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async repositionColumn(label: string, x: number, y: number) : Promise<Result<boolean, string>> {
|
async closeAllColumns() : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) };
|
return { status: "ok", data: await TAURI_INVOKE("close_all_columns") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async resizeColumn(label: string, width: number, height: number) : Promise<Result<boolean, string>> {
|
async openWindow(window: NewWindow) : Promise<Result<string, string>> {
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("resize_column", { label, width, height }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async reloadColumn(label: string) : Promise<Result<boolean, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async openWindow(window: Window) : Promise<Result<null, string>> {
|
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
|
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -432,24 +528,27 @@ async openWindow(window: Window) : Promise<Result<null, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async reopenLume() : Promise<void> {
|
async getAppSettings() : Promise<Result<Settings, string>> {
|
||||||
await TAURI_INVOKE("reopen_lume");
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_app_settings") };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async quit() : Promise<void> {
|
async setAppSettings(settings: string) : Promise<Result<null, string>> {
|
||||||
await TAURI_INVOKE("quit");
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("set_app_settings", { settings }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** user-defined events **/
|
/** user-defined events **/
|
||||||
|
|
||||||
|
|
||||||
export const events = __makeEvents__<{
|
|
||||||
newSettings: NewSettings,
|
|
||||||
subscription: Subscription
|
|
||||||
}>({
|
|
||||||
newSettings: "new-settings",
|
|
||||||
subscription: "subscription"
|
|
||||||
})
|
|
||||||
|
|
||||||
/** user-defined constants **/
|
/** user-defined constants **/
|
||||||
|
|
||||||
@@ -458,15 +557,12 @@ subscription: "subscription"
|
|||||||
/** user-defined types **/
|
/** user-defined types **/
|
||||||
|
|
||||||
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
|
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
|
||||||
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
|
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
|
||||||
export type NewSettings = Settings
|
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
|
||||||
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
|
export type NewWindow = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
|
||||||
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
|
|
||||||
export type RichEvent = { raw: string; parsed: Meta | null }
|
export type RichEvent = { raw: string; parsed: Meta | null }
|
||||||
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
|
export type Settings = { resize_service: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
|
||||||
export type SubKind = "Subscribe" | "Unsubscribe"
|
export type TAURI_CHANNEL<TSend> = null
|
||||||
export type Subscription = { label: string; kind: SubKind; event_id: string | null }
|
|
||||||
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
|
|
||||||
|
|
||||||
/** tauri-specta globals **/
|
/** tauri-specta globals **/
|
||||||
|
|
||||||
@@ -484,7 +580,7 @@ type __EventObj__<T> = {
|
|||||||
once: (
|
once: (
|
||||||
cb: TAURI_API_EVENT.EventCallback<T>,
|
cb: TAURI_API_EVENT.EventCallback<T>,
|
||||||
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
|
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
|
||||||
emit: T extends null
|
emit: null extends T
|
||||||
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
|
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
|
||||||
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
|
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,29 +3,52 @@ import type {
|
|||||||
MaybePromise,
|
MaybePromise,
|
||||||
PersistedQuery,
|
PersistedQuery,
|
||||||
} from "@tanstack/query-persist-client-core";
|
} from "@tanstack/query-persist-client-core";
|
||||||
import { Store } from "@tanstack/store";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { ask, message, open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
|
||||||
import type { Store as TauriStore } from "@tauri-apps/plugin-store";
|
import type { Store as TauriStore } from "@tauri-apps/plugin-store";
|
||||||
import { check } from "@tauri-apps/plugin-updater";
|
|
||||||
import { BitcoinUnit } from "bitcoin-units";
|
import { BitcoinUnit } from "bitcoin-units";
|
||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import updateLocale from "dayjs/plugin/updateLocale";
|
import updateLocale from "dayjs/plugin/updateLocale";
|
||||||
import { decode } from "light-bolt11-decoder";
|
import { decode } from "light-bolt11-decoder";
|
||||||
import { type BaseEditor, Transforms } from "slate";
|
|
||||||
import { ReactEditor } from "slate-react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import type { RichEvent, Settings } from "./commands.gen";
|
import type { RichEvent } from "./commands.gen";
|
||||||
import { LumeEvent } from "./system";
|
import { LumeEvent } from "./system";
|
||||||
import type { NostrEvent } from "./types";
|
import type { NostrEvent } from "./types";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
export function cn(...inputs: ClassValue[]) {
|
||||||
dayjs.extend(updateLocale);
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
dayjs.updateLocale("en", {
|
export function isValidRelayUrl(string: string) {
|
||||||
|
try {
|
||||||
|
const newUrl = new URL(string);
|
||||||
|
return newUrl.protocol === "ws:" || newUrl.protocol === "wss:";
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isImagePath = (path: string) => {
|
||||||
|
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||||
|
|
||||||
|
for (const suffix of exts) {
|
||||||
|
if (path.endsWith(suffix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createdAt(time: number) {
|
||||||
|
// Config for dayjs
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(updateLocale);
|
||||||
|
|
||||||
|
// Config locale text
|
||||||
|
dayjs.updateLocale("en", {
|
||||||
relativeTime: {
|
relativeTime: {
|
||||||
past: "%s ago",
|
past: "%s ago",
|
||||||
s: "just now",
|
s: "just now",
|
||||||
@@ -36,103 +59,42 @@ dayjs.updateLocale("en", {
|
|||||||
d: "1d",
|
d: "1d",
|
||||||
dd: "%dd",
|
dd: "%dd",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isImagePath = (path: string) => {
|
|
||||||
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) {
|
|
||||||
if (path.endsWith(suffix)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isImageUrl = (url: string) => {
|
|
||||||
try {
|
|
||||||
if (!url) return false;
|
|
||||||
const ext = new URL(url).pathname.split(".").pop();
|
|
||||||
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
|
|
||||||
const text = { text: "" };
|
|
||||||
const image = [
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
url,
|
|
||||||
children: [text],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const extraText = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [text],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// @ts-ignore, idk
|
|
||||||
ReactEditor.focus(editor);
|
|
||||||
Transforms.insertNodes(editor, image);
|
|
||||||
Transforms.insertNodes(editor, extraText);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertNostrEvent = (
|
|
||||||
editor: ReactEditor | BaseEditor,
|
|
||||||
eventId: string,
|
|
||||||
) => {
|
|
||||||
const text = { text: "" };
|
|
||||||
const event = [
|
|
||||||
{
|
|
||||||
type: "event",
|
|
||||||
eventId: `nostr:${eventId}`,
|
|
||||||
children: [text],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const extraText = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [text],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
Transforms.insertNodes(editor, event);
|
|
||||||
Transforms.insertNodes(editor, extraText);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatCreatedAt(time: number, message = false) {
|
|
||||||
let formated: string;
|
|
||||||
|
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
const inputTime = dayjs.unix(time);
|
const inputTime = dayjs.unix(time);
|
||||||
const diff = now.diff(inputTime, "hour");
|
const diff = now.diff(inputTime, "hour");
|
||||||
|
|
||||||
if (message) {
|
|
||||||
if (diff < 12) {
|
|
||||||
formated = inputTime.format("HH:mm A");
|
|
||||||
} else {
|
|
||||||
formated = inputTime.format("MMM DD");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (diff < 24) {
|
if (diff < 24) {
|
||||||
formated = inputTime.from(now, true);
|
return inputTime.from(now, true);
|
||||||
} else {
|
} else {
|
||||||
formated = inputTime.format("MMM DD");
|
return inputTime.format("MMM DD");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return formated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replyTime(time: number) {
|
export function replyAt(time: number) {
|
||||||
const inputTime = dayjs.unix(time);
|
// Config for dayjs
|
||||||
const formated = inputTime.format("MM-DD-YY HH:mm");
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(updateLocale);
|
||||||
|
|
||||||
return formated;
|
// Config locale text
|
||||||
|
dayjs.updateLocale("en", {
|
||||||
|
relativeTime: {
|
||||||
|
past: "%s ago",
|
||||||
|
s: "just now",
|
||||||
|
m: "1m",
|
||||||
|
mm: "%dm",
|
||||||
|
h: "1h",
|
||||||
|
hh: "%dh",
|
||||||
|
d: "1d",
|
||||||
|
dd: "%dd",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputTime = dayjs.unix(time);
|
||||||
|
const format = inputTime.format("MM-DD-YY HH:mm");
|
||||||
|
|
||||||
|
return format;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function displayNpub(pubkey: string, len: number) {
|
export function displayNpub(pubkey: string, len: number) {
|
||||||
@@ -161,7 +123,7 @@ export function displayLongHandle(str: string) {
|
|||||||
return `${handle.substring(0, 16)}...@${service}`;
|
return `${handle.substring(0, 16)}...@${service}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
|
// Source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
|
||||||
export function getBitcoinDisplayValues(satoshis: number) {
|
export function getBitcoinDisplayValues(satoshis: number) {
|
||||||
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
|
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
|
||||||
.getValue()
|
.getValue()
|
||||||
@@ -192,53 +154,26 @@ export function getBitcoinDisplayValues(satoshis: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeZapInvoice(tags?: string[][]) {
|
export function decodeZapInvoice(tags: string[][]) {
|
||||||
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
|
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
|
||||||
if (!invoice) return;
|
if (!invoice) return;
|
||||||
|
|
||||||
const decodedInvoice = decode(invoice);
|
const decodedInvoice = decode(invoice);
|
||||||
const amountSection = decodedInvoice.sections.find(
|
const section = decodedInvoice.sections.find(
|
||||||
(s: { name: string }) => s.name === "amount",
|
(s: { name: string }) => s.name === "amount",
|
||||||
);
|
);
|
||||||
|
|
||||||
const amount = Number.parseInt(amountSection.value);
|
if (!section) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section.name === "amount") {
|
||||||
|
const amount = Number.parseInt(section.value) / 1000;
|
||||||
const displayValue = getBitcoinDisplayValues(amount);
|
const displayValue = getBitcoinDisplayValues(amount);
|
||||||
|
|
||||||
return displayValue;
|
return displayValue;
|
||||||
}
|
} else {
|
||||||
|
return null;
|
||||||
export async function checkForAppUpdates(silent: boolean) {
|
|
||||||
const update = await check();
|
|
||||||
|
|
||||||
if (!update) {
|
|
||||||
if (silent) return;
|
|
||||||
|
|
||||||
await message("You are on the latest version. Stay awesome!", {
|
|
||||||
title: "No Update Available",
|
|
||||||
kind: "info",
|
|
||||||
okLabel: "OK",
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update?.available) {
|
|
||||||
const yes = await ask(
|
|
||||||
`Update to ${update.version} is available!\n\nRelease notes: ${update.body}`,
|
|
||||||
{
|
|
||||||
title: "Update Available",
|
|
||||||
kind: "info",
|
|
||||||
okLabel: "Update",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (yes) {
|
|
||||||
await update.downloadAndInstall();
|
|
||||||
await relaunch();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,9 +192,8 @@ export async function upload(filePath?: string) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const selected =
|
const selected =
|
||||||
filePath ||
|
filePath ??
|
||||||
(
|
(await open({
|
||||||
await open({
|
|
||||||
multiple: false,
|
multiple: false,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
@@ -267,8 +201,7 @@ export async function upload(filePath?: string) {
|
|||||||
extensions: allowExts,
|
extensions: allowExts,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}));
|
||||||
).path;
|
|
||||||
|
|
||||||
// User cancelled action
|
// User cancelled action
|
||||||
if (!selected) return null;
|
if (!selected) return null;
|
||||||
@@ -325,15 +258,3 @@ export function newQueryStorage(
|
|||||||
(await store.delete(key)) as unknown as MaybePromise<void>,
|
(await store.delete(key)) as unknown as MaybePromise<void>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appSettings = new Store<Settings>({
|
|
||||||
proxy: null,
|
|
||||||
image_resize_service: "https://wsrv.nl",
|
|
||||||
use_relay_hint: true,
|
|
||||||
content_warning: true,
|
|
||||||
display_avatar: true,
|
|
||||||
display_zap_button: true,
|
|
||||||
display_repost_button: true,
|
|
||||||
display_media: true,
|
|
||||||
transparent: true,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,126 +1,68 @@
|
|||||||
import { commands } from "@/commands.gen";
|
import { commands } from "@/commands.gen";
|
||||||
|
import { useRect } from "@/system";
|
||||||
import type { LumeColumn } from "@/types";
|
import type { LumeColumn } from "@/types";
|
||||||
import { Check, DotsThree } from "@phosphor-icons/react";
|
import { CaretDown, Check } from "@phosphor-icons/react";
|
||||||
import { useParams } from "@tanstack/react-router";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Spinner } from "./spinner";
|
import { User } from "./user";
|
||||||
|
|
||||||
type WindowEvent = {
|
export function Column({ column }: { column: LumeColumn }) {
|
||||||
scroll: boolean;
|
const [rect, ref] = useRect();
|
||||||
resize: boolean;
|
const [error, setError] = useState("");
|
||||||
};
|
|
||||||
|
|
||||||
export const Column = memo(function Column({ column }: { column: LumeColumn }) {
|
|
||||||
const params = useParams({ strict: false });
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
const webviewLabel = `column-${params.account}_${column.label}`;
|
|
||||||
|
|
||||||
const [isCreated, setIsCreated] = useState(false);
|
|
||||||
|
|
||||||
const repositionWebview = useCallback(async () => {
|
|
||||||
if (!container.current) return;
|
|
||||||
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("reposition_column", {
|
|
||||||
label: webviewLabel,
|
|
||||||
x: newRect.x,
|
|
||||||
y: newRect.y,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resizeWebview = useCallback(async () => {
|
|
||||||
if (!container.current) return;
|
|
||||||
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("resize_column", {
|
|
||||||
label: webviewLabel,
|
|
||||||
width: newRect.width,
|
|
||||||
height: newRect.height,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreated) return;
|
(async () => {
|
||||||
|
if (rect) {
|
||||||
const unlisten = listen<WindowEvent>("child_webview", (data) => {
|
const res = await commands.createColumn({
|
||||||
if (data.payload.scroll) repositionWebview();
|
label: column.label,
|
||||||
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.url}?account=${params.account}&label=${column.label}&name=${column.name}`;
|
|
||||||
|
|
||||||
const prop = {
|
|
||||||
label: webviewLabel,
|
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
y: rect.y,
|
y: rect.y,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
url,
|
url: `${column.url}?label=${column.label}&name=${column.name}&account=${column.account}`,
|
||||||
};
|
|
||||||
|
|
||||||
// create new webview
|
|
||||||
invoke("create_column", { column: prop }).then(() => {
|
|
||||||
console.log("created: ", webviewLabel);
|
|
||||||
setIsCreated(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// close webview when unmounted
|
if (res.status === "error") {
|
||||||
return () => {
|
setError(res.error);
|
||||||
invoke("close_column", { label: webviewLabel }).then(() => {
|
}
|
||||||
console.log("closed: ", webviewLabel);
|
}
|
||||||
});
|
})();
|
||||||
};
|
}, [rect]);
|
||||||
}, [params.account]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[440px] shrink-0 p-2">
|
<div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
|
||||||
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/15">
|
<div className="flex flex-col gap-px size-full">
|
||||||
<Header label={column.label} name={column.name} />
|
<Header
|
||||||
<div ref={container} className="flex-1 w-full h-full">
|
label={column.label}
|
||||||
{!isCreated ? (
|
name={column.name}
|
||||||
<div className="size-full flex items-center justify-center">
|
account={column.account}
|
||||||
<Spinner />
|
/>
|
||||||
|
<div ref={ref} className="flex-1 size-full">
|
||||||
|
<div className="size-full flex flex-col items-center justify-center">
|
||||||
|
<div className="invisible text-red-500 text-sm break-all">
|
||||||
|
{error?.length ? error : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
function Header({ label, name }: { label: string; name: string }) {
|
function Header({
|
||||||
const [title, setTitle] = useState(name);
|
label,
|
||||||
|
name,
|
||||||
|
account,
|
||||||
|
}: { label: string; name: string; account?: string }) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
const [isChanged, setIsChanged] = useState(false);
|
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) => {
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const window = getCurrentWindow();
|
const window = getCurrentWindow();
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
const menuItems = await Promise.all([
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Reload",
|
text: "Reload",
|
||||||
@@ -128,10 +70,6 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
await commands.reloadColumn(label);
|
await commands.reloadColumn(label);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
MenuItem.new({
|
|
||||||
text: "Open in new window",
|
|
||||||
action: () => console.log("not implemented."),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Move left",
|
text: "Move left",
|
||||||
@@ -172,20 +110,39 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
await menu.popup().catch((e) => console.error(e));
|
await menu.popup().catch((e) => console.error(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const saveNewTitle = async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "set_title",
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
setIsChanged(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title.length !== name.length) setIsChanged(true);
|
if (title.length > 0) setIsChanged(true);
|
||||||
}, [title]);
|
}, [title.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
|
<div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
|
||||||
<div className="size-7" />
|
|
||||||
<div className="flex items-center justify-center shrink-0 h-7">
|
<div className="flex items-center justify-center shrink-0 h-7">
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
|
{account?.length ? (
|
||||||
|
<User.Provider pubkey={account}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-6 rounded-full" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
onBlur={(e) => setTitle(e.currentTarget.textContent)}
|
onBlur={(e) => {
|
||||||
className="text-sm font-medium focus:outline-none"
|
if (e.currentTarget.textContent) {
|
||||||
|
setTitle(e.currentTarget.textContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[12px] font-semibold focus:outline-none"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,9 +150,9 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => saveNewTitle()}
|
onClick={() => saveNewTitle()}
|
||||||
className="text-teal-500 hover:text-teal-600"
|
className="text-teal-500 hover:text-teal-600 inline-flex items-center justify-center size-6 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full bg-white dark:bg-black"
|
||||||
>
|
>
|
||||||
<Check className="size-4" />
|
<Check className="size-3" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,9 +160,9 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => showContextMenu(e)}
|
onClick={(e) => showContextMenu(e)}
|
||||||
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10"
|
className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 bg-white dark:bg-neutral-800 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full"
|
||||||
>
|
>
|
||||||
<DotsThree className="size-5" />
|
<CaretDown className="size-3" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { cn } from "@/commons";
|
|
||||||
import { Note } from "@/components/note";
|
|
||||||
import type { LumeEvent } from "@/system";
|
|
||||||
import { ChatsTeardrop } from "@phosphor-icons/react";
|
|
||||||
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">
|
|
||||||
<ChatsTeardrop 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,5 +1,4 @@
|
|||||||
import { cn } from "@/commons";
|
import { cn } from "@/commons";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function Frame({
|
export function Frame({
|
||||||
@@ -7,17 +6,13 @@ export function Frame({
|
|||||||
shadow,
|
shadow,
|
||||||
className,
|
className,
|
||||||
}: { children: ReactNode; shadow?: boolean; className?: string }) {
|
}: { children: ReactNode; shadow?: boolean; className?: string }) {
|
||||||
const { platform } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
platform === "linux"
|
"bg-white dark:bg-neutral-800",
|
||||||
? "bg-white dark:bg-neutral-950"
|
|
||||||
: "bg-white dark:bg-white/10",
|
|
||||||
shadow
|
shadow
|
||||||
? "shadow-lg shadow-neutral-500/10 dark:shadow-none dark:ring-1 dark:ring-white/20"
|
? "shadow-primary dark:shadow-none dark:ring-1 dark:ring-neutral-700/50"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const PublishIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 01-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 01-.15-.015A32.702 32.702 0 005.5 21.25a.75.75 0 01-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 012.031.722z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const QuoteIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.75 5.75a2 2 0 0 1 2-2h14.5a2 2 0 0 1 2 2v10.5a2 2 0 0 1-2 2h-3.874a1 1 0 0 0-.638.23l-2.098 1.738a1 1 0 0 1-1.28-.003l-2.066-1.731a1 1 0 0 0-.642-.234H4.75a2 2 0 0 1-2-2z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.523 8C8.406 8 7.5 8.91 7.5 10.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.157.251c-.353.502-.875.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .624.125c.67-.449 1.328-.913 1.79-1.569.474-.674.716-1.51.658-2.66A2.03 2.03 0 0 0 9.523 8m4.945 0c-1.117 0-2.023.91-2.023 2.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.156.251c-.353.502-.876.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .623.125c.67-.449 1.328-.913 1.79-1.569.474-.674.717-1.51.658-2.66A2.03 2.03 0 0 0 14.468 8"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const ReplyIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21.75 12c0-5.156-3.792-8.25-9.75-8.25S2.25 6.844 2.25 12c0 1.337.92 3.605 1.064 3.952l.038.091c.099.27.505 1.71-1.102 3.84 2.167 1.031 4.468-.664 4.468-.664 1.592.84 3.486 1.031 5.282 1.031 5.958 0 9.75-3.094 9.75-8.25z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const RepostIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.75 1.5l3.5 3.25L10.75 8m2.5 8l-3.5 3.25 3.5 3.25m-2.5-3.25H14a7.25 7.25 0 004.755-12.723M13.25 4.75H10a7.25 7.25 0 00-4.754 12.724"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const ZapIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.861 11.252l4.6-8a1 1 0 01.867-.502h7.053a1 1 0 01.895 1.447l-1.303 2.606a1 1 0 00.895 1.447h2.467c.891 0 1.337 1.077.707 1.707L9.103 20.897c-.701.7-1.885.063-1.687-.908l1.235-6.039a1 1 0 00-.98-1.2H4.728a1 1 0 01-.867-1.498z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -4,12 +4,17 @@ export * from "./spinner";
|
|||||||
export * from "./column";
|
export * from "./column";
|
||||||
|
|
||||||
// Newsfeed
|
// Newsfeed
|
||||||
export * from "./repost";
|
|
||||||
export * from "./conversation";
|
|
||||||
export * from "./quote";
|
|
||||||
export * from "./text";
|
export * from "./text";
|
||||||
|
export * from "./repost";
|
||||||
export * from "./reply";
|
export * from "./reply";
|
||||||
|
|
||||||
// Global components
|
// Global components
|
||||||
export * from "./note";
|
export * from "./note";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
export * from "./icons/reply";
|
||||||
|
export * from "./icons/repost";
|
||||||
|
export * from "./icons/zap";
|
||||||
|
export * from "./icons/quote";
|
||||||
|
export * from "./icons/publish";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LumeWindow } from "@/system";
|
import { LumeWindow } from "@/system";
|
||||||
import { FrameCorners } from "@phosphor-icons/react";
|
import { ListPlus } from "@phosphor-icons/react";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
@@ -8,19 +8,26 @@ export function NoteOpenThread() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={300}>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEvent(event)}
|
onClick={() =>
|
||||||
|
LumeWindow.openColumn({
|
||||||
|
name: "Thread",
|
||||||
|
label: event.id.slice(0, 6),
|
||||||
|
account: event.pubkey,
|
||||||
|
url: `/columns/events/${event.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<FrameCorners className="shrink-0 size-4" />
|
<ListPlus className="shrink-0 size-4" />
|
||||||
</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">
|
||||||
Open
|
View thread
|
||||||
<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>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { cn } from "@/commons";
|
||||||
|
import { QuoteIcon } from "@/components";
|
||||||
|
import { LumeWindow } from "@/system";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
|
export function NoteQuote({
|
||||||
|
label = false,
|
||||||
|
smol = false,
|
||||||
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openEditor(undefined, event.id)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
label
|
||||||
|
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
||||||
|
{label ? "Quote" : 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">
|
||||||
|
Quote
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { cn } from "@/commons";
|
import { cn } from "@/commons";
|
||||||
|
import { ReplyIcon } from "@/components";
|
||||||
import { LumeWindow } from "@/system";
|
import { LumeWindow } from "@/system";
|
||||||
import { ShareFat } from "@phosphor-icons/react";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
export function NoteReply({
|
||||||
|
label = false,
|
||||||
|
smol = false,
|
||||||
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -15,14 +18,12 @@ export function NoteReply({ large = false }: { large?: boolean }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEditor(event.id)}
|
onClick={() => LumeWindow.openEditor(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
|
||||||
large
|
label ? "w-24 gap-1.5" : "w-14",
|
||||||
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
: "size-7",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ShareFat className="shrink-0 size-4" />
|
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
||||||
{large ? "Reply" : null}
|
{label ? "Reply" : null}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
|
|||||||
@@ -1,81 +1,162 @@
|
|||||||
import { appSettings, cn } from "@/commons";
|
import { commands } from "@/commands.gen";
|
||||||
import { Spinner } from "@/components";
|
import { cn, displayNpub } from "@/commons";
|
||||||
import { LumeWindow } from "@/system";
|
import { RepostIcon, Spinner } from "@/components";
|
||||||
import { Repeat } from "@phosphor-icons/react";
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
import { useStore } from "@tanstack/react-store";
|
import type { Metadata } from "@/types";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
useSuspenseQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useTransition } from "react";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
export function NoteRepost({
|
||||||
const visible = useStore(appSettings, (state) => state.display_repost_button);
|
label = false,
|
||||||
|
smol = false,
|
||||||
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const { isLoading, data: status } = useQuery({
|
||||||
const [isRepost, setIsRepost] = useState(false);
|
queryKey: ["is-reposted", event.id],
|
||||||
|
queryFn: async () => {
|
||||||
const repost = async () => {
|
const res = await commands.isReposted(event.id);
|
||||||
if (isRepost) return;
|
if (res.status === "ok") {
|
||||||
|
return res.data;
|
||||||
try {
|
} else {
|
||||||
setLoading(true);
|
return false;
|
||||||
|
|
||||||
// repost
|
|
||||||
await event.repost();
|
|
||||||
|
|
||||||
// update state
|
|
||||||
setLoading(false);
|
|
||||||
setIsRepost(true);
|
|
||||||
} catch {
|
|
||||||
setLoading(false);
|
|
||||||
await message("Repost failed, try again later", {
|
|
||||||
title: "Lume",
|
|
||||||
kind: "info",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
enabled: settings.data.display_repost_button,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
const accounts = await commands.getAccounts();
|
||||||
MenuItem.new({
|
const list: Promise<MenuItem>[] = [];
|
||||||
text: "Quote",
|
|
||||||
action: async () => repost(),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Repost",
|
|
||||||
action: () => LumeWindow.openEditor(null, event.id),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
for (const account of accounts) {
|
||||||
items: menuItems,
|
const res = await commands.getProfile(account);
|
||||||
});
|
let name = "unknown";
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
const profile: Metadata = JSON.parse(res.data);
|
||||||
|
name = profile.display_name ?? profile.name ?? "anon";
|
||||||
|
}
|
||||||
|
|
||||||
|
list.push(
|
||||||
|
MenuItem.new({
|
||||||
|
text: `Repost as ${name} (${displayNpub(account, 16)})`,
|
||||||
|
action: async () => submit(account),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await Promise.all(list);
|
||||||
|
const menu = await Menu.new({ items });
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
await menu.popup().catch((e) => console.error(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!visible) return null;
|
const repost = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["is-reposted", event.id] });
|
||||||
|
|
||||||
|
// Optimistically update to the new value
|
||||||
|
queryClient.setQueryData(["is-reposted", event.id], true);
|
||||||
|
|
||||||
|
const res = await commands.repost(JSON.stringify(event.raw));
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.setQueryData(["is-reposted", event.id], false);
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
return await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["is-reposted", event.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (account: string) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
if (!status) {
|
||||||
|
const signer = await commands.hasSigner(account);
|
||||||
|
|
||||||
|
if (signer.status === "ok") {
|
||||||
|
if (!signer.data) {
|
||||||
|
const res = await commands.setSigner(account);
|
||||||
|
|
||||||
|
if (res.status === "error") {
|
||||||
|
await message(res.error, { kind: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repost.mutate();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings.data.display_repost_button) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={300}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => showContextMenu(e)}
|
onClick={(e) => showContextMenu(e)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
|
||||||
large
|
label ? "w-24 gap-1.5" : "w-14",
|
||||||
? "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 ? (
|
{isPending || isLoading ? (
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Repeat className={cn("size-4", isRepost ? "text-blue-500" : "")} />
|
<RepostIcon
|
||||||
|
className={cn(
|
||||||
|
smol ? "size-4" : "size-5",
|
||||||
|
status ? "text-blue-500" : "",
|
||||||
)}
|
)}
|
||||||
{large ? "Repost" : null}
|
/>
|
||||||
|
)}
|
||||||
|
{label ? "Repost" : null}
|
||||||
</button>
|
</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">
|
||||||
|
Repost
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import { appSettings, cn } from "@/commons";
|
import { cn } from "@/commons";
|
||||||
|
import { ZapIcon } from "@/components";
|
||||||
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
import { LumeWindow } from "@/system";
|
import { LumeWindow } from "@/system";
|
||||||
import { Lightning } from "@phosphor-icons/react";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useSearch } from "@tanstack/react-router";
|
|
||||||
import { useStore } from "@tanstack/react-store";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
export function NoteZap({
|
||||||
const search = useSearch({ strict: false });
|
label = false,
|
||||||
const visible = useStore(appSettings, (state) => state.display_zap_button);
|
smol = false,
|
||||||
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!settings.data.display_zap_button) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openZap(event.id, search.account)}
|
onClick={() => LumeWindow.openZap(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
|
||||||
large
|
label ? "w-24 gap-1.5" : "w-14",
|
||||||
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
: "size-7",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Lightning className="size-4" />
|
<ZapIcon className={smol ? "size-4" : "size-5"} />
|
||||||
{large ? "Zap" : null}
|
{label ? "Zap" : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { appSettings, cn } from "@/commons";
|
import { cn } from "@/commons";
|
||||||
import { useStore } from "@tanstack/react-store";
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { type ReactNode, memo, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { Hashtag } from "./mentions/hashtag";
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
import { MentionNote } from "./mentions/note";
|
import { MentionNote } from "./mentions/note";
|
||||||
import { MentionUser } from "./mentions/user";
|
import { MentionUser } from "./mentions/user";
|
||||||
import { Images } from "./preview/images";
|
import { Images } from "./preview/images";
|
||||||
import { Videos } from "./preview/videos";
|
|
||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
export function NoteContent({
|
export function NoteContent({
|
||||||
@@ -22,15 +22,17 @@ export function NoteContent({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const visible = useStore(appSettings, (state) => state.display_media);
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
const warning = useMemo(() => event.warning, [event]);
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
// Get parsed meta
|
// Get parsed meta
|
||||||
const { content, hashtags, events, mentions } = event.meta;
|
const { content, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
// Define rich content
|
// Define rich content
|
||||||
let richContent: ReactNode[] | string = visible ? content : event.content;
|
let richContent: ReactNode[] | string = settings.data.display_media
|
||||||
|
? content
|
||||||
|
: event.content;
|
||||||
|
|
||||||
for (const hashtag of hashtags) {
|
for (const hashtag of hashtags) {
|
||||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
@@ -89,56 +91,48 @@ export function NoteContent({
|
|||||||
}
|
}
|
||||||
}, [event.content]);
|
}, [event.content]);
|
||||||
|
|
||||||
|
const [blurred, setBlurred] = useState(() =>
|
||||||
|
event.warning ? event.warning.length > 0 : false,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-2">
|
<div className="relative flex flex-col gap-2">
|
||||||
<ContentWarning warning={warning} />
|
{!blurred ? (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"select-text text-pretty content-break overflow-hidden",
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
event.content.length > 500 ? "max-h-[250px] gradient-mask-b-0" : "",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
{visible ? (
|
{settings.data.display_media ? (
|
||||||
<>
|
event.meta?.images.length ? (
|
||||||
{event.meta?.images.length ? (
|
|
||||||
<Images urls={event.meta.images} />
|
<Images urls={event.meta.images} />
|
||||||
) : null}
|
) : null
|
||||||
{event.meta?.videos.length ? (
|
|
||||||
<Videos urls={event.meta.videos} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<div
|
||||||
);
|
className={cn(
|
||||||
}
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
|
className,
|
||||||
const ContentWarning = memo(function ContentWarning({
|
)}
|
||||||
warning,
|
>
|
||||||
}: { warning: string }) {
|
<p className="text-yellow-600 dark:text-yellow-400">
|
||||||
const [blurred, setBlurred] = useState(() => warning?.length > 0);
|
The content is hidden because the author marked it with a warning
|
||||||
|
for a reason: <span className="font-semibold">{event.warning}</span>
|
||||||
if (!blurred) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
||||||
<p className="text-sm font-medium text-white">{warning}</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBlurred(false)}
|
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"
|
className="font-medium text-sm text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
View anyway
|
View anyway
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Hashtag } from "./mentions/hashtag";
|
|||||||
import { MentionNote } from "./mentions/note";
|
import { MentionNote } from "./mentions/note";
|
||||||
import { MentionUser } from "./mentions/user";
|
import { MentionUser } from "./mentions/user";
|
||||||
import { ImagePreview } from "./preview/image";
|
import { ImagePreview } from "./preview/image";
|
||||||
import { VideoPreview } from "./preview/video";
|
|
||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
export function NoteContentLarge({
|
export function NoteContentLarge({
|
||||||
@@ -18,7 +17,7 @@ export function NoteContentLarge({
|
|||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
// Get parsed meta
|
// Get parsed meta
|
||||||
const { images, videos, hashtags, events, mentions } = event.meta;
|
const { images, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
// Define rich content
|
// Define rich content
|
||||||
let richContent: ReactNode[] | string = event.content;
|
let richContent: ReactNode[] | string = event.content;
|
||||||
@@ -48,12 +47,6 @@ export function NoteContentLarge({
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const video of videos) {
|
|
||||||
richContent = reactStringReplace(richContent, video, (match, i) => (
|
|
||||||
<VideoPreview key={match + i} url={match} />
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
richContent = reactStringReplace(
|
richContent = reactStringReplace(
|
||||||
richContent,
|
richContent,
|
||||||
/(https?:\/\/\S+)/gi,
|
/(https?:\/\/\S+)/gi,
|
||||||
@@ -75,8 +68,7 @@ export function NoteContentLarge({
|
|||||||
));
|
));
|
||||||
|
|
||||||
return richContent;
|
return richContent;
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.log("[parser]: ", e);
|
|
||||||
return event.content;
|
return event.content;
|
||||||
}
|
}
|
||||||
}, [event.content]);
|
}, [event.content]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NoteOpenThread } from "./buttons/open";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
|
import { NoteQuote } from "./buttons/quote";
|
||||||
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";
|
||||||
@@ -16,6 +17,7 @@ export const Note = {
|
|||||||
User: NoteUser,
|
User: NoteUser,
|
||||||
Menu: NoteMenu,
|
Menu: NoteMenu,
|
||||||
Reply: NoteReply,
|
Reply: NoteReply,
|
||||||
|
Quote: NoteQuote,
|
||||||
Repost: NoteRepost,
|
Repost: NoteRepost,
|
||||||
Content: NoteContent,
|
Content: NoteContent,
|
||||||
ContentLarge: NoteContentLarge,
|
ContentLarge: NoteContentLarge,
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { LumeWindow } from "@/system";
|
|
||||||
|
|
||||||
export function Hashtag({ tag }: { tag: string }) {
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
|
||||||
type="button"
|
{tag.includes("#") ? tag : `#${tag}`}
|
||||||
onClick={() => LumeWindow.openHashtag(tag)}
|
</span>
|
||||||
className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { replyTime } from "@/commons";
|
import { replyAt } from "@/commons";
|
||||||
import { Note, Spinner } from "@/components";
|
import { Note, Spinner, User } from "@/components";
|
||||||
import { User } from "@/components/user";
|
|
||||||
import { LumeWindow, useEvent } from "@/system";
|
import { LumeWindow, useEvent } from "@/system";
|
||||||
import { memo } from "react";
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { type ReactNode, memo, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./hashtag";
|
||||||
|
import { MentionUser } from "./user";
|
||||||
|
|
||||||
export const MentionNote = memo(function MentionNote({
|
export const MentionNote = memo(function MentionNote({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -15,12 +18,18 @@ export const MentionNote = memo(function MentionNote({
|
|||||||
<div className="relative my-2">
|
<div className="relative my-2">
|
||||||
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
<div className="h-[32px] flex items-center gap-2 text-sm">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
Loadng note
|
||||||
|
</div>
|
||||||
) : isError || !event ? (
|
) : isError || !event ? (
|
||||||
|
<div className="flex flex-col break-all">
|
||||||
<p className="text-sm font-medium text-red-500">
|
<p className="text-sm font-medium text-red-500">
|
||||||
{error.message ||
|
{error?.message ??
|
||||||
"Quoted note is not found with your current relay set"}
|
"Cannot found this note within your current relay set"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm">{eventId}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
@@ -33,20 +42,29 @@ export const MentionNote = memo(function MentionNote({
|
|||||||
/>
|
/>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
|
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
|
||||||
{event.content.length > 120
|
{event.content.length > 300 ? (
|
||||||
? `${event.content.substring(0, 120)}...`
|
`${event.content.substring(0, 300)}...`
|
||||||
: event.content}
|
) : (
|
||||||
|
<Content text={event.content} className="inline" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-between">
|
<div className="flex-1 flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-500">
|
<span className="text-sm text-neutral-500">
|
||||||
{replyTime(event.created_at)}
|
{replyAt(event.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
|
<div className="invisible group-hover:visible flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEvent(event)}
|
onClick={() =>
|
||||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
LumeWindow.openColumn({
|
||||||
|
name: "Thread",
|
||||||
|
label: eventId.slice(0, 6),
|
||||||
|
account: event.pubkey,
|
||||||
|
url: `/columns/events/${eventId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mr-3 text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
Show all
|
Show all
|
||||||
</button>
|
</button>
|
||||||
@@ -63,3 +81,78 @@ export const MentionNote = memo(function MentionNote({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function Content({ text, className }: { text: string; className?: string }) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
let replacedText: ReactNode[] | string = text.trim();
|
||||||
|
|
||||||
|
const nostr = replacedText
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.startsWith("nostr:"));
|
||||||
|
|
||||||
|
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 !underline"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
|
||||||
|
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
|
||||||
|
<Hashtag key={match + i} tag={match} />
|
||||||
|
));
|
||||||
|
|
||||||
|
for (const word of nostr) {
|
||||||
|
const bech32 = word.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||||
|
try {
|
||||||
|
const data = nip19.decode(bech32);
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case "npub":
|
||||||
|
replacedText = reactStringReplace(
|
||||||
|
replacedText,
|
||||||
|
word,
|
||||||
|
(match, i) => <MentionUser key={match + i} pubkey={data.data} />,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "nprofile":
|
||||||
|
replacedText = reactStringReplace(
|
||||||
|
replacedText,
|
||||||
|
word,
|
||||||
|
(match, i) => (
|
||||||
|
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
replacedText = reactStringReplace(
|
||||||
|
replacedText,
|
||||||
|
word,
|
||||||
|
(match, i) => (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={`https://njump.me/${bech32}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 !underline"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacedText;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return <div className={className}>{content}</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { memo } from "react";
|
|||||||
export const MentionUser = memo(function MentionUser({
|
export const MentionUser = memo(function MentionUser({
|
||||||
pubkey,
|
pubkey,
|
||||||
}: { pubkey: string }) {
|
}: { pubkey: string }) {
|
||||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
const { isLoading, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -14,8 +14,6 @@ export const MentionUser = memo(function MentionUser({
|
|||||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? "@anon"
|
|
||||||
: isError
|
|
||||||
? displayNpub(pubkey, 16)
|
? displayNpub(pubkey, 16)
|
||||||
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
|
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,51 +1,68 @@
|
|||||||
|
import { commands } from "@/commands.gen";
|
||||||
import { DotsThree } from "@phosphor-icons/react";
|
import { DotsThree } from "@phosphor-icons/react";
|
||||||
|
import { useSearch } from "@tanstack/react-router";
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
export function NoteMenu() {
|
export function NoteMenu() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
const { account }: { account: string } = useSearch({ strict: false });
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
const list = [
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Copy Sharable Link",
|
text: "Copy ID",
|
||||||
action: async () => {
|
|
||||||
const eventId = await event.idAsBech32();
|
|
||||||
await writeText(`https://njump.me/${eventId}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Copy Event ID",
|
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const eventId = await event.idAsBech32();
|
const eventId = await event.idAsBech32();
|
||||||
await writeText(eventId);
|
await writeText(eventId);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Copy Public Key",
|
text: "Copy author",
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const pubkey = await event.pubkeyAsBech32();
|
const pubkey = await event.pubkeyAsBech32();
|
||||||
await writeText(pubkey);
|
await writeText(pubkey);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Copy Raw Event",
|
text: "Copy sharable link",
|
||||||
action: async () => {
|
action: async () => {
|
||||||
event.meta = undefined;
|
const eventId = await event.idAsBech32();
|
||||||
const raw = JSON.stringify(event);
|
await writeText(`https://njump.me/${eventId}`);
|
||||||
await writeText(raw);
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy raw event",
|
||||||
|
action: async () => {
|
||||||
|
event.meta = undefined;
|
||||||
|
await writeText(JSON.stringify(event));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const menu = await Menu.new({
|
if (account?.length) {
|
||||||
items: menuItems,
|
const pubkey = nip19.decode(account).data;
|
||||||
});
|
|
||||||
|
if (event.pubkey === pubkey) {
|
||||||
|
list.push(
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Request delete",
|
||||||
|
action: async () => {
|
||||||
|
await commands.requestDelete(event.id);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await Promise.all(list);
|
||||||
|
const menu = await Menu.new({ items });
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
await menu.popup().catch((e) => console.error(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { appSettings } from "@/commons";
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
import { useStore } from "@tanstack/react-store";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function ImagePreview({ url }: { url: string }) {
|
export function ImagePreview({ url }: { url: string }) {
|
||||||
const [service, visible] = useStore(appSettings, (state) => [
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
state.image_resize_service,
|
|
||||||
state.display_media,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const imageUrl = useMemo(() => {
|
const imageUrl = useMemo(() => {
|
||||||
if (service?.length) {
|
if (settings.data.resize_service) {
|
||||||
const newUrl = `${service}?url=${url}&ll&af&default=1&n=-1`;
|
const newUrl = `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
|
||||||
return newUrl;
|
return newUrl;
|
||||||
} else {
|
} else {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}, [service]);
|
}, [settings.data.resize_service]);
|
||||||
|
|
||||||
if (!visible) {
|
if (!settings.data.display_media) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@@ -39,10 +36,6 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="max-h-[400px] w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[400px] w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
currentTarget.src = "/404.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,53 @@
|
|||||||
import { appSettings, cn } from "@/commons";
|
import { cn } from "@/commons";
|
||||||
import { Spinner } from "@/components";
|
import { Spinner } from "@/components";
|
||||||
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
|
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
|
||||||
import { useStore } from "@tanstack/react-store";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { open } from "@tauri-apps/plugin-shell";
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Images({ urls }: { urls: string[] }) {
|
export function Images({ urls }: { urls: string[] }) {
|
||||||
const [slidesInView, setSlidesInView] = useState([]);
|
const [slidesInView, setSlidesInView] = useState<number[]>([]);
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
dragFree: true,
|
dragFree: true,
|
||||||
align: "start",
|
align: "start",
|
||||||
watchSlides: false,
|
watchSlides: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = useStore(appSettings, (state) => state.image_resize_service);
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
|
|
||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
if (service?.length) {
|
if (settings.data.resize_service) {
|
||||||
let newUrls: string[];
|
let newUrls: string[];
|
||||||
|
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
newUrls = urls.map(
|
newUrls = urls.map((url) => {
|
||||||
(url) => `${service}?url=${url}&ll&af&default=1&n=-1`,
|
if (url.includes("_next/")) {
|
||||||
);
|
return url;
|
||||||
|
}
|
||||||
|
if (url.includes("bsky.network")) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
newUrls = urls.map(
|
newUrls = urls.map((url) => {
|
||||||
(url) => `${service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
|
if (url.includes("_next/")) {
|
||||||
);
|
return url;
|
||||||
|
}
|
||||||
|
if (url.includes("bsky.network")) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return newUrls;
|
return newUrls;
|
||||||
} else {
|
} else {
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
}, [service]);
|
}, [settings.data.resize_service]);
|
||||||
|
|
||||||
const scrollPrev = useCallback(() => {
|
const scrollPrev = useCallback(() => {
|
||||||
if (emblaApi) emblaApi.scrollPrev();
|
if (emblaApi) emblaApi.scrollPrev();
|
||||||
@@ -44,23 +57,27 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
if (emblaApi) emblaApi.scrollNext();
|
if (emblaApi) emblaApi.scrollNext();
|
||||||
}, [emblaApi]);
|
}, [emblaApi]);
|
||||||
|
|
||||||
const updateSlidesInView = useCallback((emblaApi) => {
|
const updateSlidesInView = useCallback(() => {
|
||||||
setSlidesInView((slidesInView) => {
|
setSlidesInView((slidesInView) => {
|
||||||
if (slidesInView.length === emblaApi.slideNodes().length) {
|
if (slidesInView.length === emblaApi?.slideNodes().length) {
|
||||||
emblaApi.off("slidesInView", updateSlidesInView);
|
emblaApi?.off("slidesInView", updateSlidesInView);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inView = emblaApi
|
const inView = emblaApi
|
||||||
.slidesInView()
|
?.slidesInView()
|
||||||
.filter((index) => !slidesInView.includes(index));
|
.filter((index) => !slidesInView.includes(index));
|
||||||
|
|
||||||
|
if (inView) {
|
||||||
return slidesInView.concat(inView);
|
return slidesInView.concat(inView);
|
||||||
|
} else {
|
||||||
|
return slidesInView;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [emblaApi]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emblaApi && urls.length > 1) {
|
if (emblaApi && urls.length > 1) {
|
||||||
updateSlidesInView(emblaApi);
|
updateSlidesInView();
|
||||||
|
|
||||||
emblaApi.on("slidesInView", updateSlidesInView);
|
emblaApi.on("slidesInView", updateSlidesInView);
|
||||||
emblaApi.on("reInit", updateSlidesInView);
|
emblaApi.on("reInit", updateSlidesInView);
|
||||||
@@ -83,10 +100,6 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => urls[0]}
|
onClick={() => urls[0]}
|
||||||
onKeyDown={() => urls[0]}
|
onKeyDown={() => urls[0]}
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
currentTarget.src = "/404.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -162,10 +175,6 @@ function LazyImage({ url, inView }: { url: string; inView: boolean }) {
|
|||||||
onClick={() => open(url)}
|
onClick={() => open(url)}
|
||||||
onKeyDown={() => open(url)}
|
onKeyDown={() => open(url)}
|
||||||
onLoad={setLoaded}
|
onLoad={setLoaded}
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
currentTarget.src = "/404.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { appSettings } from "@/commons";
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
import { useStore } from "@tanstack/react-store";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
export function VideoPreview({ url }: { url: string }) {
|
export function VideoPreview({ url }: { url: string }) {
|
||||||
const visible = useStore(appSettings, (state) => state.display_media);
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
|
|
||||||
if (!visible) {
|
if (!settings.data.display_zap_button) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { cn } from "@/commons";
|
|
||||||
import { Note } from "@/components/note";
|
|
||||||
import type { LumeEvent } from "@/system";
|
|
||||||
import { Quotes } from "@phosphor-icons/react";
|
|
||||||
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">
|
|
||||||
<Quotes 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,10 +1,15 @@
|
|||||||
import { cn, replyTime } from "@/commons";
|
import { cn, replyAt } from "@/commons";
|
||||||
import { Note } from "@/components/note";
|
import { Note, User } from "@/components";
|
||||||
import type { LumeEvent } from "@/system";
|
import { type LumeEvent, LumeWindow } from "@/system";
|
||||||
import { CaretDown } from "@phosphor-icons/react";
|
import { CaretDown } from "@phosphor-icons/react";
|
||||||
import { Link, useSearch } from "@tanstack/react-router";
|
import { Link, useSearch } from "@tanstack/react-router";
|
||||||
import { memo } from "react";
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
import { User } from "./user";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { type ReactNode, memo, useCallback, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./note/mentions/hashtag";
|
||||||
|
import { MentionUser } from "./note/mentions/user";
|
||||||
|
|
||||||
export const ReplyNote = memo(function ReplyNote({
|
export const ReplyNote = memo(function ReplyNote({
|
||||||
event,
|
event,
|
||||||
@@ -14,32 +19,58 @@ export const ReplyNote = memo(function ReplyNote({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const search = useSearch({ strict: false });
|
const search = useSearch({ strict: false });
|
||||||
|
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 (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
<Note.Root className={cn("flex gap-2.5", className)}>
|
<Note.Root className={cn("flex gap-2.5", className)}>
|
||||||
<User.Root className="shrink-0">
|
<User.Root className="shrink-0">
|
||||||
|
<button type="button" onClick={(e) => showContextMenu(e)}>
|
||||||
<User.Avatar className="size-8 rounded-full" />
|
<User.Avatar className="size-8 rounded-full" />
|
||||||
|
</button>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
<div className="flex-1 flex flex-col gap-1">
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
<div>
|
<div>
|
||||||
<User.Name
|
<User.Name
|
||||||
className="shrink-0 inline font-medium text-blue-500"
|
className="mr-2 shrink-0 inline font-medium text-blue-500"
|
||||||
suffix=":"
|
suffix=":"
|
||||||
/>
|
/>
|
||||||
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
|
<Content
|
||||||
{event.content}
|
text={event.content}
|
||||||
</div>
|
className="inline select-text text-balance content-break overflow-hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-between">
|
<div className="flex-1 flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-500">
|
<span className="text-sm text-neutral-500">
|
||||||
{replyTime(event.created_at)}
|
{replyAt(event.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end">
|
||||||
<Note.Reply />
|
<Note.Reply smol />
|
||||||
<Note.Repost />
|
<Note.Repost smol />
|
||||||
<Note.Zap />
|
<Note.Zap smol />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{event.replies?.length ? (
|
{event.replies?.length ? (
|
||||||
@@ -74,26 +105,53 @@ export const ReplyNote = memo(function ReplyNote({
|
|||||||
function ChildReply({ event }: { event: LumeEvent }) {
|
function ChildReply({ event }: { event: LumeEvent }) {
|
||||||
const search = useSearch({ strict: false });
|
const search = useSearch({ strict: false });
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
<div className="group flex flex-col gap-1">
|
<div className="group flex flex-col gap-1">
|
||||||
<div>
|
<div>
|
||||||
<User.Root className="inline">
|
<User.Root className="inline mr-2">
|
||||||
|
<button type="button" onClick={(e) => showContextMenu(e)}>
|
||||||
<User.Name className="font-medium text-blue-500" suffix=":" />
|
<User.Name className="font-medium text-blue-500" suffix=":" />
|
||||||
|
</button>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
|
<Content
|
||||||
{event.content}
|
text={event.content}
|
||||||
</div>
|
className="inline select-text text-balance content-break overflow-hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-between">
|
<div className="flex-1 flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-500">
|
<span className="text-sm text-neutral-500">
|
||||||
{replyTime(event.created_at)}
|
{replyAt(event.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
|
<div className="invisible group-hover:visible flex items-center justify-end">
|
||||||
<Note.Reply />
|
<Note.Reply smol />
|
||||||
<Note.Repost />
|
<Note.Repost smol />
|
||||||
<Note.Zap />
|
<Note.Zap smol />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{event.replies?.length ? (
|
{event.replies?.length ? (
|
||||||
@@ -124,3 +182,64 @@ function ChildReply({ event }: { event: LumeEvent }) {
|
|||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Content({ text, className }: { text: string; className?: string }) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
let replacedText: ReactNode[] | string = text.trim();
|
||||||
|
|
||||||
|
const nostr = replacedText
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.startsWith("nostr:"));
|
||||||
|
|
||||||
|
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 !underline"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
|
||||||
|
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
|
||||||
|
<Hashtag key={match + i} tag={match} />
|
||||||
|
));
|
||||||
|
|
||||||
|
for (const word of nostr) {
|
||||||
|
const bech32 = word.replace("nostr:", "");
|
||||||
|
const data = nip19.decode(bech32);
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case "npub":
|
||||||
|
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
||||||
|
<MentionUser key={match + i} pubkey={data.data} />
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
case "nprofile":
|
||||||
|
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
||||||
|
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={`https://njump.me/${bech32}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 !underline"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacedText;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return <div className={className}>{content}</div>;
|
||||||
|
}
|
||||||
|
|||||||