Compare commits

..

61 Commits

Author SHA1 Message Date
f346282c3c fix: calver 2024-11-01 18:33:03 +07:00
f3e875eeea fix: some issues with publish new note 2024-11-01 17:57:01 +07:00
6ad8ffddf0 chore: clean up 2024-11-01 13:14:47 +07:00
d37e2a3c80 chore: update nostr-sdk and nostr-connect 2024-11-01 09:57:46 +07:00
18e1ac0e6c feat: add relay feeds 2024-11-01 09:25:12 +07:00
aa4f21a869 fix: dark mode 2024-10-31 16:25:16 +07:00
bbc052eebc chore: switch to calver 2024-10-31 13:46:41 +07:00
c201b5816c feat: add discover newsfeeds and interests 2024-10-31 13:34:25 +07:00
043cabfd4e fix: overload 2024-10-31 10:25:00 +07:00
9b02ab5842 feat: re-enable gossip and optimize 2024-10-30 16:02:09 +07:00
618a45d349 refactor: app settings 2024-10-30 10:57:43 +07:00
11dcef4e87 feat: group metadata query 2024-10-29 15:05:03 +07:00
d87371aec4 chore: use latest nostr sdk 2024-10-28 16:19:50 +07:00
cfb017f70b chore: update deps 2024-10-28 09:00:28 +07:00
9ba95301db update 2024-10-28 08:04:19 +07:00
0518389f50 update 2024-10-27 15:57:32 +07:00
eb6e3e52df update 2024-10-27 09:57:56 +07:00
1e95a2fd95 update 2024-10-27 08:14:15 +07:00
83d24351cd update 2024-10-26 17:24:39 +07:00
470dc1c759 fix: command 2024-10-26 13:11:07 +07:00
42b780ce6a update 2024-10-26 09:08:50 +07:00
5ab2b1ae31 feat: negentropy progress 2024-10-25 14:57:12 +07:00
055d73c829 feat: rework multi account 2024-10-24 15:50:45 +07:00
469296790e wip: rework multi account 2024-10-24 07:59:41 +07:00
c032dbea1a chore: clean up 2024-10-23 10:43:39 +07:00
172566028b feat: use latest nostr sdk 2024-10-23 10:16:56 +07:00
雨宮蓮
cc7de41bfd feat: Multi Accounts (#237)
* wip: new sync

* wip: restructure routes

* update

* feat: improve sync

* feat: repost with multi-account

* feat: improve sync

* feat: publish with multi account

* fix: settings screen

* feat: add zap for multi accounts
2024-10-22 16:00:06 +07:00
ba9c81a10a feat: use upstream rust nostr 2024-10-15 10:37:07 +07:00
e158f2e4d7 feat: improve column carousel 2024-10-15 09:59:26 +07:00
62bd689031 fix: async mutex lock forever 2024-10-14 14:59:42 +07:00
cb6006f596 feat: handle error when publish note 2024-10-11 14:09:15 +07:00
adad048873 feat: improve negentropy sync 2024-10-11 08:56:43 +07:00
790fee6c05 chore: bump version 2024-10-09 14:51:20 +07:00
9f5b14956a feat: save state when close app 2024-10-09 14:02:13 +07:00
e04497d841 chore: update README 2024-10-09 09:42:49 +07:00
106c627ec4 feat: only query from local database and other improvements 2024-10-09 09:15:49 +07:00
c40762cc04 feat: add support for NIP-09 2024-10-08 16:30:26 +07:00
d2b5ae0507 feat: use negentropy as much as possible 2024-10-08 10:36:31 +07:00
8c6aea8050 chore: follow-up #236 2024-10-07 15:12:30 +07:00
雨宮蓮
090a815f99 feat: Add support for NIP-51 (#236)
* feat: and follow and interest sets

* feat: improve query

* feat: improve
2024-10-07 14:33:20 +07:00
d841163ba7 revamp 2024-10-05 08:49:09 +07:00
8398ae80d3 chore: bump version 2024-10-03 10:43:16 +07:00
fe60f75e96 chore: upgrade to tauri v2 stable 2024-10-03 09:00:48 +07:00
雨宮蓮
e098743d43 feat: new flow (#235)
* feat: redesign initial screen

* feat: improve login process
2024-10-02 14:56:26 +07:00
0c19ada1ab chore: bump version 2024-09-30 05:57:42 +07:00
09db39fce1 chore: add some small improvements 2024-09-30 04:49:17 +07:00
雨宮蓮
f0fc89724d feat: improve performance (#234)
* feat: use negentropy as much as possible

* update

* update
2024-09-29 16:53:39 +07:00
afa9327bb7 feat: add content parser to reply 2024-09-27 09:58:02 +07:00
5c3644f977 feat: update general settings 2024-09-27 09:12:38 +07:00
0a8eed9a46 feat: new post editor 2024-09-26 10:57:58 +07:00
bacfaed48a wip: local relay 2024-09-25 09:59:54 +07:00
3d5085785b feat: pow by default 2024-09-23 17:18:41 +07:00
9152c3e122 feat: add basic web of trust 2024-09-23 13:24:33 +07:00
a5574bef6c feat: add notification for nip42 2024-09-22 09:40:07 +07:00
2c7f3685b6 fix: build on macos 2024-09-20 09:26:48 +07:00
be0abc4075 chore: update deps 2024-09-20 08:42:08 +07:00
dafe35cd1f feat: add client tag 2024-09-20 08:05:32 +07:00
b23903240b fix: wrong menu item in repost button 2024-09-20 08:05:22 +07:00
872a6cee36 fix: missing context menu in user's avatar 2024-09-20 07:25:07 +07:00
雨宮蓮
ac7ce726c5 feat: Add gossip model (#232)
* feat: enable gossip

* chore: remove deprecated functions

* chore: use upstream rust nostr

* fix
2024-09-11 11:10:11 +07:00
Andrew
e5e290c0c3 fix: Improved text legibility on login/create account screens (#230)
* style: standardized placeholder text to be color neutral-400

* fix: changed dark:text-neutral-600 to dark:text-neutral-200 as in dark mode text was not visible
2024-08-30 20:04:44 +07:00
190 changed files with 17923 additions and 30868 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ dist-ssr
*.sln
*.sw?
src/router.gen.ts
src/routes.gen.ts
src/commands.gen.ts

View File

@@ -1,63 +1,30 @@
## Introduction
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
Lume is a Nostr client for macOS and Windows 11. 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
## Installation and Usage
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
- *Microsoft Windows*: See the releases area for a file named something like Lume_VERSION_x64-setup.exe or Lume_VERSION_x64_en-US.msi
Supported platform: macOS. Windows and Linux are coming soon.
- *macOS*: See the releases area for a file named something like Lume_VERSION_PLATFORM.dmg
Windows and Linux are availabel on v3 and below.
Lume only supported macOS and Windows 11. Linux user can consider using [Gossip client](https://github.com/mikedilger/gossip)
## Prerequisites
## Screenshots
- Node.js >= 18: https://nodejs.org/en
![Login Screen](https://image.nostr.build/d7a59ada0ed107e9556b0c8e547803f41f99e7973da4e52eab1b0b0a7dbdfadf.png)
![Welcome Screen](https://image.nostr.build/b6f63e5bda01a37de06e59bd2cebc7be47fb6a8b01ce3155b7269d5235e6db0c.png)
![Newsfeed](https://image.nostr.build/66fdcd96c6008794a02fa282e70a4538393c2a0041b1ee52aaf09893c17dba96.png)
![Thread](https://image.nostr.build/11538fae77da1e8b00099b92642f2d9e40f6fbf7fde49459c93a9d99c97e4cfc.png)
![Dark Mode](https://image.nostr.build/6b6c024a029a61d96d507dd7d1d8f7c48332cc77aad1bb87c6a952b8d9175348.png)
- Rust: https://rustup.rs/
## Building from Source
- 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.
See [Developing](docs/DEVELOPING.md)
## License
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors
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.

37
docs/DEVELOPING.md Normal file
View File

@@ -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
```

View File

@@ -12,73 +12,70 @@
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.6.2",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/query-persist-client-core": "^5.52.3",
"@tanstack/react-query": "^5.52.3",
"@tanstack/react-router": "^1.51.6",
"@tanstack/react-store": "^0.5.5",
"@tanstack/store": "^0.5.5",
"@tauri-apps/api": "2.0.0-rc.4",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.1",
"@tauri-apps/plugin-dialog": "2.0.0-rc.1",
"@tauri-apps/plugin-fs": "2.0.0-rc.2",
"@tauri-apps/plugin-http": "2.0.0-rc.2",
"@tauri-apps/plugin-os": "2.0.0-rc.1",
"@tauri-apps/plugin-process": "2.0.0-rc.1",
"@tauri-apps/plugin-shell": "2.0.0-rc.1",
"@tauri-apps/plugin-store": "2.0.0-rc.1",
"@tauri-apps/plugin-updater": "2.0.0-rc.1",
"@tauri-apps/plugin-upload": "2.0.0-rc.1",
"@tauri-apps/plugin-window-state": "2.0.0-rc.1",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/query-broadcast-client-experimental": "^5.59.16",
"@tanstack/query-persist-client-core": "^5.59.16",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-router": "^1.77.5",
"@tauri-apps/api": "^2.0.3",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-fs": "^2.0.1",
"@tauri-apps/plugin-http": "^2.0.1",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
"@tauri-apps/plugin-updater": "^2.0.0",
"@tauri-apps/plugin-upload": "^2.0.0",
"@tauri-apps/plugin-window-state": "^2.0.0",
"bitcoin-units": "^1.0.0",
"boring-avatars": "^1.10.2",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.2.0",
"i18next": "^23.14.0",
"embla-carousel-react": "^8.3.0",
"i18next": "^23.16.4",
"i18next-resources-to-backend": "^1.2.1",
"light-bolt11-decoder": "^3.1.1",
"light-bolt11-decoder": "^3.2.0",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.7.2",
"react": "19.0.0-rc-d025ddd3-20240722",
"nostr-tools": "^2.9.4",
"react": "19.0.0-rc-cae764ce-20241025",
"react-currency-input-field": "^3.8.0",
"react-dom": "19.0.0-rc-d025ddd3-20240722",
"react-hook-form": "^7.53.0",
"react-i18next": "^15.0.1",
"react-dom": "19.0.0-rc-cae764ce-20241025",
"react-hook-form": "^7.53.1",
"react-i18next": "^15.1.0",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.107.1",
"use-debounce": "^10.0.3",
"virtua": "^0.33.7"
"rich-textarea": "^0.26.3",
"use-debounce": "^10.0.4",
"virtua": "^0.34.2"
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@evilmartians/harmony": "^1.2.0",
"@tailwindcss/forms": "^0.5.8",
"@biomejs/biome": "^1.9.4",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/router-devtools": "^1.51.6",
"@tanstack/router-plugin": "^1.51.6",
"@tauri-apps/cli": "2.0.0-rc.8",
"@tanstack/router-devtools": "^1.77.5",
"@tanstack/router-plugin": "^1.76.4",
"@tauri-apps/cli": "^2.0.4",
"@types/react": "npm:types-react@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.3",
"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",
"postcss": "^8.4.41",
"postcss": "^8.4.47",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.5.2",
"tailwind-merge": "^2.5.4",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.10",
"tailwindcss-content-visibility": "^0.2.0",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"tailwindcss": "^3.4.14",
"tailwindcss-content-visibility": "^1.0.0",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-tsconfig-paths": "^5.0.1"
},
"overrides": {

2474
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/nosta.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

3
public/nsec_app.svg Normal file
View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

2084
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "Lume"
version = "4.0.0"
version = "24.11.0"
description = "nostr client"
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
repository = "https://github.com/lumehq/lume"
@@ -8,38 +8,39 @@ edition = "2021"
rust-version = "1.70"
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"sqlite",
tauri = { version = "2.0.0", features = [ "protocol-asset",
"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"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb"] }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
tokio = { version = "1", features = ["full"] }
serde_json = "1.0"
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-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"
url = "2.5.0"
futures = "0.3.30"
@@ -47,14 +48,11 @@ linkify = "0.10.0"
regex = "1.10.4"
keyring = { version = "3", features = ["apple-native", "windows-native"] }
keyring-search = "1.2.0"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
[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" }
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
[profile.release]
codegen-units = 1

View File

@@ -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/*"
}
]
}
]
}

View File

@@ -1,20 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"identifier": "window",
"description": "Capability for the desktop",
"platforms": [
"macOS",
"windows"
],
"windows": [
"main",
"panel",
"settings",
"search-*",
"zap-*",
"event-*",
"user-*",
"editor-*"
"*"
],
"permissions": [
"core:path:default",
@@ -24,15 +17,6 @@
"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",
@@ -43,27 +27,35 @@
"core:window:allow-set-size",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"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",
"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:allow-open",
"dialog:allow-ask",
"dialog:allow-message",
"process:allow-restart",
"process:allow-exit",
"fs:allow-read-file",
"core:menu:allow-new",
"core:menu:allow-popup",
"shell:allow-open",
"store:allow-get",
"store:allow-set",
"store:allow-delete",
"store:default",
"prevent-default:default",
"theme:default",
{
"identifier": "http:default",
"allow": [

File diff suppressed because one or more lines are too long

View File

@@ -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-start-dragging","core:window:allow-toggle-maximize","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","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:allow-open","dialog:allow-ask","dialog:allow-message","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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,83 +1,37 @@
[
{
"default": true,
"official": true,
"label": "onboarding",
"name": "Onboarding",
"description": "Tips for Mastering Lume.",
"url": "/columns/onboarding",
"picture": ""
"url": "/columns/onboarding"
},
{
"default": true,
"official": true,
"label": "columns_gallery",
"name": "Columns Gallery",
"label": "launchpad",
"name": "Launchpad",
"description": "Expand your experiences.",
"url": "/columns/gallery",
"picture": ""
"url": "/columns/launchpad"
},
{
"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",
"name": "Search",
"description": "Find anything.",
"url": "/columns/search",
"picture": ""
"url": "/columns/search"
},
{
"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",
"name": "Global Feeds",
"description": "Discover all global notes.",
"url": "/columns/global",
"picture": ""
"description": "All global notes from all connected relays.",
"url": "/columns/global"
},
{
"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",
"name": "Trending",
"description": "Discover all trending notes.",
"url": "/columns/trending",
"picture": ""
"url": "/columns/trending"
}
]

View File

@@ -1,4 +1,5 @@
wss://relay.damus.io,
wss://relay.nostr.net,
wss://purplepag.es/,
wss://directory.yabu.me/,
wss://relay.primal.net,
wss://nostr.fmt.wiz.biz,
wss://directory.yabu.me,
wss://purplepag.es,

View File

@@ -1,120 +1,81 @@
use keyring::Entry;
use keyring_search::{Limit, List, Search};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::HashSet, str::FromStr, time::Duration};
use tauri::{Emitter, Manager, State};
use std::{str::FromStr, time::Duration};
use tauri::{Emitter, State};
use crate::{
common::{get_user_settings, init_nip65},
Nostr, NEWSFEED_NEG_LIMIT, NOTIFICATION_NEG_LIMIT, NOTIFICATION_SUB_ID,
};
use crate::{common::get_all_accounts, Nostr, Settings};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account {
password: String,
secret_key: String,
nostr_connect: Option<String>,
}
#[tauri::command]
#[specta::specta]
pub fn get_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected.");
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()
get_all_accounts()
}
#[tauri::command]
#[specta::specta]
pub async fn create_account(
name: String,
about: String,
picture: String,
password: String,
pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let npub = public_key.to_bech32().map_err(|e| e.to_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>,
) -> Result<String, String> {
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();
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
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
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_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let secret_key = enc.to_secret_key(password).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key);
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)
}
// Create secret key
let secret_key = if let Some(pw) = password {
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
enc.to_secret_key(pw).map_err(|err| err.to_string())?
} else {
SecretKey::from_str(&key).map_err(|err| err.to_string())?
};
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 {
password: enc_bech32,
secret_key: hex,
nostr_connect: None,
};
// 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
client.set_signer(keys).await;
Ok(npub)
}
@@ -122,46 +83,40 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
#[specta::specta]
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
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
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().to_string();
// Local user
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().to_secret_hex();
// Get remote user
let remote_user = bunker_uri.signer_public_key().unwrap();
let remote_npub = remote_user.to_bech32().unwrap();
// Get remote user
let remote_user = bunker_uri.remote_signer_public_key().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 {
Ok(signer) => {
let mut url = Url::parse(&uri).unwrap();
let query: Vec<(String, String)> = url
.query_pairs()
.filter(|(name, _)| name != "secret")
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
url.query_pairs_mut().clear().extend_pairs(&query);
// Init nostr connect
let nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
.map_err(|err| err.to_string())?;
let key = format!("{}_nostrconnect", remote_npub);
let keyring = Entry::new("Lume Secret Storage", &key).unwrap();
let account = Account {
password: app_secret,
nostr_connect: Some(url.to_string()),
};
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
let bunker_uri = nostr_connect
.bunker_uri()
.await
.map_err(|err| err.to_string())?;
// Update signer
let _ = client.set_signer(Some(signer.into())).await;
let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
Ok(remote_npub)
}
Err(err) => Err(err.to_string()),
}
}
Err(err) => Err(err.to_string()),
}
let account = Account {
secret_key: app_secret,
nostr_connect: Some(bunker_uri.to_string()),
};
// 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
let _ = client.set_signer(nostr_connect).await;
Ok(remote_npub)
}
#[tauri::command]
@@ -175,9 +130,9 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
.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 {
password: enc_bech32,
secret_key: enc_bech32,
nostr_connect: None,
};
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
@@ -189,16 +144,17 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
#[tauri::command]
#[specta::specta]
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 account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
Ok(password)
Ok(account.secret_key)
}
#[tauri::command]
#[specta::specta]
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();
Ok(())
@@ -206,135 +162,89 @@ pub fn delete_account(id: String) -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub async fn login(
account: String,
password: String,
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
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>,
handle: tauri::AppHandle,
) -> Result<String, String> {
) -> Result<(), String> {
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() {
Ok(pw) => {
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
}
Err(e) => return Err(e.to_string()),
};
let public_key = match account.nostr_connect {
match account.nostr_connect {
None => {
let ncryptsec =
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
let secret_key = ncryptsec
.to_secret_key(password)
.map_err(|_| "Wrong password.")?;
let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
let keys = Keys::new(secret_key);
let public_key = keys.public_key().to_bech32().unwrap();
let signer = NostrSigner::Keys(keys);
// 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) => {
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.password).map_err(|e| e.to_string())?;
let app_keys = Keys::from_str(&account.secret_key).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) => {
// Update signer
client.set_signer(Some(signer.into())).await;
public_key
client.set_signer(signer).await;
// 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);
let sync = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(NOTIFICATION_NEG_LIMIT);
// Sync notification with negentropy
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)
}
}
#[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)
}
#[tauri::command]
#[specta::specta]
pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
let mut settings = state.settings.write().await;
// Update state
settings.clone_from(&parsed);
Ok(())
}

View File

@@ -1,12 +1,11 @@
use futures::future::join_all;
use nostr_sdk::prelude::*;
use serde::Serialize;
use specta::Type;
use std::{str::FromStr, time::Duration};
use tauri::State;
use crate::common::{create_event_tags, filter_converstation, parse_event, Meta};
use crate::{Nostr, FETCH_LIMIT};
use crate::common::{create_tags, parse_event, process_event, Meta};
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
#[derive(Debug, Clone, Serialize, Type)]
pub struct RichEvent {
@@ -14,115 +13,58 @@ pub struct RichEvent {
pub parsed: Option<Meta>,
}
#[tauri::command]
#[specta::specta]
pub async fn get_event_meta(content: String) -> Result<Meta, ()> {
let meta = parse_event(&content).await;
Ok(meta)
}
#[tauri::command]
#[specta::specta]
pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent, String> {
let client = &state.client;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().id(event_id);
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
let events = client
.database()
.event_by_id(&event_id)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
.map_err(|err| err.to_string())?;
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
if let Some(event) = events {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
let filter = Filter::new().id(event_id).limit(1);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(5)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Event not found.".into())
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_event_from(
id: String,
relay_hint: String,
state: State<'_, Nostr>,
) -> Result<RichEvent, String> {
let client = &state.client;
let settings = state.settings.lock().await;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().id(event_id);
if !settings.use_relay_hint {
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
} else {
// Add relay hint to relay pool
if let Err(e) = client.add_relay(&relay_hint).await {
return Err(e.to_string());
}
if let Err(e) = client.connect_relay(&relay_hint).await {
return Err(e.to_string());
}
match client
.get_events_of(
vec![Filter::new().id(event_id)],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
}
pub async fn get_meta_from_event(content: String) -> Result<Meta, ()> {
Ok(parse_event(&content).await)
}
#[tauri::command]
@@ -130,59 +72,30 @@ pub async fn get_event_from(
pub async fn get_replies(id: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
let futures = 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 }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn subscribe_to(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let subscription_id = SubscriptionId::new(&id);
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::TextNote])
.event(event_id)
.since(Timestamp::now());
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
.event(event_id);
match client
.subscribe_with_id(subscription_id, vec![filter], None)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, true).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_events_by(
pub async fn get_all_events_by_author(
public_key: String,
limit: i32,
state: State<'_, Nostr>,
@@ -191,151 +104,117 @@ pub async fn get_events_by(
let author = PublicKey::parse(&public_key).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::TextNote])
.kinds(vec![Kind::TextNote, Kind::Repost])
.author(author)
.limit(limit as usize);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.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
};
.map_err(|e| e.to_string())?;
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.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_events_from_contacts(
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let contact_list = state.contact_list.lock().await;
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
if authors.is_empty() {
return Err("Contact List is empty.".into());
}
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.authors(authors);
match client.database().query(vec![filter], Order::Desc).await {
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.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 }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_group_events(
public_keys: Vec<&str>,
until: Option<&str>,
pub async fn get_all_events_by_authors(
public_keys: Vec<String>,
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).map_err(|err| err.to_string())?,
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let authors: Vec<PublicKey> = public_keys
.iter()
.map(|p| {
if p.starts_with("npub1") {
PublicKey::from_bech32(p).map_err(|err| err.to_string())
} else {
PublicKey::from_hex(p).map_err(|err| err.to_string())
}
})
.collect::<Result<Vec<_>, _>>()?;
.filter_map(|pk| PublicKey::from_str(pk).ok())
.collect();
let filter = Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of);
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_events_by_hashtags(
hashtags: Vec<String>,
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).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.authors(authors);
.hashtags(hashtags);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.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
};
.map_err(|e| e.to_string())?;
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.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_global_events(
until: Option<&str>,
pub async fn get_all_events_from(
url: String,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let _ = client.add_read_relay(&url).await;
let _ = client.connect_relay(&url).await;
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
@@ -344,74 +223,66 @@ pub async fn get_global_events(
.limit(FETCH_LIMIT)
.until(as_of);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events_from(vec![url], vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.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
};
.map_err(|e| e.to_string())?;
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
while let Some(event) = rx.next().await {
events.insert(event);
}
Ok(rich_events)
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_local_events(
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).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events, false).await),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_hashtag_events(
hashtags: Vec<&str>,
until: Option<&str>,
pub async fn get_global_events(
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).map_err(|err| err.to_string())?,
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.hashtags(hashtags);
.until(as_of);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.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 }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events, false).await),
Err(err) => Err(err.to_string()),
}
}
@@ -426,151 +297,183 @@ pub async fn publish(
) -> Result<String, String> {
let client = &state.client;
// Create tags from content
let mut tags = create_event_tags(&content);
// Create event tags from content
let mut tags = create_tags(&content);
// Add client tag
// TODO: allow user config this setting
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
// Add content-warning tag if present
if let Some(reason) = warning {
let t = TagStandard::ContentWarning {
reason: Some(reason),
};
let tag = Tag::from(t);
let tag = Tag::from_standardized(t);
tags.push(tag)
};
// Get signer
let signer = match client.signer().await {
Ok(signer) => signer,
Err(_) => return Err("Signer is required.".into()),
};
// Get public key
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
// Create unsigned event
let unsigned_event = match difficulty {
Some(num) => EventBuilder::text_note(content, tags).to_unsigned_pow_event(public_key, num),
None => EventBuilder::text_note(content, tags).to_unsigned_event(public_key),
};
let builder =
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
// Publish
match signer.sign_event(unsigned_event).await {
Ok(event) => match client.send_event(event).await {
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
Err(err) => Err(err.to_string()),
},
// Sign event
let event = client
.sign_event_builder(builder)
.await
.map_err(|err| err.to_string())?;
// Save to local database
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 reply(
content: String,
to: String,
root: Option<String>,
state: State<'_, Nostr>,
) -> Result<String, String> {
pub async fn reply(content: String, to: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let database = client.database();
// Create tags from content
let mut tags = create_event_tags(&content);
// Create event tags from content
let mut tags = create_tags(&content);
// Add client tag
// TODO: allow user config this setting
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
// Get reply event
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
match database
.query(vec![Filter::new().id(reply_id)], Order::Desc)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(event.id)
.await
.map_err(|err| err.to_string())?
{
relays.into_iter().next().map(UncheckedUrl::new)
} else {
None
};
let t = TagStandard::Event {
event_id: event.id,
relay_url: relay_hint,
marker: Some(Marker::Reply),
public_key: Some(event.pubkey),
};
let tag = Tag::from(t);
tags.push(tag)
let reply_to = match client.database().event_by_id(&reply_id).await {
Ok(event) => {
if let Some(event) = event {
event
} else {
return Err("Reply event is not found.".into());
return Err("Event not found in database, cannot reply.".into());
}
}
Err(err) => return Err(err.to_string()),
Err(e) => return Err(e.to_string()),
};
if let Some(id) = root {
let root_id = match EventId::from_hex(id) {
Ok(val) => val,
Err(_) => return Err("Event is not valid.".into()),
};
if let Ok(events) = database
.query(vec![Filter::new().id(root_id)], Order::Desc)
.await
{
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(event.id)
.await
.map_err(|err| err.to_string())?
{
relays.into_iter().next().map(UncheckedUrl::new)
// Detect root event from reply
let root_ids: Vec<&EventId> = reply_to
.tags
.filter_standardized(TagKind::e())
.filter_map(|t| match t {
TagStandard::Event {
event_id, marker, ..
} => {
if let Some(mkr) = marker {
match mkr {
Marker::Root => Some(event_id),
Marker::Reply => Some(event_id),
_ => None,
}
} else {
None
};
let t = TagStandard::Event {
event_id: event.id,
relay_url: relay_hint,
marker: Some(Marker::Root),
public_key: Some(event.pubkey),
};
let tag = Tag::from(t);
tags.push(tag)
Some(event_id)
}
}
}
_ => None,
})
.collect();
// Get root event if exist
let root = match root_ids.first() {
Some(&id) => client
.database()
.event_by_id(id)
.await
.map_err(|err| err.to_string())?,
None => None,
};
match client.publish_text_note(content, tags).await {
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
let builder = EventBuilder::text_note_reply(content, &reply_to, root.as_ref(), None)
.add_tags(tags)
.pow(DEFAULT_DIFFICULTY);
// 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 repost(raw: &str, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
match client.repost(&event, None).await {
Ok(event_id) => Ok(event_id.to_string()),
Ok(output) => Ok(output.to_hex()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn delete(id: String, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn is_reposted(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer
.get_public_key()
.await
.map_err(|err| err.to_string())?;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(event_id) => Ok(event_id.to_string()),
let filter = Filter::new()
.event(event_id)
.kind(Kind::Repost)
.author(public_key);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer
.get_public_key()
.await
.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
@@ -579,7 +482,7 @@ pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<Stri
let seens = client
.database()
.event_seen_on_relays(event_id)
.event_seen_on_relays(&event_id)
.await
.map_err(|err| err.to_string())?;
@@ -602,18 +505,16 @@ pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<Stri
#[tauri::command]
#[specta::specta]
pub async fn user_to_bech32(user: &str, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn user_to_bech32(user: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
match client
.get_events_of(
vec![Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)],
EventSource::both(Some(Duration::from_secs(5))),
)
.database()
.query(vec![Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)])
.await
{
Ok(events) => match events.first() {
@@ -642,48 +543,12 @@ pub async fn user_to_bech32(user: &str, state: State<'_, Nostr>) -> Result<Strin
#[tauri::command]
#[specta::specta]
pub async fn search(
query: String,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let filter = Filter::new().search(query);
let timestamp = match until {
Some(str) => Timestamp::from_str(&str).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Metadata])
.search(query)
.until(timestamp)
.limit(FETCH_LIMIT);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.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 }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events, false).await),
Err(e) => Err(e.to_string()),
}
}

View File

@@ -4,55 +4,40 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::{str::FromStr, time::Duration};
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)]
pub struct Profile {
name: String,
pub struct Mention {
pubkey: String,
avatar: String,
display_name: String,
about: Option<String>,
picture: String,
banner: Option<String>,
nip05: Option<String>,
lud16: Option<String>,
website: Option<String>,
name: String,
}
#[tauri::command]
#[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 public_key: PublicKey = match id {
Some(user_id) => PublicKey::parse(&user_id).map_err(|e| e.to_string())?,
None => client.signer().await.unwrap().public_key().await.unwrap(),
};
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::Metadata)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(3))),
)
let events = client
.database()
.query(vec![filter])
.await
{
Ok(events) => {
if let Some(event) = events.first() {
if let Ok(metadata) = Metadata::from_json(&event.content) {
Ok(metadata.as_json())
} else {
Err("Parse metadata failed".into())
}
} else {
Ok(Metadata::new().as_json())
}
}
Err(e) => Err(e.to_string()),
.map_err(|e| e.to_string())?;
match events.first() {
Some(event) => match Metadata::from_json(&event.content) {
Ok(metadata) => Ok(metadata.as_json()),
Err(e) => Err(e.to_string()),
},
None => Err("Metadata not found".into()),
}
}
@@ -71,9 +56,6 @@ pub async fn set_contact_list(
})
.collect();
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
match client.set_contact_list(contact_list).await {
Ok(_) => Ok(true),
Err(err) => Err(err.to_string()),
@@ -82,52 +64,26 @@ pub async fn set_contact_list(
#[tauri::command]
#[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 public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
match client.get_contact_list(Some(Duration::from_secs(10))).await {
Ok(contact_list) => {
if !contact_list.is_empty() {
let list = contact_list
.into_iter()
.map(|f| f.public_key.to_hex())
.collect();
let contact_list = client
.database()
.contacts_public_keys(public_key)
.await
.map_err(|e| e.to_string())?;
Ok(list)
} else {
Err("Empty.".into())
}
}
Err(err) => Err(err.to_string()),
}
let pubkeys: Vec<String> = contact_list.into_iter().map(|pk| pk.to_hex()).collect();
Ok(pubkeys)
}
#[tauri::command]
#[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 mut metadata = Metadata::new()
.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)
}
}
let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
match client.set_metadata(&metadata).await {
Ok(id) => Ok(id.to_string()),
@@ -137,21 +93,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
#[tauri::command]
#[specta::specta]
pub async fn is_contact_list_empty(state: State<'_, Nostr>) -> Result<bool, ()> {
Ok(state.contact_list.lock().await.is_empty())
}
pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
#[tauri::command]
#[specta::specta]
pub async fn check_contact(hex: String, state: State<'_, Nostr>) -> Result<bool, String> {
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()),
match client.database().contacts_public_keys(public_key).await {
Ok(public_keys) => Ok(public_keys.iter().any(|i| i == &public_key)),
Err(_) => Ok(false),
}
}
@@ -181,9 +129,6 @@ pub async fn toggle_contact(
}
}
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
// Publish
match client.set_contact_list(contact_list).await {
Ok(event_id) => Ok(event_id.to_string()),
@@ -196,59 +141,276 @@ pub async fn toggle_contact(
#[tauri::command]
#[specta::specta]
pub async fn set_lume_store(
key: String,
content: String,
pub async fn set_group(
title: String,
description: Option<String>,
image: Option<String>,
users: Vec<String>,
state: State<'_, Nostr>,
) -> Result<String, String> {
let client = &state.client;
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 public_keys: Vec<PublicKey> = users
.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
.nip44_encrypt(&public_key, content)
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::follow_set(label, public_keys.clone()).add_tags(tags);
// Sign event
let event = client
.sign_event_builder(builder)
.await
.map_err(|e| e.to_string())?;
let tag = Tag::identifier(key);
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
.map_err(|err| err.to_string())?;
match client.send_event_builder(builder).await {
Ok(event_id) => Ok(event_id.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_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 signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(key)
.limit(10);
match client
.get_events_of(vec![filter], EventSource::Database)
.await
{
Ok(events) => {
if let Some(event) = get_latest_event(&events) {
match signer.nip44_decrypt(&public_key, &event.content).await {
Ok(decrypted) => Ok(decrypted),
Err(_) => Err(event.content.to_string()),
}
match client.database().event_by_id(&event_id).await {
Ok(event) => {
if let Some(ev) = event {
Ok(ev.as_json())
} else {
Err("Not found".into())
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_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()),
}
})
.collect();
Ok(data)
}
#[tauri::command]
#[specta::specta]
pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, String> {
@@ -256,7 +418,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(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())?;
client.set_zapper(nwc).await;
@@ -268,37 +432,32 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
#[tauri::command]
#[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 keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
match keyring.get_password() {
Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
let nwc = NWC::new(uri);
if client.zapper().await.is_err() {
let keyring =
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
// Get current balance
let balance = nwc.get_balance().await;
match keyring.get_password() {
Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
let nwc = NWC::new(uri);
// Update zapper
client.set_zapper(nwc).await;
match balance {
Ok(val) => Ok(val.to_string()),
Err(_) => Err("Get balance failed.".into()),
client.set_zapper(nwc).await;
}
Err(_) => return Err("Wallet not found.".into()),
}
Err(_) => Err("NWC not found.".into()),
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
match keyring.delete_credential() {
Ok(_) => {
@@ -312,52 +471,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub async fn zap_profile(
id: &str,
amount: &str,
message: &str,
id: String,
amount: String,
message: Option<String>,
state: State<'_, Nostr>,
) -> Result<bool, String> {
) -> Result<(), String> {
let client = &state.client;
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 details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(public_key, num, Some(details)).await.is_ok() {
Ok(true)
} else {
Err("Zap profile failed".into())
match client.zap(public_key, num, details).await {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn zap_event(
id: &str,
amount: &str,
message: &str,
id: String,
amount: String,
message: Option<String>,
state: State<'_, Nostr>,
) -> Result<bool, String> {
) -> Result<(), String> {
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 details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(event_id, num, Some(details)).await.is_ok() {
Ok(true)
} else {
Err("Zap event failed".into())
match client.zap(event_id, num, details).await {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
@@ -375,14 +522,11 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
.limit(1);
if let Ok(contact_list_events) = client
.get_events_of(
vec![contact_list_filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
.await
{
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 {
public_key,
relay_url,
@@ -405,143 +549,36 @@ 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]
#[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 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()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(200);
let filter = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(500);
match client
.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()),
}
}
let mut events = Events::new(&[filter.clone()]);
#[tauri::command]
#[specta::specta]
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)
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.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 {
Ok(_) => {
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()),
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
Ok(alt_events)
}
#[tauri::command]

View File

@@ -2,4 +2,5 @@ pub mod account;
pub mod event;
pub mod metadata;
pub mod relay;
pub mod sync;
pub mod window;

View File

@@ -1,14 +1,15 @@
use crate::Nostr;
use nostr_sdk::prelude::*;
use serde::Serialize;
use specta::Type;
use std::{
fs::OpenOptions,
io::{self, BufRead, Write},
time::Duration,
str::FromStr,
};
use tauri::{path::BaseDirectory, Manager, State};
use crate::{Nostr, FETCH_LIMIT};
#[derive(Serialize, Type)]
pub struct Relays {
connected: Vec<String>,
@@ -19,8 +20,9 @@ pub struct Relays {
#[tauri::command]
#[specta::specta]
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
pub async fn get_relays(id: String, state: State<'_, Nostr>) -> Result<Relays, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let connected_relays = client
.relays()
@@ -29,24 +31,12 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
.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
{
match client.database().query(vec![filter]).await {
Ok(events) => {
if let Some(event) = events.first() {
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
@@ -105,32 +95,59 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
#[tauri::command]
#[specta::specta]
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
pub async fn get_all_relays(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
let client = &state.client;
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
if status {
println!("Connecting to relay: {}", relay);
client
.connect_relay(relay)
.await
.map_err(|e| e.to_string())?;
}
let as_of = match until {
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let filter = Filter::new()
.kind(Kind::RelayList)
.limit(FETCH_LIMIT)
.until(as_of);
let events = client
.database()
.query(vec![filter])
.await
.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)
}
#[tauri::command]
#[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;
client
.remove_relay(relay)
.await
.map_err(|e| e.to_string())?;
client
.disconnect_relay(relay)
.await
.map_err(|e| e.to_string())?;
Ok(true)
let _ = client.add_relay(&relay).await;
let _ = client.connect_relay(&relay).await;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let _ = client.force_remove_relay(relay).await;
Ok(())
}
#[tauri::command]
@@ -152,7 +169,7 @@ pub fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String
#[tauri::command]
#[specta::specta]
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
pub fn set_bootstrap_relays(relays: String, app: tauri::AppHandle) -> Result<(), String> {
let relays_path = app
.path()
.resolve("resources/relays.txt", BaseDirectory::Resource)

View File

@@ -0,0 +1,116 @@
use nostr_sdk::prelude::*;
use std::fs::{self, File};
use tauri::{ipc::Channel, Manager, State};
use crate::Nostr;
#[tauri::command]
#[specta::specta]
pub fn is_account_sync(id: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
let config_dir = app_handle
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
let exist = fs::metadata(config_dir.join(id)).is_ok();
Ok(exist)
}
#[tauri::command]
#[specta::specta]
pub async fn sync_account(
id: String,
state: State<'_, Nostr>,
reader: Channel<f64>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let client = &state.client;
let public_key = PublicKey::from_bech32(&id).map_err(|e| e.to_string())?;
let filter = Filter::new().author(public_key).kinds(vec![
Kind::Metadata,
Kind::ContactList,
Kind::Interests,
Kind::InterestSet,
Kind::FollowSet,
Kind::RelayList,
Kind::MuteList,
Kind::EventDeletion,
Kind::Bookmarks,
Kind::BookmarkSet,
Kind::TextNote,
Kind::Repost,
Kind::Custom(30315),
]);
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 SyncProgress { total, current } = *rx.borrow_and_update();
if total > 0 {
reader
.send((current as f64 / total as f64) * 100.0)
.unwrap()
}
}
});
if let Ok(output) = client.sync(filter, &opts).await {
println!("Success: {:?}", output.success);
println!("Failed: {:?}", output.failed);
let event_pubkeys = client
.database()
.query(vec![Filter::new().kinds(vec![
Kind::ContactList,
Kind::FollowSet,
Kind::MuteList,
Kind::Repost,
Kind::TextNote,
])])
.await
.map_err(|e| e.to_string())?;
if !event_pubkeys.is_empty() {
let pubkeys: Vec<PublicKey> = event_pubkeys
.iter()
.flat_map(|ev| ev.tags.public_keys().copied())
.collect();
let filter = Filter::new()
.authors(pubkeys)
.kinds(vec![
Kind::Metadata,
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
Kind::Interests,
Kind::InterestSet,
Kind::FollowSet,
Kind::RelayList,
Kind::MuteList,
Kind::EventDeletion,
Kind::Bookmarks,
Kind::BookmarkSet,
Kind::Custom(30315),
])
.limit(10000);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Success: {:?}", output.success);
println!("Failed: {:?}", output.failed);
}
};
}
let config_dir = app_handle
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
let _ = File::create(config_dir.join(id));
Ok(())
}

View File

@@ -1,19 +1,23 @@
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use border::WebviewWindowExt as BorderWebviewWindowExt;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::utils::config::WindowEffectsConfig;
use tauri::window::Effect;
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl};
use tauri::{WebviewBuilder, WebviewWindowBuilder};
use tauri::{
utils::config::WindowEffectsConfig, webview::PageLoadEvent, window::Effect, LogicalPosition,
LogicalSize, Manager, WebviewBuilder, WebviewUrl, WebviewWindowBuilder, Window,
};
#[cfg(target_os = "windows")]
use tauri_plugin_decorum::WebviewWindowExt;
use crate::common::get_last_segment;
use crate::Nostr;
#[derive(Serialize, Deserialize, Type)]
pub struct Window {
pub struct NewWindow {
label: String,
title: String,
url: String,
@@ -22,6 +26,7 @@ pub struct Window {
maximizable: bool,
minimizable: bool,
hidden_title: bool,
closable: bool,
}
#[derive(Serialize, Deserialize, Type)]
@@ -34,94 +39,159 @@ pub struct Column {
height: f32,
}
#[tauri::command(async)]
#[tauri::command]
#[specta::specta]
pub fn create_column(column: Column, app_handle: tauri::AppHandle) -> Result<String, String> {
match app_handle.get_window("main") {
Some(main_window) => match app_handle.get_webview(&column.label) {
Some(_) => Ok(column.label),
None => {
let path = PathBuf::from(column.url);
let webview_url = WebviewUrl::App(path);
let builder = WebviewBuilder::new(column.label, webview_url)
.incognito(true)
.transparent(true);
if let Ok(webview) = main_window.add_child(
builder,
LogicalPosition::new(column.x, column.y),
LogicalSize::new(column.width, column.height),
) {
Ok(webview.label().into())
} else {
Err("Create webview failed".into())
}
pub async fn create_column(
column: Column,
app_handle: tauri::AppHandle,
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());
}
},
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()),
}
}
if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
return Err(e.to_string());
}
#[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()),
}
}
Ok(column.label)
}
None => {
let path = PathBuf::from(column.url);
let webview_url = WebviewUrl::App(path);
#[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()),
}
}
let builder = WebviewBuilder::new(column.label, webview_url)
.incognito(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");
#[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::async_runtime::spawn(async move {
let state = webview.state::<Nostr>();
let client = &state.client;
if is_newsfeed {
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()
.event(event_id)
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
.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,
LogicalPosition::new(column.x, column.y),
LogicalSize::new(column.width, column.height),
) {
Ok(webview) => Ok(webview.label().into()),
Err(e) => Err(e.to_string()),
}
}
}
}
#[tauri::command]
#[specta::specta]
#[cfg(target_os = "macos")]
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();
pub async 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(format!("Cannot close, column not found: {}", label)),
}
}
#[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 {
let _ = window.show();
let _ = window.set_focus();
let _ = current_window.show();
let _ = current_window.set_focus();
};
Ok(current_window.label().to_string())
} else {
let window = WebviewWindowBuilder::new(
#[cfg(target_os = "macos")]
let new_window = WebviewWindowBuilder::new(
&app_handle,
&window.label,
WebviewUrl::App(PathBuf::from(window.url)),
@@ -129,11 +199,12 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.title(&window.title)
.min_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)
.minimizable(window.minimizable)
.maximizable(window.maximizable)
.transparent(true)
.closable(window.closable)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::UnderWindowBackground],
@@ -143,26 +214,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.build()
.unwrap();
// Restore native border
window.add_border(None);
}
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(
#[cfg(target_os = "windows")]
let new_window = WebviewWindowBuilder::new(
&app_handle,
&window.label,
WebviewUrl::App(PathBuf::from(window.url)),
@@ -174,6 +227,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.maximizable(window.maximizable)
.transparent(true)
.decorations(false)
.closable(window.closable)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::Mica],
@@ -183,56 +237,14 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.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()
.unwrap();
// Set decoration
#[cfg(target_os = "windows")]
window.create_overlay_titlebar().unwrap();
new_window.create_overlay_titlebar().unwrap();
// Restore native border
#[cfg(target_os = "macos")]
window.add_border(None);
new_window.add_border(None);
// Set a custom inset to the traffic lights
#[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();
}
});
Ok(new_window.label().to_string())
}
}
#[tauri::command]
#[specta::specta]
pub fn quit() {
std::process::exit(0)
}

View File

@@ -1,24 +1,26 @@
use futures::future::join_all;
use keyring_search::{Limit, List, Search};
use linkify::LinkFinder;
use nostr_sdk::prelude::*;
use reqwest::Client as ReqClient;
use serde::Serialize;
use specta::Type;
use std::collections::HashSet;
use std::str::FromStr;
use std::time::Duration;
use std::{collections::HashSet, str::FromStr};
use crate::Settings;
use crate::RichEvent;
#[derive(Debug, Clone, Serialize, Type)]
pub struct Meta {
pub content: String,
pub images: Vec<String>,
pub videos: Vec<String>,
pub events: Vec<String>,
pub mentions: 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] = [
"@nevent1",
"@note1",
@@ -31,147 +33,23 @@ const NOSTR_EVENTS: [&str; 10] = [
"Nostr:note1",
"Nostr:nevent1",
];
const NOSTR_MENTIONS: [&str; 10] = [
const NOSTR_MENTIONS: [&str; 8] = [
"@npub1",
"nostr:npub1",
"nostr:nprofile1",
"nostr:naddr1",
"npub1",
"nprofile1",
"naddr1",
"Nostr:npub1",
"Nostr:nprofile1",
"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()
}
pub fn filter_converstation(events: Vec<Event>) -> Vec<Event> {
events
.into_iter()
.filter_map(|ev| {
let tags = ev.get_tags_content(TagKind::SingleLetter(SingleLetterTag::lowercase(
Alphabet::E,
)));
if tags.is_empty() {
Some(ev)
} else {
None
}
})
.collect::<Vec<Event>>()
}
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()
}
pub async fn parse_event(content: &str) -> Meta {
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
// Get urls
let urls: Vec<_> = finder.links(content).collect();
// Get words
let words: Vec<_> = content.split_whitespace().collect();
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let events = words
.iter()
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mentions = words
.iter()
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut videos = Vec::new();
let mut text = content.to_string();
if !urls.is_empty() {
let client = ReqClient::new();
for url in urls {
let url_str = url.as_str();
if let Ok(parsed_url) = Url::from_str(url_str) {
if let Some(ext) = parsed_url
.path_segments()
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
{
if IMAGES.contains(&ext) {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
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
if let Ok(res) = client.head(url_str).send().await {
if let Some(content_type) = res.headers().get("Content-Type") {
if content_type.to_str().unwrap_or("").starts_with("image") {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
}
}
}
}
}
// Clean up the resulting content string to remove extra spaces
let cleaned_text = text.trim().to_string();
Meta {
content: cleaned_text,
events,
mentions,
hashtags,
images,
videos,
}
}
pub fn create_event_tags(content: &str) -> Vec<Tag> {
pub fn create_tags(content: &str) -> Vec<Tag> {
let mut tags: Vec<Tag> = vec![];
let mut tag_set: HashSet<String> = HashSet::new();
@@ -189,7 +67,7 @@ pub fn create_event_tags(content: &str) -> Vec<Tag> {
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.map(|&s| s.to_string().replace("#", "").to_lowercase())
.collect::<Vec<_>>();
for mention in mentions {
@@ -252,126 +130,176 @@ pub fn create_event_tags(content: &str) -> Vec<Tag> {
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;
}
};
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();
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
accounts.into_iter().collect()
}
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);
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
.into_iter()
.filter_map(|ev| {
if ev.kind == Kind::TextNote {
let tags = ev
.tags
.iter()
.filter(|t| t.is_reply() || t.is_root())
.filter_map(|t| t.content())
.collect::<Vec<_>>();
if tags.is_empty() {
Some(ev)
} else {
None
}
} else {
println!("Connecting to relay: {} - {:?}", url, metadata);
Some(ev)
}
}
}
})
.collect()
} else {
eprintln!("Failed to get events for RelayList.");
}
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 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))?;
pub async fn parse_event(content: &str) -> Meta {
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(ident)
.limit(1);
// Get urls
let urls: Vec<_> = finder.links(content).collect();
// Get words
let words: Vec<_> = content.split_whitespace().collect();
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
match signer.nip44_decrypt(&public_key, &event.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)),
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let events = words
.iter()
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mentions = words
.iter()
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut text = content.to_string();
if !urls.is_empty() {
let client = ReqClient::new();
for url in urls {
let url_str = url.as_str();
if let Ok(parsed_url) = Url::from_str(url_str) {
if let Some(ext) = parsed_url
.path_segments()
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
{
if IMAGES.contains(&ext) {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
}
// Check the content type of URL via HEAD request
if let Ok(res) = client.head(url_str).send().await {
if let Some(content_type) = res.headers().get("Content-Type") {
if content_type.to_str().unwrap_or("").starts_with("image") {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
}
}
} 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"]);
}
// Clean up the resulting content string to remove extra spaces
let cleaned_text = text.trim().to_string();
Meta {
content: cleaned_text,
events,
mentions,
hashtags,
images,
}
}

View File

@@ -6,166 +6,161 @@
#[cfg(target_os = "macos")]
use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
use common::parse_event;
use nostr_sdk::prelude::*;
use common::{get_all_accounts, parse_event};
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
use serde::{Deserialize, Serialize};
use specta::Type;
use specta_typescript::Typescript;
use std::{
collections::HashSet,
fs,
io::{self, BufRead},
str::FromStr,
time::Duration,
};
use tauri::{path::BaseDirectory, Emitter, EventTarget, Manager};
use tauri::{path::BaseDirectory, Emitter, EventTarget, Listener, Manager};
use tauri_plugin_decorum::WebviewWindowExt;
use tauri_plugin_notification::{NotificationExt, PermissionState};
use tauri_specta::{collect_commands, collect_events, Builder, Event as TauriEvent};
use tokio::sync::Mutex;
use tauri_specta::{collect_commands, Builder};
use tokio::{sync::RwLock, time::sleep};
pub mod commands;
pub mod common;
pub struct Nostr {
client: Client,
contact_list: Mutex<Vec<Contact>>,
settings: Mutex<Settings>,
queue: RwLock<HashSet<PublicKey>>,
is_syncing: RwLock<bool>,
settings: RwLock<Settings>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Payload {
id: String,
}
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Settings {
proxy: Option<String>,
image_resize_service: Option<String>,
use_relay_hint: bool,
resize_service: bool,
content_warning: bool,
display_avatar: bool,
display_zap_button: bool,
display_repost_button: bool,
display_media: bool,
transparent: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
proxy: None,
image_resize_service: Some("https://wsrv.nl".to_string()),
use_relay_hint: true,
content_warning: true,
resize_service: true,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
display_media: true,
transparent: true,
}
}
}
#[derive(Serialize, Deserialize, Type)]
enum SubKind {
Subscribe,
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 DEFAULT_DIFFICULTY: u8 = 0;
pub const FETCH_LIMIT: usize = 50;
pub const QUEUE_DELAY: u64 = 300;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
fn main() {
let builder = Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(collect_commands![
get_relays,
connect_relay,
remove_relay,
get_bootstrap_relays,
save_bootstrap_relays,
get_accounts,
create_account,
import_account,
connect_account,
get_private_key,
delete_account,
reset_password,
login,
get_profile,
set_profile,
get_contact_list,
set_contact_list,
is_contact_list_empty,
check_contact,
toggle_contact,
get_lume_store,
set_lume_store,
set_wallet,
load_wallet,
remove_wallet,
zap_profile,
zap_event,
copy_friend,
get_notifications,
get_settings,
set_settings,
verify_nip05,
get_event_meta,
get_event,
get_event_from,
get_replies,
subscribe_to,
get_events_by,
get_events_from_contacts,
get_group_events,
get_global_events,
get_hashtag_events,
search,
publish,
reply,
repost,
event_to_bech32,
user_to_bech32,
create_column,
close_column,
reposition_column,
resize_column,
reload_column,
open_window,
reopen_lume,
quit
])
.events(collect_events![Subscription, NewSettings]);
tracing_subscriber::fmt::init();
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
get_relays,
get_all_relays,
is_relay_connected,
connect_relay,
remove_relay,
get_bootstrap_relays,
set_bootstrap_relays,
get_accounts,
watch_account,
import_account,
connect_account,
get_private_key,
delete_account,
reset_password,
has_signer,
set_signer,
get_profile,
set_profile,
get_contact_list,
set_contact_list,
is_contact,
toggle_contact,
get_all_profiles,
set_group,
get_group,
get_all_newsfeeds,
get_all_local_newsfeeds,
set_interest,
get_interest,
get_all_interests,
get_all_local_interests,
set_wallet,
load_wallet,
remove_wallet,
zap_profile,
zap_event,
copy_friend,
get_notifications,
verify_nip05,
get_meta_from_event,
get_event,
get_replies,
get_all_events_by_author,
get_all_events_by_authors,
get_all_events_by_hashtags,
get_all_events_from,
get_local_events,
get_global_events,
search,
publish,
reply,
repost,
is_reposted,
request_delete,
is_deleted_event,
event_to_bech32,
user_to_bech32,
create_column,
reload_column,
close_column,
close_all_columns,
open_window,
get_app_settings,
set_app_settings,
]);
#[cfg(debug_assertions)]
builder
.export(Typescript::default(), "../src/commands.gen.ts")
.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 mut ctx = tauri::generate_context!();
tauri_builder
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
builder.mount_events(app);
let handle = app.handle();
let handle_clone = handle.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 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
#[cfg(target_os = "windows")]
main_window.create_overlay_titlebar().unwrap();
@@ -178,35 +173,17 @@ fn main() {
#[cfg(target_os = "macos")]
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
#[cfg(target_os = "macos")]
let win = main_window.clone();
#[cfg(target_os = "macos")]
main_window.on_window_event(move |event| {
if let tauri::WindowEvent::ThemeChanged(_) = event {
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
}
});
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
let database = SQLiteDatabase::open(dir.join("nostr.db"))
.await
.expect("Database error.");
let database = NostrLMDB::open(config_dir.join("nostr"))
.expect("Error: cannot create database.");
// Config
let opts = Options::new()
.max_avg_latency(Duration::from_millis(500))
.gossip(true)
.max_avg_latency(Duration::from_millis(300))
.automatic_authentication(true)
.connection_timeout(Some(Duration::from_secs(5)))
.timeout(Duration::from_secs(20));
.timeout(Duration::from_secs(5));
// Setup nostr client
let client = ClientBuilder::default()
@@ -232,7 +209,7 @@ fn main() {
} else {
RelayOptions::new().write(true).read(false)
};
let _ = client.add_relay_with_opts(relay, opts).await;
let _ = client.pool().add_relay(relay, opts).await;
}
Err(_) => {
let _ = client.add_relay(relay).await;
@@ -242,6 +219,8 @@ fn main() {
}
}
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
// Connect
client.connect().await;
@@ -251,55 +230,188 @@ fn main() {
// Create global state
app.manage(Nostr {
client,
contact_list: Mutex::new(vec![]),
settings: Mutex::new(Settings::default()),
queue: RwLock::new(HashSet::new()),
is_syncing: RwLock::new(false),
settings: RwLock::new(Settings::default()),
});
Subscription::listen_any(app, move |event| {
let handle = handle_clone_child.to_owned();
let payload = event.payload;
// Trigger some actions for window events
main_window.on_window_event(move |event| match event {
tauri::WindowEvent::Focused(focused) => {
if !focused {
let handle = handle_clone_event.clone();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
if *state.is_syncing.read().await {
return;
}
let mut is_syncing = state.is_syncing.write().await;
// Mark sync in progress
*is_syncing = true;
let opts = SyncOptions::default();
let accounts = get_all_accounts();
if !accounts.is_empty() {
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,
]);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Received: {}", output.received.len())
}
}
let filter = Filter::new().kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::ContactList,
Kind::FollowSet,
]);
// Get all public keys in database
if let Ok(events) = client.database().query(vec![filter]).await {
let public_keys: HashSet<PublicKey> = events
.iter()
.flat_map(|ev| ev.tags.public_keys().copied())
.collect();
let pk_vec: Vec<PublicKey> = public_keys.into_iter().collect();
for chunk in pk_vec.chunks(500) {
if chunk.is_empty() {
return;
}
let authors = chunk.to_owned();
let filter = Filter::new()
.authors(authors.clone())
.kinds(vec![
Kind::Metadata,
Kind::FollowSet,
Kind::Interests,
Kind::InterestSet,
])
.limit(1000);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Received: {}", output.received.len())
}
let filter = Filter::new()
.authors(authors)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.limit(500);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Received: {}", output.received.len())
}
}
}
// Mark sync is done
*is_syncing = false;
});
}
}
tauri::WindowEvent::Moved(_size) => {}
_ => {}
});
// Listen for request metadata
app.listen_any("request_metadata", move |event| {
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 {
let state = handle.state::<Nostr>();
let client = &state.client;
match payload.kind {
SubKind::Subscribe => {
let subscription_id = SubscriptionId::new(payload.label);
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
let mut write_queue = state.queue.write().await;
write_queue.insert(public_key);
};
let filter = if let Some(id) = payload.event_id {
let event_id = EventId::from_str(&id).unwrap();
// Wait for [QUEUE_DELAY]
sleep(Duration::from_millis(QUEUE_DELAY)).await;
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();
let read_queue = state.queue.read().await;
Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now())
};
if !read_queue.is_empty() {
let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
SubKind::Unsubscribe => {
let subscription_id = SubscriptionId::new(payload.label);
client.unsubscribe(subscription_id).await
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 notification thread
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::Custom(1111),
])
.since(Timestamp::now());
// Subscribe for new notification
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscribe error: {}", e)
}
}
let allow_notification = match handle_clone.notification().request_permission() {
Ok(_) => {
@@ -314,54 +426,66 @@ fn main() {
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
client
let _ = client
.handle_notifications(|notification| async {
#[allow(clippy::collapsible_match)]
if let RelayPoolNotification::Message { message, .. } = notification {
if let RelayMessage::Event {
subscription_id,
event,
subscription_id,
..
} = message
{
// Handle events from notification subscription
if subscription_id == notification_id {
// Send native notification
if allow_notification {
let author = client
.metadata(event.pubkey)
.database()
.profile(event.pubkey)
.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::TextNote {
let payload = RichEvent {
raw: event.as_json(),
parsed: if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
},
};
if let Err(e) = handle_clone.emit_to(
EventTarget::labeled(subscription_id.to_string()),
"event",
payload,
) {
println!("Emit error: {}", e)
}
}
let label = subscription_id.to_string();
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
handle_clone
.emit_to(
EventTarget::labeled(label),
"event",
RichEvent { raw, parsed },
)
.unwrap();
} else {
println!("new message: {}", message.as_json())
}
}
Ok(false)
})
.await
.await;
});
Ok(())
})
.plugin(prevent_default())
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
.plugin(tauri_plugin_decorum::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -374,7 +498,8 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.run(tauri::generate_context!())
.plugin(tauri_plugin_window_state::Builder::default().build())
.run(ctx)
.expect("error while running tauri application");
}
@@ -392,8 +517,8 @@ fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri_plugin_prevent_default::Builder::new().build()
}
fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind() {
fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind {
Kind::TextNote => {
if let Err(e) = handle
.notification()

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Lume",
"version": "4.1.2",
"version": "24.11.0",
"identifier": "nu.lume.Lume",
"build": {
"beforeDevCommand": "pnpm dev",

View File

@@ -1,5 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"app": {
"windows": [
{

View File

@@ -1,5 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"app": {
"windows": [
{

View File

@@ -1,43 +1,17 @@
import {
type PersistedQuery,
experimental_createPersister,
} from "@tanstack/query-persist-client-core";
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os";
import { Store } from "@tauri-apps/plugin-store";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { newQueryStorage } from "./commons";
import { routeTree } from "./routes.gen"; // auto generated file
import type { LumeEvent } from "./system";
import "./app.css";
import { Store } from "@tauri-apps/plugin-store";
const platform = type();
const tauriStore = new Store(".lume.dat");
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30,
persister: experimental_createPersister<PersistedQuery>({
storage: newQueryStorage(tauriStore),
maxAge: 1000 * 60 * 60 * 24, // 24 hours,
}),
},
},
});
import { routeTree } from "./routes.gen"; // auto generated file
import "./app.css"; // global styles
const router = createRouter({
routeTree,
context: { queryClient, platform },
Wrap: ({ children }) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
@@ -47,18 +21,43 @@ declare module "@tanstack/react-router" {
}
}
function App() {
return <RouterProvider router={router} />;
}
const platform = type();
// @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
const rootElement = document.getElementById("root")!;
// Make sure all webviews use same query client
broadcastQueryClient({
queryClient,
broadcastChannel: "lume",
});
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>,
);
}
const router = createRouter({
routeTree,
context: { store, queryClient, platform },
Wrap: ({ children }) => {
return (
<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>,
);

View File

@@ -5,15 +5,31 @@
export const commands = {
async getRelays() : Promise<Result<Relays, string>> {
async getRelays(id: string) : Promise<Result<Relays, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
return { status: "ok", data: await TAURI_INVOKE("get_relays", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<boolean, string>> {
async getAllRelays(until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_relays", { 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 {
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
} catch (e) {
@@ -21,7 +37,7 @@ async connectRelay(relay: string) : Promise<Result<boolean, string>> {
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 {
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
} catch (e) {
@@ -37,9 +53,9 @@ async getBootstrapRelays() : Promise<Result<string[], string>> {
else return { status: "error", error: e as any };
}
},
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
async setBootstrapRelays(relays: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
return { status: "ok", data: await TAURI_INVOKE("set_bootstrap_relays", { relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -48,15 +64,15 @@ async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
async getAccounts() : Promise<string[]> {
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 {
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) {
if(e instanceof Error) throw e;
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 {
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
} catch (e) {
@@ -96,15 +112,23 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
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 {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
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 {
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
} catch (e) {
@@ -112,17 +136,17 @@ async getProfile(id: string | null) : Promise<Result<string, string>> {
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 {
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) };
return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getContactList() : Promise<Result<string[], string>> {
async getContactList(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -136,17 +160,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async isContactListEmpty() : Promise<Result<boolean, null>> {
async isContact(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
} 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 }) };
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 };
@@ -160,17 +176,73 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any };
}
},
async getLumeStore(key: string) : Promise<Result<string, string>> {
async getAllProfiles() : Promise<Result<Mention[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) };
return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
} catch (e) {
if(e instanceof Error) throw e;
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 {
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 };
@@ -184,7 +256,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async loadWallet() : Promise<Result<string, string>> {
async loadWallet() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
} catch (e) {
@@ -200,7 +272,7 @@ async removeWallet() : Promise<Result<null, string>> {
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 {
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
} 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 };
}
},
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 {
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
} catch (e) {
@@ -224,25 +296,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async getNotifications() : Promise<Result<string[], string>> {
async getNotifications(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_notifications") };
} 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 }) };
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 };
@@ -256,9 +312,9 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
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 {
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) {
if(e instanceof Error) throw e;
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 };
}
},
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>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
@@ -288,33 +336,41 @@ async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
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 {
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) {
if(e instanceof Error) throw e;
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 {
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) {
if(e instanceof Error) throw e;
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 {
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) {
if(e instanceof Error) throw e;
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 {
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 getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -328,17 +384,9 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
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 {
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
} 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 }) };
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 };
@@ -352,9 +400,9 @@ async publish(content: string, warning: string | null, difficulty: number | null
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 {
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -368,6 +416,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
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>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
@@ -392,6 +464,14 @@ async createColumn(column: Column) : Promise<Result<string, string>> {
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>> {
try {
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
@@ -400,31 +480,15 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
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 {
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) };
return { status: "ok", data: await TAURI_INVOKE("close_all_columns") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resizeColumn(label: string, width: number, height: number) : Promise<Result<boolean, 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>> {
async openWindow(window: NewWindow) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
} catch (e) {
@@ -432,24 +496,27 @@ async openWindow(window: Window) : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async reopenLume() : Promise<void> {
await TAURI_INVOKE("reopen_lume");
async getAppSettings() : Promise<Result<Settings, string>> {
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> {
await TAURI_INVOKE("quit");
async setAppSettings(settings: string) : Promise<Result<null, string>> {
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 **/
export const events = __makeEvents__<{
newSettings: NewSettings,
subscription: Subscription
}>({
newSettings: "new-settings",
subscription: "subscription"
})
/** user-defined constants **/
@@ -458,15 +525,12 @@ subscription: "subscription"
/** user-defined types **/
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 NewSettings = Settings
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 Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
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 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 SubKind = "Subscribe" | "Unsubscribe"
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 }
export type Settings = { resize_service: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
/** tauri-specta globals **/
@@ -484,7 +548,7 @@ type __EventObj__<T> = {
once: (
cb: TAURI_API_EVENT.EventCallback<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>;
};

View File

@@ -3,7 +3,6 @@ import type {
MaybePromise,
PersistedQuery,
} from "@tanstack/query-persist-client-core";
import { Store } from "@tanstack/store";
import { ask, message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
@@ -15,124 +14,80 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder";
import { type BaseEditor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen";
import type { RichEvent } from "./commands.gen";
import { LumeEvent } from "./system";
import type { NostrEvent } from "./types";
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
dayjs.updateLocale("en", {
relativeTime: {
past: "%s ago",
s: "just now",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
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;
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
for (const suffix of exts) {
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 function createdAt(time: number) {
// Config for dayjs
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
const text = { text: "" };
const image = [
{
type: "image",
url,
children: [text],
// 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 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 inputTime = dayjs.unix(time);
const diff = now.diff(inputTime, "hour");
if (message) {
if (diff < 12) {
formated = inputTime.format("HH:mm A");
} else {
formated = inputTime.format("MMM DD");
}
if (diff < 24) {
return inputTime.from(now, true);
} else {
if (diff < 24) {
formated = inputTime.from(now, true);
} else {
formated = inputTime.format("MMM DD");
}
return inputTime.format("MMM DD");
}
return formated;
}
export function replyTime(time: number) {
const inputTime = dayjs.unix(time);
const formated = inputTime.format("MM-DD-YY HH:mm");
export function replyAt(time: number) {
// Config for dayjs
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) {
@@ -161,7 +116,7 @@ export function displayLongHandle(str: string) {
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) {
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
.getValue()
@@ -192,19 +147,27 @@ export function getBitcoinDisplayValues(satoshis: number) {
};
}
export function decodeZapInvoice(tags?: string[][]) {
export function decodeZapInvoice(tags: string[][]) {
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
if (!invoice) return;
const decodedInvoice = decode(invoice);
const amountSection = decodedInvoice.sections.find(
const section = decodedInvoice.sections.find(
(s: { name: string }) => s.name === "amount",
);
const amount = Number.parseInt(amountSection.value);
const displayValue = getBitcoinDisplayValues(amount);
if (!section) {
return null;
}
return displayValue;
if (section.name === "amount") {
const amount = Number.parseInt(section.value) / 1000;
const displayValue = getBitcoinDisplayValues(amount);
return displayValue;
} else {
return null;
}
}
export async function checkForAppUpdates(silent: boolean) {
@@ -257,18 +220,16 @@ export async function upload(filePath?: string) {
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
filePath ??
(await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
}));
// User cancelled action
if (!selected) return null;
@@ -325,15 +286,3 @@ export function newQueryStorage(
(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,
});

View File

@@ -1,126 +1,68 @@
import { commands } from "@/commands.gen";
import { useRect } from "@/system";
import type { LumeColumn } from "@/types";
import { Check, DotsThree } 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 { CaretDown, Check } from "@phosphor-icons/react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { Spinner } from "./spinner";
import { useCallback, useEffect, useState } from "react";
import { User } from "./user";
type WindowEvent = {
scroll: boolean;
resize: boolean;
};
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,
});
}, []);
export function Column({ column }: { column: LumeColumn }) {
const [rect, ref] = useRect();
const [error, setError] = useState("");
useEffect(() => {
if (!isCreated) return;
(async () => {
if (rect) {
const res = await commands.createColumn({
label: column.label,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url: `${column.url}?label=${column.label}&name=${column.name}`,
});
const unlisten = listen<WindowEvent>("child_webview", (data) => {
if (data.payload.scroll) repositionWebview();
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
});
return () => {
unlisten.then((f) => f());
};
}, [isCreated]);
useEffect(() => {
if (!container.current) return;
const rect = container.current.getBoundingClientRect();
const url = `${column.url}?account=${params.account}&label=${column.label}&name=${column.name}`;
const prop = {
label: webviewLabel,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url,
};
// create new webview
invoke("create_column", { column: prop }).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted
return () => {
invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
};
}, [params.account]);
if (res.status === "error") {
setError(res.error);
}
}
})();
}, [rect]);
return (
<div className="h-full w-[440px] shrink-0 p-2">
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/15">
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full">
{!isCreated ? (
<div className="size-full flex items-center justify-center">
<Spinner />
<div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
<div className="flex flex-col gap-px size-full">
<Header
label={column.label}
name={column.name}
account={column.account}
/>
<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>
) : null}
</div>
</div>
</div>
</div>
);
});
}
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
function Header({
label,
name,
account,
}: { label: string; name: string; account?: string }) {
const [title, setTitle] = useState("");
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const window = getCurrentWindow();
const menuItems = await Promise.all([
MenuItem.new({
text: "Reload",
@@ -128,10 +70,6 @@ function Header({ label, name }: { label: string; name: string }) {
await commands.reloadColumn(label);
},
}),
MenuItem.new({
text: "Open in new window",
action: () => console.log("not implemented."),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Move left",
@@ -172,20 +110,39 @@ function Header({ label, name }: { label: string; name: string }) {
await menu.popup().catch((e) => console.error(e));
}, []);
const saveNewTitle = async () => {
await getCurrentWindow().emit("columns", {
type: "set_title",
label,
title,
});
setIsChanged(false);
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
if (title.length > 0) setIsChanged(true);
}, [title.length]);
return (
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" />
<div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
<div className="flex items-center justify-center shrink-0 h-7">
<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
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
onBlur={(e) => {
if (e.currentTarget.textContent) {
setTitle(e.currentTarget.textContent);
}
}}
className="text-[12px] font-semibold focus:outline-none"
>
{name}
</div>
@@ -193,9 +150,9 @@ function Header({ label, name }: { label: string; name: string }) {
<button
type="button"
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>
) : null}
</div>
@@ -203,9 +160,9 @@ function Header({ label, name }: { label: string; name: string }) {
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10"
className="hidden shrink-0 group-hover: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"
>
<DotsThree className="size-5" />
<CaretDown className="size-3" weight="bold" />
</button>
</div>
);

View File

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

View File

@@ -1,5 +1,4 @@
import { cn } from "@/commons";
import { useRouteContext } from "@tanstack/react-router";
import type { ReactNode } from "react";
export function Frame({
@@ -7,17 +6,13 @@ export function Frame({
shadow,
className,
}: { children: ReactNode; shadow?: boolean; className?: string }) {
const { platform } = useRouteContext({ strict: false });
return (
<div
className={cn(
className,
platform === "linux"
? "bg-white dark:bg-neutral-950"
: "bg-white dark:bg-white/10",
"bg-white dark:bg-neutral-800",
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"
: "",
)}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,17 @@ export * from "./spinner";
export * from "./column";
// Newsfeed
export * from "./repost";
export * from "./conversation";
export * from "./quote";
export * from "./text";
export * from "./repost";
export * from "./reply";
// Global components
export * from "./note";
export * from "./user";
// Icons
export * from "./icons/reply";
export * from "./icons/repost";
export * from "./icons/zap";
export * from "./icons/quote";
export * from "./icons/publish";

View File

@@ -1,5 +1,5 @@
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 { useNoteContext } from "../provider";
@@ -8,19 +8,26 @@ export function NoteOpenThread() {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<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"
>
<FrameCorners className="shrink-0 size-4" />
<ListPlus className="shrink-0 size-4" />
</button>
</Tooltip.Trigger>
<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">
Open
View thread
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>

View File

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

View File

@@ -1,10 +1,13 @@
import { cn } from "@/commons";
import { ReplyIcon } from "@/components";
import { LumeWindow } from "@/system";
import { ShareFat } from "@phosphor-icons/react";
import * as Tooltip from "@radix-ui/react-tooltip";
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();
return (
@@ -15,14 +18,12 @@ export function NoteReply({ large = false }: { large?: boolean }) {
type="button"
onClick={() => LumeWindow.openEditor(event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
"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",
label ? "w-24 gap-1.5" : "w-14",
)}
>
<ShareFat className="shrink-0 size-4" />
{large ? "Reply" : null}
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Reply" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>

View File

@@ -1,81 +1,164 @@
import { appSettings, cn } from "@/commons";
import { Spinner } from "@/components";
import { LumeWindow } from "@/system";
import { Repeat } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-store";
import { commands } from "@/commands.gen";
import { cn, displayNpub } from "@/commons";
import { RepostIcon, Spinner } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
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 { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import { useCallback, useTransition } from "react";
import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) {
const visible = useStore(appSettings, (state) => state.display_repost_button);
export function NoteRepost({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const event = useNoteContext();
const settings = useSuspenseQuery(settingsQueryOptions);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const { isLoading, data: status } = useQuery({
queryKey: ["is-reposted", event.id],
queryFn: async () => {
const res = await commands.isReposted(event.id);
if (res.status === "ok") {
return res.data;
} else {
return false;
}
},
enabled: settings.data.display_repost_button,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
});
const repost = async () => {
if (isRepost) return;
try {
setLoading(true);
// repost
await event.repost();
// update state
setLoading(false);
setIsRepost(true);
} catch {
setLoading(false);
await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
}
};
const [isPending, startTransition] = useTransition();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const accounts = await commands.getAccounts();
const list: Promise<MenuItem>[] = [];
const menu = await Menu.new({
items: menuItems,
});
for (const account of accounts) {
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));
}, []);
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) {
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 (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<Repeat className={cn("size-4", isRepost ? "text-blue-500" : "")} />
)}
{large ? "Repost" : null}
</button>
<Tooltip.Provider>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"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",
label ? "w-24 gap-1.5" : "w-14",
)}
>
{isPending || isLoading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn(
smol ? "size-4" : "size-5",
status ? "text-blue-500" : "",
)}
/>
)}
{label ? "Repost" : 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">
Repost
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,30 +1,32 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { ZapIcon } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
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";
export function NoteZap({ large = false }: { large?: boolean }) {
export function NoteZap({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const search = useSearch({ strict: false });
const visible = useStore(appSettings, (state) => state.display_zap_button);
const settings = useSuspenseQuery(settingsQueryOptions);
const event = useNoteContext();
if (!visible) return null;
if (!settings.data.display_zap_button) return null;
return (
<button
type="button"
onClick={() => LumeWindow.openZap(event.id, search.account)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
"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",
label ? "w-24 gap-1.5" : "w-14",
)}
>
<Lightning className="size-4" />
{large ? "Zap" : null}
<ZapIcon className={smol ? "size-4" : "size-5"} />
{label ? "Zap" : null}
</button>
);
}

View File

@@ -1,13 +1,13 @@
import { appSettings, cn } from "@/commons";
import { useStore } from "@tanstack/react-store";
import { cn } from "@/commons";
import { settingsQueryOptions } from "@/routes/__root";
import { useSuspenseQuery } from "@tanstack/react-query";
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 { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider";
export function NoteContent({
@@ -22,15 +22,17 @@ export function NoteContent({
className?: string;
}) {
const event = useNoteContext();
const visible = useStore(appSettings, (state) => state.display_media);
const warning = useMemo(() => event.warning, [event]);
const settings = useSuspenseQuery(settingsQueryOptions);
const content = useMemo(() => {
try {
// Get parsed meta
const { content, hashtags, events, mentions } = event.meta;
// 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) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
@@ -89,56 +91,48 @@ export function NoteContent({
}
}, [event.content]);
const [blurred, setBlurred] = useState(() =>
event.warning ? event.warning.length > 0 : false,
);
return (
<div className="relative flex flex-col gap-2">
<ContentWarning warning={warning} />
<div
className={cn(
"select-text text-pretty content-break overflow-hidden",
event.content.length > 500 ? "max-h-[250px] gradient-mask-b-0" : "",
className,
)}
>
{content}
</div>
{visible ? (
{!blurred ? (
<>
{event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
<div
className={cn(
"select-text text-pretty content-break overflow-hidden",
className,
)}
>
{content}
</div>
{settings.data.display_media ? (
event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null
) : null}
</>
) : null}
) : (
<div
className={cn(
"select-text text-pretty content-break overflow-hidden",
className,
)}
>
<p className="text-yellow-600 dark:text-yellow-400">
The content is hidden because the author marked it with a warning
for a reason: <span className="font-semibold">{event.warning}</span>
</p>
<button
type="button"
onClick={() => setBlurred(false)}
className="font-medium text-sm text-blue-500 hover:text-blue-600"
>
View anyway
</button>
</div>
)}
</div>
);
}
const ContentWarning = memo(function ContentWarning({
warning,
}: { warning: string }) {
const [blurred, setBlurred] = useState(() => warning?.length > 0);
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 className="text-sm font-medium text-white">{warning}</p>
<button
type="button"
onClick={() => setBlurred(false)}
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
>
View anyway
</button>
</div>
</div>
);
});

View File

@@ -6,7 +6,6 @@ import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { ImagePreview } from "./preview/image";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
@@ -18,7 +17,7 @@ export function NoteContentLarge({
const content = useMemo(() => {
try {
// Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta;
const { images, hashtags, events, mentions } = event.meta;
// Define rich 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,
/(https?:\/\/\S+)/gi,
@@ -75,8 +68,7 @@ export function NoteContentLarge({
));
return richContent;
} catch (e) {
console.log("[parser]: ", e);
} catch {
return event.content;
}
}, [event.content]);

View File

@@ -1,4 +1,5 @@
import { NoteOpenThread } from "./buttons/open";
import { NoteQuote } from "./buttons/quote";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
@@ -16,6 +17,7 @@ export const Note = {
User: NoteUser,
Menu: NoteMenu,
Reply: NoteReply,
Quote: NoteQuote,
Repost: NoteRepost,
Content: NoteContent,
ContentLarge: NoteContentLarge,

View File

@@ -1,13 +1,7 @@
import { LumeWindow } from "@/system";
export function Hashtag({ tag }: { tag: string }) {
return (
<button
type="button"
onClick={() => LumeWindow.openHashtag(tag)}
className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal"
>
{tag}
</button>
<span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
{tag.includes("#") ? tag : `#${tag}`}
</span>
);
}

View File

@@ -1,8 +1,11 @@
import { replyTime } from "@/commons";
import { Note, Spinner } from "@/components";
import { User } from "@/components/user";
import { replyAt } from "@/commons";
import { Note, Spinner, User } from "@/components";
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({
eventId,
@@ -15,12 +18,18 @@ export const MentionNote = memo(function MentionNote({
<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">
{isLoading ? (
<Spinner />
<div className="h-[32px] flex items-center gap-2 text-sm">
<Spinner />
Loadng note
</div>
) : isError || !event ? (
<p className="text-sm font-medium text-red-500">
{error.message ||
"Quoted note is not found with your current relay set"}
</p>
<div className="flex flex-col break-all">
<p className="text-sm font-medium text-red-500">
{error?.message ??
"Cannot found this note within your current relay set"}
</p>
<p className="text-sm">{eventId}</p>
</div>
) : (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
@@ -33,20 +42,29 @@ export const MentionNote = memo(function MentionNote({
/>
</User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content.length > 120
? `${event.content.substring(0, 120)}...`
: event.content}
{event.content.length > 300 ? (
`${event.content.substring(0, 300)}...`
) : (
<Content text={event.content} className="inline" />
)}
</div>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
{replyAt(event.created_at)}
</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
type="button"
onClick={() => LumeWindow.openEvent(event)}
className="text-sm font-medium text-blue-500 hover:text-blue-600"
onClick={() =>
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
</button>
@@ -63,3 +81,78 @@ export const MentionNote = memo(function MentionNote({
</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>;
}

View File

@@ -5,7 +5,7 @@ import { memo } from "react";
export const MentionUser = memo(function MentionUser({
pubkey,
}: { pubkey: string }) {
const { isLoading, isError, profile } = useProfile(pubkey);
const { isLoading, profile } = useProfile(pubkey);
return (
<button
@@ -14,10 +14,8 @@ export const MentionUser = memo(function MentionUser({
className="break-words text-start text-blue-500 hover:text-blue-600"
>
{isLoading
? "@anon"
: isError
? displayNpub(pubkey, 16)
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
? displayNpub(pubkey, 16)
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
</button>
);
});

View File

@@ -1,51 +1,68 @@
import { commands } from "@/commands.gen";
import { DotsThree } from "@phosphor-icons/react";
import { useSearch } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import { useCallback } from "react";
import { useNoteContext } from "./provider";
export function NoteMenu() {
const event = useNoteContext();
const { account }: { account: string } = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
const list = [
MenuItem.new({
text: "Copy Sharable Link",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
text: "Copy ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(eventId);
},
}),
MenuItem.new({
text: "Copy Public Key",
text: "Copy author",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Raw Event",
text: "Copy sharable link",
action: async () => {
event.meta = undefined;
const raw = JSON.stringify(event);
await writeText(raw);
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
]);
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({
items: menuItems,
});
if (account?.length) {
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));
}, []);

View File

@@ -1,23 +1,20 @@
import { appSettings } from "@/commons";
import { useStore } from "@tanstack/react-store";
import { settingsQueryOptions } from "@/routes/__root";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export function ImagePreview({ url }: { url: string }) {
const [service, visible] = useStore(appSettings, (state) => [
state.image_resize_service,
state.display_media,
]);
const settings = useSuspenseQuery(settingsQueryOptions);
const imageUrl = useMemo(() => {
if (service?.length) {
const newUrl = `${service}?url=${url}&ll&af&default=1&n=-1`;
if (settings.data.resize_service) {
const newUrl = `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
return newUrl;
} else {
return url;
}
}, [service]);
}, [settings.data.resize_service]);
if (!visible) {
if (!settings.data.display_media) {
return (
<a
href={url}
@@ -39,10 +36,6 @@ export function ImagePreview({ url }: { url: string }) {
decoding="async"
style={{ contentVisibility: "auto" }}
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>
);

View File

@@ -1,40 +1,53 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { Spinner } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
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 useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) {
const [slidesInView, setSlidesInView] = useState([]);
const [slidesInView, setSlidesInView] = useState<number[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
dragFree: true,
align: "start",
watchSlides: false,
});
const service = useStore(appSettings, (state) => state.image_resize_service);
const settings = useSuspenseQuery(settingsQueryOptions);
const imageUrls = useMemo(() => {
if (service?.length) {
if (settings.data.resize_service) {
let newUrls: string[];
if (urls.length === 1) {
newUrls = urls.map(
(url) => `${service}?url=${url}&ll&af&default=1&n=-1`,
);
newUrls = urls.map((url) => {
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 {
newUrls = urls.map(
(url) => `${service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
);
newUrls = urls.map((url) => {
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;
} else {
return urls;
}
}, [service]);
}, [settings.data.resize_service]);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
@@ -44,23 +57,27 @@ export function Images({ urls }: { urls: string[] }) {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => {
const updateSlidesInView = useCallback(() => {
setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView);
if (slidesInView.length === emblaApi?.slideNodes().length) {
emblaApi?.off("slidesInView", updateSlidesInView);
}
const inView = emblaApi
.slidesInView()
?.slidesInView()
.filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView);
if (inView) {
return slidesInView.concat(inView);
} else {
return slidesInView;
}
});
}, []);
}, [emblaApi]);
useEffect(() => {
if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi);
updateSlidesInView();
emblaApi.on("slidesInView", 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"
onClick={() => urls[0]}
onKeyDown={() => urls[0]}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);
@@ -162,10 +175,6 @@ function LazyImage({ url, inView }: { url: string; inView: boolean }) {
onClick={() => open(url)}
onKeyDown={() => open(url)}
onLoad={setLoaded}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);

View File

@@ -1,10 +1,10 @@
import { appSettings } from "@/commons";
import { useStore } from "@tanstack/react-store";
import { settingsQueryOptions } from "@/routes/__root";
import { useSuspenseQuery } from "@tanstack/react-query";
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 (
<a
href={url}

View File

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

View File

@@ -1,10 +1,15 @@
import { cn, replyTime } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { cn, replyAt } from "@/commons";
import { Note, User } from "@/components";
import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router";
import { memo } from "react";
import { User } from "./user";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
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({
event,
@@ -14,32 +19,58 @@ export const ReplyNote = memo(function ReplyNote({
className?: string;
}) {
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 (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<Note.Root className={cn("flex gap-2.5", className)}>
<User.Root className="shrink-0">
<User.Avatar className="size-8 rounded-full" />
<button type="button" onClick={(e) => showContextMenu(e)}>
<User.Avatar className="size-8 rounded-full" />
</button>
</User.Root>
<div className="flex-1 flex flex-col gap-1">
<div>
<User.Name
className="shrink-0 inline font-medium text-blue-500"
className="mr-2 shrink-0 inline font-medium text-blue-500"
suffix=":"
/>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
{replyAt(event.created_at)}
</span>
<div className="flex items-center justify-end gap-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
<div className="flex items-center justify-end">
<Note.Reply smol />
<Note.Repost smol />
<Note.Zap smol />
</div>
</div>
{event.replies?.length ? (
@@ -74,26 +105,53 @@ export const ReplyNote = memo(function ReplyNote({
function ChildReply({ event }: { event: LumeEvent }) {
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 (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<div className="group flex flex-col gap-1">
<div>
<User.Root className="inline">
<User.Name className="font-medium text-blue-500" suffix=":" />
<User.Root className="inline mr-2">
<button type="button" onClick={(e) => showContextMenu(e)}>
<User.Name className="font-medium text-blue-500" suffix=":" />
</button>
</User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
{replyAt(event.created_at)}
</span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
<div className="invisible group-hover:visible flex items-center justify-end">
<Note.Reply smol />
<Note.Repost smol />
<Note.Zap smol />
</div>
</div>
{event.replies?.length ? (
@@ -124,3 +182,64 @@ function ChildReply({ event }: { event: LumeEvent }) {
</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>;
}

View File

@@ -12,25 +12,22 @@ export const RepostNote = memo(function RepostNote({
event: LumeEvent;
className?: string;
}) {
const { isLoading, isError, data } = useEvent(event.repostId);
const { isLoading, isError, data } = useEvent(event.repostId, event.content);
return (
<Note.Root
className={cn(
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<Note.Root className={cn("", className)}>
{isLoading ? (
<div className="flex items-center justify-center h-20 gap-2">
<Spinner />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Loading event...
</span>
</p>
</div>
) : isError || !data ? (
<div className="flex items-center justify-center h-20">
Event not found within your current relay set
<p className="text-sm">
Repost event not found within your current relay set
</p>
</div>
) : (
<Note.Provider event={data}>
@@ -41,7 +38,7 @@ export const RepostNote = memo(function RepostNote({
</div>
<Note.Content className="px-3" />
<div className="flex items-center justify-between px-3 mt-3 h-14">
<div className="inline-flex items-center gap-3">
<div className="inline-flex items-center gap-2">
<Note.Open />
<Note.Reply />
<Note.Repost />

View File

@@ -12,18 +12,13 @@ export const TextNote = memo(function TextNote({
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
className,
)}
>
<Note.Root className={cn("", className)}>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<div className="flex items-center gap-2 px-3 mt-3 h-14">
<Note.Open />
<Note.Reply />
<Note.Repost />

View File

@@ -1,33 +1,36 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { settingsQueryOptions } from "@/routes/__root";
import * as Avatar from "@radix-ui/react-avatar";
import { useStore } from "@tanstack/react-store";
import { useSuspenseQuery } from "@tanstack/react-query";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const [service, visible] = useStore(appSettings, (state) => [
state.image_resize_service,
state.display_avatar,
]);
const settings = useSuspenseQuery(settingsQueryOptions);
const user = useUserContext();
const picture = useMemo(() => {
if (service?.length && user.profile?.picture?.length) {
const url = `${service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
return url;
if (settings.data.resize_service && user?.profile?.picture?.length) {
if (user.profile?.picture.includes("_next/")) {
return user.profile?.picture;
}
if (user.profile?.picture.includes("bsky.network")) {
return user.profile?.picture;
}
return `https://wsrv.nl?url=${user.profile?.picture}&w=100&h=100&n=-1&default=${user.profile?.picture}`;
} else {
return user.profile?.picture;
return user?.profile?.picture;
}
}, [user.profile?.picture]);
}, [user]);
const fallback = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey, 60, 50),
minidenticon(user ? user.pubkey : "lume", 60, 50),
)}`,
[user.pubkey],
[user],
);
return (
@@ -37,18 +40,19 @@ export function UserAvatar({ className }: { className?: string }) {
className,
)}
>
{visible ? (
{settings.data.display_avatar ? (
<Avatar.Image
src={picture}
alt={user.pubkey}
alt={user?.pubkey}
decoding="async"
onContextMenu={(e) => e.preventDefault()}
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
) : null}
<Avatar.Fallback>
<img
src={fallback}
alt={user.pubkey}
alt={user?.pubkey}
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
</Avatar.Fallback>

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