Compare commits

...
86 Commits
Author SHA1 Message Date
reya b0c49c5141 chore: release 2024-11-10 13:23:57 +07:00
reya 2ed2cb3afd chore: update dependencies 2024-11-10 10:43:38 +07:00
reya 2bcda1f2ef feat: support NIP22 for comment 2024-11-10 08:51:07 +07:00
reya ca20bbd298 chore: release 2024-11-08 09:19:51 +07:00
reya c6da06cd4d chore: clean up 2024-11-08 09:02:57 +07:00
reya 50bf6c04c1 chore: use upstream nostr-sdk instead of fork 2024-11-08 07:51:48 +07:00
reya 490417771c chore: release 2024-11-07 15:02:05 +07:00
reya 0cb491eaf9 feat: auto open browser if receive auth_url 2024-11-07 14:26:13 +07:00
reya ece6bcc125 feat: add DVM feeds 2024-11-07 09:26:28 +07:00
reya 4b79e559d2 chore: clean up 2024-11-06 09:43:31 +07:00
reya 322e510db2 fix: settings 2024-11-06 09:16:22 +07:00
reya 4e279f127d chore: release 2024-11-06 07:45:00 +07:00
reya 5655a8136d feat: improve dark mode and some copy 2024-11-05 19:33:47 +07:00
reya d80534c51f feat: allow user enter custom relay for relayfeeds 2024-11-05 15:04:39 +07:00
reya 0b97248fb8 chore: release 2024-11-04 10:42:46 +07:00
reya f54f448ecb feat: add tray 2024-11-04 10:40:50 +07:00
reya bd1f2b899d refactor: sync based on interval not window event 2024-11-04 09:40:33 +07:00
reya efd3c83193 fix: missing check for update 2024-11-03 13:50:52 +07:00
reya 85fa1e2359 fix: missing check for update 2024-11-03 13:50:31 +07:00
reya cd6ba5884f chore: release 2024-11-03 13:18:36 +07:00
reya bfed56ba13 fix: crash on windows 2024-11-03 13:05:24 +07:00
reya d1018ba8d1 feat: remove mica effect and support windows 10 2024-11-02 13:43:13 +07:00
reya a42542c16e fix: performance 2024-11-02 13:33:48 +07:00
reya 26a6ec954c fix: thread not reload after reply 2024-11-02 08:26:46 +07:00
reya f346282c3c fix: calver 2024-11-01 18:33:03 +07:00
reya f3e875eeea fix: some issues with publish new note 2024-11-01 17:57:01 +07:00
reya 6ad8ffddf0 chore: clean up 2024-11-01 13:14:47 +07:00
reya d37e2a3c80 chore: update nostr-sdk and nostr-connect 2024-11-01 09:57:46 +07:00
reya 18e1ac0e6c feat: add relay feeds 2024-11-01 09:25:12 +07:00
reya aa4f21a869 fix: dark mode 2024-10-31 16:25:16 +07:00
reya bbc052eebc chore: switch to calver 2024-10-31 13:46:41 +07:00
reya c201b5816c feat: add discover newsfeeds and interests 2024-10-31 13:34:25 +07:00
reya 043cabfd4e fix: overload 2024-10-31 10:25:00 +07:00
reya 9b02ab5842 feat: re-enable gossip and optimize 2024-10-30 16:02:09 +07:00
reya 618a45d349 refactor: app settings 2024-10-30 10:57:43 +07:00
reya 11dcef4e87 feat: group metadata query 2024-10-29 15:05:03 +07:00
reya d87371aec4 chore: use latest nostr sdk 2024-10-28 16:19:50 +07:00
reya cfb017f70b chore: update deps 2024-10-28 09:00:28 +07:00
reya 9ba95301db update 2024-10-28 08:04:19 +07:00
reya 0518389f50 update 2024-10-27 15:57:32 +07:00
reya eb6e3e52df update 2024-10-27 09:57:56 +07:00
reya 1e95a2fd95 update 2024-10-27 08:14:15 +07:00
reya 83d24351cd update 2024-10-26 17:24:39 +07:00
reya 470dc1c759 fix: command 2024-10-26 13:11:07 +07:00
reya 42b780ce6a update 2024-10-26 09:08:50 +07:00
reya 5ab2b1ae31 feat: negentropy progress 2024-10-25 14:57:12 +07:00
reya 055d73c829 feat: rework multi account 2024-10-24 15:50:45 +07:00
reya 469296790e wip: rework multi account 2024-10-24 07:59:41 +07:00
reya c032dbea1a chore: clean up 2024-10-23 10:43:39 +07:00
reya 172566028b feat: use latest nostr sdk 2024-10-23 10:16:56 +07:00
雨宮蓮andGitHub 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
reya ba9c81a10a feat: use upstream rust nostr 2024-10-15 10:37:07 +07:00
reya e158f2e4d7 feat: improve column carousel 2024-10-15 09:59:26 +07:00
reya 62bd689031 fix: async mutex lock forever 2024-10-14 14:59:42 +07:00
reya cb6006f596 feat: handle error when publish note 2024-10-11 14:09:15 +07:00
reya adad048873 feat: improve negentropy sync 2024-10-11 08:56:43 +07:00
reya 790fee6c05 chore: bump version 2024-10-09 14:51:20 +07:00
reya 9f5b14956a feat: save state when close app 2024-10-09 14:02:13 +07:00
reya e04497d841 chore: update README 2024-10-09 09:42:49 +07:00
reya 106c627ec4 feat: only query from local database and other improvements 2024-10-09 09:15:49 +07:00
reya c40762cc04 feat: add support for NIP-09 2024-10-08 16:30:26 +07:00
reya d2b5ae0507 feat: use negentropy as much as possible 2024-10-08 10:36:31 +07:00
reya 8c6aea8050 chore: follow-up #236 2024-10-07 15:12:30 +07:00
雨宮蓮andGitHub 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
reya d841163ba7 revamp 2024-10-05 08:49:09 +07:00
reya 8398ae80d3 chore: bump version 2024-10-03 10:43:16 +07:00
reya fe60f75e96 chore: upgrade to tauri v2 stable 2024-10-03 09:00:48 +07:00
雨宮蓮andGitHub e098743d43 feat: new flow (#235)
* feat: redesign initial screen

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

* update

* update
2024-09-29 16:53:39 +07:00
reya afa9327bb7 feat: add content parser to reply 2024-09-27 09:58:02 +07:00
reya 5c3644f977 feat: update general settings 2024-09-27 09:12:38 +07:00
reya 0a8eed9a46 feat: new post editor 2024-09-26 10:57:58 +07:00
reya bacfaed48a wip: local relay 2024-09-25 09:59:54 +07:00
reya 3d5085785b feat: pow by default 2024-09-23 17:18:41 +07:00
reya 9152c3e122 feat: add basic web of trust 2024-09-23 13:24:33 +07:00
reya a5574bef6c feat: add notification for nip42 2024-09-22 09:40:07 +07:00
reya 2c7f3685b6 fix: build on macos 2024-09-20 09:26:48 +07:00
reya be0abc4075 chore: update deps 2024-09-20 08:42:08 +07:00
reya dafe35cd1f feat: add client tag 2024-09-20 08:05:32 +07:00
reya b23903240b fix: wrong menu item in repost button 2024-09-20 08:05:22 +07:00
reya 872a6cee36 fix: missing context menu in user's avatar 2024-09-20 07:25:07 +07:00
雨宮蓮andGitHub 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
AndrewandGitHub 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
reya 2eab6f04c7 chore: bump version 2024-08-30 13:30:29 +07:00
201 changed files with 18979 additions and 29445 deletions
+2 -1
View File
@@ -23,4 +23,5 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
src/router.gen.ts src/routes.gen.ts
src/commands.gen.ts
+14 -47
View File
@@ -1,63 +1,30 @@
## Introduction ## Introduction
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month. Lume is a Nostr client for 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 See [Developing](docs/DEVELOPING.md)
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
## Develop
Clone project
```
git clone https://github.com/lumehq/lume.git && cd lume
```
Install packages
```
pnpm install
```
Run dev build
```
pnpm tauri dev
```
Generate production build
```
pnpm tauri build
```
## Nix
Requirements:
1. [Install Nix](https://zero-to-flakes.com/install)
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
## License ## 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. 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.
+1 -4
View File
@@ -4,10 +4,7 @@
"enabled": true "enabled": true
}, },
"files": { "files": {
"ignore": [ "ignore": ["./src/routes.gen.ts", "./src/commands.gen.ts"]
"./src/routes.gen.ts",
"./src/commands.gen.ts"
]
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
+37
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
```
+52 -55
View File
@@ -12,74 +12,71 @@
"dependencies": { "dependencies": {
"@getalby/bitcoin-connect-react": "^3.6.2", "@getalby/bitcoin-connect-react": "^3.6.2",
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1",
"@tanstack/query-persist-client-core": "^5.51.21", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.51.23", "@tanstack/query-broadcast-client-experimental": "^5.59.20",
"@tanstack/react-router": "^1.48.1", "@tanstack/query-persist-client-core": "^5.59.20",
"@tanstack/react-store": "^0.5.5", "@tanstack/react-query": "^5.59.20",
"@tanstack/store": "^0.5.5", "@tanstack/react-router": "^1.81.0",
"@tauri-apps/api": "2.0.0-rc.1", "@tauri-apps/api": "^2.1.0",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "2.0.0-rc.0", "@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-fs": "2.0.0-rc.1", "@tauri-apps/plugin-fs": "^2.0.2",
"@tauri-apps/plugin-http": "2.0.0-rc.1", "@tauri-apps/plugin-http": "^2.0.1",
"@tauri-apps/plugin-os": "2.0.0-rc.0", "@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-process": "2.0.0-rc.0", "@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "2.0.0-rc.0", "@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-store": "2.0.0-rc.0", "@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
"@tauri-apps/plugin-updater": "2.0.0-rc.0", "@tauri-apps/plugin-updater": "^2.0.0",
"@tauri-apps/plugin-upload": "2.0.0-rc.0", "@tauri-apps/plugin-upload": "^2.1.0",
"@tauri-apps/plugin-window-state": "2.0.0-rc.0", "@tauri-apps/plugin-window-state": "^2.0.0",
"bitcoin-units": "^1.0.0", "bitcoin-units": "^1.0.0",
"boring-avatars": "^1.10.2", "dayjs": "^1.11.13",
"dayjs": "^1.11.12", "embla-carousel-react": "^8.3.1",
"embla-carousel-react": "^8.1.8", "i18next": "^23.16.5",
"i18next": "^23.13.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"light-bolt11-decoder": "^3.1.1", "light-bolt11-decoder": "^3.2.0",
"minidenticons": "^4.2.1", "minidenticons": "^4.2.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.8",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.10.1",
"react": "19.0.0-rc-d025ddd3-20240722", "react": "19.0.0-rc-cae764ce-20241025",
"react-currency-input-field": "^3.8.0", "react-currency-input-field": "^3.8.0",
"react-dom": "19.0.0-rc-d025ddd3-20240722", "react-dom": "19.0.0-rc-cae764ce-20241025",
"react-hook-form": "^7.52.2", "react-hook-form": "^7.53.2",
"react-i18next": "^15.0.1", "react-i18next": "^15.1.1",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"slate": "^0.103.0", "rich-textarea": "^0.26.4",
"slate-react": "^0.107.1", "use-debounce": "^10.0.4",
"use-debounce": "^10.0.3", "virtua": "^0.34.2"
"virtua": "^0.33.7"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.8.3", "@biomejs/biome": "^1.9.4",
"@evilmartians/harmony": "^1.2.0", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/typography": "^0.5.14", "@tanstack/router-devtools": "^1.81.0",
"@tanstack/router-devtools": "^1.48.1", "@tanstack/router-plugin": "^1.79.0",
"@tanstack/router-plugin": "^1.47.0", "@tauri-apps/cli": "^2.1.0",
"@tauri-apps/cli": "2.0.0-rc.4",
"@types/react": "npm:types-react@19.0.0-rc.1", "@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625", "babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"postcss": "^8.4.41", "postcss": "^8.4.47",
"tailwind-gradient-mask-image": "^1.2.0", "tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.4",
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.10", "tailwindcss": "^3.4.14",
"tailwindcss-content-visibility": "^0.2.0", "tailwindcss-content-visibility": "^1.0.0",
"typescript": "^5.5.4", "typescript": "^5.6.3",
"vite": "^5.4.1", "vite": "^5.4.10",
"vite-tsconfig-paths": "^5.0.1" "vite-tsconfig-paths": "^5.1.1"
}, },
"overrides": { "overrides": {
"@types/react": "npm:types-react@rc", "@types/react": "npm:types-react@rc",
+1413 -1137
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+3
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

+3
View File
@@ -5,3 +5,6 @@
# Generated by Tauri # Generated by Tauri
# will have schema files for capabilities auto-completion # will have schema files for capabilities auto-completion
/gen/schemas /gen/schemas
# Config
.cargo
+1380 -937
View File
File diff suppressed because it is too large Load Diff
+37 -34
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "Lume" name = "Lume"
version = "4.0.0" version = "24.11.0"
description = "nostr client" description = "nostr client"
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"] authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
repository = "https://github.com/lumehq/lume" repository = "https://github.com/lumehq/lume"
@@ -8,54 +8,57 @@ edition = "2021"
rust-version = "1.70" rust-version = "1.70"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] } tauri-build = { version = "2.0.0", features = [] }
[dependencies] [dependencies]
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ tauri = { version = "2.0.0", features = [ "protocol-asset",
"sqlite", "unstable",
"tray-icon",
"macos-private-api"
] } ] }
tauri-plugin-window-state = "2.0.0"
tauri-plugin-clipboard-manager = "2.0.0"
tauri-plugin-dialog = "2.0.0"
tauri-plugin-fs = "2.0.0"
tauri-plugin-http = "2.0.0"
tauri-plugin-notification = "2.0.0"
tauri-plugin-os = "2.0.0"
tauri-plugin-process = "2.0.0"
tauri-plugin-shell = "2.0.0"
tauri-plugin-updater = "2.0.0"
tauri-plugin-upload = "2.0.0"
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "8c67d44" }
tauri-plugin-prevent-default = "0.6"
tauri-plugin-theme = "2.1.2"
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum" }
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-rc", features = [
"unstable",
"tray-icon",
"macos-private-api",
"protocol-asset",
] }
tauri-plugin-window-state = "2.0.0-rc"
tauri-plugin-clipboard-manager = "2.0.0-rc"
tauri-plugin-dialog = "2.0.0-rc"
tauri-plugin-fs = "2.0.0-rc"
tauri-plugin-http = "2.0.0-rc"
tauri-plugin-notification = "2.0.0-rc"
tauri-plugin-os = "2.0.0-rc"
tauri-plugin-process = "2.0.0-rc"
tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-updater = "2.0.0-rc"
tauri-plugin-upload = "2.0.0-rc"
tauri-plugin-store = "2.0.0-rc"
tauri-plugin-theme = "0.4.1"
tauri-plugin-decorum = "1.0.0"
tauri-plugin-prevent-default = "0.4"
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
reqwest = "0.12.4" reqwest = "0.12.4"
url = "2.5.0" url = "2.5.0"
futures = "0.3.30" futures = "0.3.30"
linkify = "0.10.0" linkify = "0.10.0"
regex = "1.10.4" regex = "1.10.4"
keyring = { version = "3", features = ["apple-native", "windows-native"] } keyring = { version = "3", features = ["apple-native", "windows-native"] }
keyring-search = "1.2.0" keyring-search = { git = "https://github.com/reyamir/keyring-search" }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
async-trait = "0.1.83"
webbrowser = "1.0.2"
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "webln", "all-nips"] }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
[patch.'https://github.com/rust-nostr/nostr']
nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/nip-22", features = ["lmdb", "webln", "all-nips"] }
nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/nip-22" }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
rand = "0.8.5"
monitor = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" } border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
-54
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/*"
}
]
}
]
}
+22 -34
View File
@@ -1,20 +1,13 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability", "identifier": "window",
"description": "Capability for the desktop", "description": "Capability for the desktop",
"platforms": [ "platforms": [
"macOS", "macOS",
"windows" "windows"
], ],
"windows": [ "windows": [
"main", "*"
"panel",
"settings",
"search-*",
"zap-*",
"event-*",
"user-*",
"editor-*"
], ],
"permissions": [ "permissions": [
"core:path:default", "core:path:default",
@@ -24,6 +17,23 @@
"core:resources:default", "core:resources:default",
"core:menu:default", "core:menu:default",
"core:tray:default", "core:tray:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-set-focus",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:webview:allow-create-webview-window",
"core:webview:allow-create-webview",
"core:webview:allow-set-webview-size",
"core:webview:allow-set-webview-position",
"core:webview:allow-webview-close",
"core:menu:allow-new",
"core:menu:allow-popup",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:default", "notification:default",
@@ -33,39 +43,17 @@
"updater:default", "updater:default",
"updater:allow-check", "updater:allow-check",
"updater:allow-download-and-install", "updater:allow-download-and-install",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-set-focus",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"decorum:allow-show-snap-overlay", "decorum:allow-show-snap-overlay",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text", "clipboard-manager:allow-read-text",
"core:webview:allow-create-webview-window", "dialog:default",
"core:webview:allow-create-webview",
"core:webview:allow-set-webview-size",
"core:webview:allow-set-webview-position",
"core:webview:allow-webview-close",
"dialog:allow-open",
"dialog:allow-ask",
"dialog:allow-message",
"process:allow-restart", "process:allow-restart",
"process:allow-exit", "process:allow-exit",
"fs:allow-read-file", "fs:allow-read-file",
"theme:allow-set-theme",
"theme:allow-get-theme",
"core:menu:allow-new",
"core:menu:allow-popup",
"shell:allow-open", "shell:allow-open",
"store:allow-get", "store:default",
"store:allow-set",
"store:allow-delete",
"prevent-default:default", "prevent-default:default",
"theme:default",
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [ "allow": [
File diff suppressed because one or more lines are too long
+1 -1
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-set-focus","core:window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete","prevent-default:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}} {"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","core:menu:allow-new","core:menu:allow-popup","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:default","process:allow-restart","process:allow-exit","fs:allow-read-file","shell:allow-open","store:default","prevent-default:default","theme:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

+8 -54
View File
@@ -1,83 +1,37 @@
[ [
{ {
"default": true, "default": true,
"official": true,
"label": "onboarding", "label": "onboarding",
"name": "Onboarding", "name": "Onboarding",
"description": "Tips for Mastering Lume.", "description": "Tips for Mastering Lume.",
"url": "/columns/onboarding", "url": "/columns/onboarding"
"picture": ""
}, },
{ {
"default": true, "default": true,
"official": true, "label": "launchpad",
"label": "columns_gallery", "name": "Launchpad",
"name": "Columns Gallery",
"description": "Expand your experiences.", "description": "Expand your experiences.",
"url": "/columns/gallery", "url": "/columns/launchpad"
"picture": ""
}, },
{ {
"default": false, "default": false,
"official": true,
"label": "local_feeds",
"name": "Local Feeds",
"description": "All notes from your follows.",
"url": "/columns/newsfeed",
"picture": ""
},
{
"default": false,
"official": true,
"label": "notification",
"name": "Notification",
"description": "All things around you.",
"url": "/columns/notification",
"picture": ""
},
{
"default": false,
"official": true,
"label": "search", "label": "search",
"name": "Search", "name": "Search",
"description": "Find anything.", "description": "Find anything.",
"url": "/columns/search", "url": "/columns/search"
"picture": ""
}, },
{ {
"default": false, "default": false,
"official": true,
"label": "stories",
"name": "Stories",
"description": "Keep up to date with your follows.",
"url": "/columns/stories",
"picture": ""
},
{
"default": false,
"official": true,
"label": "global_feeds", "label": "global_feeds",
"name": "Global Feeds", "name": "Global Feeds",
"description": "Discover all global notes.", "description": "All global notes from all connected relays.",
"url": "/columns/global", "url": "/columns/global"
"picture": ""
}, },
{ {
"default": false, "default": false,
"official": true,
"label": "group_feeds",
"name": "Group",
"description": "Custom feeds for group of people.",
"url": "/columns/group",
"picture": ""
},
{
"default": false,
"official": true,
"label": "trending", "label": "trending",
"name": "Trending", "name": "Trending",
"description": "Discover all trending notes.", "description": "Discover all trending notes.",
"url": "/columns/trending", "url": "/columns/trending"
"picture": ""
} }
] ]
+4 -3
View File
@@ -1,4 +1,5 @@
wss://relay.damus.io, wss://relay.damus.io,
wss://relay.nostr.net, wss://relay.primal.net,
wss://purplepag.es/, wss://nostr.fmt.wiz.biz,
wss://directory.yabu.me/, wss://directory.yabu.me,
wss://purplepag.es,
+162 -229
View File
@@ -1,120 +1,92 @@
use keyring::Entry; use keyring::Entry;
use keyring_search::{Limit, List, Search}; use nostr_connect::prelude::*;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::{collections::HashSet, str::FromStr, time::Duration}; use std::{str::FromStr, time::Duration};
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, State};
use crate::{ use crate::{common::get_all_accounts, Nostr, Settings};
common::{get_user_settings, init_nip65},
Nostr, NEWSFEED_NEG_LIMIT, NOTIFICATION_NEG_LIMIT, NOTIFICATION_SUB_ID,
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account { struct Account {
password: String, secret_key: String,
nostr_connect: Option<String>, nostr_connect: Option<String>,
} }
#[derive(Debug, Clone)]
struct AuthHandler;
#[async_trait::async_trait]
impl AuthUrlHandler for AuthHandler {
async fn on_auth_url(&self, auth_url: Url) -> Result<()> {
webbrowser::open(auth_url.as_str())?;
Ok(())
}
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_accounts() -> Vec<String> { pub fn get_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected."); get_all_accounts()
let results = search.by_service("Lume Secret Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> = list
.split_whitespace()
.filter(|v| v.starts_with("npub1"))
.map(String::from)
.collect();
accounts.into_iter().collect()
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn create_account( pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
name: String, let client = &state.client;
about: String, let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
picture: String, let npub = public_key.to_bech32().map_err(|e| e.to_string())?;
password: String, let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
// Set empty password
keyring.set_password("").map_err(|e| e.to_string())?;
// Get user's profile
let _ = client
.fetch_metadata(public_key, Some(Duration::from_secs(4)))
.await;
Ok(npub)
}
#[tauri::command]
#[specta::specta]
pub async fn import_account(
key: String,
password: Option<String>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<String, String> { ) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let keys = Keys::generate();
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
let secret_key = keys.secret_key().map_err(|e| e.to_string())?;
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
// Save account // Create secret key
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?; let secret_key = if let Some(pw) = password {
let account = Account { let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
password: enc_bech32, enc.to_secret_key(pw).map_err(|err| err.to_string())?
nostr_connect: None, } else {
}; SecretKey::from_str(&key).map_err(|err| err.to_string())?
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)
}
}; };
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?; let hex = secret_key.to_secret_hex();
let keys = Keys::new(secret_key);
let public_key = keys.public_key();
let npub = public_key.to_bech32().map_err(|err| err.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
let account = Account { let account = Account {
password: enc_bech32, secret_key: hex,
nostr_connect: None, nostr_connect: None,
}; };
// Save secret key to keyring
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?; let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
keyring.set_password(&pwd).map_err(|e| e.to_string())?; keyring.set_password(&pwd).map_err(|e| e.to_string())?;
// Update signer
client.set_signer(keys).await;
Ok(npub) Ok(npub)
} }
@@ -122,46 +94,50 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
#[specta::specta] #[specta::specta]
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> { pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let bunker_uri = NostrConnectURI::parse(&uri).map_err(|err| err.to_string())?;
match NostrConnectURI::parse(uri.clone()) { // Local user
Ok(bunker_uri) => { let app_keys = Keys::generate();
// Local user let app_secret = app_keys.secret_key().to_secret_hex();
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().unwrap().to_string();
// Get remote user // Get remote user
let remote_user = bunker_uri.signer_public_key().unwrap(); let remote_user = bunker_uri.remote_signer_public_key().unwrap();
let remote_npub = remote_user.to_bech32().unwrap(); let remote_npub = remote_user.to_bech32().map_err(|err| err.to_string())?;
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None).await { // Init nostr connect
Ok(signer) => { let mut nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
let mut url = Url::parse(&uri).unwrap(); .map_err(|err| err.to_string())?;
let query: Vec<(String, String)> = url
.query_pairs()
.filter(|(name, _)| name != "secret")
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
url.query_pairs_mut().clear().extend_pairs(&query);
let key = format!("{}_nostrconnect", remote_npub); // Handle auth url
let keyring = Entry::new("Lume Secret Storage", &key).unwrap(); nostr_connect.auth_url_handler(AuthHandler);
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);
// Update signer let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
let _ = client.set_signer(Some(signer.into())).await;
Ok(remote_npub) let reuse_bunker = nostr_connect
} .bunker_uri()
Err(err) => Err(err.to_string()), .await
} .map_err(|err| err.to_string())?;
}
Err(err) => Err(err.to_string()), let mut reuse_uri = reuse_bunker.to_string();
if let Some(secret) = reuse_bunker.secret() {
let replace = format!("&secret={}", secret);
reuse_uri = reuse_uri.replace(replace.as_str(), "");
} }
let account = Account {
secret_key: app_secret,
nostr_connect: Some(reuse_uri),
};
// 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] #[tauri::command]
@@ -175,9 +151,9 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?; let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?; let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
let account = Account { let account = Account {
password: enc_bech32, secret_key: enc_bech32,
nostr_connect: None, nostr_connect: None,
}; };
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?; let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
@@ -189,16 +165,17 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_private_key(id: String) -> Result<String, String> { pub fn get_private_key(id: String) -> Result<String, String> {
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?; let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
let password = keyring.get_password().map_err(|e| e.to_string())?; let password = keyring.get_password().map_err(|e| e.to_string())?;
let account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
Ok(password) Ok(account.secret_key)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn delete_account(id: String) -> Result<(), String> { pub fn delete_account(id: String) -> Result<(), String> {
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?; let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
let _ = keyring.delete_credential(); let _ = keyring.delete_credential();
Ok(()) Ok(())
@@ -206,135 +183,91 @@ pub fn delete_account(id: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn login( pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
account: String, let client = &state.client;
password: String, let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
match client.signer().await {
Ok(signer) => {
let signer_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
let is_match = signer_key == public_key;
Ok(is_match)
}
Err(_) => Ok(false),
}
}
#[tauri::command]
#[specta::specta]
pub async fn set_signer(
id: String,
state: State<'_, Nostr>, state: State<'_, Nostr>,
handle: tauri::AppHandle, handle: tauri::AppHandle,
) -> Result<String, String> { ) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let keyring = Entry::new("Lume Secret Storage", &account).map_err(|e| e.to_string())?; let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
let account = match keyring.get_password() { let account = match keyring.get_password() {
Ok(pw) => { Ok(pw) => {
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?; let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
if account.secret_key.is_empty() {
return Err("Watch Only account".into());
};
account account
} }
Err(e) => return Err(e.to_string()), Err(e) => return Err(e.to_string()),
}; };
let public_key = match account.nostr_connect { match account.nostr_connect {
None => { None => {
let ncryptsec = let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
let secret_key = ncryptsec
.to_secret_key(password)
.map_err(|_| "Wrong password.")?;
let keys = Keys::new(secret_key); let keys = Keys::new(secret_key);
let public_key = keys.public_key().to_bech32().unwrap();
let signer = NostrSigner::Keys(keys);
// Update signer // Update signer
client.set_signer(Some(signer)).await; client.set_signer(keys).await;
// Emit to front-end
handle.emit("signer-updated", ()).unwrap();
public_key Ok(())
} }
Some(bunker) => { Some(bunker) => {
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?; let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap(); let app_keys = Keys::from_str(&account.secret_key).map_err(|e| e.to_string())?;
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None).await { match NostrConnect::new(uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => { Ok(mut signer) => {
// Handle auth url
signer.auth_url_handler(AuthHandler);
// Update signer // Update signer
client.set_signer(Some(signer.into())).await; client.set_signer(signer).await;
public_key // Emit to front-end
handle.emit("signer-updated", ()).unwrap();
Ok(())
} }
Err(e) => return Err(e.to_string()), Err(e) => Err(e.to_string()),
} }
} }
}; }
}
// Connect to user's relay (NIP-65)
init_nip65(client).await; #[tauri::command]
#[specta::specta]
// Get user's contact list pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await { let settings = state.settings.read().await.clone();
let mut contacts_state = state.contact_list.lock().await; Ok(settings)
*contacts_state = contacts; }
};
#[tauri::command]
// Get user's settings #[specta::specta]
if let Ok(settings) = get_user_settings(client).await { pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
let mut settings_state = state.settings.lock().await; let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
*settings_state = settings; let mut settings = state.settings.write().await;
}; // Update state
settings.clone_from(&parsed);
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>(); Ok(())
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)
} }
File diff suppressed because it is too large Load Diff
+370 -304
View File
@@ -4,55 +4,40 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::{str::FromStr, time::Duration}; use std::{str::FromStr, time::Duration};
use tauri::State; use tauri::State;
use tauri_specta::Event;
use crate::{common::get_latest_event, NewSettings, Nostr, Settings}; use crate::{common::process_event, Nostr, RichEvent, FETCH_LIMIT};
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
pub struct Profile { pub struct Mention {
name: String, pubkey: String,
avatar: String,
display_name: String, display_name: String,
about: Option<String>, name: String,
picture: String,
banner: Option<String>,
nip05: Option<String>,
lud16: Option<String>,
website: Option<String>,
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> { pub async fn get_profile(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let public_key: PublicKey = match id { let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
Some(user_id) => PublicKey::parse(&user_id).map_err(|e| e.to_string())?,
None => client.signer().await.unwrap().public_key().await.unwrap(),
};
let filter = Filter::new() let filter = Filter::new()
.author(public_key) .author(public_key)
.kind(Kind::Metadata) .kind(Kind::Metadata)
.limit(1); .limit(1);
match client let events = client
.get_events_of( .database()
vec![filter], .query(vec![filter])
EventSource::both(Some(Duration::from_secs(3))),
)
.await .await
{ .map_err(|e| e.to_string())?;
Ok(events) => {
if let Some(event) = events.first() { match events.first() {
if let Ok(metadata) = Metadata::from_json(&event.content) { Some(event) => match Metadata::from_json(&event.content) {
Ok(metadata.as_json()) Ok(metadata) => Ok(metadata.as_json()),
} else { Err(e) => Err(e.to_string()),
Err("Parse metadata failed".into()) },
} None => Err("Metadata not found".into()),
} else {
Ok(Metadata::new().as_json())
}
}
Err(e) => Err(e.to_string()),
} }
} }
@@ -71,9 +56,6 @@ pub async fn set_contact_list(
}) })
.collect(); .collect();
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
match client.set_contact_list(contact_list).await { match client.set_contact_list(contact_list).await {
Ok(_) => Ok(true), Ok(_) => Ok(true),
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
@@ -82,52 +64,26 @@ pub async fn set_contact_list(
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> { pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client; let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
match client.get_contact_list(Some(Duration::from_secs(10))).await { let contact_list = client
Ok(contact_list) => { .database()
if !contact_list.is_empty() { .contacts_public_keys(public_key)
let list = contact_list .await
.into_iter() .map_err(|e| e.to_string())?;
.map(|f| f.public_key.to_hex())
.collect();
Ok(list) let pubkeys: Vec<String> = contact_list.into_iter().map(|pk| pk.to_hex()).collect();
} else {
Err("Empty.".into()) Ok(pubkeys)
}
}
Err(err) => Err(err.to_string()),
}
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<String, String> { pub async fn set_profile(new_profile: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let mut metadata = Metadata::new() let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
.name(profile.name)
.display_name(profile.display_name)
.about(profile.about.unwrap_or_default())
.nip05(profile.nip05.unwrap_or_default())
.lud16(profile.lud16.unwrap_or_default());
if let Ok(url) = Url::parse(&profile.picture) {
metadata = metadata.picture(url)
}
if let Some(b) = profile.banner {
if let Ok(url) = Url::parse(&b) {
metadata = metadata.banner(url)
}
}
if let Some(w) = profile.website {
if let Ok(url) = Url::parse(&w) {
metadata = metadata.website(url)
}
}
match client.set_metadata(&metadata).await { match client.set_metadata(&metadata).await {
Ok(id) => Ok(id.to_string()), Ok(id) => Ok(id.to_string()),
@@ -137,21 +93,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn is_contact_list_empty(state: State<'_, Nostr>) -> Result<bool, ()> { pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
Ok(state.contact_list.lock().await.is_empty()) let client = &state.client;
} let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
#[tauri::command] match client.database().contacts_public_keys(public_key).await {
#[specta::specta] Ok(public_keys) => Ok(public_keys.iter().any(|i| i == &public_key)),
pub async fn check_contact(hex: String, state: State<'_, Nostr>) -> Result<bool, String> { Err(_) => Ok(false),
let contact_list = state.contact_list.lock().await;
match PublicKey::parse(&hex) {
Ok(public_key) => match contact_list.iter().position(|x| x.public_key == public_key) {
Some(_) => Ok(true),
None => Ok(false),
},
Err(e) => Err(e.to_string()),
} }
} }
@@ -181,9 +129,6 @@ pub async fn toggle_contact(
} }
} }
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
// Publish // Publish
match client.set_contact_list(contact_list).await { match client.set_contact_list(contact_list).await {
Ok(event_id) => Ok(event_id.to_string()), Ok(event_id) => Ok(event_id.to_string()),
@@ -196,57 +141,303 @@ pub async fn toggle_contact(
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn set_lume_store( pub async fn set_group(
key: String, title: String,
content: String, description: Option<String>,
image: Option<String>,
users: Vec<String>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<String, String> { ) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?; let public_keys: Vec<PublicKey> = users
let public_key = signer.public_key().await.map_err(|e| e.to_string())?; .iter()
.filter_map(|u| {
if let Ok(pk) = PublicKey::from_str(u) {
Some(pk)
} else {
None
}
})
.collect();
let label = title.to_lowercase().replace(" ", "-");
let mut tags: Vec<Tag> = vec![Tag::title(title)];
let encrypted = signer if let Some(desc) = description {
.nip44_encrypt(public_key, content) tags.push(Tag::description(desc))
};
if let Some(img) = image {
let url = UncheckedUrl::new(img);
tags.push(Tag::image(url, None));
}
let builder = EventBuilder::follow_set(label, public_keys.clone()).add_tags(tags);
// Sign event
let event = client
.sign_event_builder(builder)
.await .await
.map_err(|e| e.to_string())?; .map_err(|err| err.to_string())?;
let tag = Tag::identifier(key);
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
match client.send_event_builder(builder).await { match client.send_event(event).await {
Ok(event_id) => Ok(event_id.to_string()), Ok(output) => Ok(output.to_hex()),
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
} }
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_lume_store(key: String, state: State<'_, Nostr>) -> Result<String, String> { pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?; let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
match client.database().event_by_id(&event_id).await {
Ok(event) => {
if let Some(ev) = event {
Ok(ev.as_json())
} else {
Err("Event not found".into())
}
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_newsfeeds(
id: String,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
let groups = Filter::new().kind(Kind::FollowSet).author(public_key);
let contacts = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
let mut remote_events = Events::new(&[groups.clone()]);
let mut rx = client
.stream_events(vec![groups], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
remote_events.insert(event);
}
let contact_events = client
.fetch_events(vec![contacts], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
let events = remote_events.merge(contact_events);
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_local_newsfeeds(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let filter = Filter::new()
.kind(Kind::FollowSet)
.limit(FETCH_LIMIT)
.until(as_of);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|err| err.to_string())?;
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn set_interest(
title: String,
description: Option<String>,
image: Option<String>,
hashtags: Vec<String>,
state: State<'_, Nostr>,
) -> Result<String, String> {
let client = &state.client;
let label = title.to_lowercase().replace(" ", "-");
let mut tags: Vec<Tag> = vec![Tag::title(title)];
if let Some(desc) = description {
tags.push(Tag::description(desc))
};
if let Some(img) = image {
let url = UncheckedUrl::new(img);
tags.push(Tag::image(url, None));
}
let builder = EventBuilder::interest_set(label, hashtags.clone()).add_tags(tags);
// Sign event
let event = client
.sign_event_builder(builder)
.await
.map_err(|err| err.to_string())?;
match client.send_event(event).await {
Ok(output) => Ok(output.to_hex()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
match client.database().event_by_id(&event_id).await {
Ok(event) => {
if let Some(ev) = event {
Ok(ev.as_json())
} else {
Err("Event not found".into())
}
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_interests(
id: String,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::InterestSet, Kind::Interests])
.author(public_key);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_local_interests(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::Interests, Kind::InterestSet])
.limit(FETCH_LIMIT)
.until(as_of);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|err| err.to_string())?;
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_relay_list(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
let filter = Filter::new() let filter = Filter::new()
.author(public_key) .author(public_key)
.kind(Kind::ApplicationSpecificData) .kind(Kind::RelayList)
.identifier(key) .limit(1);
.limit(10);
match client let mut events = Events::new(&[filter.clone()]);
.get_events_of(vec![filter], EventSource::Database)
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await .await
{ .map_err(|e| e.to_string())?;
Ok(events) => {
if let Some(event) = get_latest_event(&events) { while let Some(event) = rx.next().await {
match signer.nip44_decrypt(public_key, event.content()).await { events.insert(event);
Ok(decrypted) => Ok(decrypted),
Err(_) => Err(event.content.to_string()),
}
} else {
Err("Not found".into())
}
}
Err(err) => Err(err.to_string()),
} }
if let Some(event) = events.first() {
Ok(event.as_json())
} else {
Err("Relay list not found".into())
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|e| e.to_string())?;
let data: Vec<Mention> = events
.iter()
.map(|event| {
let pubkey = event.pubkey.to_bech32().unwrap();
let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new());
Mention {
pubkey,
avatar: metadata.picture.unwrap_or_else(|| "".to_string()),
display_name: metadata.display_name.unwrap_or_else(|| "".to_string()),
name: metadata.name.unwrap_or_else(|| "".to_string()),
}
})
.collect();
Ok(data)
} }
#[tauri::command] #[tauri::command]
@@ -256,7 +447,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) { if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
let nwc = NWC::new(nwc_uri); let nwc = NWC::new(nwc_uri);
let keyring = Entry::new("Lume Secret", "Bitcoin Connect").map_err(|e| e.to_string())?; let keyring =
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
keyring.set_password(uri).map_err(|e| e.to_string())?; keyring.set_password(uri).map_err(|e| e.to_string())?;
client.set_zapper(nwc).await; client.set_zapper(nwc).await;
@@ -268,37 +461,32 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<String, String> { pub async fn load_wallet(state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
match keyring.get_password() { if client.zapper().await.is_err() {
Ok(val) => { let keyring =
let uri = NostrWalletConnectURI::from_str(&val).unwrap(); Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
let nwc = NWC::new(uri);
// Get current balance match keyring.get_password() {
let balance = nwc.get_balance().await; Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
let nwc = NWC::new(uri);
// Update zapper client.set_zapper(nwc).await;
client.set_zapper(nwc).await;
match balance {
Ok(val) => Ok(val.to_string()),
Err(_) => Err("Get balance failed.".into()),
} }
Err(_) => return Err("Wallet not found.".into()),
} }
Err(_) => Err("NWC not found.".into()),
} }
Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> { pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let keyring = let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
match keyring.delete_credential() { match keyring.delete_credential() {
Ok(_) => { Ok(_) => {
@@ -312,52 +500,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn zap_profile( pub async fn zap_profile(
id: &str, id: String,
amount: &str, amount: String,
message: &str, message: Option<String>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<bool, String> { ) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?; let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
let details = ZapDetails::new(ZapType::Private).message(message);
let num = amount.parse::<u64>().map_err(|e| e.to_string())?; let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(public_key, num, Some(details)).await.is_ok() { match client.zap(public_key, num, details).await {
Ok(true) Ok(()) => Ok(()),
} else { Err(e) => Err(e.to_string()),
Err("Zap profile failed".into())
} }
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn zap_event( pub async fn zap_event(
id: &str, id: String,
amount: &str, amount: String,
message: &str, message: Option<String>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<bool, String> { ) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let event_id = match Nip19::from_bech32(id) {
Ok(val) => match val {
Nip19::EventId(id) => id,
Nip19::Event(event) => event.event_id,
_ => return Err("Event ID is invalid.".into()),
},
Err(_) => match EventId::from_hex(id) {
Ok(val) => val,
Err(_) => return Err("Event ID is invalid.".into()),
},
};
let details = ZapDetails::new(ZapType::Private).message(message); let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let num = amount.parse::<u64>().map_err(|e| e.to_string())?; let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(event_id, num, Some(details)).await.is_ok() { match client.zap(event_id, num, details).await {
Ok(true) Ok(()) => Ok(()),
} else { Err(e) => Err(e.to_string()),
Err("Zap event failed".into())
} }
} }
@@ -375,14 +551,11 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
.limit(1); .limit(1);
if let Ok(contact_list_events) = client if let Ok(contact_list_events) = client
.get_events_of( .fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
vec![contact_list_filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await .await
{ {
for event in contact_list_events.into_iter() { for event in contact_list_events.into_iter() {
for tag in event.into_iter_tags() { for tag in event.tags.into_iter() {
if let Some(TagStandard::PublicKey { if let Some(TagStandard::PublicKey {
public_key, public_key,
relay_url, relay_url,
@@ -405,143 +578,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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> { pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client; let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
match client.signer().await { let filter = Filter::new()
Ok(signer) => { .pubkey(public_key)
let public_key = signer.public_key().await.unwrap(); .kinds(vec![
let filter = Filter::new() Kind::TextNote,
.pubkey(public_key) Kind::Repost,
.kinds(vec![ Kind::Reaction,
Kind::TextNote, Kind::ZapReceipt,
Kind::Repost, ])
Kind::Reaction, .limit(500);
Kind::ZapReceipt,
])
.limit(200);
match client let mut events = Events::new(&[filter.clone()]);
.database()
.query(vec![filter], Order::default())
.await
{
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Err(err) => Err(err.to_string()),
}
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command] let mut rx = client
#[specta::specta] .stream_events(vec![filter], Some(Duration::from_secs(3)))
pub async fn get_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
Ok(state.settings.lock().await.clone())
}
#[tauri::command]
#[specta::specta]
pub async fn set_settings(
settings: &str,
state: State<'_, Nostr>,
handle: tauri::AppHandle,
) -> Result<(), String> {
let client = &state.client;
let ident = "lume_v4:settings";
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let encrypted = signer
.nip44_encrypt(public_key, settings)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let tag = Tag::identifier(ident);
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
match client.send_event_builder(builder).await { while let Some(event) = rx.next().await {
Ok(_) => { events.insert(event);
let parsed: Settings = serde_json::from_str(settings).map_err(|e| e.to_string())?;
// Update state
state.settings.lock().await.clone_from(&parsed);
// Emit new changes to frontend
NewSettings(parsed).emit(&handle).unwrap();
Ok(())
}
Err(err) => Err(err.to_string()),
} }
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
Ok(alt_events)
} }
#[tauri::command] #[tauri::command]
+49 -134
View File
@@ -1,13 +1,10 @@
use crate::Nostr;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use std::{ use std::str::FromStr;
fs::OpenOptions, use tauri::State;
io::{self, BufRead, Write},
time::Duration, use crate::{Nostr, FETCH_LIMIT};
};
use tauri::{path::BaseDirectory, Manager, State};
#[derive(Serialize, Type)] #[derive(Serialize, Type)]
pub struct Relays { pub struct Relays {
@@ -19,149 +16,67 @@ pub struct Relays {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> { pub async fn get_all_relays(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client; let client = &state.client;
let relays = client.pool().all_relays().await;
let v: Vec<String> = relays.iter().map(|item| item.0.to_string()).collect();
let connected_relays = client Ok(v)
.relays()
.await
.into_keys()
.map(|url| url.to_string())
.collect::<Vec<_>>();
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::Relays {
timeout: Some(Duration::from_secs(5)),
specific_relays: None,
},
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
let read = nip65_list
.iter()
.filter_map(|(url, meta)| {
if let Some(RelayMetadata::Read) = meta {
Some(url.to_string())
} else {
None
}
})
.collect();
let write = nip65_list
.iter()
.filter_map(|(url, meta)| {
if let Some(RelayMetadata::Write) = meta {
Some(url.to_string())
} else {
None
}
})
.collect();
let both = nip65_list
.iter()
.filter_map(|(url, meta)| {
if meta.is_none() {
Some(url.to_string())
} else {
None
}
})
.collect();
Ok(Relays {
connected: connected_relays,
read: Some(read),
write: Some(write),
both: Some(both),
})
} else {
Ok(Relays {
connected: connected_relays,
read: None,
write: None,
both: None,
})
}
}
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> { pub async fn get_all_relay_lists(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
let client = &state.client; let client = &state.client;
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
if status { let as_of = match until {
println!("Connecting to relay: {}", relay); Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
client None => Timestamp::now(),
.connect_relay(relay) };
.await
.map_err(|e| e.to_string())?; 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) Ok(status)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn remove_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> { pub async fn connect_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client; let client = &state.client;
client let _ = client.add_relay(&relay).await;
.remove_relay(relay) let _ = client.connect_relay(&relay).await;
.await
.map_err(|e| e.to_string())?; Ok(())
client
.disconnect_relay(relay)
.await
.map_err(|e| e.to_string())?;
Ok(true)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String> { pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
let relays_path = app let client = &state.client;
.path() let _ = client.force_remove_relay(relay).await;
.resolve("resources/relays.txt", BaseDirectory::Resource)
.map_err(|e| e.to_string())?;
let file = std::fs::File::open(relays_path).map_err(|e| e.to_string())?; Ok(())
let reader = io::BufReader::new(file);
reader
.lines()
.collect::<Result<Vec<String>, io::Error>>()
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
let relays_path = app
.path()
.resolve("resources/relays.txt", BaseDirectory::Resource)
.map_err(|e| e.to_string())?;
let mut file = OpenOptions::new()
.write(true)
.open(relays_path)
.map_err(|e| e.to_string())?;
file.write_all(relays.as_bytes()).map_err(|e| e.to_string())
} }
+162 -153
View File
@@ -1,19 +1,23 @@
use std::path::PathBuf;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use border::WebviewWindowExt as BorderWebviewWindowExt; use border::WebviewWindowExt as BorderWebviewWindowExt;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use tauri::utils::config::WindowEffectsConfig; use std::path::PathBuf;
use tauri::window::Effect;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::TitleBarStyle; use tauri::TitleBarStyle;
use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl}; use tauri::{
use tauri::{WebviewBuilder, WebviewWindowBuilder}; utils::config::WindowEffectsConfig, webview::PageLoadEvent, window::Effect, LogicalPosition,
LogicalSize, Manager, WebviewBuilder, WebviewUrl, WebviewWindowBuilder, Window,
};
#[cfg(target_os = "windows")]
use tauri_plugin_decorum::WebviewWindowExt; use tauri_plugin_decorum::WebviewWindowExt;
use crate::common::get_last_segment;
use crate::Nostr;
#[derive(Serialize, Deserialize, Type)] #[derive(Serialize, Deserialize, Type)]
pub struct Window { pub struct NewWindow {
label: String, label: String,
title: String, title: String,
url: String, url: String,
@@ -22,6 +26,7 @@ pub struct Window {
maximizable: bool, maximizable: bool,
minimizable: bool, minimizable: bool,
hidden_title: bool, hidden_title: bool,
closable: bool,
} }
#[derive(Serialize, Deserialize, Type)] #[derive(Serialize, Deserialize, Type)]
@@ -34,94 +39,162 @@ pub struct Column {
height: f32, height: f32,
} }
#[tauri::command(async)] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn create_column(column: Column, app_handle: tauri::AppHandle) -> Result<String, String> { pub async fn create_column(
match app_handle.get_window("main") { column: Column,
Some(main_window) => match app_handle.get_webview(&column.label) { app_handle: tauri::AppHandle,
Some(_) => Ok(column.label), main_window: Window,
None => { ) -> Result<String, String> {
let path = PathBuf::from(column.url); match app_handle.get_webview(&column.label) {
let webview_url = WebviewUrl::App(path); Some(webview) => {
if let Err(e) = webview.set_size(LogicalSize::new(column.width, column.height)) {
let builder = WebviewBuilder::new(column.label, webview_url) return Err(e.to_string());
.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())
}
} }
},
None => Err("Main window not found".into()),
}
}
#[tauri::command(async)] if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
#[specta::specta] return Err(e.to_string());
pub fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> { }
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.close().is_ok()),
None => Err("Not found.".into()),
}
}
#[tauri::command(async)] Ok(column.label)
#[specta::specta] }
pub fn reposition_column( None => {
label: String, let path = PathBuf::from(column.url);
x: f32, let webview_url = WebviewUrl::App(path);
y: f32,
app_handle: tauri::AppHandle,
) -> Result<bool, String> {
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.set_position(LogicalPosition::new(x, y)).is_ok()),
None => Err("Not found".into()),
}
}
#[tauri::command(async)] let builder = WebviewBuilder::new(column.label, webview_url)
#[specta::specta] .incognito(true)
pub fn resize_column( .transparent(true)
label: String, .on_page_load(|webview, payload| match payload.event() {
width: f32, PageLoadEvent::Started => {
height: f32, if let Ok(id) = get_last_segment(payload.url()) {
app_handle: tauri::AppHandle, if let Ok(public_key) = PublicKey::parse(&id) {
) -> Result<bool, String> { let is_newsfeed = payload.url().to_string().contains("newsfeed");
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.set_size(LogicalSize::new(width, height)).is_ok()),
None => Err("Not found".into()),
}
}
#[tauri::command(async)] if is_newsfeed {
#[specta::specta] tauri::async_runtime::spawn(async move {
pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> { let state = webview.state::<Nostr>();
match app_handle.get_webview(&label) { let client = &state.client;
Some(webview) => Ok(webview.eval("window.location.reload()").is_ok()),
None => Err("Not found".into()), if let Ok(contact_list) =
client.database().contacts_public_keys(public_key).await
{
let subscription_id =
SubscriptionId::new(webview.label());
let filter = Filter::new()
.authors(contact_list)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(
subscription_id,
vec![filter],
None,
)
.await
{
println!("Subscription error: {}", e);
}
}
});
}
} else if let Ok(event_id) = EventId::parse(&id) {
tauri::async_runtime::spawn(async move {
let state = webview.state::<Nostr>();
let client = &state.client;
let subscription_id = SubscriptionId::new(webview.label());
let filter = Filter::new()
.custom_tag(
SingleLetterTag::uppercase(Alphabet::E),
[event_id],
)
.kind(Kind::Comment)
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e);
}
});
}
}
}
PageLoadEvent::Finished => {
println!("{} finished loading", payload.url());
}
});
match main_window.add_child(
builder,
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] #[tauri::command]
#[specta::specta] #[specta::specta]
#[cfg(target_os = "macos")] pub async fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> { match app_handle.get_webview(&label) {
if let Some(window) = app_handle.get_window(&window.label) { Some(webview) => Ok(webview.close().is_ok()),
if window.is_visible().unwrap_or_default() { None => Err(format!("Cannot close, column not found: {}", label)),
let _ = window.set_focus(); }
}
#[tauri::command]
#[specta::specta]
pub async fn close_all_columns(app_handle: tauri::AppHandle) -> Result<(), String> {
let mut webviews = app_handle.webviews();
webviews.remove("main");
for webview in webviews.values() {
webview.close().unwrap()
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<(), String> {
match app_handle.get_webview(&label) {
Some(webview) => {
webview.eval("location.reload(true)").unwrap();
Ok(())
}
None => Err("Cannot reload, column not found.".into()),
}
}
#[tauri::command]
#[specta::specta]
pub fn open_window(window: NewWindow, app_handle: tauri::AppHandle) -> Result<String, String> {
if let Some(current_window) = app_handle.get_window(&window.label) {
if current_window.is_visible().unwrap_or_default() {
let _ = current_window.set_focus();
} else { } else {
let _ = window.show(); let _ = current_window.show();
let _ = window.set_focus(); let _ = current_window.set_focus();
}; };
Ok(current_window.label().to_string())
} else { } else {
let window = WebviewWindowBuilder::new( #[cfg(target_os = "macos")]
let new_window = WebviewWindowBuilder::new(
&app_handle, &app_handle,
&window.label, &window.label,
WebviewUrl::App(PathBuf::from(window.url)), WebviewUrl::App(PathBuf::from(window.url)),
@@ -129,11 +202,12 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.title(&window.title) .title(&window.title)
.min_inner_size(window.width, window.height) .min_inner_size(window.width, window.height)
.inner_size(window.width, window.height) .inner_size(window.width, window.height)
.hidden_title(true) .hidden_title(window.hidden_title)
.title_bar_style(TitleBarStyle::Overlay) .title_bar_style(TitleBarStyle::Overlay)
.minimizable(window.minimizable) .minimizable(window.minimizable)
.maximizable(window.maximizable) .maximizable(window.maximizable)
.transparent(true) .transparent(true)
.closable(window.closable)
.effects(WindowEffectsConfig { .effects(WindowEffectsConfig {
state: None, state: None,
effects: vec![Effect::UnderWindowBackground], effects: vec![Effect::UnderWindowBackground],
@@ -143,26 +217,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.build() .build()
.unwrap(); .unwrap();
// Restore native border #[cfg(target_os = "windows")]
window.add_border(None); let new_window = WebviewWindowBuilder::new(
}
Ok(())
}
#[tauri::command(async)]
#[specta::specta]
#[cfg(target_os = "windows")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app_handle.get_window(&window.label) {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
};
} else {
let window = WebviewWindowBuilder::new(
&app_handle, &app_handle,
&window.label, &window.label,
WebviewUrl::App(PathBuf::from(window.url)), WebviewUrl::App(PathBuf::from(window.url)),
@@ -174,65 +230,18 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.maximizable(window.maximizable) .maximizable(window.maximizable)
.transparent(true) .transparent(true)
.decorations(false) .decorations(false)
.effects(WindowEffectsConfig { .closable(window.closable)
state: None,
effects: vec![Effect::Mica],
radius: None,
color: None,
})
.build() .build()
.unwrap(); .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 // Set decoration
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
window.create_overlay_titlebar().unwrap(); new_window.create_overlay_titlebar().unwrap();
// Restore native border // Restore native border
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
window.add_border(None); new_window.add_border(None);
// Set a custom inset to the traffic lights Ok(new_window.label().to_string())
#[cfg(target_os = "macos")]
window.set_traffic_lights_inset(7.0, 13.0).unwrap();
#[cfg(target_os = "macos")]
let win = window.clone();
#[cfg(target_os = "macos")]
window.on_window_event(move |event| {
if let tauri::WindowEvent::ThemeChanged(_) = event {
win.set_traffic_lights_inset(7.0, 13.0).unwrap();
}
});
} }
} }
#[tauri::command]
#[specta::specta]
pub fn quit() {
std::process::exit(0)
}
+182 -254
View File
@@ -1,24 +1,26 @@
use futures::future::join_all;
use keyring_search::{Limit, List, Search};
use linkify::LinkFinder; use linkify::LinkFinder;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use reqwest::Client as ReqClient; use reqwest::Client as ReqClient;
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use std::collections::HashSet; use std::{collections::HashSet, str::FromStr};
use std::str::FromStr;
use std::time::Duration;
use crate::Settings; use crate::RichEvent;
#[derive(Debug, Clone, Serialize, Type)] #[derive(Debug, Clone, Serialize, Type)]
pub struct Meta { pub struct Meta {
pub content: String, pub content: String,
pub images: Vec<String>, pub images: Vec<String>,
pub videos: Vec<String>,
pub events: Vec<String>, pub events: Vec<String>,
pub mentions: Vec<String>, pub mentions: Vec<String>,
pub hashtags: Vec<String>, pub hashtags: Vec<String>,
} }
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
// const VIDEOS: [&str; 6] = ["mp4", "avi", "mov", "mkv", "wmv", "webm"];
const NOSTR_EVENTS: [&str; 10] = [ const NOSTR_EVENTS: [&str; 10] = [
"@nevent1", "@nevent1",
"@note1", "@note1",
@@ -31,60 +33,200 @@ const NOSTR_EVENTS: [&str; 10] = [
"Nostr:note1", "Nostr:note1",
"Nostr:nevent1", "Nostr:nevent1",
]; ];
const NOSTR_MENTIONS: [&str; 10] = [
const NOSTR_MENTIONS: [&str; 8] = [
"@npub1", "@npub1",
"nostr:npub1", "nostr:npub1",
"nostr:nprofile1", "nostr:nprofile1",
"nostr:naddr1",
"npub1", "npub1",
"nprofile1", "nprofile1",
"naddr1",
"Nostr:npub1", "Nostr:npub1",
"Nostr:nprofile1", "Nostr:nprofile1",
"Nostr:naddr1", "Nostr:naddr1",
]; ];
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
const VIDEOS: [&str; 5] = ["mp4", "mov", "avi", "webm", "mkv"];
pub fn get_latest_event(events: &[Event]) -> Option<&Event> { pub fn get_latest_event(events: &Events) -> Option<&Event> {
events.iter().next() events.iter().next()
} }
pub fn filter_converstation(events: Vec<Event>) -> Vec<Event> { pub fn create_tags(content: &str) -> Vec<Tag> {
events let mut tags: Vec<Tag> = vec![];
.into_iter() let mut tag_set: HashSet<String> = HashSet::new();
.filter_map(|ev| {
let tags = ev.get_tags_content(TagKind::SingleLetter(SingleLetterTag::lowercase(
Alphabet::E,
)));
if tags.is_empty() { // Get words
Some(ev) let words: Vec<_> = content.split_whitespace().collect();
} else {
None // Get hashtags
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string().replace("#", "").to_lowercase())
.collect::<Vec<_>>();
// Get mentions
let mentions = words
.iter()
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
for mention in mentions {
let entity = mention.replace("nostr:", "").replace('@', "");
if !tag_set.contains(&entity) {
if entity.starts_with("npub") {
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
let tag = Tag::public_key(public_key);
tags.push(tag);
} else {
continue;
}
} }
}) if entity.starts_with("nprofile") {
.collect::<Vec<Event>>() if let Ok(public_key) = PublicKey::from_bech32(&entity) {
let tag = Tag::public_key(public_key);
tags.push(tag);
} else {
continue;
}
}
if entity.starts_with("note") {
if let Ok(event_id) = EventId::from_bech32(&entity) {
let tag = Tag::from_standardized(TagStandard::Quote {
event_id,
relay_url: None,
public_key: None,
});
tags.push(tag);
} else {
continue;
}
}
if entity.starts_with("nevent") {
if let Ok(event) = Nip19Event::from_bech32(&entity) {
let relay_url = event.relays.first().map(UncheckedUrl::from);
let tag = Tag::from_standardized(TagStandard::Quote {
event_id: event.event_id,
relay_url,
public_key: event.author,
});
tags.push(tag);
} else {
continue;
}
}
tag_set.insert(entity);
}
}
for hashtag in hashtags {
if !tag_set.contains(&hashtag) {
let tag = Tag::hashtag(hashtag.clone());
tags.push(tag);
tag_set.insert(hashtag);
}
}
tags
} }
pub fn dedup_event(events: &[Event]) -> Vec<Event> { pub fn get_all_accounts() -> Vec<String> {
let mut seen_ids = HashSet::new(); let search = Search::new().expect("Unexpected.");
events let results = search.by_service("Lume Safe Storage");
.iter() let list = List::list_credentials(&results, Limit::All);
.filter(|&event| { let accounts: HashSet<String> = list
let e = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)); .split_whitespace()
let e_tags: Vec<&Tag> = event.tags.iter().filter(|el| el.kind() == e).collect(); .filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
let ids: Vec<&str> = e_tags.iter().filter_map(|tag| tag.content()).collect(); .map(String::from)
let is_dup = ids.iter().any(|id| seen_ids.contains(*id)); .collect();
for id in &ids { accounts.into_iter().collect()
seen_ids.insert(*id); }
}
!is_dup pub fn get_last_segment(url: &Url) -> Result<String, String> {
}) url.path_segments()
.cloned() .ok_or("No segments".to_string())?
.collect() .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 {
Some(ev)
}
})
.collect()
} else {
events.into_iter().collect()
};
// Get deletion request by event's authors
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
let mut final_events: Vec<Event> = events.clone();
if let Ok(requests) = client.database().query(vec![filter]).await {
if !requests.is_empty() {
let ids: Vec<&str> = requests
.iter()
.flat_map(|event| {
event
.tags
.iter()
.filter(|t| t.kind() == TagKind::e())
.filter_map(|t| t.content())
.collect::<Vec<&str>>()
})
.collect();
// Remove event if event is deleted by author
final_events = events
.into_iter()
.filter_map(|ev| {
if ids.iter().any(|&i| i == ev.id.to_hex()) {
None
} else {
Some(ev)
}
})
.collect();
}
};
// Convert raw event to rich event
let futures = final_events.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
join_all(futures).await
} }
pub async fn parse_event(content: &str) -> Meta { pub async fn parse_event(content: &str) -> Meta {
@@ -115,7 +257,6 @@ pub async fn parse_event(content: &str) -> Meta {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut images = Vec::new(); let mut images = Vec::new();
let mut videos = Vec::new();
let mut text = content.to_string(); let mut text = content.to_string();
if !urls.is_empty() { if !urls.is_empty() {
@@ -135,12 +276,6 @@ pub async fn parse_event(content: &str) -> Meta {
// Process the next item. // Process the next item.
continue; continue;
} }
if VIDEOS.contains(&ext) {
text = text.replace(url_str, "");
videos.push(url_str.to_string());
// Process the next item.
continue;
}
} }
// Check the content type of URL via HEAD request // Check the content type of URL via HEAD request
@@ -167,212 +302,5 @@ pub async fn parse_event(content: &str) -> Meta {
mentions, mentions,
hashtags, hashtags,
images, images,
videos,
}
}
pub fn create_event_tags(content: &str) -> Vec<Tag> {
let mut tags: Vec<Tag> = vec![];
let mut tag_set: HashSet<String> = HashSet::new();
// Get words
let words: Vec<_> = content.split_whitespace().collect();
// Get mentions
let mentions = words
.iter()
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
// Get hashtags
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
for mention in mentions {
let entity = mention.replace("nostr:", "").replace('@', "");
if !tag_set.contains(&entity) {
if entity.starts_with("npub") {
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
let tag = Tag::public_key(public_key);
tags.push(tag);
} else {
continue;
}
}
if entity.starts_with("nprofile") {
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
let tag = Tag::public_key(public_key);
tags.push(tag);
} else {
continue;
}
}
if entity.starts_with("note") {
if let Ok(event_id) = EventId::from_bech32(&entity) {
let hex = event_id.to_hex();
let tag = Tag::parse(&["e", &hex, "", "mention"]).unwrap();
tags.push(tag);
} else {
continue;
}
}
if entity.starts_with("nevent") {
if let Ok(event) = Nip19Event::from_bech32(&entity) {
let hex = event.event_id.to_hex();
let relay = event.clone().relays.into_iter().next().unwrap_or("".into());
let tag = Tag::parse(&["e", &hex, &relay, "mention"]).unwrap();
if let Some(author) = event.author {
let tag = Tag::public_key(author);
tags.push(tag);
}
tags.push(tag);
} else {
continue;
}
}
tag_set.insert(entity);
}
}
for hashtag in hashtags {
if !tag_set.contains(&hashtag) {
let tag = Tag::hashtag(hashtag.clone());
tags.push(tag);
tag_set.insert(hashtag);
}
}
tags
}
pub async fn init_nip65(client: &Client) {
let signer = match client.signer().await {
Ok(signer) => signer,
Err(e) => {
eprintln!("Failed to get signer: {:?}", e);
return;
}
};
let public_key = match signer.public_key().await {
Ok(public_key) => public_key,
Err(e) => {
eprintln!("Failed to get public key: {:?}", e);
return;
}
};
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
if let Ok(events) = client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
if let Some(event) = events.first() {
let relay_list = nip65::extract_relay_list(event);
for (url, metadata) in relay_list {
let opts = match metadata {
Some(RelayMetadata::Read) => RelayOptions::new().read(true).write(false),
Some(_) => RelayOptions::new().write(true).read(false),
None => RelayOptions::default(),
};
if let Err(e) = client.add_relay_with_opts(&url.to_string(), opts).await {
eprintln!("Failed to add relay {}: {:?}", url, e);
}
if let Err(e) = client.connect_relay(url.to_string()).await {
eprintln!("Failed to connect to relay {}: {:?}", url, e);
} else {
println!("Connecting to relay: {} - {:?}", url, metadata);
}
}
}
} else {
eprintln!("Failed to get events for RelayList.");
}
}
pub async fn get_user_settings(client: &Client) -> Result<Settings, String> {
let ident = "lume_v4:settings";
let signer = client
.signer()
.await
.map_err(|e| format!("Failed to get signer: {:?}", e))?;
let public_key = signer
.public_key()
.await
.map_err(|e| format!("Failed to get public key: {:?}", e))?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(ident)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let content = event.content();
match signer.nip44_decrypt(public_key, content).await {
Ok(decrypted) => match serde_json::from_str(&decrypted) {
Ok(parsed) => Ok(parsed),
Err(_) => Err("Could not parse settings payload".into()),
},
Err(e) => Err(format!("Failed to decrypt settings content: {:?}", e)),
}
} else {
Err("Settings not found.".into())
}
}
Err(e) => Err(format!(
"Failed to get events for ApplicationSpecificData: {:?}",
e
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_parse_event() {
let content = "Check this image: https://example.com/image.jpg #cool @npub1";
let meta = parse_event(content).await;
assert_eq!(meta.content, "Check this image: #cool @npub1");
assert_eq!(meta.images, vec!["https://example.com/image.jpg"]);
assert_eq!(meta.videos, Vec::<String>::new());
assert_eq!(meta.hashtags, vec!["#cool"]);
assert_eq!(meta.mentions, vec!["@npub1"]);
}
#[tokio::test]
async fn test_parse_video() {
let content = "Check this video: https://example.com/video.mp4 #cool @npub1";
let meta = parse_event(content).await;
assert_eq!(meta.content, "Check this video: #cool @npub1");
assert_eq!(meta.images, Vec::<String>::new());
assert_eq!(meta.videos, vec!["https://example.com/video.mp4"]);
assert_eq!(meta.hashtags, vec!["#cool"]);
assert_eq!(meta.mentions, vec!["@npub1"]);
} }
} }
+391 -189
View File
@@ -6,168 +6,169 @@
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use border::WebviewWindowExt as BorderWebviewWindowExt; use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*}; use commands::{account::*, event::*, metadata::*, relay::*, window::*};
use common::parse_event; use common::{get_all_accounts, parse_event};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use specta_typescript::Typescript; use specta_typescript::Typescript;
use std::{ use std::{collections::HashSet, fs, str::FromStr, time::Duration};
fs, use tauri::{
io::{self, BufRead}, menu::{Menu, MenuItem},
str::FromStr, Emitter, EventTarget, Listener, Manager, WebviewWindowBuilder,
time::Duration,
}; };
use tauri::{path::BaseDirectory, Emitter, EventTarget, Manager};
use tauri_plugin_decorum::WebviewWindowExt; use tauri_plugin_decorum::WebviewWindowExt;
use tauri_plugin_notification::{NotificationExt, PermissionState}; use tauri_plugin_notification::{NotificationExt, PermissionState};
use tauri_specta::{collect_commands, collect_events, Builder, Event as TauriEvent}; use tauri_plugin_store::StoreExt;
use tokio::sync::Mutex; use tauri_specta::{collect_commands, Builder};
use tokio::{sync::RwLock, time::sleep};
pub mod commands; pub mod commands;
pub mod common; pub mod common;
pub struct Nostr { pub struct Nostr {
client: Client, client: Client,
contact_list: Mutex<Vec<Contact>>, queue: RwLock<HashSet<PublicKey>>,
settings: Mutex<Settings>, settings: RwLock<Settings>,
} }
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Serialize, Deserialize, Debug)]
pub struct Payload {
id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct Settings { pub struct Settings {
proxy: Option<String>, resize_service: bool,
image_resize_service: Option<String>,
use_relay_hint: bool,
content_warning: bool, content_warning: bool,
display_avatar: bool, display_avatar: bool,
display_zap_button: bool, display_zap_button: bool,
display_repost_button: bool, display_repost_button: bool,
display_media: bool, display_media: bool,
transparent: bool,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy: None,
image_resize_service: Some("https://wsrv.nl".to_string()),
use_relay_hint: true,
content_warning: true, content_warning: true,
resize_service: true,
display_avatar: true, display_avatar: true,
display_zap_button: true, display_zap_button: true,
display_repost_button: true, display_repost_button: true,
display_media: true, display_media: true,
transparent: true,
} }
} }
} }
#[derive(Serialize, Deserialize, Type)] pub const DEFAULT_DIFFICULTY: u8 = 0;
enum SubKind { pub const FETCH_LIMIT: usize = 50;
Subscribe, pub const QUEUE_DELAY: u64 = 150;
Unsubscribe,
}
#[derive(Serialize, Deserialize, Type, TauriEvent)]
struct Subscription {
label: String,
kind: SubKind,
event_id: Option<String>,
}
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
struct NewSettings(Settings);
pub const FETCH_LIMIT: usize = 44;
pub const NEWSFEED_NEG_LIMIT: usize = 256;
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification"; pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
// Will be removed when almost relays support negentropy
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.fmt.wiz.biz",
"wss://directory.yabu.me",
"wss://purplepag.es",
];
fn main() { fn main() {
let builder = Builder::<tauri::Wry>::new() tracing_subscriber::fmt::init();
// Then register them (separated by a comma)
.commands(collect_commands![ let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
get_relays, get_all_relays,
connect_relay, get_all_relay_lists,
remove_relay, is_relay_connected,
get_bootstrap_relays, connect_relay,
save_bootstrap_relays, remove_relay,
get_accounts, get_accounts,
create_account, watch_account,
import_account, import_account,
connect_account, connect_account,
get_private_key, get_private_key,
delete_account, delete_account,
reset_password, reset_password,
login, has_signer,
get_profile, set_signer,
set_profile, get_profile,
get_contact_list, set_profile,
set_contact_list, get_contact_list,
is_contact_list_empty, set_contact_list,
check_contact, is_contact,
toggle_contact, toggle_contact,
get_lume_store, get_all_profiles,
set_lume_store, set_group,
set_wallet, get_group,
load_wallet, get_all_newsfeeds,
remove_wallet, get_all_local_newsfeeds,
zap_profile, set_interest,
zap_event, get_interest,
copy_friend, get_all_interests,
get_notifications, get_all_local_interests,
get_settings, get_relay_list,
set_settings, set_wallet,
verify_nip05, load_wallet,
get_event_meta, remove_wallet,
get_event, zap_profile,
get_event_from, zap_event,
get_replies, copy_friend,
subscribe_to, get_notifications,
get_events_by, verify_nip05,
get_events_from_contacts, get_meta_from_event,
get_group_events, get_event,
get_global_events, get_replies,
get_hashtag_events, get_all_events_by_author,
search, get_all_events_by_authors,
publish, get_all_events_by_hashtags,
reply, get_all_events_from,
repost, get_all_events_by_kind,
event_to_bech32, get_all_providers,
user_to_bech32, request_events_from_provider,
create_column, get_all_events_by_request,
close_column, get_local_events,
reposition_column, get_global_events,
resize_column, search,
reload_column, publish,
open_window, reply,
reopen_lume, repost,
quit is_reposted,
]) request_delete,
.events(collect_events![Subscription, NewSettings]); 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)] #[cfg(debug_assertions)]
builder builder
.export(Typescript::default(), "../src/commands.gen.ts") .export(Typescript::default(), "../src/commands.gen.ts")
.expect("Failed to export typescript bindings"); .expect("Failed to export typescript bindings");
#[cfg(target_os = "macos")]
let tauri_builder = tauri::Builder::default().plugin(tauri_nspanel::init());
#[cfg(not(target_os = "macos"))]
let tauri_builder = tauri::Builder::default(); let tauri_builder = tauri::Builder::default();
let mut ctx = tauri::generate_context!(); let mut ctx = tauri::generate_context!();
tauri_builder tauri_builder
.invoke_handler(builder.invoke_handler()) .invoke_handler(builder.invoke_handler())
.setup(move |app| { .setup(move |app| {
builder.mount_events(app);
let handle = app.handle(); let handle = app.handle();
let handle_clone = handle.clone(); let handle_clone = handle.clone();
let handle_clone_child = handle_clone.clone(); let handle_clone_child = handle_clone.clone();
let handle_clone_event = handle_clone_child.clone();
let main_window = app.get_webview_window("main").unwrap(); let main_window = app.get_webview_window("main").unwrap();
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
let _ = fs::create_dir_all(&config_dir);
// Set custom decoration for Windows // Set custom decoration for Windows
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
main_window.create_overlay_titlebar().unwrap(); main_window.create_overlay_titlebar().unwrap();
@@ -180,35 +181,63 @@ fn main() {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap(); main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
#[cfg(target_os = "macos")] // Setup tray menu item
let win = main_window.clone(); let open_i = MenuItem::with_id(app, "open", "Open Lume", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
// Create tray menu
let menu = Menu::with_items(app, &[&open_i, &quit_i])?;
// Get main tray
let tray = app.tray_by_id("main").unwrap();
// Set menu
tray.set_menu(Some(menu)).unwrap();
// Listen to tray events
tray.on_menu_event(|handle, event| match event.id().as_ref() {
"open" => {
if let Some(window) = handle.get_window("main") {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
};
} else {
let window = WebviewWindowBuilder::from_config(
handle,
handle.config().app.windows.first().unwrap(),
)
.unwrap()
.build()
.unwrap();
#[cfg(target_os = "macos")] // Set decoration
main_window.on_window_event(move |event| { #[cfg(target_os = "windows")]
if let tauri::WindowEvent::ThemeChanged(_) = event { window.create_overlay_titlebar().unwrap();
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
// Restore native border
#[cfg(target_os = "macos")]
window.add_border(None);
// Set a custom inset to the traffic lights
#[cfg(target_os = "macos")]
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
}
} }
"quit" => {
std::process::exit(0);
}
_ => {}
}); });
let client = tauri::async_runtime::block_on(async move { let client = tauri::async_runtime::block_on(async move {
// Create data folder if not exist
let dir = handle
.path()
.app_config_dir()
.expect("App config directory not found.");
let _ = fs::create_dir_all(dir.clone());
// Setup database // Setup database
let database = SQLiteDatabase::open(dir.join("nostr.db")) let database = NostrLMDB::open(config_dir.join("nostr"))
.await .expect("Error: cannot create database.");
.expect("Database error.");
// Config // Config
let opts = Options::new() let opts = Options::new()
.max_avg_latency(Duration::from_millis(500)) .gossip(true)
.automatic_authentication(true) .max_avg_latency(Duration::from_secs(2))
.connection_timeout(Some(Duration::from_secs(5))) .timeout(Duration::from_secs(10));
.timeout(Duration::from_secs(20));
// Setup nostr client // Setup nostr client
let client = ClientBuilder::default() let client = ClientBuilder::default()
@@ -216,7 +245,7 @@ fn main() {
.opts(opts) .opts(opts)
.build(); .build();
// Get bootstrap relays /* Get bootstrap relays
if let Ok(path) = handle if let Ok(path) = handle
.path() .path()
.resolve("resources/relays.txt", BaseDirectory::Resource) .resolve("resources/relays.txt", BaseDirectory::Resource)
@@ -234,7 +263,7 @@ fn main() {
} else { } else {
RelayOptions::new().write(true).read(false) RelayOptions::new().write(true).read(false)
}; };
let _ = client.add_relay_with_opts(relay, opts).await; let _ = client.pool().add_relay(relay, opts).await;
} }
Err(_) => { Err(_) => {
let _ = client.add_relay(relay).await; let _ = client.add_relay(relay).await;
@@ -243,6 +272,13 @@ fn main() {
} }
} }
} }
*/
for relay in BOOTSTRAP_RELAYS {
let _ = client.add_relay(relay).await;
}
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
// Connect // Connect
client.connect().await; client.connect().await;
@@ -250,58 +286,207 @@ fn main() {
client client
}); });
// Load app settings
let store = app.store(".data")?;
// Parse app settings if exist
let settings = if let Some(data) = store.get("tanstack-query-[\"settings\"]") {
if let Some(str) = data.as_str() {
let v: Value = serde_json::from_str(str).unwrap();
let data = v["state"]["data"].clone();
let parse: Settings = serde_json::from_value(data).unwrap();
RwLock::new(parse)
} else {
RwLock::new(Settings::default())
}
} else {
RwLock::new(Settings::default())
};
// Create global state // Create global state
app.manage(Nostr { app.manage(Nostr {
client, client,
contact_list: Mutex::new(vec![]), settings,
settings: Mutex::new(Settings::default()), queue: RwLock::new(HashSet::new()),
}); });
Subscription::listen_any(app, move |event| { // Listen for request metadata
let handle = handle_clone_child.to_owned(); app.listen_any("request_metadata", move |event| {
let payload = event.payload; let payload = event.payload();
let parsed_payload: Payload = serde_json::from_str(payload).expect("Parse failed");
let handle = handle_clone_child.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>(); let state = handle.state::<Nostr>();
let client = &state.client; let client = &state.client;
match payload.kind { if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
SubKind::Subscribe => { let mut write_queue = state.queue.write().await;
let subscription_id = SubscriptionId::new(payload.label); write_queue.insert(public_key);
};
let filter = if let Some(id) = payload.event_id { // Wait for [QUEUE_DELAY]
let event_id = EventId::from_str(&id).unwrap(); sleep(Duration::from_millis(QUEUE_DELAY)).await;
Filter::new().event(event_id).since(Timestamp::now()) let read_queue = state.queue.read().await;
} else {
let contact_list = state.contact_list.lock().await;
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
Filter::new() if !read_queue.is_empty() {
.kinds(vec![Kind::TextNote, Kind::Repost]) let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
.authors(authors)
.since(Timestamp::now())
};
if let Err(e) = client let filter = Filter::new()
.subscribe_with_id(subscription_id, vec![filter], None) .authors(authors)
.await .kind(Kind::Metadata)
{ .limit(200);
println!("Subscription error: {}", e)
} let opts = SubscribeAutoCloseOptions::default()
} .filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
SubKind::Unsubscribe => {
let subscription_id = SubscriptionId::new(payload.label); // Drop queue, you don't need it at this time anymore
client.unsubscribe(subscription_id).await drop(read_queue);
// Clear queue
let mut write_queue = state.queue.write().await;
write_queue.clear();
if let Err(e) = client.subscribe(vec![filter], Some(opts)).await {
println!("Subscribe error: {}", e);
} }
} }
}); });
}); });
// Run a thread for negentropy
tauri::async_runtime::spawn(async move {
let state = handle_clone_event.state::<Nostr>();
let client = &state.client;
// Use default sync options
let opts = SyncOptions::default();
// Set interval
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
loop {
interval.tick().await;
let accounts = get_all_accounts();
let public_keys: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| PublicKey::from_str(acc).ok())
.collect();
if !public_keys.is_empty() {
// Create filter for notification
//
let filter = Filter::new().pubkeys(public_keys.clone()).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]);
// Sync notification
//
if let Ok(output) = client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
{
println!("Received: {}", output.received.len())
}
// Create filter for contact list
//
let filter = Filter::new()
.authors(public_keys)
.kinds(vec![Kind::ContactList, Kind::FollowSet]);
// Sync events for contact list
//
if let Ok(events) = client.database().query(vec![filter]).await {
// Get unique public keys
let public_keys: HashSet<PublicKey> = events
.iter()
.flat_map(|ev| ev.tags.public_keys().copied())
.collect();
// Convert to vector
let public_keys: Vec<PublicKey> = public_keys.into_iter().collect();
for chunk in public_keys.chunks(1000) {
if chunk.is_empty() {
return;
}
let authors = chunk.to_owned();
// Create filter for metadata
//
let filter = Filter::new().authors(authors.clone()).kinds(vec![
Kind::Metadata,
Kind::FollowSet,
Kind::Interests,
Kind::InterestSet,
]);
// Sync metadata
//
if let Ok(output) =
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
{
println!("Received: {}", output.received.len())
}
// Create filter for text note
//
let filter = Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost, Kind::EventDeletion])
.limit(100);
// Sync text note
//
if let Ok(output) =
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
{
println!("Received: {}", output.received.len())
}
}
}
}
}
});
// Run a thread for handle notification
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle_clone.state::<Nostr>(); let state = handle_clone.state::<Nostr>();
let client = &state.client; 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() { let allow_notification = match handle_clone.notification().request_permission() {
Ok(_) => { Ok(_) => {
@@ -316,49 +501,60 @@ fn main() {
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID); let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
client let _ = client
.handle_notifications(|notification| async { .handle_notifications(|notification| async {
#[allow(clippy::collapsible_match)]
if let RelayPoolNotification::Message { message, .. } = notification { if let RelayPoolNotification::Message { message, .. } = notification {
if let RelayMessage::Event { if let RelayMessage::Event {
subscription_id,
event, event,
subscription_id,
..
} = message } = message
{ {
// Handle events from notification subscription
if subscription_id == notification_id { if subscription_id == notification_id {
// Send native notification // Send native notification
if allow_notification { if allow_notification {
let author = client let author = client
.metadata(event.pubkey) .database()
.profile(event.pubkey)
.await .await
.unwrap_or_else(|_| Metadata::new()); .unwrap_or_else(|_| {
DatabaseProfile::new(event.pubkey, Metadata::new())
});
send_notification(&event, author, &handle_clone); send_event_notification(
&event,
author.metadata(),
&handle_clone,
);
}
} else if event.kind == Kind::Metadata {
if let Err(e) = handle_clone.emit("metadata", event.as_json()) {
println!("Emit error: {}", e)
}
} else if event.kind == Kind::Comment {
let 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()),
"comment",
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) Ok(false)
}) })
.await .await;
}); });
Ok(()) Ok(())
@@ -377,8 +573,14 @@ fn main() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init()) .plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.run(ctx) .plugin(tauri_plugin_window_state::Builder::default().build())
.expect("error while running tauri application"); .build(ctx)
.expect("error while running tauri application")
.run(|_app_handle, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
}
});
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -395,8 +597,8 @@ fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri_plugin_prevent_default::Builder::new().build() tauri_plugin_prevent_default::Builder::new().build()
} }
fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) { fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind() { match event.kind {
Kind::TextNote => { Kind::TextNote => {
if let Err(e) = handle if let Err(e) = handle
.notification() .notification()
+10 -6
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", "productName": "Lume",
"version": "4.1.1", "version": "24.11.7",
"identifier": "nu.lume.Lume", "identifier": "nu.lume.Lume",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -30,6 +30,13 @@
"$RESOURCE/*" "$RESOURCE/*"
] ]
} }
},
"trayIcon": {
"id": "main",
"iconAsTemplate": true,
"menuOnLeftClick": true,
"tooltip": "Lume",
"iconPath": "./icons/tray.png"
} }
}, },
"bundle": { "bundle": {
@@ -39,10 +46,7 @@
"targets": "all", "targets": "all",
"active": true, "active": true,
"category": "SocialNetworking", "category": "SocialNetworking",
"resources": [ "resources": ["resources/*", "locales/*"],
"resources/*",
"locales/*"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+2 -4
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"app": { "app": {
"windows": [ "windows": [
{ {
@@ -13,9 +13,7 @@
"hiddenTitle": true, "hiddenTitle": true,
"transparent": true, "transparent": true,
"windowEffects": { "windowEffects": {
"effects": [ "effects": ["underWindowBackground"]
"underWindowBackground"
]
} }
} }
] ]
+2 -7
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"app": { "app": {
"windows": [ "windows": [
{ {
@@ -10,12 +10,7 @@
"minWidth": 480, "minWidth": 480,
"minHeight": 700, "minHeight": 700,
"transparent": true, "transparent": true,
"decorations": false, "decorations": false
"windowEffects": {
"effects": [
"mica"
]
}
} }
] ]
} }
+43 -44
View File
@@ -1,43 +1,17 @@
import { import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
type PersistedQuery, import { experimental_createPersister } from "@tanstack/query-persist-client-core";
experimental_createPersister,
} from "@tanstack/query-persist-client-core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os"; import { type } from "@tauri-apps/plugin-os";
import { Store } from "@tauri-apps/plugin-store";
import { StrictMode } from "react"; import { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { newQueryStorage } from "./commons"; import { newQueryStorage } from "./commons";
import { routeTree } from "./routes.gen"; // auto generated file
import type { LumeEvent } from "./system"; import type { LumeEvent } from "./system";
import "./app.css";
import { Store } from "@tauri-apps/plugin-store";
const platform = type(); import { routeTree } from "./routes.gen"; // auto generated file
const tauriStore = new Store(".lume.dat"); import "./app.css"; // global styles
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30,
persister: experimental_createPersister<PersistedQuery>({
storage: newQueryStorage(tauriStore),
maxAge: 1000 * 60 * 60 * 24, // 24 hours,
}),
},
},
});
const router = createRouter({
routeTree,
context: { queryClient, platform },
Wrap: ({ children }) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router; router: typeof router;
@@ -47,18 +21,43 @@ declare module "@tanstack/react-router" {
} }
} }
function App() { const platform = type();
return <RouterProvider router={router} />; // @ts-ignore, won't fix
} const store = await Store.load(".data", { autoSave: 300 });
const storage = newQueryStorage(store);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 20, // 20 seconds
persister: experimental_createPersister({
storage: storage,
maxAge: 1000 * 60 * 60 * 6, // 6 hours
}),
},
},
});
// biome-ignore lint/style/noNonNullAssertion: idk // Make sure all webviews use same query client
const rootElement = document.getElementById("root")!; broadcastQueryClient({
queryClient,
broadcastChannel: "lume",
});
if (!rootElement.innerHTML) { const router = createRouter({
const root = ReactDOM.createRoot(rootElement); routeTree,
root.render( context: { store, queryClient, platform },
<StrictMode> Wrap: ({ children }) => {
<App /> return (
</StrictMode>, <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
); );
} },
});
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement as unknown as HTMLElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+221 -134
View File
@@ -5,15 +5,31 @@
export const commands = { export const commands = {
async getRelays() : Promise<Result<Relays, string>> { async getAllRelays() : Promise<Result<string[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") }; return { status: "ok", data: await TAURI_INVOKE("get_all_relays") };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async connectRelay(relay: string) : Promise<Result<boolean, string>> { async getAllRelayLists(until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_relay_lists", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isRelayConnected(relay: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_relay_connected", { relay }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) }; return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
} catch (e) { } catch (e) {
@@ -21,7 +37,7 @@ async connectRelay(relay: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async removeRelay(relay: string) : Promise<Result<boolean, string>> { async removeRelay(relay: string) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) }; return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
} catch (e) { } catch (e) {
@@ -29,34 +45,18 @@ async removeRelay(relay: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getBootstrapRelays() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAccounts() : Promise<string[]> { async getAccounts() : Promise<string[]> {
return await TAURI_INVOKE("get_accounts"); return await TAURI_INVOKE("get_accounts");
}, },
async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> { async watchAccount(id: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) }; return { status: "ok", data: await TAURI_INVOKE("watch_account", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async importAccount(key: string, password: string) : Promise<Result<string, string>> { async importAccount(key: string, password: string | null) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) }; return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
} catch (e) { } catch (e) {
@@ -96,15 +96,23 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async login(account: string, password: string) : Promise<Result<string, string>> { async hasSigner(id: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) }; return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getProfile(id: string | null) : Promise<Result<string, string>> { async setSigner(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_signer", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getProfile(id: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) }; return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
} catch (e) { } catch (e) {
@@ -112,17 +120,17 @@ async getProfile(id: string | null) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async setProfile(profile: Profile) : Promise<Result<string, string>> { async setProfile(newProfile: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) }; return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getContactList() : Promise<Result<string[], string>> { async getContactList(id: string) : Promise<Result<string[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") }; return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -136,17 +144,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async isContactListEmpty() : Promise<Result<boolean, null>> { async isContact(id: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") }; return { status: "ok", data: await TAURI_INVOKE("is_contact", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async checkContact(hex: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -160,17 +160,81 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getLumeStore(key: string) : Promise<Result<string, string>> { async getAllProfiles() : Promise<Result<Mention[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) }; return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async setLumeStore(key: string, content: string) : Promise<Result<string, string>> { async setGroup(title: string, description: string | null, image: string | null, users: string[]) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("set_lume_store", { key, content }) }; return { status: "ok", data: await TAURI_INVOKE("set_group", { title, description, image, users }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGroup(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_group", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllNewsfeeds(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_newsfeeds", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllLocalNewsfeeds(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_local_newsfeeds", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setInterest(title: string, description: string | null, image: string | null, hashtags: string[]) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_interest", { title, description, image, hashtags }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getInterest(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_interest", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllInterests(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_interests", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllLocalInterests(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_local_interests", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getRelayList(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_relay_list", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -184,7 +248,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async loadWallet() : Promise<Result<string, string>> { async loadWallet() : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("load_wallet") }; return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
} catch (e) { } catch (e) {
@@ -200,7 +264,7 @@ async removeWallet() : Promise<Result<null, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async zapProfile(id: string, amount: string, message: string) : Promise<Result<boolean, string>> { async zapProfile(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) }; return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
} catch (e) { } catch (e) {
@@ -208,7 +272,7 @@ async zapProfile(id: string, amount: string, message: string) : Promise<Result<b
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async zapEvent(id: string, amount: string, message: string) : Promise<Result<boolean, string>> { async zapEvent(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) }; return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
} catch (e) { } catch (e) {
@@ -224,25 +288,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getNotifications() : Promise<Result<string[], string>> { async getNotifications(id: string) : Promise<Result<string[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_notifications") }; return { status: "ok", data: await TAURI_INVOKE("get_notifications", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getSettings() : Promise<Result<Settings, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setSettings(settings: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_settings", { settings }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -256,9 +304,9 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getEventMeta(content: string) : Promise<Result<Meta, null>> { async getMetaFromEvent(content: string) : Promise<Result<Meta, null>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) }; return { status: "ok", data: await TAURI_INVOKE("get_meta_from_event", { content }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -272,14 +320,6 @@ async getEvent(id: string) : Promise<Result<RichEvent, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getEventFrom(id: string, relayHint: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_from", { id, relayHint }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<RichEvent[], string>> { async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) }; return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
@@ -288,33 +328,73 @@ async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async subscribeTo(id: string) : Promise<Result<null, string>> { async getAllEventsByAuthor(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("subscribe_to", { id }) }; return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getEventsBy(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> { async getAllEventsByAuthors(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, limit }) }; return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_authors", { publicKeys, until }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getEventsFromContacts(until: string | null) : Promise<Result<RichEvent[], string>> { async getAllEventsByHashtags(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_events_from_contacts", { until }) }; return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_hashtags", { hashtags, until }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> { async getAllEventsFrom(url: string, until: string | null) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) }; return { status: "ok", data: await TAURI_INVOKE("get_all_events_from", { url, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllEventsByKind(kind: number, until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_kind", { kind, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllProviders() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_providers") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestEventsFromProvider(provider: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_events_from_provider", { provider }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllEventsByRequest(id: string, provider: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_request", { id, provider }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -328,17 +408,9 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> { async search(query: string) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) }; return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async search(query: string, until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("search", { query, until }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -352,9 +424,9 @@ async publish(content: string, warning: string | null, difficulty: number | null
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async reply(content: string, to: string, root: string | null) : Promise<Result<string, string>> { async reply(content: string, to: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) }; return { status: "ok", data: await TAURI_INVOKE("reply", { content, to }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -368,6 +440,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async isReposted(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_reposted", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string) : Promise<Result<string, string>> { async eventToBech32(id: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) }; return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
@@ -392,6 +488,14 @@ async createColumn(column: Column) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async reloadColumn(label: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async closeColumn(label: string) : Promise<Result<boolean, string>> { async closeColumn(label: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) }; return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
@@ -400,31 +504,15 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async repositionColumn(label: string, x: number, y: number) : Promise<Result<boolean, string>> { async closeAllColumns() : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) }; return { status: "ok", data: await TAURI_INVOKE("close_all_columns") };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async resizeColumn(label: string, width: number, height: number) : Promise<Result<boolean, string>> { async openWindow(window: NewWindow) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("resize_column", { label, width, height }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async reloadColumn(label: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openWindow(window: Window) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) }; return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
} catch (e) { } catch (e) {
@@ -432,24 +520,27 @@ async openWindow(window: Window) : Promise<Result<null, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async reopenLume() : Promise<void> { async getAppSettings() : Promise<Result<Settings, string>> {
await TAURI_INVOKE("reopen_lume"); try {
return { status: "ok", data: await TAURI_INVOKE("get_app_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}, },
async quit() : Promise<void> { async setAppSettings(settings: string) : Promise<Result<null, string>> {
await TAURI_INVOKE("quit"); try {
return { status: "ok", data: await TAURI_INVOKE("set_app_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
} }
} }
/** user-defined events **/ /** user-defined events **/
export const events = __makeEvents__<{
newSettings: NewSettings,
subscription: Subscription
}>({
newSettings: "new-settings",
subscription: "subscription"
})
/** user-defined constants **/ /** user-defined constants **/
@@ -458,15 +549,11 @@ subscription: "subscription"
/** user-defined types **/ /** user-defined types **/
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number } export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] } export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type NewSettings = Settings export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null } export type NewWindow = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null } export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean } export type Settings = { resize_service: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
export type SubKind = "Subscribe" | "Unsubscribe"
export type Subscription = { label: string; kind: SubKind; event_id: string | null }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
/** tauri-specta globals **/ /** tauri-specta globals **/
@@ -484,7 +571,7 @@ type __EventObj__<T> = {
once: ( once: (
cb: TAURI_API_EVENT.EventCallback<T>, cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>; ) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: T extends null emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit> ? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>; : (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
}; };
+81 -160
View File
@@ -3,136 +3,98 @@ import type {
MaybePromise, MaybePromise,
PersistedQuery, PersistedQuery,
} from "@tanstack/query-persist-client-core"; } from "@tanstack/query-persist-client-core";
import { Store } from "@tanstack/store"; import { open } from "@tauri-apps/plugin-dialog";
import { ask, message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
import type { Store as TauriStore } from "@tauri-apps/plugin-store"; import type { Store as TauriStore } from "@tauri-apps/plugin-store";
import { check } from "@tauri-apps/plugin-updater";
import { BitcoinUnit } from "bitcoin-units"; import { BitcoinUnit } from "bitcoin-units";
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale"; import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder"; import { decode } from "light-bolt11-decoder";
import { type BaseEditor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen"; import type { RichEvent } from "./commands.gen";
import { LumeEvent } from "./system"; import { LumeEvent } from "./system";
import type { NostrEvent } from "./types"; import type { NostrEvent } from "./types";
dayjs.extend(relativeTime);
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[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export const isImagePath = (path: string) => { export function isValidRelayUrl(string: string) {
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) { try {
if (path.endsWith(suffix)) return true; const newUrl = new URL(string);
return newUrl.protocol === "ws:" || newUrl.protocol === "wss:";
} catch (err) {
return false;
} }
}
export const isImagePath = (path: string) => {
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
for (const suffix of exts) {
if (path.endsWith(suffix)) {
return true;
}
}
return false; return false;
}; };
export const isImageUrl = (url: string) => { export function createdAt(time: number) {
try { // Config for dayjs
if (!url) return false; dayjs.extend(relativeTime);
const ext = new URL(url).pathname.split(".").pop(); dayjs.extend(updateLocale);
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
} catch {
return false;
}
};
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => { // Config locale text
const text = { text: "" }; dayjs.updateLocale("en", {
const image = [ relativeTime: {
{ past: "%s ago",
type: "image", s: "just now",
url, m: "1m",
children: [text], 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 now = dayjs();
const inputTime = dayjs.unix(time); const inputTime = dayjs.unix(time);
const diff = now.diff(inputTime, "hour"); const diff = now.diff(inputTime, "hour");
if (message) { if (diff < 24) {
if (diff < 12) { return inputTime.from(now, true);
formated = inputTime.format("HH:mm A");
} else {
formated = inputTime.format("MMM DD");
}
} else { } else {
if (diff < 24) { return inputTime.format("MMM DD");
formated = inputTime.from(now, true);
} else {
formated = inputTime.format("MMM DD");
}
} }
return formated;
} }
export function replyTime(time: number) { export function replyAt(time: number) {
const inputTime = dayjs.unix(time); // Config for dayjs
const formated = inputTime.format("MM-DD-YY HH:mm"); dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
return formated; // Config locale text
dayjs.updateLocale("en", {
relativeTime: {
past: "%s ago",
s: "just now",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
},
});
const inputTime = dayjs.unix(time);
const format = inputTime.format("MM-DD-YY HH:mm");
return format;
} }
export function displayNpub(pubkey: string, len: number) { export function displayNpub(pubkey: string, len: number) {
@@ -161,7 +123,7 @@ export function displayLongHandle(str: string) {
return `${handle.substring(0, 16)}...@${service}`; return `${handle.substring(0, 16)}...@${service}`;
} }
// source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts // Source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
export function getBitcoinDisplayValues(satoshis: number) { export function getBitcoinDisplayValues(satoshis: number) {
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis") let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
.getValue() .getValue()
@@ -192,53 +154,26 @@ export function getBitcoinDisplayValues(satoshis: number) {
}; };
} }
export function decodeZapInvoice(tags?: string[][]) { export function decodeZapInvoice(tags: string[][]) {
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1]; const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
if (!invoice) return; if (!invoice) return;
const decodedInvoice = decode(invoice); const decodedInvoice = decode(invoice);
const amountSection = decodedInvoice.sections.find( const section = decodedInvoice.sections.find(
(s: { name: string }) => s.name === "amount", (s: { name: string }) => s.name === "amount",
); );
const amount = Number.parseInt(amountSection.value); if (!section) {
const displayValue = getBitcoinDisplayValues(amount); return null;
return displayValue;
}
export async function checkForAppUpdates(silent: boolean) {
const update = await check();
if (!update) {
if (silent) return;
await message("You are on the latest version. Stay awesome!", {
title: "No Update Available",
kind: "info",
okLabel: "OK",
});
return;
} }
if (update?.available) { if (section.name === "amount") {
const yes = await ask( const amount = Number.parseInt(section.value) / 1000;
`Update to ${update.version} is available!\n\nRelease notes: ${update.body}`, const displayValue = getBitcoinDisplayValues(amount);
{
title: "Update Available",
kind: "info",
okLabel: "Update",
cancelLabel: "Cancel",
},
);
if (yes) { return displayValue;
await update.downloadAndInstall(); } else {
await relaunch(); return null;
}
return;
} }
} }
@@ -257,18 +192,16 @@ export async function upload(filePath?: string) {
]; ];
const selected = const selected =
filePath || filePath ??
( (await open({
await open({ multiple: false,
multiple: false, filters: [
filters: [ {
{ name: "Media",
name: "Media", extensions: allowExts,
extensions: allowExts, },
}, ],
], }));
})
).path;
// User cancelled action // User cancelled action
if (!selected) return null; if (!selected) return null;
@@ -325,15 +258,3 @@ export function newQueryStorage(
(await store.delete(key)) as unknown as MaybePromise<void>, (await store.delete(key)) as unknown as MaybePromise<void>,
}; };
} }
export const appSettings = new Store<Settings>({
proxy: null,
image_resize_service: "https://wsrv.nl",
use_relay_hint: true,
content_warning: true,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
display_media: true,
transparent: true,
});
+72 -115
View File
@@ -1,126 +1,68 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { useRect } from "@/system";
import type { LumeColumn } from "@/types"; import type { LumeColumn } from "@/types";
import { Check, DotsThree } from "@phosphor-icons/react"; import { CaretDown, Check } from "@phosphor-icons/react";
import { useParams } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Spinner } from "./spinner"; import { User } from "./user";
type WindowEvent = { export function Column({ column }: { column: LumeColumn }) {
scroll: boolean; const [rect, ref] = useRect();
resize: boolean; const [error, setError] = useState("");
};
export const Column = memo(function Column({ column }: { column: LumeColumn }) {
const params = useParams({ strict: false });
const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${params.account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false);
const repositionWebview = useCallback(async () => {
if (!container.current) return;
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webviewLabel,
x: newRect.x,
y: newRect.y,
});
}, []);
const resizeWebview = useCallback(async () => {
if (!container.current) return;
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webviewLabel,
width: newRect.width,
height: newRect.height,
});
}, []);
useEffect(() => { useEffect(() => {
if (!isCreated) return; (async () => {
if (rect) {
const 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}&account=${column.account}`,
});
const unlisten = listen<WindowEvent>("child_webview", (data) => { if (res.status === "error") {
if (data.payload.scroll) repositionWebview(); setError(res.error);
if (data.payload.resize) repositionWebview().then(() => resizeWebview()); }
}); }
})();
return () => { }, [rect]);
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]);
return ( return (
<div className="h-full w-[440px] shrink-0 p-2"> <div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/15"> <div className="flex flex-col gap-px size-full">
<Header label={column.label} name={column.name} /> <Header
<div ref={container} className="flex-1 w-full h-full"> label={column.label}
{!isCreated ? ( name={column.name}
<div className="size-full flex items-center justify-center"> account={column.account}
<Spinner /> />
<div ref={ref} className="flex-1 size-full">
<div className="size-full flex flex-col items-center justify-center">
<div className="invisible text-red-500 text-sm break-all">
{error?.length ? error : null}
</div> </div>
) : null} </div>
</div> </div>
</div> </div>
</div> </div>
); );
}); }
function Header({ label, name }: { label: string; name: string }) { function Header({
const [title, setTitle] = useState(name); label,
name,
account,
}: { label: string; name: string; account?: string }) {
const [title, setTitle] = useState("");
const [isChanged, setIsChanged] = useState(false); const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const window = getCurrentWindow(); const window = getCurrentWindow();
const menuItems = await Promise.all([ const menuItems = await Promise.all([
MenuItem.new({ MenuItem.new({
text: "Reload", text: "Reload",
@@ -128,10 +70,6 @@ function Header({ label, name }: { label: string; name: string }) {
await commands.reloadColumn(label); await commands.reloadColumn(label);
}, },
}), }),
MenuItem.new({
text: "Open in new window",
action: () => console.log("not implemented."),
}),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Move left", text: "Move left",
@@ -172,20 +110,39 @@ function Header({ label, name }: { label: string; name: string }) {
await menu.popup().catch((e) => console.error(e)); await menu.popup().catch((e) => console.error(e));
}, []); }, []);
const saveNewTitle = async () => {
await getCurrentWindow().emit("columns", {
type: "set_title",
label,
title,
});
setIsChanged(false);
};
useEffect(() => { useEffect(() => {
if (title.length !== name.length) setIsChanged(true); if (title.length > 0) setIsChanged(true);
}, [title]); }, [title.length]);
return ( return (
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0"> <div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
<div className="size-7" />
<div className="flex items-center justify-center shrink-0 h-7"> <div className="flex items-center justify-center shrink-0 h-7">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
{account?.length ? (
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-6 rounded-full" />
</User.Root>
</User.Provider>
) : null}
<div <div
contentEditable contentEditable
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)} onBlur={(e) => {
className="text-sm font-medium focus:outline-none" if (e.currentTarget.textContent) {
setTitle(e.currentTarget.textContent);
}
}}
className="text-[12px] font-semibold focus:outline-none"
> >
{name} {name}
</div> </div>
@@ -193,9 +150,9 @@ function Header({ label, name }: { label: string; name: string }) {
<button <button
type="button" type="button"
onClick={() => saveNewTitle()} onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600" className="text-teal-500 hover:text-teal-600 inline-flex items-center justify-center size-6 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full bg-white dark:bg-black"
> >
<Check className="size-4" /> <Check className="size-3" weight="bold" />
</button> </button>
) : null} ) : null}
</div> </div>
@@ -203,9 +160,9 @@ function Header({ label, name }: { label: string; name: string }) {
<button <button
type="button" type="button"
onClick={(e) => showContextMenu(e)} onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10" className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 bg-white dark:bg-neutral-800 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full"
> >
<DotsThree className="size-5" /> <CaretDown className="size-3" weight="bold" />
</button> </button>
</div> </div>
); );
-47
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>
);
});
+2 -7
View File
@@ -1,5 +1,4 @@
import { cn } from "@/commons"; import { cn } from "@/commons";
import { useRouteContext } from "@tanstack/react-router";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function Frame({ export function Frame({
@@ -7,17 +6,13 @@ export function Frame({
shadow, shadow,
className, className,
}: { children: ReactNode; shadow?: boolean; className?: string }) { }: { children: ReactNode; shadow?: boolean; className?: string }) {
const { platform } = useRouteContext({ strict: false });
return ( return (
<div <div
className={cn( className={cn(
className, className,
platform === "linux" "bg-white dark:bg-neutral-800",
? "bg-white dark:bg-neutral-950"
: "bg-white dark:bg-white/10",
shadow shadow
? "shadow-lg shadow-neutral-500/10 dark:shadow-none dark:ring-1 dark:ring-white/20" ? "shadow-primary dark:shadow-none dark:ring-1 dark:ring-neutral-700/50"
: "", : "",
)} )}
> >
+19
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>
);
+23
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>
);
+20
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>
);
+20
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>
);
+20
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>
);
+8 -3
View File
@@ -4,12 +4,17 @@ export * from "./spinner";
export * from "./column"; export * from "./column";
// Newsfeed // Newsfeed
export * from "./repost";
export * from "./conversation";
export * from "./quote";
export * from "./text"; export * from "./text";
export * from "./repost";
export * from "./reply"; export * from "./reply";
// Global components // Global components
export * from "./note"; export * from "./note";
export * from "./user"; export * from "./user";
// Icons
export * from "./icons/reply";
export * from "./icons/repost";
export * from "./icons/zap";
export * from "./icons/quote";
export * from "./icons/publish";
+12 -5
View File
@@ -1,5 +1,5 @@
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import { FrameCorners } from "@phosphor-icons/react"; import { ListPlus } from "@phosphor-icons/react";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
@@ -8,19 +8,26 @@ export function NoteOpenThread() {
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => LumeWindow.openEvent(event)} onClick={() =>
LumeWindow.openColumn({
name: "Thread",
label: event.id.slice(0, 6),
account: event.pubkey,
url: `/columns/events/${event.id}`,
})
}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
> >
<FrameCorners className="shrink-0 size-4" /> <ListPlus className="shrink-0 size-4" />
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Open View thread
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
+40
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(undefined, event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Quote" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Quote
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
+9 -8
View File
@@ -1,10 +1,13 @@
import { cn } from "@/commons"; import { cn } from "@/commons";
import { ReplyIcon } from "@/components";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import { ShareFat } from "@phosphor-icons/react";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteReply({ large = false }: { large?: boolean }) { export function NoteReply({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
return ( return (
@@ -15,14 +18,12 @@ export function NoteReply({ large = false }: { large?: boolean }) {
type="button" type="button"
onClick={() => LumeWindow.openEditor(event.id)} onClick={() => LumeWindow.openEditor(event.id)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
large label ? "w-24 gap-1.5" : "w-14",
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)} )}
> >
<ShareFat className="shrink-0 size-4" /> <ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{large ? "Reply" : null} {label ? "Reply" : null}
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
+142 -61
View File
@@ -1,81 +1,162 @@
import { appSettings, cn } from "@/commons"; import { commands } from "@/commands.gen";
import { Spinner } from "@/components"; import { cn, displayNpub } from "@/commons";
import { LumeWindow } from "@/system"; import { RepostIcon, Spinner } from "@/components";
import { Repeat } from "@phosphor-icons/react"; import { settingsQueryOptions } from "@/routes/__root";
import { useStore } from "@tanstack/react-store"; import type { Metadata } from "@/types";
import * as Tooltip from "@radix-ui/react-tooltip";
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react"; import { useCallback, useTransition } from "react";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) { export function NoteRepost({
const visible = useStore(appSettings, (state) => state.display_repost_button); label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const settings = useSuspenseQuery(settingsQueryOptions);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false); const { isLoading, data: status } = useQuery({
const [isRepost, setIsRepost] = useState(false); queryKey: ["is-reposted", event.id],
queryFn: async () => {
const 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 () => { const [isPending, startTransition] = useTransition();
if (isRepost) return;
try {
setLoading(true);
// repost
await event.repost();
// update state
setLoading(false);
setIsRepost(true);
} catch {
setLoading(false);
await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
}
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const menuItems = await Promise.all([ const accounts = await commands.getAccounts();
MenuItem.new({ const list: Promise<MenuItem>[] = [];
text: "Quote",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const menu = await Menu.new({ for (const account of accounts) {
items: menuItems, const res = await commands.getProfile(account);
}); let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name ?? "anon";
}
list.push(
MenuItem.new({
text: `Repost as ${name} (${displayNpub(account, 16)})`,
action: async () => submit(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e)); await menu.popup().catch((e) => console.error(e));
}, []); }, []);
if (!visible) return null; const repost = useMutation({
mutationFn: async () => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["is-reposted", event.id] });
// Optimistically update to the new value
queryClient.setQueryData(["is-reposted", event.id], true);
const res = await commands.repost(JSON.stringify(event.raw));
if (res.status === "ok") {
return;
} else {
throw new Error(res.error);
}
},
onError: () => {
queryClient.setQueryData(["is-reposted", event.id], false);
},
onSettled: async () => {
return await queryClient.invalidateQueries({
queryKey: ["is-reposted", event.id],
});
},
});
const submit = (account: string) => {
startTransition(async () => {
if (!status) {
const signer = await commands.hasSigner(account);
if (signer.status === "ok") {
if (!signer.data) {
const res = await commands.setSigner(account);
if (res.status === "error") {
await message(res.error, { kind: "error" });
return;
}
}
repost.mutate();
} else {
return;
}
} else {
return;
}
});
};
if (!settings.data.display_repost_button) return null;
return ( return (
<button <Tooltip.Provider>
type="button" <Tooltip.Root delayDuration={300}>
onClick={(e) => showContextMenu(e)} <Tooltip.Trigger asChild>
className={cn( <button
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", type="button"
large onClick={(e) => showContextMenu(e)}
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10" className={cn(
: "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",
> )}
{loading ? ( >
<Spinner className="size-4" /> {isPending || isLoading ? (
) : ( <Spinner className="size-4" />
<Repeat className={cn("size-4", isRepost ? "text-blue-500" : "")} /> ) : (
)} <RepostIcon
{large ? "Repost" : null} className={cn(
</button> 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>
); );
} }
+15 -15
View File
@@ -1,30 +1,30 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { ZapIcon } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import { Lightning } from "@phosphor-icons/react"; import { useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteZap({ large = false }: { large?: boolean }) { export function NoteZap({
const search = useSearch({ strict: false }); label = false,
const visible = useStore(appSettings, (state) => state.display_zap_button); smol = false,
}: { label?: boolean; smol?: boolean }) {
const settings = useSuspenseQuery(settingsQueryOptions);
const event = useNoteContext(); const event = useNoteContext();
if (!visible) return null; if (!settings.data.display_zap_button) return null;
return ( return (
<button <button
type="button" type="button"
onClick={() => LumeWindow.openZap(event.id, search.account)} onClick={() => LumeWindow.openZap(event.id)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
large label ? "w-24 gap-1.5" : "w-14",
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)} )}
> >
<Lightning className="size-4" /> <ZapIcon className={smol ? "size-4" : "size-5"} />
{large ? "Zap" : null} {label ? "Zap" : null}
</button> </button>
); );
} }
+46 -52
View File
@@ -1,13 +1,13 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { useStore } from "@tanstack/react-store"; import { settingsQueryOptions } from "@/routes/__root";
import { useSuspenseQuery } from "@tanstack/react-query";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { type ReactNode, memo, useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag"; import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note"; import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user"; import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images"; import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteContent({ export function NoteContent({
@@ -22,15 +22,17 @@ export function NoteContent({
className?: string; className?: string;
}) { }) {
const event = useNoteContext(); const event = useNoteContext();
const visible = useStore(appSettings, (state) => state.display_media); const settings = useSuspenseQuery(settingsQueryOptions);
const warning = useMemo(() => event.warning, [event]);
const content = useMemo(() => { const content = useMemo(() => {
try { try {
// Get parsed meta // Get parsed meta
const { content, hashtags, events, mentions } = event.meta; const { content, hashtags, events, mentions } = event.meta;
// Define rich content // Define rich content
let richContent: ReactNode[] | string = visible ? content : event.content; let richContent: ReactNode[] | string = settings.data.display_media
? content
: event.content;
for (const hashtag of hashtags) { for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g"); const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
@@ -89,56 +91,48 @@ export function NoteContent({
} }
}, [event.content]); }, [event.content]);
const [blurred, setBlurred] = useState(() =>
event.warning ? event.warning.length > 0 : false,
);
return ( return (
<div className="relative flex flex-col gap-2"> <div className="relative flex flex-col gap-2">
<ContentWarning warning={warning} /> {!blurred ? (
<div
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 ? (
<> <>
{event.meta?.images.length ? ( <div
<Images urls={event.meta.images} /> className={cn(
) : null} "select-text text-pretty content-break overflow-hidden",
{event.meta?.videos.length ? ( className,
<Videos urls={event.meta.videos} /> )}
>
{content}
</div>
{settings.data.display_media ? (
event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null
) : 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> </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>
);
});
+2 -10
View File
@@ -6,7 +6,6 @@ import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note"; import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user"; import { MentionUser } from "./mentions/user";
import { ImagePreview } from "./preview/image"; import { ImagePreview } from "./preview/image";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteContentLarge({ export function NoteContentLarge({
@@ -18,7 +17,7 @@ export function NoteContentLarge({
const content = useMemo(() => { const content = useMemo(() => {
try { try {
// Get parsed meta // Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta; const { images, hashtags, events, mentions } = event.meta;
// Define rich content // Define rich content
let richContent: ReactNode[] | string = event.content; let richContent: ReactNode[] | string = event.content;
@@ -48,12 +47,6 @@ export function NoteContentLarge({
)); ));
} }
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace( richContent = reactStringReplace(
richContent, richContent,
/(https?:\/\/\S+)/gi, /(https?:\/\/\S+)/gi,
@@ -75,8 +68,7 @@ export function NoteContentLarge({
)); ));
return richContent; return richContent;
} catch (e) { } catch {
console.log("[parser]: ", e);
return event.content; return event.content;
} }
}, [event.content]); }, [event.content]);
+2
View File
@@ -1,4 +1,5 @@
import { NoteOpenThread } from "./buttons/open"; import { NoteOpenThread } from "./buttons/open";
import { NoteQuote } from "./buttons/quote";
import { NoteReply } from "./buttons/reply"; import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost"; import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap"; import { NoteZap } from "./buttons/zap";
@@ -16,6 +17,7 @@ export const Note = {
User: NoteUser, User: NoteUser,
Menu: NoteMenu, Menu: NoteMenu,
Reply: NoteReply, Reply: NoteReply,
Quote: NoteQuote,
Repost: NoteRepost, Repost: NoteRepost,
Content: NoteContent, Content: NoteContent,
ContentLarge: NoteContentLarge, ContentLarge: NoteContentLarge,
+3 -9
View File
@@ -1,13 +1,7 @@
import { LumeWindow } from "@/system";
export function Hashtag({ tag }: { tag: string }) { export function Hashtag({ tag }: { tag: string }) {
return ( return (
<button <span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
type="button" {tag.includes("#") ? tag : `#${tag}`}
onClick={() => LumeWindow.openHashtag(tag)} </span>
className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal"
>
{tag}
</button>
); );
} }
+109 -16
View File
@@ -1,8 +1,11 @@
import { replyTime } from "@/commons"; import { replyAt } from "@/commons";
import { Note, Spinner } from "@/components"; import { Note, Spinner, User } from "@/components";
import { User } from "@/components/user";
import { LumeWindow, useEvent } from "@/system"; import { LumeWindow, useEvent } from "@/system";
import { memo } from "react"; import { nip19 } from "nostr-tools";
import { type ReactNode, memo, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./hashtag";
import { MentionUser } from "./user";
export const MentionNote = memo(function MentionNote({ export const MentionNote = memo(function MentionNote({
eventId, eventId,
@@ -15,12 +18,18 @@ export const MentionNote = memo(function MentionNote({
<div className="relative my-2"> <div className="relative my-2">
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10"> <div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
{isLoading ? ( {isLoading ? (
<Spinner /> <div className="h-[32px] flex items-center gap-2 text-sm">
<Spinner />
Loadng note
</div>
) : isError || !event ? ( ) : isError || !event ? (
<p className="text-sm font-medium text-red-500"> <div className="flex flex-col break-all">
{error.message || <p className="text-sm font-medium text-red-500">
"Quoted note is not found with your current relay set"} {error?.message ??
</p> "Cannot found this note within your current relay set"}
</p>
<p className="text-sm">{eventId}</p>
</div>
) : ( ) : (
<Note.Provider event={event}> <Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
@@ -33,20 +42,29 @@ export const MentionNote = memo(function MentionNote({
/> />
</User.Root> </User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden"> <div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content.length > 120 {event.content.length > 300 ? (
? `${event.content.substring(0, 120)}...` `${event.content.substring(0, 300)}...`
: event.content} ) : (
<Content text={event.content} className="inline" />
)}
</div> </div>
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3"> <div className="invisible group-hover:visible flex items-center justify-end">
<button <button
type="button" type="button"
onClick={() => LumeWindow.openEvent(event)} onClick={() =>
className="text-sm font-medium text-blue-500 hover:text-blue-600" LumeWindow.openColumn({
name: "Thread",
label: eventId.slice(0, 6),
account: event.pubkey,
url: `/columns/events/${eventId}`,
})
}
className="mr-3 text-sm font-medium text-blue-500 hover:text-blue-600"
> >
Show all Show all
</button> </button>
@@ -63,3 +81,78 @@ export const MentionNote = memo(function MentionNote({
</div> </div>
); );
}); });
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "").replace(/[^\w\s]/gi, "");
try {
const data = nip19.decode(bech32);
switch (data.type) {
case "npub":
replacedText = reactStringReplace(
replacedText,
word,
(match, i) => <MentionUser key={match + i} pubkey={data.data} />,
);
break;
case "nprofile":
replacedText = reactStringReplace(
replacedText,
word,
(match, i) => (
<MentionUser key={match + i} pubkey={data.data.pubkey} />
),
);
break;
default:
replacedText = reactStringReplace(
replacedText,
word,
(match, i) => (
<a
key={match + i}
href={`https://njump.me/${bech32}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
),
);
break;
}
} catch {
console.log(word);
}
}
return replacedText;
}, [text]);
return <div className={className}>{content}</div>;
}
+3 -5
View File
@@ -5,7 +5,7 @@ import { memo } from "react";
export const MentionUser = memo(function MentionUser({ export const MentionUser = memo(function MentionUser({
pubkey, pubkey,
}: { pubkey: string }) { }: { pubkey: string }) {
const { isLoading, isError, profile } = useProfile(pubkey); const { isLoading, profile } = useProfile(pubkey);
return ( return (
<button <button
@@ -14,10 +14,8 @@ export const MentionUser = memo(function MentionUser({
className="break-words text-start text-blue-500 hover:text-blue-600" className="break-words text-start text-blue-500 hover:text-blue-600"
> >
{isLoading {isLoading
? "@anon" ? displayNpub(pubkey, 16)
: isError : `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
? displayNpub(pubkey, 16)
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
</button> </button>
); );
}); });
+36 -19
View File
@@ -1,51 +1,68 @@
import { commands } from "@/commands.gen";
import { DotsThree } from "@phosphor-icons/react"; import { DotsThree } from "@phosphor-icons/react";
import { useSearch } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import { useCallback } from "react"; import { useCallback } from "react";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteMenu() { export function NoteMenu() {
const event = useNoteContext(); const event = useNoteContext();
const { account }: { account: string } = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const menuItems = await Promise.all([ const list = [
MenuItem.new({ MenuItem.new({
text: "Copy Sharable Link", text: "Copy ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
action: async () => { action: async () => {
const eventId = await event.idAsBech32(); const eventId = await event.idAsBech32();
await writeText(eventId); await writeText(eventId);
}, },
}), }),
MenuItem.new({ MenuItem.new({
text: "Copy Public Key", text: "Copy author",
action: async () => { action: async () => {
const pubkey = await event.pubkeyAsBech32(); const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey); await writeText(pubkey);
}, },
}), }),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Copy Raw Event", text: "Copy sharable link",
action: async () => { action: async () => {
event.meta = undefined; const eventId = await event.idAsBech32();
const raw = JSON.stringify(event); await writeText(`https://njump.me/${eventId}`);
await writeText(raw);
}, },
}), }),
]); PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy raw event",
action: async () => {
event.meta = undefined;
await writeText(JSON.stringify(event));
},
}),
];
const menu = await Menu.new({ if (account?.length) {
items: menuItems, const pubkey = nip19.decode(account).data;
});
if (event.pubkey === pubkey) {
list.push(
MenuItem.new({
text: "Request delete",
action: async () => {
await commands.requestDelete(event.id);
},
}),
);
}
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e)); await menu.popup().catch((e) => console.error(e));
}, []); }, []);
+7 -14
View File
@@ -1,23 +1,20 @@
import { appSettings } from "@/commons"; import { settingsQueryOptions } from "@/routes/__root";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
export function ImagePreview({ url }: { url: string }) { export function ImagePreview({ url }: { url: string }) {
const [service, visible] = useStore(appSettings, (state) => [ const settings = useSuspenseQuery(settingsQueryOptions);
state.image_resize_service,
state.display_media,
]);
const imageUrl = useMemo(() => { const imageUrl = useMemo(() => {
if (service?.length) { if (settings.data.resize_service) {
const newUrl = `${service}?url=${url}&ll&af&default=1&n=-1`; const newUrl = `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
return newUrl; return newUrl;
} else { } else {
return url; return url;
} }
}, [service]); }, [settings.data.resize_service]);
if (!visible) { if (!settings.data.display_media) {
return ( return (
<a <a
href={url} href={url}
@@ -39,10 +36,6 @@ export function ImagePreview({ url }: { url: string }) {
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[400px] w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</div> </div>
); );
+36 -27
View File
@@ -1,40 +1,53 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react"; import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-shell"; import { open } from "@tauri-apps/plugin-shell";
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) { export function Images({ urls }: { urls: string[] }) {
const [slidesInView, setSlidesInView] = useState([]); const [slidesInView, setSlidesInView] = useState<number[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
dragFree: true, dragFree: true,
align: "start", align: "start",
watchSlides: false, watchSlides: false,
}); });
const service = useStore(appSettings, (state) => state.image_resize_service); const settings = useSuspenseQuery(settingsQueryOptions);
const imageUrls = useMemo(() => { const imageUrls = useMemo(() => {
if (service?.length) { if (settings.data.resize_service) {
let newUrls: string[]; let newUrls: string[];
if (urls.length === 1) { if (urls.length === 1) {
newUrls = urls.map( newUrls = urls.map((url) => {
(url) => `${service}?url=${url}&ll&af&default=1&n=-1`, if (url.includes("_next/")) {
); return url;
}
if (url.includes("bsky.network")) {
return url;
}
return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
});
} else { } else {
newUrls = urls.map( newUrls = urls.map((url) => {
(url) => `${service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`, if (url.includes("_next/")) {
); return url;
}
if (url.includes("bsky.network")) {
return url;
}
return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
});
} }
return newUrls; return newUrls;
} else { } else {
return urls; return urls;
} }
}, [service]); }, [settings.data.resize_service]);
const scrollPrev = useCallback(() => { const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev(); if (emblaApi) emblaApi.scrollPrev();
@@ -44,23 +57,27 @@ export function Images({ urls }: { urls: string[] }) {
if (emblaApi) emblaApi.scrollNext(); if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]); }, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => { const updateSlidesInView = useCallback(() => {
setSlidesInView((slidesInView) => { setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) { if (slidesInView.length === emblaApi?.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView); emblaApi?.off("slidesInView", updateSlidesInView);
} }
const inView = emblaApi const inView = emblaApi
.slidesInView() ?.slidesInView()
.filter((index) => !slidesInView.includes(index)); .filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView); if (inView) {
return slidesInView.concat(inView);
} else {
return slidesInView;
}
}); });
}, []); }, [emblaApi]);
useEffect(() => { useEffect(() => {
if (emblaApi && urls.length > 1) { if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi); updateSlidesInView();
emblaApi.on("slidesInView", updateSlidesInView); emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView); emblaApi.on("reInit", updateSlidesInView);
@@ -83,10 +100,6 @@ export function Images({ urls }: { urls: string[] }) {
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => urls[0]} onClick={() => urls[0]}
onKeyDown={() => urls[0]} onKeyDown={() => urls[0]}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</div> </div>
); );
@@ -162,10 +175,6 @@ function LazyImage({ url, inView }: { url: string; inView: boolean }) {
onClick={() => open(url)} onClick={() => open(url)}
onKeyDown={() => open(url)} onKeyDown={() => open(url)}
onLoad={setLoaded} onLoad={setLoaded}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</div> </div>
); );
+4 -4
View File
@@ -1,10 +1,10 @@
import { appSettings } from "@/commons"; import { settingsQueryOptions } from "@/routes/__root";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
export function VideoPreview({ url }: { url: string }) { export function VideoPreview({ url }: { url: string }) {
const visible = useStore(appSettings, (state) => state.display_media); const settings = useSuspenseQuery(settingsQueryOptions);
if (!visible) { if (!settings.data.display_zap_button) {
return ( return (
<a <a
href={url} href={url}
-44
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>
);
});
+144 -25
View File
@@ -1,10 +1,15 @@
import { cn, replyTime } from "@/commons"; import { cn, replyAt } from "@/commons";
import { Note } from "@/components/note"; import { Note, User } from "@/components";
import type { LumeEvent } from "@/system"; import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react"; import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router"; import { Link, useSearch } from "@tanstack/react-router";
import { memo } from "react"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { User } from "./user"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import { type ReactNode, memo, useCallback, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./note/mentions/hashtag";
import { MentionUser } from "./note/mentions/user";
export const ReplyNote = memo(function ReplyNote({ export const ReplyNote = memo(function ReplyNote({
event, event,
@@ -14,32 +19,58 @@ export const ReplyNote = memo(function ReplyNote({
className?: string; className?: string;
}) { }) {
const search = useSearch({ strict: false }); const search = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<Note.Root className={cn("flex gap-2.5", className)}> <Note.Root className={cn("flex gap-2.5", className)}>
<User.Root className="shrink-0"> <User.Root className="shrink-0">
<User.Avatar className="size-8 rounded-full" /> <button type="button" onClick={(e) => showContextMenu(e)}>
<User.Avatar className="size-8 rounded-full" />
</button>
</User.Root> </User.Root>
<div className="flex-1 flex flex-col gap-1"> <div className="flex-1 flex flex-col gap-1">
<div> <div>
<User.Name <User.Name
className="shrink-0 inline font-medium text-blue-500" className="mr-2 shrink-0 inline font-medium text-blue-500"
suffix=":" suffix=":"
/> />
<div className="pl-2 inline select-text text-balance content-break overflow-hidden"> <Content
{event.content} text={event.content}
</div> className="inline select-text text-balance content-break overflow-hidden"
/>
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end">
<Note.Reply /> <Note.Reply smol />
<Note.Repost /> <Note.Repost smol />
<Note.Zap /> <Note.Zap smol />
</div> </div>
</div> </div>
{event.replies?.length ? ( {event.replies?.length ? (
@@ -74,26 +105,53 @@ export const ReplyNote = memo(function ReplyNote({
function ChildReply({ event }: { event: LumeEvent }) { function ChildReply({ event }: { event: LumeEvent }) {
const search = useSearch({ strict: false }); const search = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<div className="group flex flex-col gap-1"> <div className="group flex flex-col gap-1">
<div> <div>
<User.Root className="inline"> <User.Root className="inline mr-2">
<User.Name className="font-medium text-blue-500" suffix=":" /> <button type="button" onClick={(e) => showContextMenu(e)}>
<User.Name className="font-medium text-blue-500" suffix=":" />
</button>
</User.Root> </User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden"> <Content
{event.content} text={event.content}
</div> className="inline select-text text-balance content-break overflow-hidden"
/>
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3"> <div className="invisible group-hover:visible flex items-center justify-end">
<Note.Reply /> <Note.Reply smol />
<Note.Repost /> <Note.Repost smol />
<Note.Zap /> <Note.Zap smol />
</div> </div>
</div> </div>
{event.replies?.length ? ( {event.replies?.length ? (
@@ -124,3 +182,64 @@ function ChildReply({ event }: { event: LumeEvent }) {
</Note.Provider> </Note.Provider>
); );
} }
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "");
const data = nip19.decode(bech32);
switch (data.type) {
case "npub":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data} />
));
break;
case "nprofile":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data.pubkey} />
));
break;
default:
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<a
key={match + i}
href={`https://njump.me/${bech32}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
break;
}
}
return replacedText;
}, [text]);
return <div className={className}>{content}</div>;
}
+8 -11
View File
@@ -12,25 +12,22 @@ export const RepostNote = memo(function RepostNote({
event: LumeEvent; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const { isLoading, isError, data } = useEvent(event.repostId); const { isLoading, isError, data } = useEvent(event.repostId, event.content);
return ( return (
<Note.Root <Note.Root className={cn("", className)}>
className={cn(
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-20 gap-2"> <div className="flex items-center justify-center h-20 gap-2">
<Spinner /> <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... Loading event...
</span> </p>
</div> </div>
) : isError || !data ? ( ) : isError || !data ? (
<div className="flex items-center justify-center h-20"> <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> </div>
) : ( ) : (
<Note.Provider event={data}> <Note.Provider event={data}>
@@ -41,7 +38,7 @@ export const RepostNote = memo(function RepostNote({
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="flex items-center justify-between px-3 mt-3 h-14"> <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.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />
+2 -7
View File
@@ -12,18 +12,13 @@ export const TextNote = memo(function TextNote({
}) { }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root className={cn("", className)}>
className={cn(
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
className,
)}
>
<div className="flex items-center justify-between px-3 h-14"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" /> <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.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />

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