Compare commits

..
30 Commits
Author SHA1 Message Date
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
102 changed files with 12996 additions and 24478 deletions
+2 -1
View File
@@ -23,4 +23,5 @@ dist-ssr
*.sln
*.sw?
src/router.gen.ts
src/routes.gen.ts
src/commands.gen.ts
+14 -47
View File
@@ -1,63 +1,30 @@
## Introduction
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
Lume is a Nostr client for macOS and Windows 11. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
## Usage
## Installation and Usage
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
- *Microsoft Windows*: See the releases area for a file named something like Lume_VERSION_x64-setup.exe or Lume_VERSION_x64_en-US.msi
Supported platform: macOS. Windows and Linux are coming soon.
- *macOS*: See the releases area for a file named something like Lume_VERSION_PLATFORM.dmg
Windows and Linux are availabel on v3 and below.
Lume only supported macOS and Windows 11. Linux user can consider using [Gossip client](https://github.com/mikedilger/gossip)
## Prerequisites
## Screenshots
- Node.js >= 18: https://nodejs.org/en
![Login Screen](https://image.nostr.build/d7a59ada0ed107e9556b0c8e547803f41f99e7973da4e52eab1b0b0a7dbdfadf.png)
![Welcome Screen](https://image.nostr.build/b6f63e5bda01a37de06e59bd2cebc7be47fb6a8b01ce3155b7269d5235e6db0c.png)
![Newsfeed](https://image.nostr.build/66fdcd96c6008794a02fa282e70a4538393c2a0041b1ee52aaf09893c17dba96.png)
![Thread](https://image.nostr.build/11538fae77da1e8b00099b92642f2d9e40f6fbf7fde49459c93a9d99c97e4cfc.png)
![Dark Mode](https://image.nostr.build/6b6c024a029a61d96d507dd7d1d8f7c48332cc77aad1bb87c6a952b8d9175348.png)
- Rust: https://rustup.rs/
## Building from Source
- PNPM: https://pnpm.io
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
## Develop
Clone project
```
git clone https://github.com/lumehq/lume.git && cd lume
```
Install packages
```
pnpm install
```
Run dev build
```
pnpm tauri dev
```
Generate production build
```
pnpm tauri build
```
## Nix
Requirements:
1. [Install Nix](https://zero-to-flakes.com/install)
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
See [Developing](docs/DEVELOPING.md)
## License
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+37
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
```
+44 -46
View File
@@ -12,73 +12,71 @@
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.6.2",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/query-persist-client-core": "^5.51.21",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-router": "^1.48.1",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/query-persist-client-core": "^5.59.0",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.63.5",
"@tanstack/react-store": "^0.5.5",
"@tanstack/store": "^0.5.5",
"@tauri-apps/api": "2.0.0-rc.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "2.0.0-rc.0",
"@tauri-apps/plugin-fs": "2.0.0-rc.1",
"@tauri-apps/plugin-http": "2.0.0-rc.1",
"@tauri-apps/plugin-os": "2.0.0-rc.0",
"@tauri-apps/plugin-process": "2.0.0-rc.0",
"@tauri-apps/plugin-shell": "2.0.0-rc.0",
"@tauri-apps/plugin-store": "2.0.0-rc.0",
"@tauri-apps/plugin-updater": "2.0.0-rc.0",
"@tauri-apps/plugin-upload": "2.0.0-rc.0",
"@tauri-apps/plugin-window-state": "2.0.0-rc.0",
"@tauri-apps/api": "^2.0.2",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-http": "^2.0.0",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"@tauri-apps/plugin-upload": "^2.0.0",
"@tauri-apps/plugin-window-state": "^2.0.0",
"bitcoin-units": "^1.0.0",
"boring-avatars": "^1.10.2",
"dayjs": "^1.11.12",
"embla-carousel-react": "^8.1.8",
"i18next": "^23.13.0",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.3.0",
"i18next": "^23.15.2",
"i18next-resources-to-backend": "^1.2.1",
"light-bolt11-decoder": "^3.1.1",
"light-bolt11-decoder": "^3.2.0",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.7.2",
"react": "19.0.0-rc-d025ddd3-20240722",
"react-currency-input-field": "^3.8.0",
"react-dom": "19.0.0-rc-d025ddd3-20240722",
"react-hook-form": "^7.52.2",
"react-i18next": "^15.0.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^15.0.2",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.107.1",
"rich-textarea": "^0.26.3",
"use-debounce": "^10.0.3",
"virtua": "^0.33.7"
"virtua": "^0.34.2"
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@biomejs/biome": "^1.9.3",
"@evilmartians/harmony": "^1.2.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.14",
"@tanstack/router-devtools": "^1.48.1",
"@tanstack/router-plugin": "^1.47.0",
"@tauri-apps/cli": "2.0.0-rc.4",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/router-devtools": "^1.63.5",
"@tanstack/router-plugin": "^1.63.5",
"@tauri-apps/cli": "^2.0.2",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
"clsx": "^2.1.1",
"postcss": "^8.4.41",
"postcss": "^8.4.47",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.5.2",
"tailwind-merge": "^2.5.3",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.10",
"tailwindcss-content-visibility": "^0.2.0",
"typescript": "^5.5.4",
"vite": "^5.4.1",
"tailwindcss": "^3.4.13",
"tailwindcss-content-visibility": "^1.0.0",
"typescript": "^5.6.3",
"vite": "^5.4.8",
"vite-tsconfig-paths": "^5.0.1"
},
"overrides": {
+1070 -835
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
# will have schema files for capabilities auto-completion
/gen/schemas
# Config
.cargo
+1105 -996
View File
File diff suppressed because it is too large Load Diff
+24 -23
View File
@@ -8,39 +8,39 @@ edition = "2021"
rust-version = "1.70"
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"sqlite",
] }
tokio = { version = "1", features = ["full"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-rc", features = [
tauri = { version = "2.0.0", 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-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 = "2.0.0"
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum.git" }
tauri-plugin-prevent-default = "0.6"
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb"] }
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
tokio = { version = "1", features = ["full"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
reqwest = "0.12.4"
url = "2.5.0"
futures = "0.3.30"
@@ -48,6 +48,7 @@ linkify = "0.10.0"
regex = "1.10.4"
keyring = { version = "3", features = ["apple-native", "windows-native"] }
keyring-search = "1.2.0"
tracing-subscriber = "0.3.18"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"identifier": "column",
"description": "Capability for the column",
"platforms": [
"linux",
+5 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"identifier": "window",
"description": "Capability for the desktop",
"platforms": [
"macOS",
@@ -14,7 +14,8 @@
"zap-*",
"event-*",
"user-*",
"editor-*"
"editor-*",
"popup-*"
],
"permissions": [
"core:path:default",
@@ -41,8 +42,8 @@
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"decorum:allow-show-snap-overlay",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
@@ -57,14 +58,10 @@
"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",
"store:default",
"prevent-default:default",
{
"identifier": "http:default",
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"]}}
{"column":{"identifier":"column","description":"Capability for the column","local":true,"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/*"}]}],"platforms":["linux","macOS","windows"]},"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*","popup-*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:default","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"]}}
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
+7 -16
View File
@@ -11,18 +11,18 @@
{
"default": true,
"official": true,
"label": "columns_gallery",
"name": "Columns Gallery",
"label": "Launchpad",
"name": "Launchpad",
"description": "Expand your experiences.",
"url": "/columns/gallery",
"url": "/columns/launchpad",
"picture": ""
},
{
"default": false,
"official": true,
"label": "local_feeds",
"name": "Local Feeds",
"description": "All notes from your follows.",
"label": "newsfeed",
"name": "Newsfeed",
"description": "All notes from you're following.",
"url": "/columns/newsfeed",
"picture": ""
},
@@ -58,19 +58,10 @@
"official": true,
"label": "global_feeds",
"name": "Global Feeds",
"description": "Discover all global notes.",
"description": "All global notes from all connected relays.",
"url": "/columns/global",
"picture": ""
},
{
"default": false,
"official": true,
"label": "group_feeds",
"name": "Group",
"description": "Custom feeds for group of people.",
"url": "/columns/group",
"picture": ""
},
{
"default": false,
"official": true,
+2 -2
View File
@@ -1,4 +1,4 @@
wss://relay.damus.io,
wss://relay.nostr.net,
wss://purplepag.es/,
wss://directory.yabu.me/,
wss://nostr.fmt.wiz.biz,
wss://offchain.pub,
+164 -67
View File
@@ -3,12 +3,18 @@ use keyring_search::{Limit, List, Search};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::HashSet, str::FromStr, time::Duration};
use std::{
collections::HashSet,
fs::{self, File},
str::FromStr,
time::Duration,
};
use tauri::{Emitter, Manager, State};
use tokio::time::sleep;
use crate::{
common::{get_user_settings, init_nip65},
Nostr, NEWSFEED_NEG_LIMIT, NOTIFICATION_NEG_LIMIT, NOTIFICATION_SUB_ID,
common::{get_latest_event, init_nip65},
Nostr, NOTIFICATION_SUB_ID,
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
@@ -44,7 +50,7 @@ pub async fn create_account(
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 secret_key = keys.secret_key();
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
@@ -127,13 +133,13 @@ pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<Str
Ok(bunker_uri) => {
// Local user
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().unwrap().to_string();
let app_secret = app_keys.secret_key().to_secret_hex();
// Get remote user
let remote_user = bunker_uri.signer_public_key().unwrap();
let remote_npub = remote_user.to_bech32().unwrap();
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None).await {
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => {
let mut url = Url::parse(&uri).unwrap();
let query: Vec<(String, String)> = url
@@ -204,6 +210,28 @@ pub fn delete_account(id: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
fs::metadata(config_dir.join(id)).is_ok()
}
#[tauri::command]
#[specta::specta]
pub fn create_sync_file(id: String, handle: tauri::AppHandle) -> bool {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
File::create(config_dir.join(id)).is_ok()
}
#[tauri::command]
#[specta::specta]
pub async fn login(
@@ -244,7 +272,7 @@ pub async fn login(
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap();
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None).await {
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => {
// Update signer
client.set_signer(Some(signer.into())).await;
@@ -255,86 +283,155 @@ pub async fn login(
}
};
// Connect to user's relay (NIP-65)
init_nip65(client).await;
// NIP-65: Connect to user's relay list
init_nip65(client, &public_key).await;
// Get user's contact list
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
let mut contacts_state = state.contact_list.lock().await;
*contacts_state = contacts;
// NIP-03: Get user's contact list
let contact_list = {
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
state.contact_list.lock().await.clone_from(&contacts);
contacts
} else {
Vec::new()
}
};
// Get user's settings
if let Ok(settings) = get_user_settings(client).await {
let mut settings_state = state.settings.lock().await;
*settings_state = settings;
};
let public_key_clone = public_key.clone();
// Run seperate thread for sync
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
let contact_list = state.contact_list.lock().await;
let author = PublicKey::from_str(&public_key).unwrap();
let signer = client.signer().await.unwrap();
let public_key = signer.public_key().await.unwrap();
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
// Subscribe for new notification
if let Ok(e) = client
.subscribe_with_id(
SubscriptionId::new(NOTIFICATION_SUB_ID),
vec![Filter::new().pubkey(author).since(Timestamp::now())],
None,
)
.await
{
println!("Subscribed: {}", e.success.len())
}
// Get events from contact list
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())
// Syncing all metadata events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::EventDeletion])
.limit(authors.len() * 20),
NegentropyOptions::default(),
)
.await
.is_ok()
{
handle.emit("newsfeed_synchronized", ()).unwrap();
println!("Received: {}", report.received.len());
}
};
drop(contact_list);
// Syncing all events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(authors.len() * 50),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
let sync = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(NOTIFICATION_NEG_LIMIT);
// Create the trusted public key list from contact list
// TODO: create a cached file
let mut trusted_list: HashSet<PublicKey> = HashSet::new();
// Sync notification with negentropy
if client
.reconcile(sync, NegentropyOptions::default())
.await
.is_ok()
{
handle.emit("notification_synchronized", ()).unwrap();
for author in authors.into_iter() {
trusted_list.insert(author);
let filter = Filter::new()
.author(author)
.kind(Kind::ContactList)
.limit(1);
if let Ok(events) = client.database().query(vec![filter]).await {
if let Some(event) = get_latest_event(&events) {
for tag in event.tags.iter() {
if let Some(TagStandard::PublicKey {
public_key,
uppercase: false,
..
}) = tag.to_owned().to_standardized()
{
trusted_list.insert(public_key);
};
}
}
}
}
// Update app's state
state.trusted_list.lock().await.clone_from(&trusted_list);
// Syncing all user's events
if let Ok(report) = client
.reconcile(Filter::new().author(author), NegentropyOptions::default())
.await
{
println!("Received: {}", report.received.len())
}
// Syncing all tagged events for current user
if let Ok(report) = client
.reconcile(
Filter::new().pubkey(author).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Syncing all events for trusted list
let trusted: Vec<PublicKey> = trusted_list.into_iter().collect();
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(trusted)
.kinds(vec![
Kind::Metadata,
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.limit(30000),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Wait a little longer
// TODO: remove?
sleep(Duration::from_secs(5)).await;
}
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)
}
handle
.emit("neg_synchronized", ())
.expect("Something wrong!");
});
Ok(public_key)
Ok(public_key_clone)
}
+138 -238
View File
@@ -5,8 +5,8 @@ use specta::Type;
use std::{str::FromStr, time::Duration};
use tauri::State;
use crate::common::{create_event_tags, filter_converstation, parse_event, Meta};
use crate::{Nostr, FETCH_LIMIT};
use crate::common::{create_tags, get_latest_event, parse_event, process_event, Meta};
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
#[derive(Debug, Clone, Serialize, Type)]
pub struct RichEvent {
@@ -28,15 +28,9 @@ pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent,
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().id(event_id);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
match client.database().query(vec![filter.clone()]).await {
Ok(events) => {
if let Some(event) = events.first() {
if let Some(event) = get_latest_event(&events) {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
@@ -46,7 +40,28 @@ pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent,
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
println!("Not found, getting event from relays...");
match client
.stream_events_of(vec![filter], Some(Duration::from_secs(10)))
.await
{
Ok(mut rx) => {
let mut raw: String = String::new();
let mut parsed: Option<Meta> = None;
while let Some(event) = rx.next().await {
raw = event.as_json();
parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
}
Ok(RichEvent { raw, parsed })
}
Err(err) => Err(err.to_string()),
}
}
}
Err(err) => Err(err.to_string()),
@@ -182,7 +197,7 @@ pub async fn subscribe_to(id: String, state: State<'_, Nostr>) -> Result<(), Str
#[tauri::command]
#[specta::specta]
pub async fn get_events_by(
pub async fn get_all_events_by_author(
public_key: String,
limit: i32,
state: State<'_, Nostr>,
@@ -191,106 +206,36 @@ pub async fn get_events_by(
let author = PublicKey::parse(&public_key).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::TextNote])
.kinds(vec![Kind::TextNote, Kind::Repost])
.author(author)
.limit(limit as usize);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.get_events_of(vec![filter], EventSource::Database)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_events_from_contacts(
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let contact_list = state.contact_list.lock().await;
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
if authors.is_empty() {
return Err("Contact List is empty.".into());
}
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.authors(authors);
match client.database().query(vec![filter], Order::Desc).await {
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_group_events(
public_keys: Vec<&str>,
until: Option<&str>,
pub async fn get_all_events_by_authors(
public_keys: Vec<String>,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let authors: Vec<PublicKey> = public_keys
.iter()
.map(|p| {
if p.starts_with("npub1") {
PublicKey::from_bech32(p).map_err(|err| err.to_string())
} else {
PublicKey::from_hex(p).map_err(|err| err.to_string())
}
})
.map(|pk| PublicKey::from_str(pk).map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, _>>()?;
let filter = Filter::new()
@@ -299,6 +244,35 @@ pub async fn get_group_events(
.until(as_of)
.authors(authors);
match client
.get_events_of(vec![filter], EventSource::Database)
.await
{
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_events_by_hashtags(
hashtags: Vec<String>,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.hashtags(hashtags);
match client
.get_events_of(
vec![filter],
@@ -306,23 +280,31 @@ pub async fn get_group_events(
)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
}
}
RichEvent { raw, parsed }
});
#[tauri::command]
#[specta::specta]
pub async fn get_local_events(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let rich_events = join_all(futures).await;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
Ok(rich_events)
}
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
}
}
@@ -330,12 +312,13 @@ pub async fn get_group_events(
#[tauri::command]
#[specta::specta]
pub async fn get_global_events(
until: Option<&str>,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
@@ -351,67 +334,7 @@ pub async fn get_global_events(
)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_hashtag_events(
hashtags: Vec<&str>,
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.hashtags(hashtags);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
}
}
@@ -427,38 +350,28 @@ pub async fn publish(
let client = &state.client;
// Create tags from content
let mut tags = create_event_tags(&content);
let mut tags = create_tags(&content);
// Add client tag
// TODO: allow user config this setting
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
// Add content-warning tag if present
if let Some(reason) = warning {
let t = TagStandard::ContentWarning {
reason: Some(reason),
};
let tag = Tag::from(t);
let tag = Tag::from_standardized(t);
tags.push(tag)
};
// Get signer
let signer = match client.signer().await {
Ok(signer) => signer,
Err(_) => return Err("Signer is required.".into()),
};
// Get public key
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
// Create unsigned event
let unsigned_event = match difficulty {
Some(num) => EventBuilder::text_note(content, tags).to_unsigned_pow_event(public_key, num),
None => EventBuilder::text_note(content, tags).to_unsigned_event(public_key),
};
let builder =
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
// Publish
match signer.sign_event(unsigned_event).await {
Ok(event) => match client.send_event(event).await {
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
Err(err) => Err(err.to_string()),
},
match client.send_event_builder(builder).await {
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
Err(err) => Err(err.to_string()),
}
}
@@ -474,19 +387,14 @@ pub async fn reply(
let client = &state.client;
let database = client.database();
// Create tags from content
let mut tags = create_event_tags(&content);
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
let mut tags = create_tags(&content);
match database
.query(vec![Filter::new().id(reply_id)], Order::Desc)
.await
{
match database.query(vec![Filter::new().id(reply_id)]).await {
Ok(events) => {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(event.id)
.event_seen_on_relays(&event.id)
.await
.map_err(|err| err.to_string())?
{
@@ -515,13 +423,10 @@ pub async fn reply(
Err(_) => return Err("Event is not valid.".into()),
};
if let Ok(events) = database
.query(vec![Filter::new().id(root_id)], Order::Desc)
.await
{
if let Ok(events) = database.query(vec![Filter::new().id(root_id)]).await {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(event.id)
.event_seen_on_relays(&event.id)
.await
.map_err(|err| err.to_string())?
{
@@ -549,7 +454,7 @@ pub async fn reply(
#[tauri::command]
#[specta::specta]
pub async fn repost(raw: &str, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
@@ -579,7 +484,7 @@ pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<Stri
let seens = client
.database()
.event_seen_on_relays(event_id)
.event_seen_on_relays(&event_id)
.await
.map_err(|err| err.to_string())?;
@@ -602,7 +507,7 @@ pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<Stri
#[tauri::command]
#[specta::specta]
pub async fn user_to_bech32(user: &str, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn user_to_bech32(user: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
@@ -642,48 +547,43 @@ pub async fn user_to_bech32(user: &str, state: State<'_, Nostr>) -> Result<Strin
#[tauri::command]
#[specta::specta]
pub async fn search(
query: String,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let filter = Filter::new().search(query);
let timestamp = match until {
Some(str) => Timestamp::from_str(&str).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Metadata])
.search(query)
.until(timestamp)
.limit(FETCH_LIMIT);
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
RichEvent { raw, parsed }
});
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let builder = EventBuilder::delete(vec![event_id]);
let rich_events = join_all(futures).await;
Ok(rich_events)
}
match client.send_event_builder(builder).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
+243 -170
View File
@@ -3,10 +3,13 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{str::FromStr, time::Duration};
use tauri::State;
use tauri::{Emitter, Manager, State};
use tauri_specta::Event;
use crate::{common::get_latest_event, NewSettings, Nostr, Settings};
use crate::{
common::{get_latest_event, process_event},
NewSettings, Nostr, RichEvent, Settings,
};
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Profile {
@@ -20,13 +23,25 @@ pub struct Profile {
website: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Mention {
pubkey: String,
avatar: String,
display_name: String,
name: String,
}
#[tauri::command]
#[specta::specta]
pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key: PublicKey = match id {
Some(user_id) => PublicKey::parse(&user_id).map_err(|e| e.to_string())?,
None => client.signer().await.unwrap().public_key().await.unwrap(),
None => {
let signer = client.signer().await.map_err(|e| e.to_string())?;
signer.public_key().await.map_err(|e| e.to_string())?
}
};
let filter = Filter::new()
@@ -34,22 +49,35 @@ pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<
.kind(Kind::Metadata)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(3))),
)
.await
{
match client.database().query(vec![filter.clone()]).await {
Ok(events) => {
if let Some(event) = events.first() {
if let Some(event) = get_latest_event(&events) {
if let Ok(metadata) = Metadata::from_json(&event.content) {
Ok(metadata.as_json())
} else {
Err("Parse metadata failed".into())
}
} else {
Ok(Metadata::new().as_json())
println!("Not found, getting event from relays...");
match client
.stream_events_of(vec![filter], Some(Duration::from_secs(10)))
.await
{
Ok(mut rx) => {
let mut metadata: String = Metadata::new().as_json();
while let Some(event) = rx.next().await {
println!("Event: {}", event.as_json());
if let Ok(m) = Metadata::from_json(&event.content) {
metadata = m.as_json();
break;
}
}
Ok(metadata)
}
Err(e) => Err(e.to_string()),
}
}
}
Err(e) => Err(e.to_string()),
@@ -71,9 +99,6 @@ pub async fn set_contact_list(
})
.collect();
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
match client.set_contact_list(contact_list).await {
Ok(_) => Ok(true),
Err(err) => Err(err.to_string()),
@@ -83,23 +108,13 @@ pub async fn set_contact_list(
#[tauri::command]
#[specta::specta]
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client;
let contact_list = state.contact_list.lock().await.clone();
let vec: Vec<String> = contact_list
.into_iter()
.map(|f| f.public_key.to_hex())
.collect();
match client.get_contact_list(Some(Duration::from_secs(10))).await {
Ok(contact_list) => {
if !contact_list.is_empty() {
let list = contact_list
.into_iter()
.map(|f| f.public_key.to_hex())
.collect();
Ok(list)
} else {
Err("Empty.".into())
}
}
Err(err) => Err(err.to_string()),
}
Ok(vec)
}
#[tauri::command]
@@ -137,21 +152,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
#[tauri::command]
#[specta::specta]
pub async fn is_contact_list_empty(state: State<'_, Nostr>) -> Result<bool, ()> {
Ok(state.contact_list.lock().await.is_empty())
}
pub async fn check_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let contact_list = &state.contact_list.lock().await;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
#[tauri::command]
#[specta::specta]
pub async fn check_contact(hex: String, state: State<'_, Nostr>) -> Result<bool, String> {
let contact_list = state.contact_list.lock().await;
match PublicKey::parse(&hex) {
Ok(public_key) => match contact_list.iter().position(|x| x.public_key == public_key) {
Some(_) => Ok(true),
None => Ok(false),
},
Err(e) => Err(e.to_string()),
match contact_list.iter().position(|x| x.public_key == public_key) {
Some(_) => Ok(true),
None => Ok(false),
}
}
@@ -181,9 +188,6 @@ pub async fn toggle_contact(
}
}
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
// Publish
match client.set_contact_list(contact_list).await {
Ok(event_id) => Ok(event_id.to_string()),
@@ -196,59 +200,198 @@ pub async fn toggle_contact(
#[tauri::command]
#[specta::specta]
pub async fn set_lume_store(
key: String,
content: String,
pub async fn set_group(
title: String,
description: Option<String>,
image: Option<String>,
users: Vec<String>,
state: State<'_, Nostr>,
handle: tauri::AppHandle,
) -> Result<String, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let public_keys: Vec<PublicKey> = users
.iter()
.map(|u| PublicKey::from_str(u).unwrap())
.collect();
let label = title.to_lowercase().replace(" ", "-");
let mut tags: Vec<Tag> = vec![Tag::title(title)];
let encrypted = signer
.nip44_encrypt(public_key, content)
.await
.map_err(|e| e.to_string())?;
let tag = Tag::identifier(key);
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
if let Some(desc) = description {
tags.push(Tag::description(desc))
};
if let Some(img) = image {
let url = UncheckedUrl::new(img);
tags.push(Tag::image(url, None));
}
let builder = EventBuilder::follow_set(label, public_keys.clone()).add_tags(tags);
match client.send_event_builder(builder).await {
Ok(event_id) => Ok(event_id.to_string()),
Err(err) => Err(err.to_string()),
Ok(report) => {
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(public_keys)
.limit(500);
if let Ok(report) = client.reconcile(filter, NegentropyOptions::default()).await {
println!("Received: {}", report.received.len());
handle.emit("synchronized", ()).unwrap();
};
});
Ok(report.id().to_hex())
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_lume_store(key: String, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let filter = Filter::new().kind(Kind::FollowSet).id(event_id);
match client.database().query(vec![filter]).await {
Ok(events) => match get_latest_event(&events) {
Some(ev) => Ok(ev.as_json()),
None => Err("Not found.".to_string()),
},
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_groups(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let filter = Filter::new().kind(Kind::FollowSet).author(public_key);
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(key)
.limit(10);
match client
.get_events_of(vec![filter], EventSource::Database)
.await
{
Ok(events) => {
if let Some(event) = get_latest_event(&events) {
match signer.nip44_decrypt(public_key, event.content()).await {
Ok(decrypted) => Ok(decrypted),
Err(_) => Err(event.content.to_string()),
}
} else {
Err("Not found".into())
}
}
Err(err) => Err(err.to_string()),
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn set_interest(
title: String,
description: Option<String>,
image: Option<String>,
hashtags: Vec<String>,
state: State<'_, Nostr>,
handle: tauri::AppHandle,
) -> 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);
match client.send_event_builder(builder).await {
Ok(report) => {
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.hashtags(hashtags)
.limit(500);
if let Ok(report) = client.reconcile(filter, NegentropyOptions::default()).await {
println!("Received: {}", report.received.len());
handle.emit("synchronized", ()).unwrap();
};
});
Ok(report.id().to_hex())
}
Err(e) => Err(e.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())?;
let filter = Filter::new()
.kinds(vec![Kind::Interests, Kind::InterestSet])
.id(event_id);
match client.database().query(vec![filter]).await {
Ok(events) => match get_latest_event(&events) {
Some(ev) => Ok(ev.as_json()),
None => Err("Not found.".to_string()),
},
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_interests(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::InterestSet, Kind::Interests])
.author(public_key);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|e| e.to_string())?;
let data: Vec<Mention> = events
.iter()
.map(|event| {
let pubkey = event.pubkey.to_bech32().unwrap();
let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new());
Mention {
pubkey,
avatar: metadata.picture.unwrap_or_else(|| "".to_string()),
display_name: metadata.display_name.unwrap_or_else(|| "".to_string()),
name: metadata.name.unwrap_or_else(|| "".to_string()),
}
})
.collect();
Ok(data)
}
#[tauri::command]
#[specta::specta]
pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, String> {
@@ -382,7 +525,7 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
.await
{
for event in contact_list_events.into_iter() {
for tag in event.into_iter_tags() {
for tag in event.tags.into_iter() {
if let Some(TagStandard::PublicKey {
public_key,
relay_url,
@@ -405,74 +548,6 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
}
}
pub async fn get_following(
state: State<'_, Nostr>,
public_key: &str,
) -> Result<Vec<String>, String> {
let client = &state.client;
let public_key = PublicKey::parse(public_key).map_err(|e| e.to_string())?;
let filter = Filter::new().kind(Kind::ContactList).author(public_key);
let events = match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => events,
Err(err) => return Err(err.to_string()),
};
let mut ret: Vec<String> = vec![];
if let Some(latest_event) = events.iter().max_by_key(|event| event.created_at()) {
ret.extend(latest_event.tags().iter().filter_map(|tag| {
if let Some(TagStandard::PublicKey {
uppercase: false, ..
}) = <nostr_sdk::Tag as Clone>::clone(tag).to_standardized()
{
tag.content().map(String::from)
} else {
None
}
}));
}
Ok(ret)
}
pub async fn get_followers(
state: State<'_, Nostr>,
public_key: &str,
) -> Result<Vec<String>, String> {
let client = &state.client;
let public_key = PublicKey::parse(public_key).map_err(|e| e.to_string())?;
let filter = Filter::new().kind(Kind::ContactList).custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
vec![public_key.to_hex()],
);
let events = match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => events,
Err(err) => return Err(err.to_string()),
};
let ret: Vec<String> = events
.into_iter()
.map(|event| event.author().to_hex())
.collect();
Ok(ret)
// TODO: get more than 500 events
}
#[tauri::command]
#[specta::specta]
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
@@ -491,11 +566,7 @@ pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, S
])
.limit(200);
match client
.database()
.query(vec![filter], Order::default())
.await
{
match client.database().query(vec![filter]).await {
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Err(err) => Err(err.to_string()),
}
@@ -506,31 +577,24 @@ pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, S
#[tauri::command]
#[specta::specta]
pub async fn get_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
pub async fn get_user_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
Ok(state.settings.lock().await.clone())
}
#[tauri::command]
#[specta::specta]
pub async fn set_settings(
settings: &str,
pub async fn set_user_settings(
settings: String,
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
.map_err(|e| e.to_string())?;
let tag = Tag::identifier(ident);
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
let tags = vec![Tag::identifier("lume_user_setting")];
let builder = EventBuilder::new(Kind::ApplicationSpecificData, &settings, tags);
match client.send_event_builder(builder).await {
Ok(_) => {
let parsed: Settings = serde_json::from_str(settings).map_err(|e| e.to_string())?;
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
// Update state
state.settings.lock().await.clone_from(&parsed);
@@ -555,3 +619,12 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let trusted_list = &state.trusted_list.lock().await;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
Ok(trusted_list.contains(&public_key))
}
+10 -10
View File
@@ -113,15 +113,15 @@ pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool
#[specta::specta]
#[cfg(target_os = "macos")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app_handle.get_window(&window.label) {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
if let Some(current_window) = app_handle.get_window(&window.label) {
if current_window.is_visible().unwrap_or_default() {
let _ = current_window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
let _ = current_window.show();
let _ = current_window.set_focus();
};
} else {
let window = WebviewWindowBuilder::new(
let new_window = WebviewWindowBuilder::new(
&app_handle,
&window.label,
WebviewUrl::App(PathBuf::from(window.url)),
@@ -129,7 +129,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.title(&window.title)
.min_inner_size(window.width, window.height)
.inner_size(window.width, window.height)
.hidden_title(true)
.hidden_title(window.hidden_title)
.title_bar_style(TitleBarStyle::Overlay)
.minimizable(window.minimizable)
.maximizable(window.maximizable)
@@ -144,7 +144,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.unwrap();
// Restore native border
window.add_border(None);
new_window.add_border(None);
}
Ok(())
@@ -217,7 +217,7 @@ pub fn reopen_lume(app: tauri::AppHandle) {
// Set a custom inset to the traffic lights
#[cfg(target_os = "macos")]
window.set_traffic_lights_inset(7.0, 13.0).unwrap();
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
#[cfg(target_os = "macos")]
let win = window.clone();
@@ -225,7 +225,7 @@ pub fn reopen_lume(app: tauri::AppHandle) {
#[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();
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
}
});
}
+140 -210
View File
@@ -1,3 +1,4 @@
use futures::future::join_all;
use linkify::LinkFinder;
use nostr_sdk::prelude::*;
use reqwest::Client as ReqClient;
@@ -7,18 +8,18 @@ use std::collections::HashSet;
use std::str::FromStr;
use std::time::Duration;
use crate::Settings;
use crate::RichEvent;
#[derive(Debug, Clone, Serialize, Type)]
pub struct Meta {
pub content: String,
pub images: Vec<String>,
pub videos: Vec<String>,
pub events: Vec<String>,
pub mentions: Vec<String>,
pub hashtags: Vec<String>,
}
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
const NOSTR_EVENTS: [&str; 10] = [
"@nevent1",
"@note1",
@@ -43,135 +44,12 @@ const NOSTR_MENTIONS: [&str; 10] = [
"Nostr:nprofile1",
"Nostr:naddr1",
];
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
const VIDEOS: [&str; 5] = ["mp4", "mov", "avi", "webm", "mkv"];
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
events.iter().next()
}
pub fn filter_converstation(events: Vec<Event>) -> Vec<Event> {
events
.into_iter()
.filter_map(|ev| {
let tags = ev.get_tags_content(TagKind::SingleLetter(SingleLetterTag::lowercase(
Alphabet::E,
)));
if tags.is_empty() {
Some(ev)
} else {
None
}
})
.collect::<Vec<Event>>()
}
pub fn dedup_event(events: &[Event]) -> Vec<Event> {
let mut seen_ids = HashSet::new();
events
.iter()
.filter(|&event| {
let e = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E));
let e_tags: Vec<&Tag> = event.tags.iter().filter(|el| el.kind() == e).collect();
let ids: Vec<&str> = e_tags.iter().filter_map(|tag| tag.content()).collect();
let is_dup = ids.iter().any(|id| seen_ids.contains(*id));
for id in &ids {
seen_ids.insert(*id);
}
!is_dup
})
.cloned()
.collect()
}
pub async fn parse_event(content: &str) -> Meta {
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
// Get urls
let urls: Vec<_> = finder.links(content).collect();
// Get words
let words: Vec<_> = content.split_whitespace().collect();
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let events = words
.iter()
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mentions = words
.iter()
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut videos = Vec::new();
let mut text = content.to_string();
if !urls.is_empty() {
let client = ReqClient::new();
for url in urls {
let url_str = url.as_str();
if let Ok(parsed_url) = Url::from_str(url_str) {
if let Some(ext) = parsed_url
.path_segments()
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
{
if IMAGES.contains(&ext) {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
if VIDEOS.contains(&ext) {
text = text.replace(url_str, "");
videos.push(url_str.to_string());
// Process the next item.
continue;
}
}
// Check the content type of URL via HEAD request
if let Ok(res) = client.head(url_str).send().await {
if let Some(content_type) = res.headers().get("Content-Type") {
if content_type.to_str().unwrap_or("").starts_with("image") {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
}
}
}
}
}
// Clean up the resulting content string to remove extra spaces
let cleaned_text = text.trim().to_string();
Meta {
content: cleaned_text,
events,
mentions,
hashtags,
images,
videos,
}
}
pub fn create_event_tags(content: &str) -> Vec<Tag> {
pub fn create_tags(content: &str) -> Vec<Tag> {
let mut tags: Vec<Tag> = vec![];
let mut tag_set: HashSet<String> = HashSet::new();
@@ -252,31 +130,83 @@ pub fn create_event_tags(content: &str) -> Vec<Tag> {
tags
}
pub async fn init_nip65(client: &Client) {
let signer = match client.signer().await {
Ok(signer) => signer,
Err(e) => {
eprintln!("Failed to get signer: {:?}", e);
return;
}
};
let public_key = match signer.public_key().await {
Ok(public_key) => public_key,
Err(e) => {
eprintln!("Failed to get public key: {:?}", e);
return;
pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent> {
// Remove event thread if event is TextNote
let events: Vec<Event> = 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();
// 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(|ev| ev.get_tags_content(TagKind::e()))
.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();
}
};
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
// 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 init_nip65(client: &Client, public_key: &str) {
let author = PublicKey::from_str(public_key).unwrap();
let filter = Filter::new().author(author).kind(Kind::RelayList).limit(1);
// client.add_relay("ws://127.0.0.1:1984").await.unwrap();
// client.connect_relay("ws://127.0.0.1:1984").await.unwrap();
if let Ok(events) = client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
EventSource::relays(Some(Duration::from_secs(5))),
)
.await
{
@@ -288,7 +218,7 @@ pub async fn init_nip65(client: &Client) {
Some(_) => RelayOptions::new().write(true).read(false),
None => RelayOptions::default(),
};
if let Err(e) = client.add_relay_with_opts(&url.to_string(), opts).await {
if let Err(e) = client.pool().add_relay(&url.to_string(), opts).await {
eprintln!("Failed to add relay {}: {:?}", url, e);
}
if let Err(e) = client.connect_relay(url.to_string()).await {
@@ -298,81 +228,81 @@ pub async fn init_nip65(client: &Client) {
}
}
}
} 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))?;
pub async fn parse_event(content: &str) -> Meta {
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(ident)
.limit(1);
// Get urls
let urls: Vec<_> = finder.links(content).collect();
// Get words
let words: Vec<_> = content.split_whitespace().collect();
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
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)),
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let events = words
.iter()
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mentions = words
.iter()
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut text = content.to_string();
if !urls.is_empty() {
let client = ReqClient::new();
for url in urls {
let url_str = url.as_str();
if let Ok(parsed_url) = Url::from_str(url_str) {
if let Some(ext) = parsed_url
.path_segments()
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
{
if IMAGES.contains(&ext) {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
}
// Check the content type of URL via HEAD request
if let Ok(res) = client.head(url_str).send().await {
if let Some(content_type) = res.headers().get("Content-Type") {
if content_type.to_str().unwrap_or("").starts_with("image") {
text = text.replace(url_str, "");
images.push(url_str.to_string());
// Process the next item.
continue;
}
}
}
} else {
Err("Settings not found.".into())
}
}
Err(e) => Err(format!(
"Failed to get events for ApplicationSpecificData: {:?}",
e
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
// Clean up the resulting content string to remove extra spaces
let cleaned_text = text.trim().to_string();
#[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"]);
Meta {
content: cleaned_text,
events,
mentions,
hashtags,
images,
}
}
+212 -65
View File
@@ -7,11 +7,12 @@
use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
use common::parse_event;
use nostr_sdk::prelude::*;
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
use serde::{Deserialize, Serialize};
use specta::Type;
use specta_typescript::Typescript;
use std::{
collections::HashSet,
fs,
io::{self, BufRead},
str::FromStr,
@@ -28,8 +29,9 @@ pub mod common;
pub struct Nostr {
client: Client,
contact_list: Mutex<Vec<Contact>>,
settings: Mutex<Settings>,
contact_list: Mutex<Vec<Contact>>,
trusted_list: Mutex<HashSet<PublicKey>>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -38,6 +40,7 @@ pub struct Settings {
image_resize_service: Option<String>,
use_relay_hint: bool,
content_warning: bool,
trusted_only: bool,
display_avatar: bool,
display_zap_button: bool,
display_repost_button: bool,
@@ -52,6 +55,7 @@ impl Default for Settings {
image_resize_service: Some("https://wsrv.nl".to_string()),
use_relay_hint: true,
content_warning: true,
trusted_only: false,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
@@ -77,12 +81,15 @@ struct Subscription {
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
struct NewSettings(Settings);
pub const FETCH_LIMIT: usize = 44;
pub const NEWSFEED_NEG_LIMIT: usize = 256;
pub const DEFAULT_DIFFICULTY: u8 = 21;
pub const FETCH_LIMIT: usize = 100;
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
fn main() {
#[cfg(debug_assertions)]
tracing_subscriber::fmt::init();
let builder = Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(collect_commands![
@@ -98,16 +105,22 @@ fn main() {
get_private_key,
delete_account,
reset_password,
is_account_sync,
create_sync_file,
login,
get_profile,
set_profile,
get_contact_list,
set_contact_list,
is_contact_list_empty,
check_contact,
toggle_contact,
get_lume_store,
set_lume_store,
get_mention_list,
set_group,
get_group,
get_all_groups,
set_interest,
get_interest,
get_all_interests,
set_wallet,
load_wallet,
remove_wallet,
@@ -115,19 +128,22 @@ fn main() {
zap_event,
copy_friend,
get_notifications,
get_settings,
set_settings,
get_user_settings,
set_user_settings,
verify_nip05,
is_trusted_user,
get_event_meta,
get_event,
get_event_from,
get_replies,
subscribe_to,
get_events_by,
get_events_from_contacts,
get_group_events,
get_all_events_by_author,
get_all_events_by_authors,
get_all_events_by_hashtags,
get_local_events,
get_global_events,
get_hashtag_events,
is_deleted_event,
request_delete,
search,
publish,
reply,
@@ -156,8 +172,6 @@ fn main() {
#[cfg(not(target_os = "macos"))]
let tauri_builder = tauri::Builder::default();
let mut ctx = tauri::generate_context!();
tauri_builder
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
@@ -168,6 +182,19 @@ fn main() {
let handle_clone_child = handle_clone.clone();
let main_window = app.get_webview_window("main").unwrap();
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
let data_dir = handle
.path()
.app_data_dir()
.expect("Error: app data directory not found.");
let _ = fs::create_dir_all(&config_dir);
let _ = fs::create_dir_all(&data_dir);
// Set custom decoration for Windows
#[cfg(target_os = "windows")]
main_window.create_overlay_titlebar().unwrap();
@@ -191,23 +218,17 @@ fn main() {
});
let client = tauri::async_runtime::block_on(async move {
// Create data folder if not exist
let dir = handle
.path()
.app_config_dir()
.expect("App config directory not found.");
let _ = fs::create_dir_all(dir.clone());
// Setup database
let database = SQLiteDatabase::open(dir.join("nostr.db"))
.await
.expect("Database error.");
let database = NostrLMDB::open(config_dir.join("nostr-lmdb"))
.expect("Error: cannot create database.");
// Config
let opts = Options::new()
.gossip(true)
.max_avg_latency(Duration::from_millis(500))
.automatic_authentication(true)
.connection_timeout(Some(Duration::from_secs(5)))
.automatic_authentication(false)
.connection_timeout(Some(Duration::from_secs(20)))
.send_timeout(Some(Duration::from_secs(10)))
.timeout(Duration::from_secs(20));
// Setup nostr client
@@ -234,7 +255,7 @@ fn main() {
} else {
RelayOptions::new().write(true).read(false)
};
let _ = client.add_relay_with_opts(relay, opts).await;
let _ = client.pool().add_relay(relay, opts).await;
}
Err(_) => {
let _ = client.add_relay(relay).await;
@@ -244,8 +265,16 @@ fn main() {
}
}
if let Err(e) = client.add_discovery_relay("wss://purplepag.es/").await {
println!("Add discovery relay failed: {}", e)
}
if let Err(e) = client.add_discovery_relay("wss://directory.yabu.me/").await {
println!("Add discovery relay failed: {}", e)
}
// Connect
client.connect().await;
client.connect_with_timeout(Duration::from_secs(20)).await;
client
});
@@ -253,8 +282,9 @@ fn main() {
// Create global state
app.manage(Nostr {
client,
contact_list: Mutex::new(vec![]),
settings: Mutex::new(Settings::default()),
contact_list: Mutex::new(Vec::new()),
trusted_list: Mutex::new(HashSet::new()),
});
Subscription::listen_any(app, move |event| {
@@ -269,27 +299,43 @@ fn main() {
SubKind::Subscribe => {
let subscription_id = SubscriptionId::new(payload.label);
let filter = if let Some(id) = payload.event_id {
let event_id = EventId::from_str(&id).unwrap();
match payload.event_id {
Some(id) => {
let event_id = EventId::from_str(&id).unwrap();
let filter =
Filter::new().event(event_id).since(Timestamp::now());
Filter::new().event(event_id).since(Timestamp::now())
} else {
let contact_list = state.contact_list.lock().await;
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
None => {
let contact_list = client
.get_contact_list(Some(Duration::from_secs(5)))
.await
.unwrap();
Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now())
if !contact_list.is_empty() {
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
}
};
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
SubKind::Unsubscribe => {
let subscription_id = SubscriptionId::new(payload.label);
@@ -299,6 +345,22 @@ fn main() {
});
});
// Run local relay thread
//tauri::async_runtime::spawn(async move {
// let database = NostrLMDB::open(data_dir.join("local-relay"))
// .expect("Error: cannot create database.");
// let builder = RelayBuilder::default().database(database).port(1984);
//
// if let Ok(relay) = LocalRelay::run(builder).await {
// println!("Running local relay: {}", relay.url())
// }
//
// loop {
// tokio::time::sleep(Duration::from_secs(60)).await;
// }
//});
// Run notification thread
tauri::async_runtime::spawn(async move {
let state = handle_clone.state::<Nostr>();
let client = &state.client;
@@ -315,11 +377,52 @@ fn main() {
};
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
let mut notifications = client.pool().notifications();
client
.handle_notifications(|notification| async {
if let RelayPoolNotification::Message { message, .. } = notification {
if let RelayMessage::Event {
while let Ok(notification) = notifications.recv().await {
match notification {
RelayPoolNotification::Message { relay_url, message } => {
if let RelayMessage::Auth { challenge } = message {
match client.auth(challenge, relay_url.clone()).await {
Ok(..) => {
if let Ok(relay) = client.relay(&relay_url).await {
let msg =
format!("Authenticated to {} relay.", relay_url);
let opts = RelaySendOptions::new()
.skip_send_confirmation(true);
if let Err(e) = relay.resubscribe(opts).await {
println!("Error: {}", e);
}
if allow_notification {
if let Err(e) = &handle_clone
.notification()
.builder()
.body(&msg)
.title("Lume")
.show()
{
println!("Error: {}", e);
}
}
}
}
Err(e) => {
if allow_notification {
if let Err(e) = &handle_clone
.notification()
.builder()
.body(e.to_string())
.title("Lume")
.show()
{
println!("Error: {}", e);
}
}
}
}
} else if let RelayMessage::Event {
subscription_id,
event,
} = message
@@ -329,11 +432,15 @@ fn main() {
// Send native notification
if allow_notification {
let author = client
.metadata(event.pubkey)
.database()
.profile(event.pubkey)
.await
.unwrap_or_else(|_| Metadata::new());
.unwrap_or_else(|_| {
DatabaseProfile::new(event.pubkey, Metadata::new())
});
let metadata = author.metadata();
send_notification(&event, author, &handle_clone);
send_event_notification(&event, metadata, &handle_clone);
}
}
@@ -352,19 +459,58 @@ fn main() {
RichEvent { raw, parsed },
)
.unwrap();
} else {
println!("new message: {}", message.as_json())
}
};
}
Ok(false)
})
.await
RelayPoolNotification::Shutdown => break,
_ => (),
}
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::Focused(focused) = event {
if !focused {
let handle = window.app_handle().to_owned();
let config_dir = handle.path().app_config_dir().unwrap();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
if let Ok(signer) = client.signer().await {
let public_key = signer.public_key().await.unwrap();
let bech32 = public_key.to_bech32().unwrap();
if fs::metadata(config_dir.join(bech32)).is_ok() {
if let Ok(contact_list) =
client.get_contact_list(Some(Duration::from_secs(5))).await
{
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if client
.reconcile(
Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(1000),
NegentropyOptions::default(),
)
.await
.is_ok()
{
handle.emit("synchronized", ()).unwrap();
}
}
}
}
});
}
}
})
.plugin(prevent_default())
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
.plugin(tauri_plugin_decorum::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -377,7 +523,8 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.run(ctx)
.plugin(tauri_plugin_window_state::Builder::default().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -395,8 +542,8 @@ fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri_plugin_prevent_default::Builder::new().build()
}
fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind() {
fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind {
Kind::TextNote => {
if let Err(e) = handle
.notification()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"productName": "Lume",
"version": "4.1.1",
"version": "4.3.0",
"identifier": "nu.lume.Lume",
"build": {
"beforeDevCommand": "pnpm dev",
+11 -11
View File
@@ -1,27 +1,27 @@
import {
type PersistedQuery,
experimental_createPersister,
} from "@tanstack/query-persist-client-core";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { newQueryStorage } from "./commons";
import { routeTree } from "./routes.gen"; // auto generated file
import type { LumeEvent } from "./system";
import "./app.css";
import { Store } from "@tauri-apps/plugin-store";
import "./app.css"; // global styles
import { createStore } from "@tauri-apps/plugin-store";
import { routeTree } from "./routes.gen"; // auto generated file
const platform = type();
const tauriStore = new Store(".lume.dat");
// @ts-expect-error, required: https://github.com/tauri-apps/plugins-workspace/pull/1860
const store = await createStore(".cache", { autoSave: 250 });
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30,
persister: experimental_createPersister<PersistedQuery>({
storage: newQueryStorage(tauriStore),
maxAge: 1000 * 60 * 60 * 24, // 24 hours,
persister: experimental_createPersister({
storage: newQueryStorage(store),
maxAge: 1000 * 60 * 60 * 12,
}),
},
},
+94 -31
View File
@@ -96,6 +96,12 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
else return { status: "error", error: e as any };
}
},
async isAccountSync(id: string) : Promise<boolean> {
return await TAURI_INVOKE("is_account_sync", { id });
},
async createSyncFile(id: string) : Promise<boolean> {
return await TAURI_INVOKE("create_sync_file", { id });
},
async login(account: string, password: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
@@ -136,17 +142,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async isContactListEmpty() : Promise<Result<boolean, null>> {
async checkContact(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async checkContact(hex: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
return { status: "ok", data: await TAURI_INVOKE("check_contact", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -160,17 +158,57 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any };
}
},
async getLumeStore(key: string) : Promise<Result<string, string>> {
async getMentionList() : Promise<Result<Mention[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) };
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setLumeStore(key: string, content: string) : Promise<Result<string, string>> {
async setGroup(title: string, description: string | null, image: string | null, users: string[]) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_lume_store", { key, content }) };
return { status: "ok", data: await TAURI_INVOKE("set_group", { title, description, image, users }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGroup(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_group", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllGroups() : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_groups") };
} 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() : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_interests") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -232,17 +270,17 @@ async getNotifications() : Promise<Result<string[], string>> {
else return { status: "error", error: e as any };
}
},
async getSettings() : Promise<Result<Settings, null>> {
async getUserSettings() : Promise<Result<Settings, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_settings") };
return { status: "ok", data: await TAURI_INVOKE("get_user_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>> {
async setUserSettings(settings: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_settings", { settings }) };
return { status: "ok", data: await TAURI_INVOKE("set_user_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -256,6 +294,14 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
else return { status: "error", error: e as any };
}
},
async isTrustedUser(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_trusted_user", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
@@ -296,25 +342,33 @@ async subscribeTo(id: string) : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async getEventsBy(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
async getAllEventsByAuthor(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, limit }) };
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventsFromContacts(until: string | null) : Promise<Result<RichEvent[], string>> {
async getAllEventsByAuthors(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_events_from_contacts", { until }) };
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_authors", { publicKeys, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
async getAllEventsByHashtags(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) };
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_hashtags", { hashtags, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -328,17 +382,25 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
else return { status: "error", error: e as any };
}
},
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
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 search(query: string, until: string | null) : Promise<Result<RichEvent[], string>> {
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("search", { query, until }) };
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 search(query: string) : Promise<Result<RichEvent[], string>> {
try {
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 };
@@ -458,12 +520,13 @@ subscription: "subscription"
/** user-defined types **/
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type NewSettings = Settings
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
export type SubKind = "Subscribe" | "Unsubscribe"
export type Subscription = { label: string; kind: SubKind; event_id: string | null }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
@@ -484,7 +547,7 @@ type __EventObj__<T> = {
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: T extends null
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
+15 -60
View File
@@ -15,12 +15,10 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder";
import { type BaseEditor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen";
import { LumeEvent } from "./system";
import type { NostrEvent } from "./types";
import type { LumeColumn, NostrEvent } from "./types";
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
@@ -59,51 +57,6 @@ export const isImageUrl = (url: string) => {
}
};
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
const text = { text: "" };
const image = [
{
type: "image",
url,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
// @ts-ignore, idk
ReactEditor.focus(editor);
Transforms.insertNodes(editor, image);
Transforms.insertNodes(editor, extraText);
};
export const insertNostrEvent = (
editor: ReactEditor | BaseEditor,
eventId: string,
) => {
const text = { text: "" };
const event = [
{
type: "event",
eventId: `nostr:${eventId}`,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
Transforms.insertNodes(editor, event);
Transforms.insertNodes(editor, extraText);
};
export function formatCreatedAt(time: number, message = false) {
let formated: string;
@@ -201,6 +154,7 @@ export function decodeZapInvoice(tags?: string[][]) {
(s: { name: string }) => s.name === "amount",
);
// @ts-ignore, its fine.
const amount = Number.parseInt(amountSection.value);
const displayValue = getBitcoinDisplayValues(amount);
@@ -257,18 +211,16 @@ export async function upload(filePath?: string) {
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
filePath ??
(await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
}));
// User cancelled action
if (!selected) return null;
@@ -331,9 +283,12 @@ export const appSettings = new Store<Settings>({
image_resize_service: "https://wsrv.nl",
use_relay_hint: true,
content_warning: true,
trusted_only: true,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
display_media: true,
transparent: true,
});
export const appColumns = new Store<LumeColumn[]>([]);
+22 -17
View File
@@ -1,7 +1,9 @@
import { commands } from "@/commands.gen";
import { appColumns } from "@/commons";
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 { useStore } from "@tanstack/react-store";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
@@ -86,10 +88,10 @@ export const Column = memo(function Column({ column }: { column: LumeColumn }) {
}, [params.account]);
return (
<div className="h-full w-[440px] shrink-0 p-2">
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/15">
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full">
<div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
<div className="flex flex-col gap-px size-full">
<Header label={column.label} />
<div ref={container} className="flex-1 size-full">
{!isCreated ? (
<div className="size-full flex items-center justify-center">
<Spinner />
@@ -101,10 +103,14 @@ export const Column = memo(function Column({ column }: { column: LumeColumn }) {
);
});
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
function Header({ label }: { label: string }) {
const [title, setTitle] = useState("");
const [isChanged, setIsChanged] = useState(false);
const column = useStore(appColumns, (state) =>
state.find((col) => col.label === label),
);
const saveNewTitle = async () => {
const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "set_title", label, title });
@@ -173,29 +179,28 @@ function Header({ label, name }: { label: string; name: string }) {
}, []);
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
if (title.length > 0) setIsChanged(true);
}, [title.length]);
return (
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" />
<div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
<div className="flex items-center justify-center shrink-0 h-7">
<div className="relative flex items-center gap-2">
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
className="text-[12px] font-semibold focus:outline-none"
>
{name}
{column.name}
</div>
{isChanged ? (
<button
type="button"
onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600"
className="text-teal-500 hover:text-teal-600 inline-flex items-center justify-center size-6 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full bg-white dark:bg-black"
>
<Check className="size-4" />
<Check className="size-3" weight="bold" />
</button>
) : null}
</div>
@@ -203,9 +208,9 @@ function Header({ label, name }: { label: string; name: string }) {
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10"
className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full bg-white dark:bg-black"
>
<DotsThree className="size-5" />
<CaretDown className="size-3" weight="bold" />
</button>
</div>
);
+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>
);
+5
View File
@@ -13,3 +13,8 @@ export * from "./reply";
// Global components
export * from "./note";
export * from "./user";
// Icons
export * from "./icons/reply";
export * from "./icons/repost";
export * from "./icons/zap";
+3 -3
View File
@@ -1,5 +1,5 @@
import { LumeWindow } from "@/system";
import { FrameCorners } from "@phosphor-icons/react";
import { ListPlus } from "@phosphor-icons/react";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
@@ -15,12 +15,12 @@ export function NoteOpenThread() {
onClick={() => LumeWindow.openEvent(event)}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
>
<FrameCorners className="shrink-0 size-4" />
<ListPlus className="shrink-0 size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Open
View thread
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
+8 -5
View File
@@ -1,10 +1,13 @@
import { cn } from "@/commons";
import { ReplyIcon } from "@/components";
import { LumeWindow } from "@/system";
import { ShareFat } from "@phosphor-icons/react";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
export function NoteReply({ large = false }: { large?: boolean }) {
export function NoteReply({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const event = useNoteContext();
return (
@@ -16,13 +19,13 @@ export function NoteReply({ large = false }: { large?: boolean }) {
onClick={() => LumeWindow.openEditor(event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
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",
)}
>
<ShareFat className="shrink-0 size-4" />
{large ? "Reply" : null}
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Reply" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
+15 -8
View File
@@ -1,14 +1,16 @@
import { appSettings, cn } from "@/commons";
import { Spinner } from "@/components";
import { RepostIcon, Spinner } from "@/components";
import { LumeWindow } from "@/system";
import { Repeat } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) {
export function NoteRepost({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const visible = useStore(appSettings, (state) => state.display_repost_button);
const event = useNoteContext();
@@ -41,11 +43,11 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
text: "Repost",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
text: "Quote",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
@@ -65,7 +67,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
label
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
@@ -73,9 +75,14 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
{loading ? (
<Spinner className="size-4" />
) : (
<Repeat className={cn("size-4", isRepost ? "text-blue-500" : "")} />
<RepostIcon
className={cn(
smol ? "size-4" : "size-5",
isRepost ? "text-blue-500" : "",
)}
/>
)}
{large ? "Repost" : null}
{label ? "Repost" : null}
</button>
);
}
+8 -5
View File
@@ -1,11 +1,14 @@
import { appSettings, cn } from "@/commons";
import { ZapIcon } from "@/components";
import { LumeWindow } from "@/system";
import { Lightning } from "@phosphor-icons/react";
import { useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { useNoteContext } from "../provider";
export function NoteZap({ large = false }: { large?: boolean }) {
export function NoteZap({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const search = useSearch({ strict: false });
const visible = useStore(appSettings, (state) => state.display_zap_button);
const event = useNoteContext();
@@ -18,13 +21,13 @@ export function NoteZap({ large = false }: { large?: boolean }) {
onClick={() => LumeWindow.openZap(event.id, search.account)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
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",
)}
>
<Lightning className="size-4" />
{large ? "Zap" : null}
<ZapIcon className={smol ? "size-4" : "size-5"} />
{label ? "Zap" : null}
</button>
);
}
+3 -10
View File
@@ -7,7 +7,6 @@ import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider";
export function NoteContent({
@@ -95,21 +94,15 @@ export function NoteContent({
<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 ? (
<Images urls={event.meta.images} />
) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</>
event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null
) : null}
</div>
);
+2 -10
View File
@@ -6,7 +6,6 @@ import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { ImagePreview } from "./preview/image";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
@@ -18,7 +17,7 @@ export function NoteContentLarge({
const content = useMemo(() => {
try {
// Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta;
const { images, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = event.content;
@@ -48,12 +47,6 @@ export function NoteContentLarge({
));
}
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
@@ -75,8 +68,7 @@ export function NoteContentLarge({
));
return richContent;
} catch (e) {
console.log("[parser]: ", e);
} catch {
return event.content;
}
}, [event.content]);
+2 -8
View File
@@ -1,13 +1,7 @@
import { LumeWindow } from "@/system";
export function Hashtag({ tag }: { tag: string }) {
return (
<button
type="button"
onClick={() => LumeWindow.openHashtag(tag)}
className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal"
>
<span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
{tag}
</button>
</span>
);
}
+80 -10
View File
@@ -2,7 +2,11 @@ import { replyTime } from "@/commons";
import { Note, Spinner } from "@/components";
import { User } from "@/components/user";
import { LumeWindow, useEvent } from "@/system";
import { memo } from "react";
import { nip19 } from "nostr-tools";
import { type ReactNode, memo, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./hashtag";
import { MentionUser } from "./user";
export const MentionNote = memo(function MentionNote({
eventId,
@@ -13,14 +17,17 @@ export const MentionNote = memo(function MentionNote({
return (
<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="min-h-[64px] pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
{isLoading ? (
<Spinner />
<div className="h-[64px] flex items-center">
<Spinner />
</div>
) : isError || !event ? (
<p className="text-sm font-medium text-red-500">
{error.message ||
"Quoted note is not found with your current relay set"}
</p>
<div className="h-[64px] flex items-center">
<p className="text-sm font-medium text-red-500">
{error.message || "Note can be found with your current relay set"}
</p>
</div>
) : (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
@@ -33,9 +40,11 @@ export const MentionNote = memo(function MentionNote({
/>
</User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content.length > 120
? `${event.content.substring(0, 120)}...`
: event.content}
{event.content.length > 300 ? (
`${event.content.substring(0, 300)}...`
) : (
<Content text={event.content} className="inline" />
)}
</div>
</div>
<div className="flex-1 flex items-center justify-between">
@@ -63,3 +72,64 @@ export const MentionNote = memo(function MentionNote({
</div>
);
});
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "");
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>;
}
+36 -19
View File
@@ -1,51 +1,68 @@
import { commands } from "@/commands.gen";
import { DotsThree } from "@phosphor-icons/react";
import { useSearch } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import { useCallback } from "react";
import { useNoteContext } from "./provider";
export function NoteMenu() {
const event = useNoteContext();
const { account }: { account: string } = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
const list = [
MenuItem.new({
text: "Copy Sharable Link",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
text: "Copy ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(eventId);
},
}),
MenuItem.new({
text: "Copy Public Key",
text: "Copy author",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Raw Event",
text: "Copy sharable link",
action: async () => {
event.meta = undefined;
const raw = JSON.stringify(event);
await writeText(raw);
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
]);
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy raw event",
action: async () => {
event.meta = undefined;
await writeText(JSON.stringify(event));
},
}),
];
const menu = await Menu.new({
items: menuItems,
});
if (account?.length) {
const pubkey = nip19.decode(account).data;
if (event.pubkey === pubkey) {
list.push(
MenuItem.new({
text: "Request delete",
action: async () => {
await commands.requestDelete(event.id);
},
}),
);
}
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
}, []);
+1 -6
View File
@@ -13,12 +13,7 @@ export const Quote = memo(function Quote({
}) {
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,
)}
>
<Note.Root className={cn("", className)}>
<div className="flex flex-col gap-3">
<Note.Child event={event.quote} isRoot />
<div className="flex items-center gap-2 px-3">
+172 -21
View File
@@ -1,9 +1,24 @@
import { cn, replyTime } from "@/commons";
import { commands } from "@/commands.gen";
import { appSettings, cn, replyTime } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router";
import { memo } from "react";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import {
type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./note/mentions/hashtag";
import { MentionUser } from "./note/mentions/user";
import { User } from "./user";
export const ReplyNote = memo(function ReplyNote({
@@ -13,33 +28,81 @@ export const ReplyNote = memo(function ReplyNote({
event: LumeEvent;
className?: string;
}) {
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
const search = useSearch({ strict: false });
const [isTrusted, setIsTrusted] = useState<boolean>(null);
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));
}, []);
useEffect(() => {
async function check() {
const res = await commands.isTrustedUser(event.pubkey);
if (res.status === "ok") {
setIsTrusted(res.data);
}
}
if (trustedOnly) {
check();
}
}, []);
if (isTrusted !== null && isTrusted === false) {
return null;
}
return (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<Note.Root className={cn("flex gap-2.5", className)}>
<User.Root className="shrink-0">
<User.Avatar className="size-8 rounded-full" />
<button type="button" onClick={(e) => showContextMenu(e)}>
<User.Avatar className="size-8 rounded-full" />
</button>
</User.Root>
<div className="flex-1 flex flex-col gap-1">
<div>
<User.Name
className="shrink-0 inline font-medium text-blue-500"
className="mr-2 shrink-0 inline font-medium text-blue-500"
suffix=":"
/>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
</span>
<div className="flex items-center justify-end gap-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
<div className="flex items-center justify-end gap-5">
<Note.Reply smol />
<Note.Repost smol />
<Note.Zap smol />
</div>
</div>
{event.replies?.length ? (
@@ -74,26 +137,53 @@ export const ReplyNote = memo(function ReplyNote({
function ChildReply({ event }: { event: LumeEvent }) {
const search = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<div className="group flex flex-col gap-1">
<div>
<User.Root className="inline">
<User.Name className="font-medium text-blue-500" suffix=":" />
<User.Root className="inline mr-2">
<button type="button" onClick={(e) => showContextMenu(e)}>
<User.Name className="font-medium text-blue-500" suffix=":" />
</button>
</User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
</span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
<div className="invisible group-hover:visible flex items-center justify-end gap-5">
<Note.Reply smol />
<Note.Repost smol />
<Note.Zap smol />
</div>
</div>
{event.replies?.length ? (
@@ -124,3 +214,64 @@ function ChildReply({ event }: { event: LumeEvent }) {
</Note.Provider>
);
}
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "");
const data = nip19.decode(bech32);
switch (data.type) {
case "npub":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data} />
));
break;
case "nprofile":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data.pubkey} />
));
break;
default:
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<a
key={match + i}
href={`https://njump.me/${bech32}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
break;
}
}
return replacedText;
}, [text]);
return <div className={className}>{content}</div>;
}
+3 -8
View File
@@ -12,15 +12,10 @@ export const RepostNote = memo(function RepostNote({
event: LumeEvent;
className?: string;
}) {
const { isLoading, isError, data } = useEvent(event.repostId);
const { isLoading, isError, data } = useEvent(event.repostId, event.content);
return (
<Note.Root
className={cn(
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<Note.Root className={cn("", className)}>
{isLoading ? (
<div className="flex items-center justify-center h-20 gap-2">
<Spinner />
@@ -41,7 +36,7 @@ export const RepostNote = memo(function RepostNote({
</div>
<Note.Content className="px-3" />
<div className="flex items-center justify-between px-3 mt-3 h-14">
<div className="inline-flex items-center gap-3">
<div className="inline-flex items-center gap-6">
<Note.Open />
<Note.Reply />
<Note.Repost />
+2 -7
View File
@@ -12,18 +12,13 @@ export const TextNote = memo(function TextNote({
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
className,
)}
>
<Note.Root className={cn("", className)}>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<div className="flex items-center gap-6 px-3 mt-3 h-14">
<Note.Open />
<Note.Reply />
<Note.Repost />
+4 -2
View File
@@ -15,8 +15,10 @@ export function UserAvatar({ className }: { className?: string }) {
const picture = useMemo(() => {
if (service?.length && user.profile?.picture?.length) {
const url = `${service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
return url;
if (user.profile?.picture.includes("_next/")) {
return user.profile?.picture;
}
return `${service}?url=${user.profile?.picture}&w=100&h=100&n=-1&default=${user.profile?.picture}`;
} else {
return user.profile?.picture;
}
+2 -2
View File
@@ -59,10 +59,10 @@ export function UserFollowButton({ className }: { className?: string }) {
type="button"
disabled={isPending}
onClick={() => toggleFollow()}
className={cn("w-max", className)}
className={cn("w-max gap-1", className)}
>
{isError ? "Error" : null}
{isPending || isLoading ? <Spinner className="size-4" /> : null}
{isPending || isLoading ? <Spinner className="size-3" /> : null}
{isFollow ? "Unfollow" : "Follow"}
</button>
);
+518 -137
View File
@@ -13,6 +13,9 @@ import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as SetInterestImport } from './routes/set-interest'
import { Route as SetGroupImport } from './routes/set-group'
import { Route as LoadingImport } from './routes/loading'
import { Route as BootstrapRelaysImport } from './routes/bootstrap-relays'
import { Route as IndexImport } from './routes/index'
import { Route as EditorIndexImport } from './routes/editor/index'
@@ -21,17 +24,17 @@ import { Route as ColumnsLayoutImport } from './routes/columns/_layout'
import { Route as AccountBackupImport } from './routes/$account/backup'
import { Route as AccountAppImport } from './routes/$account/_app'
import { Route as ColumnsLayoutStoriesImport } from './routes/columns/_layout/stories'
import { Route as ColumnsLayoutGroupImport } from './routes/columns/_layout/group'
import { Route as ColumnsLayoutNewsfeedImport } from './routes/columns/_layout/newsfeed'
import { Route as ColumnsLayoutGlobalImport } from './routes/columns/_layout/global'
import { Route as ColumnsLayoutGalleryImport } from './routes/columns/_layout/gallery'
import { Route as ColumnsLayoutCreateNewsfeedImport } from './routes/columns/_layout/create-newsfeed'
import { Route as ColumnsLayoutCreateGroupImport } from './routes/columns/_layout/create-group'
import { Route as AccountSettingsWalletImport } from './routes/$account/_settings/wallet'
import { Route as AccountSettingsRelayImport } from './routes/$account/_settings/relay'
import { Route as AccountSettingsProfileImport } from './routes/$account/_settings/profile'
import { Route as AccountSettingsGeneralImport } from './routes/$account/_settings/general'
import { Route as AccountSettingsBitcoinConnectImport } from './routes/$account/_settings/bitcoin-connect'
import { Route as AccountAppHomeImport } from './routes/$account/_app/home'
import { Route as ColumnsLayoutInterestsIdImport } from './routes/columns/_layout/interests.$id'
import { Route as ColumnsLayoutGroupsIdImport } from './routes/columns/_layout/groups.$id'
import { Route as ColumnsLayoutCreateNewsfeedUsersImport } from './routes/columns/_layout/create-newsfeed.users'
import { Route as ColumnsLayoutCreateNewsfeedF2fImport } from './routes/columns/_layout/create-newsfeed.f2f'
@@ -57,8 +60,8 @@ const ColumnsLayoutOnboardingLazyImport = createFileRoute(
const ColumnsLayoutNotificationLazyImport = createFileRoute(
'/columns/_layout/notification',
)()
const ColumnsLayoutNewsfeedLazyImport = createFileRoute(
'/columns/_layout/newsfeed',
const ColumnsLayoutLaunchpadLazyImport = createFileRoute(
'/columns/_layout/launchpad',
)()
const ColumnsLayoutUsersIdLazyImport = createFileRoute(
'/columns/_layout/users/$id',
@@ -66,9 +69,6 @@ const ColumnsLayoutUsersIdLazyImport = createFileRoute(
const ColumnsLayoutRepliesIdLazyImport = createFileRoute(
'/columns/_layout/replies/$id',
)()
const ColumnsLayoutHashtagsContentLazyImport = createFileRoute(
'/columns/_layout/hashtags/$content',
)()
const ColumnsLayoutEventsIdLazyImport = createFileRoute(
'/columns/_layout/events/$id',
)()
@@ -95,6 +95,21 @@ const NewLazyRoute = NewLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/new.lazy').then((d) => d.Route))
const SetInterestRoute = SetInterestImport.update({
path: '/set-interest',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/set-interest.lazy').then((d) => d.Route))
const SetGroupRoute = SetGroupImport.update({
path: '/set-group',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/set-group.lazy').then((d) => d.Route))
const LoadingRoute = LoadingImport.update({
path: '/loading',
getParentRoute: () => rootRoute,
} as any)
const BootstrapRelaysRoute = BootstrapRelaysImport.update({
path: '/bootstrap-relays',
getParentRoute: () => rootRoute,
@@ -184,11 +199,13 @@ const ColumnsLayoutNotificationLazyRoute =
import('./routes/columns/_layout/notification.lazy').then((d) => d.Route),
)
const ColumnsLayoutNewsfeedLazyRoute = ColumnsLayoutNewsfeedLazyImport.update({
path: '/newsfeed',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/newsfeed.lazy').then((d) => d.Route),
const ColumnsLayoutLaunchpadLazyRoute = ColumnsLayoutLaunchpadLazyImport.update(
{
path: '/launchpad',
getParentRoute: () => ColumnsLayoutRoute,
} as any,
).lazy(() =>
import('./routes/columns/_layout/launchpad.lazy').then((d) => d.Route),
)
const ColumnsLayoutStoriesRoute = ColumnsLayoutStoriesImport.update({
@@ -198,11 +215,11 @@ const ColumnsLayoutStoriesRoute = ColumnsLayoutStoriesImport.update({
import('./routes/columns/_layout/stories.lazy').then((d) => d.Route),
)
const ColumnsLayoutGroupRoute = ColumnsLayoutGroupImport.update({
path: '/group',
const ColumnsLayoutNewsfeedRoute = ColumnsLayoutNewsfeedImport.update({
path: '/newsfeed',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/group.lazy').then((d) => d.Route),
import('./routes/columns/_layout/newsfeed.lazy').then((d) => d.Route),
)
const ColumnsLayoutGlobalRoute = ColumnsLayoutGlobalImport.update({
@@ -210,26 +227,12 @@ const ColumnsLayoutGlobalRoute = ColumnsLayoutGlobalImport.update({
getParentRoute: () => ColumnsLayoutRoute,
} as any)
const ColumnsLayoutGalleryRoute = ColumnsLayoutGalleryImport.update({
path: '/gallery',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/gallery.lazy').then((d) => d.Route),
)
const ColumnsLayoutCreateNewsfeedRoute =
ColumnsLayoutCreateNewsfeedImport.update({
path: '/create-newsfeed',
getParentRoute: () => ColumnsLayoutRoute,
} as any)
const ColumnsLayoutCreateGroupRoute = ColumnsLayoutCreateGroupImport.update({
path: '/create-group',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/create-group.lazy').then((d) => d.Route),
)
const AccountSettingsWalletRoute = AccountSettingsWalletImport.update({
path: '/wallet',
getParentRoute: () => AccountSettingsLazyRoute,
@@ -291,16 +294,6 @@ const ColumnsLayoutRepliesIdLazyRoute = ColumnsLayoutRepliesIdLazyImport.update(
import('./routes/columns/_layout/replies.$id.lazy').then((d) => d.Route),
)
const ColumnsLayoutHashtagsContentLazyRoute =
ColumnsLayoutHashtagsContentLazyImport.update({
path: '/hashtags/$content',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/hashtags.$content.lazy').then(
(d) => d.Route,
),
)
const ColumnsLayoutEventsIdLazyRoute = ColumnsLayoutEventsIdLazyImport.update({
path: '/events/$id',
getParentRoute: () => ColumnsLayoutRoute,
@@ -308,6 +301,20 @@ const ColumnsLayoutEventsIdLazyRoute = ColumnsLayoutEventsIdLazyImport.update({
import('./routes/columns/_layout/events.$id.lazy').then((d) => d.Route),
)
const ColumnsLayoutInterestsIdRoute = ColumnsLayoutInterestsIdImport.update({
path: '/interests/$id',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/interests.$id.lazy').then((d) => d.Route),
)
const ColumnsLayoutGroupsIdRoute = ColumnsLayoutGroupsIdImport.update({
path: '/groups/$id',
getParentRoute: () => ColumnsLayoutRoute,
} as any).lazy(() =>
import('./routes/columns/_layout/groups.$id.lazy').then((d) => d.Route),
)
const ColumnsLayoutCreateNewsfeedUsersRoute =
ColumnsLayoutCreateNewsfeedUsersImport.update({
path: '/users',
@@ -338,6 +345,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BootstrapRelaysImport
parentRoute: typeof rootRoute
}
'/loading': {
id: '/loading'
path: '/loading'
fullPath: '/loading'
preLoaderRoute: typeof LoadingImport
parentRoute: typeof rootRoute
}
'/set-group': {
id: '/set-group'
path: '/set-group'
fullPath: '/set-group'
preLoaderRoute: typeof SetGroupImport
parentRoute: typeof rootRoute
}
'/set-interest': {
id: '/set-interest'
path: '/set-interest'
fullPath: '/set-interest'
preLoaderRoute: typeof SetInterestImport
parentRoute: typeof rootRoute
}
'/new': {
id: '/new'
path: '/new'
@@ -471,13 +499,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AccountSettingsWalletImport
parentRoute: typeof AccountSettingsLazyImport
}
'/columns/_layout/create-group': {
id: '/columns/_layout/create-group'
path: '/create-group'
fullPath: '/columns/create-group'
preLoaderRoute: typeof ColumnsLayoutCreateGroupImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/create-newsfeed': {
id: '/columns/_layout/create-newsfeed'
path: '/create-newsfeed'
@@ -485,13 +506,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ColumnsLayoutCreateNewsfeedImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/gallery': {
id: '/columns/_layout/gallery'
path: '/gallery'
fullPath: '/columns/gallery'
preLoaderRoute: typeof ColumnsLayoutGalleryImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/global': {
id: '/columns/_layout/global'
path: '/global'
@@ -499,11 +513,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ColumnsLayoutGlobalImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/group': {
id: '/columns/_layout/group'
path: '/group'
fullPath: '/columns/group'
preLoaderRoute: typeof ColumnsLayoutGroupImport
'/columns/_layout/newsfeed': {
id: '/columns/_layout/newsfeed'
path: '/newsfeed'
fullPath: '/columns/newsfeed'
preLoaderRoute: typeof ColumnsLayoutNewsfeedImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/stories': {
@@ -513,11 +527,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ColumnsLayoutStoriesImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/newsfeed': {
id: '/columns/_layout/newsfeed'
path: '/newsfeed'
fullPath: '/columns/newsfeed'
preLoaderRoute: typeof ColumnsLayoutNewsfeedLazyImport
'/columns/_layout/launchpad': {
id: '/columns/_layout/launchpad'
path: '/launchpad'
fullPath: '/columns/launchpad'
preLoaderRoute: typeof ColumnsLayoutLaunchpadLazyImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/notification': {
@@ -562,6 +576,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ColumnsLayoutCreateNewsfeedUsersImport
parentRoute: typeof ColumnsLayoutCreateNewsfeedImport
}
'/columns/_layout/groups/$id': {
id: '/columns/_layout/groups/$id'
path: '/groups/$id'
fullPath: '/columns/groups/$id'
preLoaderRoute: typeof ColumnsLayoutGroupsIdImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/interests/$id': {
id: '/columns/_layout/interests/$id'
path: '/interests/$id'
fullPath: '/columns/interests/$id'
preLoaderRoute: typeof ColumnsLayoutInterestsIdImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/events/$id': {
id: '/columns/_layout/events/$id'
path: '/events/$id'
@@ -569,13 +597,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ColumnsLayoutEventsIdLazyImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/hashtags/$content': {
id: '/columns/_layout/hashtags/$content'
path: '/hashtags/$content'
fullPath: '/columns/hashtags/$content'
preLoaderRoute: typeof ColumnsLayoutHashtagsContentLazyImport
parentRoute: typeof ColumnsLayoutImport
}
'/columns/_layout/replies/$id': {
id: '/columns/_layout/replies/$id'
path: '/replies/$id'
@@ -595,51 +616,404 @@ declare module '@tanstack/react-router' {
// Create and export the route tree
export const routeTree = rootRoute.addChildren({
IndexRoute,
BootstrapRelaysRoute,
NewLazyRoute,
ResetLazyRoute,
AccountRoute: AccountRoute.addChildren({
AccountAppRoute: AccountAppRoute.addChildren({ AccountAppHomeRoute }),
AccountBackupRoute,
AccountSettingsLazyRoute: AccountSettingsLazyRoute.addChildren({
AccountSettingsBitcoinConnectRoute,
AccountSettingsGeneralRoute,
AccountSettingsProfileRoute,
AccountSettingsRelayRoute,
AccountSettingsWalletRoute,
}),
}),
ColumnsRoute: ColumnsRoute.addChildren({
ColumnsLayoutRoute: ColumnsLayoutRoute.addChildren({
ColumnsLayoutCreateGroupRoute,
ColumnsLayoutCreateNewsfeedRoute:
ColumnsLayoutCreateNewsfeedRoute.addChildren({
ColumnsLayoutCreateNewsfeedF2fRoute,
ColumnsLayoutCreateNewsfeedUsersRoute,
}),
ColumnsLayoutGalleryRoute,
ColumnsLayoutGlobalRoute,
ColumnsLayoutGroupRoute,
ColumnsLayoutStoriesRoute,
ColumnsLayoutNewsfeedLazyRoute,
ColumnsLayoutNotificationLazyRoute,
ColumnsLayoutOnboardingLazyRoute,
ColumnsLayoutSearchLazyRoute,
ColumnsLayoutTrendingLazyRoute,
ColumnsLayoutEventsIdLazyRoute,
ColumnsLayoutHashtagsContentLazyRoute,
ColumnsLayoutRepliesIdLazyRoute,
ColumnsLayoutUsersIdLazyRoute,
}),
}),
ZapIdRoute,
AuthConnectLazyRoute,
AuthImportLazyRoute,
AuthNewLazyRoute,
EditorIndexRoute,
})
interface AccountAppRouteChildren {
AccountAppHomeRoute: typeof AccountAppHomeRoute
}
const AccountAppRouteChildren: AccountAppRouteChildren = {
AccountAppHomeRoute: AccountAppHomeRoute,
}
const AccountAppRouteWithChildren = AccountAppRoute._addFileChildren(
AccountAppRouteChildren,
)
interface AccountSettingsLazyRouteChildren {
AccountSettingsBitcoinConnectRoute: typeof AccountSettingsBitcoinConnectRoute
AccountSettingsGeneralRoute: typeof AccountSettingsGeneralRoute
AccountSettingsProfileRoute: typeof AccountSettingsProfileRoute
AccountSettingsRelayRoute: typeof AccountSettingsRelayRoute
AccountSettingsWalletRoute: typeof AccountSettingsWalletRoute
}
const AccountSettingsLazyRouteChildren: AccountSettingsLazyRouteChildren = {
AccountSettingsBitcoinConnectRoute: AccountSettingsBitcoinConnectRoute,
AccountSettingsGeneralRoute: AccountSettingsGeneralRoute,
AccountSettingsProfileRoute: AccountSettingsProfileRoute,
AccountSettingsRelayRoute: AccountSettingsRelayRoute,
AccountSettingsWalletRoute: AccountSettingsWalletRoute,
}
const AccountSettingsLazyRouteWithChildren =
AccountSettingsLazyRoute._addFileChildren(AccountSettingsLazyRouteChildren)
interface AccountRouteChildren {
AccountAppRoute: typeof AccountAppRouteWithChildren
AccountBackupRoute: typeof AccountBackupRoute
AccountSettingsLazyRoute: typeof AccountSettingsLazyRouteWithChildren
}
const AccountRouteChildren: AccountRouteChildren = {
AccountAppRoute: AccountAppRouteWithChildren,
AccountBackupRoute: AccountBackupRoute,
AccountSettingsLazyRoute: AccountSettingsLazyRouteWithChildren,
}
const AccountRouteWithChildren =
AccountRoute._addFileChildren(AccountRouteChildren)
interface ColumnsLayoutCreateNewsfeedRouteChildren {
ColumnsLayoutCreateNewsfeedF2fRoute: typeof ColumnsLayoutCreateNewsfeedF2fRoute
ColumnsLayoutCreateNewsfeedUsersRoute: typeof ColumnsLayoutCreateNewsfeedUsersRoute
}
const ColumnsLayoutCreateNewsfeedRouteChildren: ColumnsLayoutCreateNewsfeedRouteChildren =
{
ColumnsLayoutCreateNewsfeedF2fRoute: ColumnsLayoutCreateNewsfeedF2fRoute,
ColumnsLayoutCreateNewsfeedUsersRoute:
ColumnsLayoutCreateNewsfeedUsersRoute,
}
const ColumnsLayoutCreateNewsfeedRouteWithChildren =
ColumnsLayoutCreateNewsfeedRoute._addFileChildren(
ColumnsLayoutCreateNewsfeedRouteChildren,
)
interface ColumnsLayoutRouteChildren {
ColumnsLayoutCreateNewsfeedRoute: typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
ColumnsLayoutGlobalRoute: typeof ColumnsLayoutGlobalRoute
ColumnsLayoutNewsfeedRoute: typeof ColumnsLayoutNewsfeedRoute
ColumnsLayoutStoriesRoute: typeof ColumnsLayoutStoriesRoute
ColumnsLayoutLaunchpadLazyRoute: typeof ColumnsLayoutLaunchpadLazyRoute
ColumnsLayoutNotificationLazyRoute: typeof ColumnsLayoutNotificationLazyRoute
ColumnsLayoutOnboardingLazyRoute: typeof ColumnsLayoutOnboardingLazyRoute
ColumnsLayoutSearchLazyRoute: typeof ColumnsLayoutSearchLazyRoute
ColumnsLayoutTrendingLazyRoute: typeof ColumnsLayoutTrendingLazyRoute
ColumnsLayoutGroupsIdRoute: typeof ColumnsLayoutGroupsIdRoute
ColumnsLayoutInterestsIdRoute: typeof ColumnsLayoutInterestsIdRoute
ColumnsLayoutEventsIdLazyRoute: typeof ColumnsLayoutEventsIdLazyRoute
ColumnsLayoutRepliesIdLazyRoute: typeof ColumnsLayoutRepliesIdLazyRoute
ColumnsLayoutUsersIdLazyRoute: typeof ColumnsLayoutUsersIdLazyRoute
}
const ColumnsLayoutRouteChildren: ColumnsLayoutRouteChildren = {
ColumnsLayoutCreateNewsfeedRoute:
ColumnsLayoutCreateNewsfeedRouteWithChildren,
ColumnsLayoutGlobalRoute: ColumnsLayoutGlobalRoute,
ColumnsLayoutNewsfeedRoute: ColumnsLayoutNewsfeedRoute,
ColumnsLayoutStoriesRoute: ColumnsLayoutStoriesRoute,
ColumnsLayoutLaunchpadLazyRoute: ColumnsLayoutLaunchpadLazyRoute,
ColumnsLayoutNotificationLazyRoute: ColumnsLayoutNotificationLazyRoute,
ColumnsLayoutOnboardingLazyRoute: ColumnsLayoutOnboardingLazyRoute,
ColumnsLayoutSearchLazyRoute: ColumnsLayoutSearchLazyRoute,
ColumnsLayoutTrendingLazyRoute: ColumnsLayoutTrendingLazyRoute,
ColumnsLayoutGroupsIdRoute: ColumnsLayoutGroupsIdRoute,
ColumnsLayoutInterestsIdRoute: ColumnsLayoutInterestsIdRoute,
ColumnsLayoutEventsIdLazyRoute: ColumnsLayoutEventsIdLazyRoute,
ColumnsLayoutRepliesIdLazyRoute: ColumnsLayoutRepliesIdLazyRoute,
ColumnsLayoutUsersIdLazyRoute: ColumnsLayoutUsersIdLazyRoute,
}
const ColumnsLayoutRouteWithChildren = ColumnsLayoutRoute._addFileChildren(
ColumnsLayoutRouteChildren,
)
interface ColumnsRouteChildren {
ColumnsLayoutRoute: typeof ColumnsLayoutRouteWithChildren
}
const ColumnsRouteChildren: ColumnsRouteChildren = {
ColumnsLayoutRoute: ColumnsLayoutRouteWithChildren,
}
const ColumnsRouteWithChildren =
ColumnsRoute._addFileChildren(ColumnsRouteChildren)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/loading': typeof LoadingRoute
'/set-group': typeof SetGroupRoute
'/set-interest': typeof SetInterestRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/$account': typeof AccountSettingsLazyRouteWithChildren
'/$account/backup': typeof AccountBackupRoute
'/columns': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute
'/auth/connect': typeof AuthConnectLazyRoute
'/auth/import': typeof AuthImportLazyRoute
'/auth/new': typeof AuthNewLazyRoute
'/editor': typeof EditorIndexRoute
'/$account/home': typeof AccountAppHomeRoute
'/$account/bitcoin-connect': typeof AccountSettingsBitcoinConnectRoute
'/$account/general': typeof AccountSettingsGeneralRoute
'/$account/profile': typeof AccountSettingsProfileRoute
'/$account/relay': typeof AccountSettingsRelayRoute
'/$account/wallet': typeof AccountSettingsWalletRoute
'/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/global': typeof ColumnsLayoutGlobalRoute
'/columns/newsfeed': typeof ColumnsLayoutNewsfeedRoute
'/columns/stories': typeof ColumnsLayoutStoriesRoute
'/columns/launchpad': typeof ColumnsLayoutLaunchpadLazyRoute
'/columns/notification': typeof ColumnsLayoutNotificationLazyRoute
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
'/columns/search': typeof ColumnsLayoutSearchLazyRoute
'/columns/trending': typeof ColumnsLayoutTrendingLazyRoute
'/columns/create-newsfeed/f2f': typeof ColumnsLayoutCreateNewsfeedF2fRoute
'/columns/create-newsfeed/users': typeof ColumnsLayoutCreateNewsfeedUsersRoute
'/columns/groups/$id': typeof ColumnsLayoutGroupsIdRoute
'/columns/interests/$id': typeof ColumnsLayoutInterestsIdRoute
'/columns/events/$id': typeof ColumnsLayoutEventsIdLazyRoute
'/columns/replies/$id': typeof ColumnsLayoutRepliesIdLazyRoute
'/columns/users/$id': typeof ColumnsLayoutUsersIdLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/loading': typeof LoadingRoute
'/set-group': typeof SetGroupRoute
'/set-interest': typeof SetInterestRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/$account': typeof AccountSettingsLazyRouteWithChildren
'/$account/backup': typeof AccountBackupRoute
'/columns': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute
'/auth/connect': typeof AuthConnectLazyRoute
'/auth/import': typeof AuthImportLazyRoute
'/auth/new': typeof AuthNewLazyRoute
'/editor': typeof EditorIndexRoute
'/$account/home': typeof AccountAppHomeRoute
'/$account/bitcoin-connect': typeof AccountSettingsBitcoinConnectRoute
'/$account/general': typeof AccountSettingsGeneralRoute
'/$account/profile': typeof AccountSettingsProfileRoute
'/$account/relay': typeof AccountSettingsRelayRoute
'/$account/wallet': typeof AccountSettingsWalletRoute
'/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/global': typeof ColumnsLayoutGlobalRoute
'/columns/newsfeed': typeof ColumnsLayoutNewsfeedRoute
'/columns/stories': typeof ColumnsLayoutStoriesRoute
'/columns/launchpad': typeof ColumnsLayoutLaunchpadLazyRoute
'/columns/notification': typeof ColumnsLayoutNotificationLazyRoute
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
'/columns/search': typeof ColumnsLayoutSearchLazyRoute
'/columns/trending': typeof ColumnsLayoutTrendingLazyRoute
'/columns/create-newsfeed/f2f': typeof ColumnsLayoutCreateNewsfeedF2fRoute
'/columns/create-newsfeed/users': typeof ColumnsLayoutCreateNewsfeedUsersRoute
'/columns/groups/$id': typeof ColumnsLayoutGroupsIdRoute
'/columns/interests/$id': typeof ColumnsLayoutInterestsIdRoute
'/columns/events/$id': typeof ColumnsLayoutEventsIdLazyRoute
'/columns/replies/$id': typeof ColumnsLayoutRepliesIdLazyRoute
'/columns/users/$id': typeof ColumnsLayoutUsersIdLazyRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/loading': typeof LoadingRoute
'/set-group': typeof SetGroupRoute
'/set-interest': typeof SetInterestRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/$account': typeof AccountRouteWithChildren
'/$account/_app': typeof AccountAppRouteWithChildren
'/$account/backup': typeof AccountBackupRoute
'/columns': typeof ColumnsRouteWithChildren
'/columns/_layout': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute
'/$account/_settings': typeof AccountSettingsLazyRouteWithChildren
'/auth/connect': typeof AuthConnectLazyRoute
'/auth/import': typeof AuthImportLazyRoute
'/auth/new': typeof AuthNewLazyRoute
'/editor/': typeof EditorIndexRoute
'/$account/_app/home': typeof AccountAppHomeRoute
'/$account/_settings/bitcoin-connect': typeof AccountSettingsBitcoinConnectRoute
'/$account/_settings/general': typeof AccountSettingsGeneralRoute
'/$account/_settings/profile': typeof AccountSettingsProfileRoute
'/$account/_settings/relay': typeof AccountSettingsRelayRoute
'/$account/_settings/wallet': typeof AccountSettingsWalletRoute
'/columns/_layout/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/_layout/global': typeof ColumnsLayoutGlobalRoute
'/columns/_layout/newsfeed': typeof ColumnsLayoutNewsfeedRoute
'/columns/_layout/stories': typeof ColumnsLayoutStoriesRoute
'/columns/_layout/launchpad': typeof ColumnsLayoutLaunchpadLazyRoute
'/columns/_layout/notification': typeof ColumnsLayoutNotificationLazyRoute
'/columns/_layout/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
'/columns/_layout/search': typeof ColumnsLayoutSearchLazyRoute
'/columns/_layout/trending': typeof ColumnsLayoutTrendingLazyRoute
'/columns/_layout/create-newsfeed/f2f': typeof ColumnsLayoutCreateNewsfeedF2fRoute
'/columns/_layout/create-newsfeed/users': typeof ColumnsLayoutCreateNewsfeedUsersRoute
'/columns/_layout/groups/$id': typeof ColumnsLayoutGroupsIdRoute
'/columns/_layout/interests/$id': typeof ColumnsLayoutInterestsIdRoute
'/columns/_layout/events/$id': typeof ColumnsLayoutEventsIdLazyRoute
'/columns/_layout/replies/$id': typeof ColumnsLayoutRepliesIdLazyRoute
'/columns/_layout/users/$id': typeof ColumnsLayoutUsersIdLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/bootstrap-relays'
| '/loading'
| '/set-group'
| '/set-interest'
| '/new'
| '/reset'
| '/$account'
| '/$account/backup'
| '/columns'
| '/zap/$id'
| '/auth/connect'
| '/auth/import'
| '/auth/new'
| '/editor'
| '/$account/home'
| '/$account/bitcoin-connect'
| '/$account/general'
| '/$account/profile'
| '/$account/relay'
| '/$account/wallet'
| '/columns/create-newsfeed'
| '/columns/global'
| '/columns/newsfeed'
| '/columns/stories'
| '/columns/launchpad'
| '/columns/notification'
| '/columns/onboarding'
| '/columns/search'
| '/columns/trending'
| '/columns/create-newsfeed/f2f'
| '/columns/create-newsfeed/users'
| '/columns/groups/$id'
| '/columns/interests/$id'
| '/columns/events/$id'
| '/columns/replies/$id'
| '/columns/users/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/bootstrap-relays'
| '/loading'
| '/set-group'
| '/set-interest'
| '/new'
| '/reset'
| '/$account'
| '/$account/backup'
| '/columns'
| '/zap/$id'
| '/auth/connect'
| '/auth/import'
| '/auth/new'
| '/editor'
| '/$account/home'
| '/$account/bitcoin-connect'
| '/$account/general'
| '/$account/profile'
| '/$account/relay'
| '/$account/wallet'
| '/columns/create-newsfeed'
| '/columns/global'
| '/columns/newsfeed'
| '/columns/stories'
| '/columns/launchpad'
| '/columns/notification'
| '/columns/onboarding'
| '/columns/search'
| '/columns/trending'
| '/columns/create-newsfeed/f2f'
| '/columns/create-newsfeed/users'
| '/columns/groups/$id'
| '/columns/interests/$id'
| '/columns/events/$id'
| '/columns/replies/$id'
| '/columns/users/$id'
id:
| '__root__'
| '/'
| '/bootstrap-relays'
| '/loading'
| '/set-group'
| '/set-interest'
| '/new'
| '/reset'
| '/$account'
| '/$account/_app'
| '/$account/backup'
| '/columns'
| '/columns/_layout'
| '/zap/$id'
| '/$account/_settings'
| '/auth/connect'
| '/auth/import'
| '/auth/new'
| '/editor/'
| '/$account/_app/home'
| '/$account/_settings/bitcoin-connect'
| '/$account/_settings/general'
| '/$account/_settings/profile'
| '/$account/_settings/relay'
| '/$account/_settings/wallet'
| '/columns/_layout/create-newsfeed'
| '/columns/_layout/global'
| '/columns/_layout/newsfeed'
| '/columns/_layout/stories'
| '/columns/_layout/launchpad'
| '/columns/_layout/notification'
| '/columns/_layout/onboarding'
| '/columns/_layout/search'
| '/columns/_layout/trending'
| '/columns/_layout/create-newsfeed/f2f'
| '/columns/_layout/create-newsfeed/users'
| '/columns/_layout/groups/$id'
| '/columns/_layout/interests/$id'
| '/columns/_layout/events/$id'
| '/columns/_layout/replies/$id'
| '/columns/_layout/users/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BootstrapRelaysRoute: typeof BootstrapRelaysRoute
LoadingRoute: typeof LoadingRoute
SetGroupRoute: typeof SetGroupRoute
SetInterestRoute: typeof SetInterestRoute
NewLazyRoute: typeof NewLazyRoute
ResetLazyRoute: typeof ResetLazyRoute
AccountRoute: typeof AccountRouteWithChildren
ColumnsRoute: typeof ColumnsRouteWithChildren
ZapIdRoute: typeof ZapIdRoute
AuthConnectLazyRoute: typeof AuthConnectLazyRoute
AuthImportLazyRoute: typeof AuthImportLazyRoute
AuthNewLazyRoute: typeof AuthNewLazyRoute
EditorIndexRoute: typeof EditorIndexRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BootstrapRelaysRoute: BootstrapRelaysRoute,
LoadingRoute: LoadingRoute,
SetGroupRoute: SetGroupRoute,
SetInterestRoute: SetInterestRoute,
NewLazyRoute: NewLazyRoute,
ResetLazyRoute: ResetLazyRoute,
AccountRoute: AccountRouteWithChildren,
ColumnsRoute: ColumnsRouteWithChildren,
ZapIdRoute: ZapIdRoute,
AuthConnectLazyRoute: AuthConnectLazyRoute,
AuthImportLazyRoute: AuthImportLazyRoute,
AuthNewLazyRoute: AuthNewLazyRoute,
EditorIndexRoute: EditorIndexRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
@@ -651,6 +1025,9 @@ export const routeTree = rootRoute.addChildren({
"children": [
"/",
"/bootstrap-relays",
"/loading",
"/set-group",
"/set-interest",
"/new",
"/reset",
"/$account",
@@ -668,6 +1045,15 @@ export const routeTree = rootRoute.addChildren({
"/bootstrap-relays": {
"filePath": "bootstrap-relays.tsx"
},
"/loading": {
"filePath": "loading.tsx"
},
"/set-group": {
"filePath": "set-group.tsx"
},
"/set-interest": {
"filePath": "set-interest.tsx"
},
"/new": {
"filePath": "new.lazy.tsx"
},
@@ -703,19 +1089,18 @@ export const routeTree = rootRoute.addChildren({
"filePath": "columns/_layout.tsx",
"parent": "/columns",
"children": [
"/columns/_layout/create-group",
"/columns/_layout/create-newsfeed",
"/columns/_layout/gallery",
"/columns/_layout/global",
"/columns/_layout/group",
"/columns/_layout/stories",
"/columns/_layout/newsfeed",
"/columns/_layout/stories",
"/columns/_layout/launchpad",
"/columns/_layout/notification",
"/columns/_layout/onboarding",
"/columns/_layout/search",
"/columns/_layout/trending",
"/columns/_layout/groups/$id",
"/columns/_layout/interests/$id",
"/columns/_layout/events/$id",
"/columns/_layout/hashtags/$content",
"/columns/_layout/replies/$id",
"/columns/_layout/users/$id"
]
@@ -770,10 +1155,6 @@ export const routeTree = rootRoute.addChildren({
"filePath": "$account/_settings/wallet.tsx",
"parent": "/$account/_settings"
},
"/columns/_layout/create-group": {
"filePath": "columns/_layout/create-group.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/create-newsfeed": {
"filePath": "columns/_layout/create-newsfeed.tsx",
"parent": "/columns/_layout",
@@ -782,24 +1163,20 @@ export const routeTree = rootRoute.addChildren({
"/columns/_layout/create-newsfeed/users"
]
},
"/columns/_layout/gallery": {
"filePath": "columns/_layout/gallery.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/global": {
"filePath": "columns/_layout/global.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/group": {
"filePath": "columns/_layout/group.tsx",
"/columns/_layout/newsfeed": {
"filePath": "columns/_layout/newsfeed.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/stories": {
"filePath": "columns/_layout/stories.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/newsfeed": {
"filePath": "columns/_layout/newsfeed.lazy.tsx",
"/columns/_layout/launchpad": {
"filePath": "columns/_layout/launchpad.lazy.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/notification": {
@@ -826,12 +1203,16 @@ export const routeTree = rootRoute.addChildren({
"filePath": "columns/_layout/create-newsfeed.users.tsx",
"parent": "/columns/_layout/create-newsfeed"
},
"/columns/_layout/events/$id": {
"filePath": "columns/_layout/events.$id.lazy.tsx",
"/columns/_layout/groups/$id": {
"filePath": "columns/_layout/groups.$id.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/hashtags/$content": {
"filePath": "columns/_layout/hashtags.$content.lazy.tsx",
"/columns/_layout/interests/$id": {
"filePath": "columns/_layout/interests.$id.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/events/$id": {
"filePath": "columns/_layout/events.$id.lazy.tsx",
"parent": "/columns/_layout"
},
"/columns/_layout/replies/$id": {
+2 -11
View File
@@ -1,9 +1,8 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { User } from "@/components/user";
import { LumeWindow } from "@/system";
import { CaretDown, Feather, MagnifyingGlass } from "@phosphor-icons/react";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { memo, useCallback } from "react";
@@ -14,7 +13,6 @@ export const Route = createLazyFileRoute("/$account/_app")({
function Screen() {
const context = Route.useRouteContext();
const transparent = useStore(appSettings, (state) => state.transparent);
return (
<div className="flex flex-col w-screen h-screen">
@@ -54,14 +52,7 @@ function Screen() {
className="relative z-[200] flex-1 flex items-center justify-end gap-1"
/>
</div>
<div
className={cn(
"flex-1",
transparent
? ""
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
)}
>
<div className="flex-1 bg-neutral-100 dark:bg-neutral-900 border-t-[.5px] border-black/20 dark:border-white/20">
<Outlet />
</div>
</div>
+1 -13
View File
@@ -1,15 +1,3 @@
import type { LumeColumn } from "@/types";
import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/$account/_app")({
beforeLoad: async () => {
const systemPath = "resources/columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
return { systemColumns };
},
});
export const Route = createFileRoute("/$account/_app")();
+47 -35
View File
@@ -1,13 +1,16 @@
import { commands } from "@/commands.gen";
import { appColumns } from "@/commons";
import { Spinner } from "@/components";
import { Column } from "@/components/column";
import { LumeWindow } from "@/system";
import type { ColumnEvent, LumeColumn } from "@/types";
import { ArrowLeft, ArrowRight, Plus, StackPlus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
import useEmblaCarousel from "embla-carousel-react";
import { nanoid } from "nanoid";
import {
@@ -25,9 +28,9 @@ export const Route = createLazyFileRoute("/$account/_app/home")({
});
function Screen() {
const { initialColumns } = Route.useRouteContext();
const params = Route.useParams();
const columns = useStore(appColumns, (state) => state);
const [columns, setColumns] = useState<LumeColumn[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
watchDrag: false,
loop: false,
@@ -51,11 +54,11 @@ function Screen() {
const add = useDebouncedCallback((column: LumeColumn) => {
column.label = `${column.label}-${nanoid()}`; // update col label
setColumns((prev) => [column, ...prev]);
appColumns.setState((prev) => [column, ...prev]);
}, 150);
const remove = useDebouncedCallback((label: string) => {
setColumns((prev) => prev.filter((t) => t.label !== label));
appColumns.setState((prev) => prev.filter((t) => t.label !== label));
}, 150);
const move = useDebouncedCallback(
@@ -70,12 +73,12 @@ function Screen() {
if (direction === "left") newCols.splice(colIndex - 1, 0, col);
if (direction === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
appColumns.setState(() => newCols);
},
150,
);
const updateName = useDebouncedCallback((label: string, title: string) => {
const update = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label);
const updatedCol = Object.assign({}, columns[currentColIndex]);
@@ -84,10 +87,10 @@ function Screen() {
const newCols = columns.slice();
newCols[currentColIndex] = updatedCol;
setColumns(newCols);
appColumns.setState(() => newCols);
}, 150);
const reset = useDebouncedCallback(() => setColumns([]), 150);
const reset = useDebouncedCallback(() => appColumns.setState(() => []), 150);
const handleKeyDown = useDebouncedCallback((event) => {
if (event.defaultPrevented) return;
@@ -106,18 +109,6 @@ function Screen() {
event.preventDefault();
}, 150);
const saveAllColumns = useDebouncedCallback(async () => {
const key = "lume_v4:columns";
const content = JSON.stringify(columns);
const res = await commands.setLumeStore(key, content);
if (res.status === "ok") {
return res.data;
} else {
console.log(res.error);
}
}, 200);
useEffect(() => {
if (emblaApi) {
emblaApi.on("scroll", emitScrollEvent);
@@ -132,14 +123,6 @@ function Screen() {
};
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
useEffect(() => {
if (columns) saveAllColumns();
}, [columns]);
useEffect(() => {
setColumns(initialColumns);
}, [initialColumns]);
// Listen for keyboard event
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
@@ -158,7 +141,7 @@ function Screen() {
if (data.payload.type === "move")
move(data.payload.label, data.payload.direction);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
update(data.payload.label, data.payload.title);
});
return () => {
@@ -166,6 +149,35 @@ function Screen() {
};
}, []);
useEffect(() => {
async function getSystemColumns() {
const systemPath = "resources/columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const cols: LumeColumn[] = JSON.parse(resourceFile);
appColumns.setState(() => cols.filter((col) => col.default));
}
if (!columns.length) {
const prevColumns = window.localStorage.getItem(
`${params.account}_columns`,
);
if (!prevColumns) {
getSystemColumns();
} else {
const parsed: LumeColumn[] = JSON.parse(prevColumns);
appColumns.setState(() => parsed);
}
} else {
window.localStorage.setItem(
`${params.account}_columns`,
JSON.stringify(columns),
);
}
}, [columns.length]);
return (
<div className="size-full">
<div ref={emblaRef} className="overflow-hidden size-full">
@@ -179,8 +191,8 @@ function Screen() {
<Column key={column.label} column={column} />
))
)}
<div className="shrink-0 p-2 h-full w-[450px]">
<div className="size-full bg-black/5 dark:bg-white/15 rounded-xl flex items-center justify-center">
<div className="shrink-0 p-2 h-full w-[440px]">
<div className="size-full flex items-center justify-center">
<button
type="button"
onClick={() => LumeWindow.openColumnsGallery()}
@@ -220,16 +232,16 @@ function ManageButton() {
const menuItems = await Promise.all([
MenuItem.new({
text: "Open Columns Gallery",
text: "Open Launchpad",
action: () => LumeWindow.openColumnsGallery(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Add local feeds",
text: "Open Newsfeed",
action: () => LumeWindow.openLocalFeeds(),
}),
MenuItem.new({
text: "Add notification",
text: "Open Notification",
action: () => LumeWindow.openNotification(),
}),
]);
+1 -18
View File
@@ -1,20 +1,3 @@
import { commands } from "@/commands.gen";
import type { LumeColumn } from "@/types";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_app/home")({
beforeLoad: async ({ context }) => {
const key = "lume_v4:columns";
const defaultColumns = context.systemColumns.filter((col) => col.default);
const query = await commands.getLumeStore(key);
let initialColumns: LumeColumn[] = defaultColumns;
if (query.status === "ok") {
initialColumns = JSON.parse(query.data);
return { initialColumns };
}
return { initialColumns };
},
});
export const Route = createFileRoute("/$account/_app/home")();
@@ -29,7 +29,7 @@ function Screen() {
const updateSettings = () => {
startTransition(async () => {
const newSettings = JSON.stringify(appSettings.state);
const res = await commands.setSettings(newSettings);
const res = await commands.setUserSettings(newSettings);
if (res.status === "error") {
await message(res.error, { kind: "error" });
@@ -61,6 +61,11 @@ function Screen() {
description="Shows a warning for notes that have a content warning."
label="content_warning"
/>
<Setting
name="Trusted Only"
description="Only shows note's replies from your inner circle."
label="trusted_only"
/>
</div>
</div>
<div className="flex flex-col gap-2">
@@ -128,11 +133,12 @@ function Screen() {
</div>
</div>
</div>
<div className="sticky bottom-0 left-0 w-full h-11 flex items-center justify-end px-3 bg-white/20 dark:bg-black-20 backdrop-blur-md border-t border-black/5 dark:border-white/5">
<div className="sticky bottom-0 left-0 w-full h-16 flex items-center justify-end px-3">
<div className="absolute left-0 bottom-0 w-full h-11 gradient-mask-t-0 bg-neutral-100 dark:bg-neutral-900" />
<button
type="button"
onClick={() => updateSettings()}
className="inline-flex items-center justify-center w-20 rounded-md shadow h-7 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
className="relative z-10 inline-flex items-center justify-center w-20 rounded-md shadow h-8 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
>
{isPending ? <Spinner className="size-4" /> : "Update"}
</button>
+1 -1
View File
@@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/general")({
beforeLoad: async () => {
const res = await commands.getSettings();
const res = await commands.getUserSettings();
if (res.status === "ok") {
appSettings.setState((state) => {
+13
View File
@@ -3,6 +3,7 @@ import { appSettings } from "@/commons";
import { Spinner } from "@/components";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import type { OsType } from "@tauri-apps/plugin-os";
import { useEffect } from "react";
@@ -17,6 +18,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
});
function Screen() {
const { queryClient } = Route.useRouteContext();
useEffect(() => {
const unlisten = events.newSettings.listen((data) => {
appSettings.setState((state) => {
@@ -29,6 +32,16 @@ function Screen() {
};
}, []);
useEffect(() => {
const unlisten = listen("synchronized", async () => {
await queryClient.invalidateQueries();
});
return () => {
unlisten.then((f) => f());
};
}, []);
return <Outlet />;
}
+2 -2
View File
@@ -68,12 +68,12 @@ function Screen() {
placeholder="bunker://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none"
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => pasteFromClipboard()}
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500"
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500 dark:text-blue-300"
>
Paste
</button>
+2 -2
View File
@@ -84,12 +84,12 @@ function Screen() {
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => pasteFromClipboard()}
className="absolute uppercase top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500"
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500 dark:text-blue-300"
>
Paste
</button>
+3 -3
View File
@@ -110,7 +110,7 @@ function Screen() {
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice"
spellCheck={false}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-600"
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/>
</div>
<div className="flex flex-col gap-1">
@@ -126,7 +126,7 @@ function Screen() {
onChange={(e) => setAbout(e.target.value)}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="px-3 py-1.5 rounded-lg min-h-16 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-600"
className="px-3 py-1.5 rounded-lg min-h-16 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/>
</div>
<div className="h-px w-full mt-2 bg-neutral-100 dark:bg-neutral-900" />
@@ -142,7 +142,7 @@ function Screen() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-600"
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/>
</div>
</Frame>
+1 -1
View File
@@ -12,7 +12,7 @@ export const Route = createFileRoute("/columns/_layout")({
};
},
beforeLoad: async () => {
const res = await commands.getSettings();
const res = await commands.getUserSettings();
if (res.status === "ok") {
appSettings.setState((state) => {
@@ -1,185 +0,0 @@
import { commands } from "@/commands.gen";
import { Spinner } from "@/components";
import { User } from "@/components/user";
import { Plus, X } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/columns/_layout/create-group")({
component: Screen,
});
const REYA_NPUB =
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445";
function Screen() {
const contacts = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const { queryClient } = Route.useRouteContext();
const [title, setTitle] = useState("");
const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([REYA_NPUB]);
const [isPending, startTransition] = useTransition();
const toggleUser = (pubkey: string) => {
setUsers((prev) =>
prev.includes(pubkey)
? prev.filter((i) => i !== pubkey)
: [...prev, pubkey],
);
};
const addUser = () => {
if (!npub.startsWith("npub1")) return;
if (users.includes(npub)) return;
setUsers((prev) => [...prev, npub]);
setNpub("");
};
const submit = () => {
startTransition(async () => {
const key = `lume_v4:group:${search.label}`;
const res = await commands.setLumeStore(key, JSON.stringify(users));
if (res.status === "ok") {
await queryClient.invalidateQueries({
queryKey: [search.label, search.account],
});
navigate({ to: search.redirect, search: { ...search, name: title } });
} else {
await message(res.error, {
title: "Create Group",
kind: "error",
});
return;
}
});
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">Create a group</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
For the people that you want to keep up.
</p>
</div>
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<label
htmlFor="name"
className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
>
Name
</label>
<input
name="name"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a name for this group"
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex gap-2">
<input
name="npub"
value={npub}
onChange={(e) => setNpub(e.target.value)}
placeholder="npub1..."
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => addUser()}
className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
>
<Plus className="size-5" />
</button>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold">Added</span>
<div className="flex flex-col gap-2">
{users.length ? (
users.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
<X className="size-4" />
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
Empty.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold">Contacts</span>
<div className="flex flex-col gap-2">
{contacts.length ? (
contacts.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
<p>
Find more user at{" "}
<a
href="https://www.nostr.directory/"
target="_blank"
className="text-blue-600 after:content-['_↗']"
rel="noreferrer"
>
Nostr Directory
</a>
</p>
</div>
)}
</div>
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isPending || users.length < 1}
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
}
+17 -19
View File
@@ -29,9 +29,12 @@ function Screen() {
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="h-full pt-1 px-3 pb-3">
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Virtualizer scrollRef={ref}>
<RootEvent />
<ReplyList />
@@ -54,37 +57,33 @@ function RootEvent() {
if (isLoading) {
return (
<div className="bg-white flex items-center justify-center h-32 dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<div className="flex items-center gap-2 text-sm">
<Spinner />
Loading...
</div>
<div className="flex items-center gap-2 text-sm">
<Spinner />
Loading...
</div>
);
}
if (isError) {
return (
<div className="bg-white flex items-center justify-center h-32 dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<div className="flex items-center gap-2 text-sm text-red-500">
{error.message}
</div>
<div className="flex items-center gap-2 text-sm text-red-500">
{error.message}
</div>
);
}
return (
<Note.Provider event={event}>
<Note.Root className="bg-white dark:bg-white/10 rounded-xl shadow-primary dark:shadow-none">
<Note.Root className="border-b-[.5px] border-neutral-300 dark:border-neutral-700">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center gap-2 px-3 mt-6 h-12 rounded-b-xl bg-neutral-50 dark:bg-white/5">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
<Note.Reply label />
<Note.Repost label />
<Note.Zap label />
</div>
</Note.Root>
</Note.Provider>
@@ -182,7 +181,7 @@ function ReplyList() {
useEffect(() => {
events.subscription
.emit({ label, kind: "Subscribe", event_id: id, local_only: undefined })
.emit({ label, kind: "Subscribe", event_id: id })
.then(() => console.log("Subscribe: ", label));
return () => {
@@ -191,7 +190,6 @@ function ReplyList() {
label,
kind: "Unsubscribe",
event_id: id,
local_only: undefined,
})
.then(() => console.log("Unsubscribe: ", label));
};
@@ -218,14 +216,14 @@ function ReplyList() {
}, []);
return (
<div>
<div className="px-3">
<div className="flex items-center text-sm font-semibold h-14 text-neutral-600 dark:text-white/30">
All replies
</div>
{isLoading ? (
<div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<Spinner className="size-4" />
<span className="text-sm font-medium">Getting replies...</span>
</div>
</div>
@@ -1,75 +0,0 @@
import type { LumeColumn } from "@/types";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import Avatar from "boring-avatars";
export const Route = createLazyFileRoute("/columns/_layout/gallery")({
component: Screen,
});
function Screen() {
const { columns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport className="relative h-full px-3">
{columns.map((column) => (
<div
key={column.label}
className="mb-3 group flex px-2 items-center justify-between h-16 rounded-xl bg-white dark:bg-white/20 shadow-sm shadow-neutral-500/10"
>
<div className="inline-flex items-center gap-2">
<div className="size-11 bg-neutral-200 rounded-lg overflow-hidden">
<Avatar
name={column.name}
size={44}
square={true}
variant="pixel"
colors={[
"#84cc16",
"#22c55e",
"#0ea5e9",
"#3b82f6",
"#6366f1",
]}
/>
</div>
<div className="text-sm">
<div className="mb-px leading-tight font-semibold">
{column.name}
</div>
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
{column.description}
</div>
</div>
</div>
<button
type="button"
onClick={() => install(column)}
className="text-xs uppercase font-semibold w-16 h-7 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
>
Open
</button>
</div>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
-19
View File
@@ -1,19 +0,0 @@
import type { LumeColumn } from "@/types";
import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/columns/_layout/gallery")({
beforeLoad: async () => {
const systemPath = "resources/columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const columns = systemColumns.filter((col) => !col.default);
return {
columns,
};
},
});
+27 -17
View File
@@ -1,9 +1,9 @@
import { commands } from "@/commands.gen";
import { toLumeEvents } from "@/commons";
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
import { RepostNote, Spinner, TextNote } from "@/components";
import type { LumeEvent } from "@/system";
import { Kind } from "@/types";
import { ArrowCircleRight } from "@phosphor-icons/react";
import { ArrowDown } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
@@ -48,14 +48,21 @@ export function Screen() {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
} else {
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
return (
<RepostNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
default:
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[data],
@@ -65,9 +72,12 @@ export function Screen() {
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
@@ -79,11 +89,11 @@ export function Screen() {
) : null}
{isLoading ? (
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<Spinner className="size-4" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
<div className="mb-3 flex items-center justify-center h-20 text-sm">
🎉 Yo. You're catching up on all latest notes.
</div>
) : (
@@ -95,13 +105,13 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 text-sm font-medium text-blue-500 h-11 focus:outline-none"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
<Spinner className="size-4" />
) : (
<>
<ArrowCircleRight className="size-5" />
<ArrowDown className="size-4" />
Load more
</>
)}
-25
View File
@@ -1,25 +0,0 @@
import { commands } from "@/commands.gen";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/group")({
beforeLoad: async ({ search }) => {
const key = `lume_v4:group:${search.label}`;
const res = await commands.getLumeStore(key);
if (res.status === "ok") {
const groups: string[] = JSON.parse(res.data);
if (groups.length) {
return { groups };
}
}
throw redirect({
to: "/columns/create-group",
search: {
...search,
redirect: "/columns/group",
},
});
},
});
@@ -1,22 +1,23 @@
import { commands } from "@/commands.gen";
import { toLumeEvents } from "@/commons";
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
import { RepostNote, Spinner, TextNote } from "@/components";
import type { LumeEvent } from "@/system";
import { Kind } from "@/types";
import { ArrowCircleRight } from "@phosphor-icons/react";
import { ArrowDown } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/group")({
export const Route = createLazyFileRoute("/columns/_layout/groups/$id")({
component: Screen,
});
export function Screen() {
const { label, account } = Route.useSearch();
const { groups } = Route.useRouteContext();
const group = Route.useLoaderData();
const params = Route.useParams();
const {
data,
isLoading,
@@ -25,11 +26,11 @@ export function Screen() {
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
queryKey: ["groups", params.id],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const until = pageParam > 0 ? pageParam.toString() : undefined;
const res = await commands.getGroupEvents(groups, until);
const res = await commands.getAllEventsByAuthors(group, until);
if (res.status === "error") {
throw new Error(res.error);
@@ -39,6 +40,7 @@ export function Screen() {
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flat(),
enabled: group?.length > 0,
refetchOnWindowFocus: false,
});
@@ -49,14 +51,21 @@ export function Screen() {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
} else {
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
return (
<RepostNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
default:
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[data],
@@ -66,9 +75,12 @@ export function Screen() {
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
@@ -80,11 +92,11 @@ export function Screen() {
) : null}
{isLoading ? (
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<Spinner className="size-4" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
<div className="mb-3 flex items-center justify-center h-20 text-sm">
🎉 Yo. You're catching up on all latest notes.
</div>
) : (
@@ -96,13 +108,13 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 text-sm font-medium text-blue-500 h-11 focus:outline-none"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
<Spinner className="size-4" />
) : (
<>
<ArrowCircleRight className="size-5" />
<ArrowDown className="size-4" />
Load more
</>
)}
+20
View File
@@ -0,0 +1,20 @@
import { commands } from "@/commands.gen";
import type { NostrEvent } from "@/types";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/groups/$id")({
loader: async ({ params }) => {
const res = await commands.getGroup(params.id);
if (res.status === "ok") {
const event: NostrEvent = JSON.parse(res.data);
const tag = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1]);
return tag;
} else {
throw new Error(res.error);
}
},
});
@@ -1,22 +1,23 @@
import { commands } from "@/commands.gen";
import { toLumeEvents } from "@/commons";
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
import { RepostNote, Spinner, TextNote } from "@/components";
import type { LumeEvent } from "@/system";
import { Kind } from "@/types";
import { ArrowCircleRight } from "@phosphor-icons/react";
import { ArrowDown } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/hashtags/$content")({
export const Route = createLazyFileRoute("/columns/_layout/interests/$id")({
component: Screen,
});
export function Screen() {
const { label, account } = Route.useSearch();
const { content } = Route.useParams();
const hashtags = Route.useLoaderData();
const params = Route.useParams();
const {
data,
isLoading,
@@ -25,12 +26,12 @@ export function Screen() {
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
queryKey: ["hashtags", params.id],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const hashtags = content.split("_");
const tags = hashtags.map((tag) => tag.toLowerCase().replace("#", ""));
const until = pageParam > 0 ? pageParam.toString() : undefined;
const res = await commands.getHashtagEvents(hashtags, until);
const res = await commands.getAllEventsByHashtags(tags, until);
if (res.status === "error") {
throw new Error(res.error);
@@ -50,14 +51,21 @@ export function Screen() {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
} else {
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
return (
<RepostNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
default:
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[data],
@@ -67,9 +75,12 @@ export function Screen() {
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
@@ -81,11 +92,11 @@ export function Screen() {
) : null}
{isLoading ? (
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<Spinner className="size-4" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
<div className="mb-3 flex items-center justify-center h-20 text-sm">
🎉 Yo. You're catching up on all latest notes.
</div>
) : (
@@ -97,13 +108,13 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 text-sm font-medium text-blue-500 h-11 focus:outline-none"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
<Spinner className="size-4" />
) : (
<>
<ArrowCircleRight className="size-5" />
<ArrowDown className="size-4" />
Load more
</>
)}
@@ -0,0 +1,20 @@
import { commands } from "@/commands.gen";
import type { NostrEvent } from "@/types";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/interests/$id")({
loader: async ({ params }) => {
const res = await commands.getInterest(params.id);
if (res.status === "ok") {
const event: NostrEvent = JSON.parse(res.data);
const tag = event.tags
.filter((tag) => tag[0] === "t")
.map((tag) => tag[1]);
return tag;
} else {
throw new Error(res.error);
}
},
});
@@ -0,0 +1,324 @@
import { commands } from "@/commands.gen";
import { cn, toLumeEvents } from "@/commons";
import { Spinner, User } from "@/components";
import { LumeWindow } from "@/system";
import type { LumeColumn, NostrEvent } from "@/types";
import { ArrowClockwise, Plus } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { nanoid } from "nanoid";
import { useCallback } from "react";
export const Route = createLazyFileRoute("/columns/_layout/launchpad")({
component: Screen,
});
function Screen() {
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport className="relative h-full px-3 pb-3">
<Groups />
<Interests />
<Core />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
function Core() {
const { isLoading, data } = useQuery({
queryKey: ["core"],
queryFn: async () => {
const systemPath = "resources/columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const columns = systemColumns.filter((col) => !col.default);
return columns;
},
refetchOnWindowFocus: false,
});
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold">Core</h3>
</div>
<div className="flex flex-col gap-3">
{isLoading ? (
<div className="inline-flex items-center gap-1.5">
<Spinner className="size-4" />
Loading...
</div>
) : (
data.map((column) => (
<div
key={column.label}
className="group flex px-4 items-center justify-between h-16 rounded-xl bg-white dark:bg-black border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<div className="text-sm">
<div className="mb-px leading-tight font-semibold">
{column.name}
</div>
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
{column.description}
</div>
</div>
<button
type="button"
onClick={() => LumeWindow.openColumn(column)}
className="text-xs uppercase font-semibold w-16 h-7 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
>
Open
</button>
</div>
))
)}
</div>
</div>
);
}
function Groups() {
const { account } = Route.useSearch();
const { isLoading, data, refetch, isRefetching } = useQuery({
queryKey: ["groups", account],
queryFn: async () => {
const res = await commands.getAllGroups();
if (res.status === "ok") {
const data = toLumeEvents(res.data);
return data;
} else {
throw new Error(res.error);
}
},
select: (data) =>
data.filter(
(item) => item.tags.filter((tag) => tag[0] === "p")?.length > 0,
),
refetchOnWindowFocus: false,
});
const renderItem = useCallback(
(item: NostrEvent) => {
const name =
item.tags.find((tag) => tag[0] === "title")?.[1] || "Unnamed";
const label = item.tags.find((tag) => tag[0] === "d")?.[1] || nanoid();
return (
<div
key={item.id}
className="group flex flex-col rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50 border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<div className="p-3 h-16 flex flex-wrap items-center justify-center gap-2 overflow-y-auto">
{item.tags
.filter((tag) => tag[0] === "p")
.map((tag) => (
<div key={tag[1]}>
<User.Provider pubkey={tag[1]}>
<User.Root>
<User.Avatar className="size-8 rounded-full" />
</User.Root>
</User.Provider>
</div>
))}
</div>
<div className="p-3 flex items-center justify-between">
<div className="text-sm font-medium">{name}</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() =>
LumeWindow.openColumn({
label,
name,
url: `/columns/groups/${item.id}`,
})
}
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-blue-600 hover:bg-blue-500 text-white"
>
Add
</button>
</div>
</div>
</div>
);
},
[data],
);
return (
<div className="mb-12 flex flex-col gap-3">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold">Groups</h3>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => refetch()}
className={cn(
"size-7 inline-flex items-center justify-center rounded-full",
isRefetching ? "animate-spin" : "",
)}
>
<ArrowClockwise className="size-4" />
</button>
<button
type="button"
onClick={() =>
LumeWindow.openPopup("New group", `/set-group?account=${account}`)
}
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>
<Plus className="size-3" weight="bold" />
New
</button>
</div>
</div>
<div className="flex flex-col gap-3">
{isLoading ? (
<div className="inline-flex items-center gap-1.5">
<Spinner className="size-4" />
Loading...
</div>
) : !data.length ? (
<div className="flex flex-col items-center justify-center h-16 w-full rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50">
<p className="text-center">You don't have any groups yet.</p>
</div>
) : (
data.map((item) => renderItem(item))
)}
</div>
</div>
);
}
function Interests() {
const { account } = Route.useSearch();
const { isLoading, data, refetch, isRefetching } = useQuery({
queryKey: ["interests", account],
queryFn: async () => {
const res = await commands.getAllInterests();
if (res.status === "ok") {
const data = toLumeEvents(res.data);
return data;
} else {
throw new Error(res.error);
}
},
select: (data) =>
data.filter(
(item) => item.tags.filter((tag) => tag[0] === "t")?.length > 0,
),
refetchOnWindowFocus: false,
});
const renderItem = useCallback(
(item: NostrEvent) => {
const name =
item.tags.find((tag) => tag[0] === "title")?.[1] || "Unnamed";
const label =
item.tags.find((tag) => tag[0] === "label")?.[1] || nanoid();
return (
<div
key={item.id}
className="group flex flex-col rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50 border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<div className="p-3 h-16 flex flex-wrap items-center justify-center gap-2 overflow-y-auto">
{item.tags
.filter((tag) => tag[0] === "t")
.map((tag) => (
<div key={tag[1]} className="text-sm font-medium">
{tag[1]}
</div>
))}
</div>
<div className="p-3 flex items-center justify-between">
<div className="text-sm font-medium">{name}</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() =>
LumeWindow.openColumn({
label,
name,
url: `/columns/interests/${item.id}`,
})
}
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-blue-600 hover:bg-blue-500 text-white"
>
Add
</button>
</div>
</div>
</div>
);
},
[data],
);
return (
<div className="mb-12 flex flex-col gap-3">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold">Interests</h3>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => refetch()}
className={cn(
"size-7 inline-flex items-center justify-center rounded-full",
isRefetching ? "animate-spin" : "",
)}
>
<ArrowClockwise className="size-4" />
</button>
<button
type="button"
onClick={() =>
LumeWindow.openPopup(
"New interest",
`/set-interest?account=${account}`,
)
}
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>
<Plus className="size-3" weight="bold" />
New
</button>
</div>
</div>
<div className="flex flex-col gap-3">
{isLoading ? (
<div className="inline-flex items-center gap-1.5">
<Spinner className="size-4" />
Loading...
</div>
) : !data.length ? (
<div className="flex flex-col items-center justify-center h-16 w-full rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50">
<p className="text-center">You don't have any interests yet.</p>
</div>
) : (
data.map((item) => renderItem(item))
)}
</div>
</div>
);
}
+33 -48
View File
@@ -1,17 +1,12 @@
import { events, commands } from "@/commands.gen";
import { toLumeEvents } from "@/commons";
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
import { RepostNote, Spinner, TextNote } from "@/components";
import { LumeEvent } from "@/system";
import { Kind, type Meta } from "@/types";
import { ArrowCircleRight, ArrowUp } from "@phosphor-icons/react";
import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import {
Navigate,
createLazyFileRoute,
useLocation,
} from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
memo,
@@ -33,12 +28,11 @@ export const Route = createLazyFileRoute("/columns/_layout/newsfeed")({
});
export function Screen() {
const { queryClient } = Route.useRouteContext();
const contacts = Route.useLoaderData();
const { label, account } = Route.useSearch();
const {
data,
isLoading,
isError,
isFetching,
isFetchingNextPage,
hasNextPage,
@@ -48,7 +42,7 @@ export function Screen() {
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const until = pageParam > 0 ? pageParam.toString() : undefined;
const res = await commands.getEventsFromContacts(until);
const res = await commands.getAllEventsByAuthors(contacts, until);
if (res.status === "error") {
throw new Error(res.error);
@@ -58,9 +52,9 @@ export function Screen() {
},
getNextPageParam: (lastPage) => lastPage?.at?.(-1)?.created_at - 1,
select: (data) => data?.pages.flat(),
enabled: contacts?.length > 0,
});
const location = useLocation();
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
@@ -68,46 +62,37 @@ export function Screen() {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
} else {
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
return (
<RepostNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
default:
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[data],
);
useEffect(() => {
const unlisten = listen("newsfeed_synchronized", async () => {
await queryClient.invalidateQueries({ queryKey: [label, account] });
});
return () => {
unlisten.then((f) => f());
};
}, []);
if (isError) {
return (
<Navigate
to="/columns/create-newsfeed/users"
search={{ label, account, redirect: location.href }}
/>
);
}
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
<Listerner />
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Listener />
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
@@ -119,11 +104,11 @@ export function Screen() {
) : null}
{isLoading ? (
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<Spinner className="size-4" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
<div className="mb-3 flex items-center justify-center h-20 text-sm">
🎉 Yo. You're catching up on all latest notes.
</div>
) : (
@@ -135,13 +120,13 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 text-sm font-medium text-blue-500 h-11 focus:outline-none"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
<Spinner className="size-4" />
) : (
<>
<ArrowCircleRight className="size-5" />
<ArrowDown className="size-4" />
Load more
</>
)}
@@ -161,7 +146,7 @@ export function Screen() {
);
}
const Listerner = memo(function Listerner() {
const Listener = memo(function Listerner() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch();
@@ -1,7 +1,7 @@
import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/create-group")({
export const Route = createFileRoute("/columns/_layout/newsfeed")({
loader: async () => {
const res = await commands.getContactList();
+172 -139
View File
@@ -1,16 +1,16 @@
import { commands } from "@/commands.gen";
import { decodeZapInvoice, formatCreatedAt } from "@/commons";
import { Note, Spinner, User } from "@/components";
import { LumeEvent, useEvent } from "@/system";
import { Note, RepostIcon, Spinner, User } from "@/components";
import { LumeEvent, LumeWindow, useEvent } from "@/system";
import { Kind, type NostrEvent } from "@/types";
import { Info, Repeat } from "@phosphor-icons/react";
import { Info } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import * as Tabs from "@radix-ui/react-tabs";
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { nip19 } from "nostr-tools";
import { type ReactNode, useEffect, useRef } from "react";
import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/notification")({
@@ -99,110 +99,104 @@ function Screen() {
}
return (
<Tabs.Root defaultValue="replies" className="flex flex-col h-full">
<Tabs.List className="h-8 shrink-0 flex items-center">
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="replies"
>
Replies
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="reactions"
>
Reactions
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="zaps"
>
Zaps
</Tabs.Trigger>
</Tabs.List>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="min-h-0 flex-1 overflow-x-hidden"
<div className="px-3 h-full overflow-y-auto">
<Tabs.Root
defaultValue="replies"
className="flex flex-col h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Tab value="replies">
{data.texts.map((event, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TextNote key={event.id + index} event={event} />
))}
</Tab>
<Tab value="reactions">
{[...data.reactions.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider key={event.id} pubkey={event.pubkey}>
<User.Root className="shrink-0 flex rounded-full h-7 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="flex-1 rounded-full size-6" />
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<Repeat className="text-teal-400 size-4 dark:text-teal-600" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tab>
<Tab value="zaps">
{[...data.zaps.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider
key={event.id}
pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
>
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-7 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="rounded-full size-6" />
<div className="flex-1 h-6 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-xs font-semibold truncate">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tab>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
<Tabs.List className="h-11 shrink-0 flex items-center">
<Tabs.Trigger
className="flex-1 inline-flex h-11 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-neutral-100 dark:border-neutral-900 data-[state=active]:border-neutral-200 dark:data-[state=active]:border-neutral-800 data-[state=inactive]:opacity-50"
value="replies"
>
Replies
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-11 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-neutral-100 dark:border-neutral-900 data-[state=active]:border-neutral-200 dark:data-[state=active]:border-neutral-800 data-[state=inactive]:opacity-50"
value="reactions"
>
Reactions
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-11 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-neutral-100 dark:border-neutral-900 data-[state=active]:border-neutral-200 dark:data-[state=active]:border-neutral-800 data-[state=inactive]:opacity-50"
value="zaps"
>
Zaps
</Tabs.Trigger>
</Tabs.List>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="min-h-0 flex-1 overflow-x-hidden"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</Tabs.Root>
<Tab value="replies">
{data.texts.map((event, index) => (
<TextNote key={event.id + index} event={event} />
))}
</Tab>
<Tab value="reactions">
{[...data.reactions.entries()].map(([root, events]) => (
<div
key={root}
className="p-3 w-full border-b-[.5px] border-neutral-300 dark:border-neutral-700 hover:border-blue-500"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider key={event.id} pubkey={event.pubkey}>
<User.Root className="shrink-0 flex rounded-full h-7 bg-neutral-100 dark:bg-neutral-900 p-[2px]">
<User.Avatar className="flex-1 rounded-full size-6" />
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-6">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tab>
<Tab value="zaps">
{[...data.zaps.entries()].map(([root, events]) => (
<div
key={root}
className="p-3 w-full border-b-[.5px] border-neutral-300 dark:border-neutral-700 hover:border-blue-500"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<ZapReceipt key={event.id} event={event} />
))}
</div>
</div>
</div>
))}
</Tab>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</Tabs.Root>
</div>
);
}
@@ -211,7 +205,7 @@ function Tab({ value, children }: { value: string; children: ReactNode[] }) {
return (
<Tabs.Content value={value} className="size-full">
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<ScrollArea.Viewport ref={ref} className="h-full">
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
</ScrollArea.Viewport>
</Tabs.Content>
@@ -264,40 +258,79 @@ function TextNote({ event }: { event: LumeEvent }) {
.slice(0, 3);
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="rounded-full size-9" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{[...new Set(pTags)].map((replyTo) => (
<User.Provider key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
<button
type="button"
onClick={() => LumeWindow.openEvent(event)}
className="p-3 w-full border-b-[.5px] border-neutral-300 dark:border-neutral-700 hover:border-blue-500"
>
<Note.Provider event={event}>
<Note.Root className="flex flex-col">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="rounded-full size-9" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{[...new Set(pTags)].map((replyTo) => (
<User.Provider key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
</div>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1 text-start">{event.content}</div>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1 text-start">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
</button>
);
}
function ZapReceipt({ event }: { event: LumeEvent }) {
const amount = useMemo(
() => decodeZapInvoice(event.tags).bitcoinFormatted ?? "0",
[event.id],
);
const sender = useMemo(
() => event.tags.find((tag) => tag[0] === "P")?.[1],
[event.id],
);
if (!sender) {
return (
<div className="shrink-0 flex gap-1.5 rounded-full h-7 bg-black/10 dark:bg-white/10 p-[2px]">
<div className="rounded-full size-6 bg-blue-500" />
<div className="flex-1 h-6 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-xs font-semibold truncate">
{amount}
</div>
</Note.Root>
</Note.Provider>
</div>
);
}
return (
<User.Provider pubkey={sender}>
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-7 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="rounded-full size-6" />
<div className="flex-1 h-6 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-xs font-semibold truncate">
{amount}
</div>
</User.Root>
</User.Provider>
);
}
+89 -98
View File
@@ -1,3 +1,4 @@
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/columns/_layout/onboarding")({
@@ -6,107 +7,97 @@ export const Route = createLazyFileRoute("/columns/_layout/onboarding")({
function Screen() {
return (
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are a few suggestions to help you get started.
</p>
</div>
<div className="px-3 flex flex-col gap-3">
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
01.
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport className="relative h-full px-3 pb-3">
<div className="my-10 text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are a few suggestions to help you get started.
</p>
</div>
<div className="flex flex-col gap-3">
<div className="relative flex flex-col items-center justify-center rounded-xl bg-neutral-200 dark:bg-neutral-800">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
01.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Navigate between columns.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full rounded-lg shadow-md"
controls
muted
preload="none"
poster="https://image.nostr.build/143354665d94b20013fde14ad05e53e958e11eec568a11b273921d1808c410cc.png"
>
<source
src="https://video.nostr.build/8fc3598ef85a1be292cee4ad6ad85b2c8bbf86da0aefd693e60416b56ec96e5f.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Navigate between columns.
<div className="relative flex flex-col items-center justify-center rounded-xl bg-neutral-200 dark:bg-neutral-800">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
02.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Add a new column.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full rounded-lg shadow-md"
controls
muted
preload="none"
poster="https://image.nostr.build/216015bc81931725c23171700b4d2d73556ecfe3efe662afd7eea6627574b506.png"
>
<source
src="https://video.nostr.build/cdb842a1ffc6864bab009a668f68acb0a672e04e9538dbe7e2ac44182722d956.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_1.jpeg"
>
<source
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-neutral-200 dark:bg-neutral-800">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
03.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
View a thread.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full rounded-lg shadow-md"
controls
muted
preload="none"
poster="https://image.nostr.build/3a3ead93bc64224e6397df4097998708b5aef4c6e7104f8e28c77db47ad1625a.png"
>
<source
src="https://video.nostr.build/088636b03976a4e54c5053c718300816a7c3f9f361a1fe7d8e7a0f663ab6a582.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
02.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Switch between accounts.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_2.jpeg"
>
<source
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
03.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Open Lume Store.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_3.jpeg"
>
<source
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
04.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Use the Tray Menu.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_4.jpeg"
>
<source
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
+34 -30
View File
@@ -19,37 +19,41 @@ function Screen() {
const { events } = useRouterState({ select: (s) => s.location.state });
return (
<div className="h-full flex flex-col">
<div className="h-10 shrink-0 border-b border-black/5 dark:border-white/5 flex items-center justify-between px-2">
<button
type="button"
onClick={() => router.history.back()}
className="inline-flex items-center justify-center gap-1.5 h-7 w-max px-1 text-sm font-semibold hover:bg-black/10 dark:hover:bg-white/10 rounded-md"
>
<ArrowLeft className="size-4" />
Back
</button>
<div className="px-3 h-full">
<div className="size-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700">
<div className="h-full flex flex-col">
<div className="h-10 shrink-0 border-b border-black/5 dark:border-white/5 flex items-center justify-between px-2">
<button
type="button"
onClick={() => router.history.back()}
className="inline-flex items-center justify-center gap-1.5 h-7 w-max px-1 text-sm font-semibold hover:bg-black/10 dark:hover:bg-white/10 rounded-md"
>
<ArrowLeft className="size-4" />
Back
</button>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<Virtualizer scrollRef={ref}>
{events.map((event) => (
<ReplyNote key={event.id} event={event} />
))}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<Virtualizer scrollRef={ref}>
{events.map((event) => (
<ReplyNote key={event.id} event={event} />
))}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
+67 -53
View File
@@ -27,12 +27,18 @@ function Screen() {
switch (event.kind) {
case Kind.Text:
return <TextNote key={event.id} event={event} className="mb-3" />;
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
case Kind.Metadata:
return (
<div
key={event.id}
className="p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
className="p-3 border-b-[.5px] border-neutral-300 dark:border-neutral-700"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex flex-col w-full h-full gap-2">
@@ -61,7 +67,13 @@ function Screen() {
</div>
);
default:
return <TextNote key={event.id} event={event} className="mb-3" />;
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[events],
@@ -71,7 +83,7 @@ function Screen() {
startTransition(async () => {
if (!query.length) return;
const res = await commands.search(query, null);
const res = await commands.search(query);
if (res.status === "ok") {
const data = toLumeEvents(res.data);
@@ -84,59 +96,61 @@ function Screen() {
};
return (
<div className="flex flex-col gap-3 size-full overflow-hidden">
<div className="h-9 shrink-0 px-3 flex items-center gap-2">
<input
name="search"
placeholder="Search nostr ..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") search();
}}
className="h-9 px-5 flex-1 rounded-full border-none bg-black/5 dark:bg-white/10 placeholder:text-neutral-500 dark:placeholder:text-neutral-400 focus:bg-black/10 dark:focus:bg-white/10 focus:outline-none focus:ring-0"
/>
<button
type="button"
disabled={!query.length || isPending}
className="size-9 shrink-0 inline-flex items-center justify-center rounded-full bg-black/5 dark:bg-white/10"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<MagnifyingGlass className="size-4" />
)}
</button>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-3">
<Virtualizer scrollRef={ref}>
<div className="px-3 h-full">
<div className="flex flex-col gap-3 size-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700">
<div className="h-9 shrink-0 pt-6 px-3 flex items-center gap-2">
<input
name="search"
placeholder="Search nostr ..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") search();
}}
className="h-9 px-5 flex-1 rounded-full border-none bg-black/5 dark:bg-white/10 placeholder:text-neutral-500 dark:placeholder:text-neutral-400 focus:bg-black/10 dark:focus:bg-white/10 focus:outline-none focus:ring-0"
/>
<button
type="button"
disabled={!query.length || isPending}
className="size-9 shrink-0 inline-flex items-center justify-center rounded-full bg-black/5 dark:bg-white/10"
>
{isPending ? (
<div className="w-full h-[200px] flex gap-2 items-center justify-center">
<Spinner />
Searching...
</div>
) : !events.length ? (
<div className="w-full h-[200px] flex gap-2 items-center justify-center">
Type somethings to search.
</div>
<Spinner className="size-4" />
) : (
events.map((event) => renderItem(event))
<MagnifyingGlass className="size-4" />
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
</button>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
<ScrollArea.Viewport ref={ref} className="relative h-full">
<Virtualizer scrollRef={ref}>
{isPending ? (
<div className="w-full h-[200px] flex gap-2 items-center justify-center">
<Spinner />
Searching...
</div>
) : !events.length ? (
<div className="w-full h-[200px] flex gap-2 items-center justify-center">
Type somethings to search.
</div>
) : (
events.map((event) => renderItem(event))
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
</div>
);
}
+88 -13
View File
@@ -1,12 +1,17 @@
import { commands } from "@/commands.gen";
import { replyTime, toLumeEvents } from "@/commons";
import { Note, Spinner, User } from "@/components";
import { Hashtag } from "@/components/note/mentions/hashtag";
import { MentionUser } from "@/components/note/mentions/user";
import { type LumeEvent, LumeWindow } from "@/system";
import { ColumnsPlusLeft } from "@phosphor-icons/react";
import { Kind } from "@/types";
import { ArrowRight } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { memo, useRef } from "react";
import { nip19 } from "nostr-tools";
import { type ReactNode, memo, useMemo, useRef } from "react";
import reactStringReplace from "react-string-replace";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/stories")({
@@ -14,7 +19,7 @@ export const Route = createLazyFileRoute("/columns/_layout/stories")({
});
function Screen() {
const { contacts } = Route.useRouteContext();
const contacts = Route.useLoaderData();
const ref = useRef<HTMLDivElement>(null);
return (
@@ -25,9 +30,15 @@ function Screen() {
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
<Virtualizer scrollRef={ref} overscan={0}>
{contacts.map((contact) => (
<StoryItem key={contact} contact={contact} />
))}
{!contacts ? (
<div className="w-full h-24 flex items-center justify-center">
<Spinner className="size-4" />
</div>
) : (
contacts.map((contact) => (
<StoryItem key={contact} contact={contact} />
))
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
@@ -50,7 +61,7 @@ function StoryItem({ contact }: { contact: string }) {
} = useQuery({
queryKey: ["stories", contact],
queryFn: async () => {
const res = await commands.getEventsBy(contact, 10);
const res = await commands.getAllEventsByAuthor(contact, 10);
if (res.status === "ok") {
const data = toLumeEvents(res.data);
@@ -59,13 +70,14 @@ function StoryItem({ contact }: { contact: string }) {
throw new Error(res.error);
}
},
select: (data) => data.filter((ev) => ev.kind === Kind.Text),
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
return (
<div className="mb-3 flex flex-col w-full h-[300px] bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<div className="mb-3 flex flex-col w-full h-[300px] bg-white dark:bg-black rounded-xl border-[.5px] border-neutral-300 dark:border-neutral-700">
<div className="h-12 shrink-0 px-2 flex items-center justify-between border-b border-neutral-100 dark:border-white/5">
<User.Provider pubkey={contact}>
<User.Root className="inline-flex items-center gap-2">
@@ -77,9 +89,10 @@ function StoryItem({ contact }: { contact: string }) {
<button
type="button"
onClick={() => LumeWindow.openProfile(contact)}
className="size-7 inline-flex items-center justify-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-white/20"
className="h-7 w-max px-2.5 inline-flex gap-1 items-center justify-center rounded-full text-sm font-medium hover:bg-neutral-100 dark:hover:bg-white/20"
>
<ColumnsPlusLeft className="size-4" />
Open
<ArrowRight className="size-3" weight="bold" />
</button>
</div>
</div>
@@ -129,9 +142,10 @@ const StoryEvent = memo(function StoryEvent({ event }: { event: LumeEvent }) {
className="shrink-0 inline font-medium text-blue-500"
suffix=":"
/>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="pl-2 inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
@@ -148,3 +162,64 @@ const StoryEvent = memo(function StoryEvent({ event }: { event: LumeEvent }) {
</Note.Provider>
);
});
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "");
const data = nip19.decode(bech32);
switch (data.type) {
case "npub":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data} />
));
break;
case "nprofile":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data.pubkey} />
));
break;
default:
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<a
key={match + i}
href={`https://njump.me/${bech32}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
break;
}
}
return replacedText;
}, [text]);
return <div className={className}>{content}</div>;
}
+2 -3
View File
@@ -2,12 +2,11 @@ import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/stories")({
beforeLoad: async () => {
loader: async () => {
const res = await commands.getContactList();
if (res.status === "ok") {
const contacts = res.data;
return { contacts };
return res.data;
} else {
throw new Error(res.error);
}
+29 -23
View File
@@ -1,4 +1,4 @@
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
import { RepostNote, Spinner, TextNote } from "@/components";
import { LumeEvent } from "@/system";
import { Kind, type NostrEvent } from "@/types";
import * as ScrollArea from "@radix-ui/react-scroll-area";
@@ -11,8 +11,8 @@ export const Route = createLazyFileRoute("/columns/_layout/trending")({
component: Screen,
});
export function Screen() {
const { isLoading, isError, data } = useQuery({
function Screen() {
const { isLoading, data } = useQuery({
queryKey: ["trending-notes"],
queryFn: async ({ signal }) => {
const res = await fetch("https://api.nostr.band/v0/trending/notes", {
@@ -43,14 +43,21 @@ export function Screen() {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
} else {
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
return (
<RepostNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
default:
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[data],
@@ -60,22 +67,21 @@ export function Screen() {
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3">
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Virtualizer scrollRef={ref} overscan={1}>
{isLoading ? (
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<div className="inline-flex items-center gap-2 text-sm font-medium">
<Spinner className="size-5" />
Loading...
</div>
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-4" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : isError ? (
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<div className="inline-flex items-center gap-2 text-sm font-medium">
Error.
</div>
) : !data.length ? (
<div className="mb-3 flex items-center justify-center h-20 text-sm">
🎉 Yo. You're catching up on all latest notes.
</div>
) : (
data.map((item) => renderItem(item))
+25 -18
View File
@@ -1,10 +1,6 @@
import { commands } from "@/commands.gen";
import { toLumeEvents } from "@/commons";
import { Spinner } from "@/components";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { User } from "@/components/user";
import { RepostNote, Spinner, TextNote, User } from "@/components";
import type { LumeEvent } from "@/system";
import { Kind } from "@/types";
import * as ScrollArea from "@radix-ui/react-scroll-area";
@@ -22,7 +18,7 @@ function Screen() {
const { isLoading, data: events } = useQuery({
queryKey: ["stories", id],
queryFn: async () => {
const res = await commands.getEventsBy(id, 20);
const res = await commands.getAllEventsByAuthor(id, 100);
if (res.status === "ok") {
const data = toLumeEvents(res.data);
@@ -41,13 +37,21 @@ function Screen() {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
return (
<RepostNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
default:
return (
<TextNote
key={event.id}
event={event}
className="border-b-[.5px] border-neutral-300 dark:border-neutral-700"
/>
);
}
},
[events],
@@ -57,21 +61,24 @@ function Screen() {
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
className="overflow-hidden size-full px-3"
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
<ScrollArea.Viewport
ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<Virtualizer scrollRef={ref} overscan={0}>
<User.Provider pubkey={id}>
<User.Root className="relative">
<User.Cover className="object-cover w-full h-44 rounded-t-lg gradient-mask-b-0" />
<User.Button className="z-10 absolute top-4 right-4 inline-flex items-center justify-center w-20 text-xs font-medium text-white shadow-md bg-black hover:bg-black/80 rounded-full h-7" />
<div className="z-10 relative flex flex-col items-center gap-1.5 -mt-16">
<div className="z-10 relative flex flex-col gap-1.5 -mt-16 px-4">
<User.Avatar className="rounded-full size-14" />
<div className="flex items-center gap-1">
<div className="flex gap-1">
<User.Name className="text-lg font-semibold leading-tight" />
<User.NIP05 />
</div>
<User.About className="text-center" />
<User.About className="text-sm" />
</div>
</User.Root>
</User.Provider>
+19 -7
View File
@@ -1,20 +1,31 @@
import { insertImage, isImagePath, upload } from "@/commons";
import { isImagePath, upload } from "@/commons";
import { Spinner } from "@/components";
import { Images } from "@phosphor-icons/react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useTransition } from "react";
import { useSlateStatic } from "slate-react";
import {
type Dispatch,
type SetStateAction,
useEffect,
useTransition,
} from "react";
export function MediaButton() {
const editor = useSlateStatic();
export function MediaButton({
setText,
setAttaches,
}: {
setText: Dispatch<SetStateAction<string>>;
setAttaches: Dispatch<SetStateAction<string[]>>;
}) {
const [isPending, startTransition] = useTransition();
const uploadMedia = () => {
startTransition(async () => {
try {
const image = await upload();
return insertImage(editor, image);
setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
return;
} catch (e) {
await message(String(e), { title: "Upload", kind: "error" });
return;
@@ -32,7 +43,8 @@ export function MediaButton() {
for (const item of items) {
if (isImagePath(item)) {
const image = await upload(item);
insertImage(editor, image);
setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
}
}
+283 -309
View File
@@ -1,23 +1,20 @@
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@/commons";
// @ts-nocheck
import { type Mention, commands } from "@/commands.gen";
import { cn } from "@/commons";
import { Spinner } from "@/components";
import { Note } from "@/components/note";
import { MentionNote } from "@/components/note/mentions/note";
import { User } from "@/components/user";
import { LumeEvent, useEvent } from "@/system";
import { Feather } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import {
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
RichTextarea,
type RichTextareaHandle,
createRegexRenderer,
} from "rich-textarea";
import { MediaButton } from "./-components/media";
import { PowButton } from "./-components/pow";
import { WarningButton } from "./-components/warning";
@@ -27,11 +24,33 @@ type EditorSearch = {
quote: string;
};
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
const MENTION_REG = /\B@([\-+\w]*)$/;
const MAX_LIST_LENGTH = 5;
const renderer = createRegexRenderer([
[
/https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+/g,
({ children, key, value }) => (
<a
key={key}
href={value}
target="_blank"
rel="noreferrer"
className="text-blue-500 !underline"
>
{children}
</a>
),
],
[
/(?:^|\W)nostr:(\w+)(?!\w)/g,
({ children, key, value }) => (
<span key={key} className="text-blue-500">
{children}
</span>
),
],
]);
export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => {
@@ -40,201 +59,295 @@ export const Route = createFileRoute("/editor/")({
quote: search.quote,
};
},
beforeLoad: ({ search }) => {
let initialValue: EditorElement[];
beforeLoad: async ({ search }) => {
let users: Mention[] = [];
let initialValue: string;
if (search?.quote?.length) {
const eventId = nip19.noteEncode(search.quote);
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${eventId}`,
children: [{ text: "" }],
},
];
initialValue = `\nnostr:${nip19.noteEncode(search.quote)}`;
} else {
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
initialValue = "";
}
return { initialValue };
const res = await commands.getMentionList();
if (res.status === "ok") {
users = res.data;
}
return { users, initialValue };
},
component: Screen,
});
function Screen() {
const { reply_to } = Route.useSearch();
const { initialValue } = Route.useRouteContext();
const { users, initialValue } = Route.useRouteContext();
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [text, setText] = useState("");
const [attaches, setAttaches] = useState<string[]>(null);
const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
const [index, setIndex] = useState<number>(0);
const [pos, setPos] = useState<{
top: number;
left: number;
caret: number;
} | null>(null);
const ref = useRef<RichTextareaHandle>(null);
const targetText = pos ? text.slice(0, pos.caret) : text;
const match = pos && targetText.match(MENTION_REG);
const name = match?.[1] ?? "";
const filtered = useMemo(
() =>
users
.filter((u) => u.name.toLowerCase().startsWith(name.toLowerCase()))
.slice(0, MAX_LIST_LENGTH),
[name],
);
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const insert = (i: number) => {
if (!ref.current || !pos) return;
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
const selected = filtered[i];
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
ref.current.setRangeText(
`nostr:${selected.pubkey} `,
pos.caret - name.length - 1,
pos.caret,
"end",
);
return Node.string(n);
})
.join("\n");
setPos(null);
setIndex(0);
};
const publish = async () => {
try {
// start loading
setLoading(true);
startTransition(async () => {
try {
const content = text.trim();
const content = serialize(editor.children);
const eventId = await LumeEvent.publish(
content,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
reply_to,
);
await LumeEvent.publish(
content,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.num,
reply_to,
);
if (eventId) {
// stop loading
setLoading(false);
// reset form
reset();
setText("");
} catch {
return;
}
} catch (e) {
setLoading(false);
}
});
};
useEffect(() => {
setEditorValue(initialValue);
if (initialValue?.length) {
setText(initialValue);
}
}, [initialValue]);
if (!editorValue) return null;
return (
<div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div data-tauri-drag-region className="h-11 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<span className="text-sm font-semibold">Reply to:</span>
<ChildNote id={reply_to} />
</div>
) : null}
<div className="px-4 py-4 overflow-y-auto">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : "What're you up to?"
}
className="focus:outline-none"
/>
</div>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
<div data-tauri-drag-region className="h-11 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<span className="text-sm font-semibold">Reply to:</span>
<EmbedNote id={reply_to} />
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
<div className="p-4 overflow-y-auto h-full">
<RichTextarea
ref={ref}
value={text}
placeholder={reply_to ? "Type your reply..." : "What're you up to?"}
style={{ width: "100%", height: "100%" }}
className="text-[15px] leading-normal resize-none border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 placeholder:pt-[1.5px] placeholder:pl-2"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (!pos || !filtered.length) return;
switch (e.code) {
case "ArrowUp": {
e.preventDefault();
const nextIndex =
index <= 0 ? filtered.length - 1 : index - 1;
setIndex(nextIndex);
break;
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
case "ArrowDown": {
e.preventDefault();
const prevIndex =
index >= filtered.length - 1 ? 0 : index + 1;
setIndex(prevIndex);
break;
}
case "Enter":
e.preventDefault();
insert(index);
break;
case "Escape":
e.preventDefault();
setPos(null);
setIndex(0);
break;
default:
break;
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
}}
onSelectionChange={(r) => {
if (
r.focused &&
MENTION_REG.test(text.slice(0, r.selectionStart))
) {
setPos({
top: r.top + r.height,
left: r.left,
caret: r.selectionStart,
});
setIndex(0);
} else {
setPos(null);
setIndex(0);
}
}}
>
{loading ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
{renderer}
</RichTextarea>
{pos
? createPortal(
<Menu
top={pos.top}
left={pos.left}
users={filtered}
index={index}
insert={insert}
/>,
document.body,
)
: null}
</div>
</Slate>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton setText={setText} setAttaches={setAttaches} />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</div>
);
}
function ChildNote({ id }: { id: string }) {
function Menu({
users,
index,
top,
left,
insert,
}: {
users: Mention[];
index: number;
top: number;
left: number;
insert: (index: number) => void;
}) {
return (
<div
style={{
top: top,
left: left,
}}
className="fixed w-[200px] text-sm bg-white dark:bg-black shadow-lg shadow-neutral-500/20 dark:shadow-none dark:ring-1 dark:ring-neutral-700 rounded-lg overflow-hidden"
>
{users.map((u, i) => (
<div
key={u.pubkey}
className={cn(
"flex items-center gap-1.5 p-2",
index === i ? "bg-neutral-100 dark:bg-neutral-900" : null,
)}
onMouseDown={(e) => {
e.preventDefault();
insert(i);
}}
>
<div className="size-7 shrink-0">
{u.avatar?.length ? (
<img
src={u.avatar}
className="size-7 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-7 rounded-full bg-blue-500" />
)}
</div>
{u.name}
</div>
))}
</div>
);
}
function EmbedNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
@@ -258,142 +371,3 @@ function ChildNote({ id }: { id: string }) {
</Note.Provider>
);
}
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent") || text.startsWith("note")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const selected = useSelected();
const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<img
src={element.url}
alt={element.url}
className={cn(
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<div
contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
>
<MentionNote eventId={element.eventId} />
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-[15px]">
{children}
</p>
);
}
};
+53 -39
View File
@@ -2,7 +2,7 @@ import { commands } from "@/commands.gen";
import { appSettings, displayNpub } from "@/commons";
import { Frame, Spinner, User } from "@/components";
import { ArrowRight, DotsThree, GearSix, Plus } from "@phosphor-icons/react";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import {
@@ -37,6 +37,32 @@ function Screen() {
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
const showContextMenu = useCallback(
async (e: React.MouseEvent, account: string) => {
e.stopPropagation();
const menuItems = await Promise.all([
MenuItem.new({
text: "Reset password",
enabled: !account.includes("_nostrconnect"),
// @ts-ignore, this is tanstack router bug
action: () => navigate({ to: "/reset", search: { account } }),
}),
MenuItem.new({
text: "Delete account",
action: async () => await deleteAccount(account),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[],
);
const deleteAccount = async (account: string) => {
const res = await commands.deleteAccount(account);
@@ -58,17 +84,29 @@ function Screen() {
const res = await commands.login(value, password);
if (res.status === "ok") {
const settings = await commands.getSettings();
const settings = await commands.getUserSettings();
if (settings.status === "ok") {
appSettings.setState(() => settings.data);
}
navigate({
to: "/$account/home",
params: { account: res.data },
replace: true,
});
const status = await commands.isAccountSync(res.data);
if (status) {
navigate({
to: "/$account/home",
// @ts-ignore, this is tanstack router bug
params: { account: res.data },
replace: true,
});
} else {
navigate({
to: "/loading",
// @ts-ignore, this is tanstack router bug
search: { account: res.data },
replace: true,
});
}
} else {
await message(res.error, { title: "Login", kind: "error" });
return;
@@ -76,31 +114,6 @@ function Screen() {
});
};
const showContextMenu = useCallback(
async (e: React.MouseEvent, account: string) => {
e.stopPropagation();
const menuItems = await Promise.all([
MenuItem.new({
text: "Reset password",
enabled: !account.includes("_nostrconnect"),
action: () => navigate({ to: "/reset", search: { account } }),
}),
MenuItem.new({
text: "Delete account",
action: async () => await deleteAccount(account),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[],
);
useEffect(() => {
if (autoLogin) {
loginWith();
@@ -147,8 +160,9 @@ function Screen() {
onKeyDown={(e) => {
if (e.key === "Enter") loginWith();
}}
disabled={isPending}
placeholder="Password"
className="px-3 rounded-full w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
className="px-3 rounded-full w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
</div>
) : (
@@ -193,8 +207,8 @@ function Screen() {
</div>
</div>
))}
<Link
to="/new"
<a
href="/new"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
@@ -205,17 +219,17 @@ function Screen() {
New account
</span>
</div>
</Link>
</a>
</Frame>
</div>
<div className="absolute bottom-2 right-2">
<Link
to="/bootstrap-relays"
<a
href="/bootstrap-relays"
className="h-8 w-max text-xs px-3 inline-flex items-center justify-center gap-1.5 bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 rounded-full"
>
<GearSix className="size-4" />
Manage Relays
</Link>
</a>
</div>
</div>
);
+3 -1
View File
@@ -17,6 +17,8 @@ export const Route = createFileRoute("/")({
});
}
return { accounts };
return {
accounts: accounts.filter((account) => !account.endsWith("Lume")),
};
},
});
+58
View File
@@ -0,0 +1,58 @@
import { commands } from "@/commands.gen";
import { Frame, Spinner } from "@/components";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { useEffect } from "react";
type RouteSearch = {
account: string;
};
export const Route = createFileRoute("/loading")({
validateSearch: (search: Record<string, string>): RouteSearch => {
return {
account: search.account,
};
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const search = Route.useSearch();
useEffect(() => {
const unlisten = listen("neg_synchronized", async () => {
const status = await commands.createSyncFile(search.account);
if (status) {
navigate({
to: "/$account/home",
// @ts-ignore, this is tanstack router bug
params: { account: search.account },
replace: true,
});
} else {
throw new Error("System error.");
}
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<div className="size-full flex items-center justify-center">
<Frame
className="p-6 h-36 flex flex-col gap-2 items-center justify-center text-center rounded-xl overflow-hidden"
shadow
>
<Spinner />
<p className="text-sm text-neutral-600 dark:text-neutral-40">
Syncing all necessary data for the first time login...
</p>
</Frame>
</div>
);
}
+58 -27
View File
@@ -1,6 +1,4 @@
import { GoBack } from "@/components";
import { ArrowLeft } from "@phosphor-icons/react";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/new")({
component: Screen,
@@ -10,42 +8,75 @@ function Screen() {
return (
<div
data-tauri-drag-region
className="relative size-full flex items-center justify-center"
className="bg-white/50 dark:bg-black/50 relative size-full flex items-center justify-center"
>
<div className="w-[320px] flex flex-col gap-8">
<div className="w-[350px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">
Welcome to Nostr.
<h1 className="leading-tight text-lg font-semibold">
How would you like to use Lume?
</h1>
</div>
<div className="flex flex-col gap-4">
<Link
to="/auth/new"
className="w-full h-10 bg-blue-500 font-medium hover:bg-blue-600 text-white rounded-lg inline-flex items-center justify-center shadow"
<a
href="/auth/connect"
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-900 ring-1 ring-black/5 dark:ring-white/5"
>
Create a new identity
</Link>
<div className="w-full h-px bg-black/5 dark:bg-white/5" />
<h3 className="mb-1 font-medium">Continue with Nostr Connect</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-600">
Your account will be handled by a remote signer. Lume will not
store your account keys.
</p>
</a>
<a
href="/auth/import"
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-900 ring-1 ring-black/5 dark:ring-white/5"
>
<h3 className="mb-1 font-medium">Continue with Secret Key</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-600">
Lume will store your keys in secure storage. You can provide a
password to add extra security.
</p>
</a>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 h-px bg-black/5 dark:bg-white/5" />
<div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400">
Do you not have a Nostr account yet?
</div>
<div className="flex-1 h-px bg-black/5 dark:bg-white/5" />
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/connect"
className="w-full h-10 bg-white hover:bg-neutral-100 dark:hover:bg-neutral-100 dark:text-black rounded-lg inline-flex items-center justify-center"
<a
href="https://nsec.app"
target="_blank"
rel="noreferrer"
className="text-sm bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg flex items-center gap-1.5 h-9 px-1"
>
Login with Nostr Connect
</Link>
<Link
to="/auth/import"
className="w-full h-10 bg-white hover:bg-neutral-100 dark:hover:bg-neutral-100 dark:text-black rounded-lg inline-flex items-center justify-center"
<div className="size-7 rounded-md bg-black inline-flex items-center justify-center">
<img src="/nsec_app.svg" alt="nsec.app" className="size-5" />
</div>
Create one with nsec.app
</a>
<a
href="https://nosta.me"
target="_blank"
rel="noreferrer"
className="text-sm bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg flex items-center gap-1.5 h-9 px-1"
>
Login with Private Key
</Link>
<div className="size-7 rounded-md bg-black overflow-hidden">
<img
src="/nosta.jpg"
alt="nosta"
className="size-7 object-cover"
/>
</div>
Create one with nosta.me
</a>
<p className="text-xs text-neutral-400 dark:text-neutral-600">
Or you can create account from other Nostr clients.
</p>
</div>
</div>
</div>
<GoBack className="fixed top-11 left-2 flex items-center gap-1.5 text-sm font-medium">
<ArrowLeft className="size-5" />
Back
</GoBack>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { commands } from "@/commands.gen";
import { Spinner } from "@/components";
import { User } from "@/components/user";
import { Plus, X } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/set-group")({
component: Screen,
});
function Screen() {
const contacts = Route.useLoaderData();
const { account } = Route.useSearch();
const { queryClient } = Route.useRouteContext();
const [title, setTitle] = useState("");
const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const toggleUser = (pubkey: string) => {
setUsers((prev) =>
prev.includes(pubkey)
? prev.filter((i) => i !== pubkey)
: [...prev, pubkey],
);
};
const addUser = () => {
if (!npub.startsWith("npub1")) return;
if (users.includes(npub)) return;
setUsers((prev) => [...prev, npub]);
setNpub("");
};
const submit = () => {
startTransition(async () => {
const res = await commands.setGroup(title, null, null, users);
if (res.status === "ok") {
const window = getCurrentWindow();
// Invalidate cache
await queryClient.invalidateQueries({
queryKey: ["mygroups", account],
});
// Create column in the main window
await window.emitTo("main", "columns", {
type: "add",
column: {
label: res.data,
name: title,
url: `/columns/groups/${res.data}`,
},
});
// Close current popup
await window.close();
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
return (
<div className="flex flex-col size-full">
<div data-tauri-drag-region className="shrink-0 h-11" />
<div className="shrink-0 h-14 px-3 flex items-center gap-2 justify-between border-b border-black/5 dark:border-white/5">
<div className="flex items-center flex-1 rounded-lg h-9 shrink-0 bg-black/10 dark:bg-white/10">
<label
htmlFor="name"
className="w-16 text-sm font-semibold text-center border-r border-neutral-300 dark:border-neutral-700 shrink-0"
>
Name
</label>
<input
name="name"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="family, bff, devs,..."
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isPending || users.length < 1}
className="shrink-0 inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-lg w-20 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Create"}
</button>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="flex-1 overflow-hidden"
>
<ScrollArea.Viewport className="bg-white dark:bg-black h-full p-3">
<div className="mb-3 flex flex-col gap-2">
<h3 className="text-sm font-semibold">Added</h3>
<div className="flex gap-2">
<input
name="npub"
value={npub}
onChange={(e) => setNpub(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addUser();
}}
placeholder="npub1..."
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-neutral-100 dark:bg-neutral-900 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => addUser()}
className="inline-flex items-center justify-center text-neutral-500 rounded-lg size-9 bg-neutral-200 dark:bg-neutral-800 shrink-0 hover:bg-blue-500 hover:text-white"
>
<Plus className="size-5" />
</button>
</div>
<div className="flex flex-col gap-2">
{users.length ? (
users.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
<X className="size-4" />
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-neutral-100 dark:bg-neutral-900 h-14">
Please add some user to your group.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Contacts</h3>
<div className="flex flex-col gap-2">
{contacts.length ? (
contacts
.filter((c) => !users.includes(c))
.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg border-[.5px] border-neutral-300 dark:border-neutral-700 hover:border-blue-500"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
<p>
Find more user at{" "}
<a
href="https://www.nostr.directory/"
target="_blank"
className="text-blue-600 after:content-['_↗']"
rel="noreferrer"
>
Nostr Directory
</a>
</p>
</div>
)}
</div>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router";
type RouteSearch = {
account: string;
};
export const Route = createFileRoute("/set-group")({
validateSearch: (search: Record<string, string>): RouteSearch => {
return {
account: search.account,
};
},
loader: async () => {
const res = await commands.getContactList();
if (res.status === "ok") {
return res.data;
} else {
throw new Error(res.error);
}
},
});
+195
View File
@@ -0,0 +1,195 @@
import { commands } from "@/commands.gen";
import { cn } from "@/commons";
import { Spinner } from "@/components";
import { Plus, X } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
const TOPICS = [
{
title: "Popular hashtags",
content: [
"#nostr",
"#introductions",
"#grownostr",
"#zap",
"#meme",
"#asknostr",
"#bitcoin",
],
},
];
export const Route = createLazyFileRoute("/set-interest")({
component: Screen,
});
function Screen() {
const [title, setTitle] = useState("");
const [hashtag, setHashtag] = useState("");
const [hashtags, setHashtags] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const { account } = Route.useSearch();
const { queryClient } = Route.useRouteContext();
const toggleHashtag = (tag: string) => {
setHashtags((prev) =>
prev.includes(tag) ? prev.filter((i) => i !== tag) : [...prev, tag],
);
};
const addHashtag = () => {
if (!hashtag.startsWith("#")) return;
if (hashtags.includes(hashtag)) return;
setHashtags((prev) => [...prev, hashtag]);
setHashtag("");
};
const submit = () => {
startTransition(async () => {
const content = hashtags.map((tag) =>
tag.toLowerCase().replace(" ", "-").replace("#", ""),
);
const res = await commands.setInterest(title, null, null, content);
if (res.status === "ok") {
const window = getCurrentWindow();
// Invalidate cache
await queryClient.invalidateQueries({
queryKey: ["myinterests", account],
});
// Create column in the main window
await window.emitTo("main", "columns", {
type: "add",
column: {
label: res.data,
name: title,
url: `/columns/interests/${res.data}`,
},
});
// Close current popup
await window.close();
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
return (
<div className="flex flex-col size-full">
<div data-tauri-drag-region className="shrink-0 h-11" />
<div className="shrink-0 h-14 px-3 flex items-center gap-2 justify-between border-b border-black/5 dark:border-white/5">
<div className="flex items-center flex-1 rounded-lg h-9 shrink-0 bg-black/10 dark:bg-white/10">
<label
htmlFor="name"
className="w-16 text-sm font-semibold text-center border-r border-neutral-300 dark:border-neutral-700 shrink-0"
>
Name
</label>
<input
name="name"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="anime, sport, art,..."
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
className="shrink-0 inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-lg w-20 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Create"}
</button>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="flex-1 overflow-hidden"
>
<ScrollArea.Viewport className="bg-white dark:bg-black h-full p-3">
<div className="mb-3 flex flex-col gap-2">
<span className="text-sm font-semibold">Added</span>
<div className="flex gap-2">
<input
name="hashtag"
placeholder="#nostr"
onChange={(e) => setHashtag(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addHashtag();
}}
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-neutral-100 dark:bg-neutral-900 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => addHashtag()}
className="inline-flex items-center justify-center text-neutral-500 rounded-lg size-9 bg-neutral-200 dark:bg-neutral-800 shrink-0 hover:bg-blue-500 hover:text-white"
>
<Plus className="size-5" />
</button>
</div>
<div className="flex flex-col gap-2">
{hashtags.length ? (
hashtags.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleHashtag(item)}
className="inline-flex items-center justify-between p-3 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<p>{item}</p>
<X className="size-4" />
</button>
))
) : (
<div className="flex items-center justify-center text-sm rounded-lg bg-neutral-100 dark:bg-neutral-900 h-14">
Please add some hashtag.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4">
{TOPICS.map((topic) => (
<div key={topic.title} className="flex flex-col gap-2">
<div className="text-sm font-semibold">{topic.title}</div>
<div className="flex flex-wrap gap-2">
{topic.content.map((item) => (
<button
key={item}
type="button"
onClick={() => toggleHashtag(item)}
className={cn(
"text-sm p-2 rounded-full",
hashtags.includes(item)
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
{item}
</button>
))}
</div>
</div>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
type RouteSearch = {
account: string;
};
export const Route = createFileRoute("/set-interest")({
validateSearch: (search: Record<string, string>): RouteSearch => {
return {
account: search.account,
};
},
});
+13 -4
View File
@@ -4,11 +4,22 @@ import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { LumeEvent } from "../event";
export function useEvent(id: string) {
export function useEvent(id: string, repost?: string) {
const { isLoading, isError, error, data } = useQuery({
queryKey: ["event", id],
queryFn: async () => {
try {
if (repost?.length) {
const nostrEvent: NostrEvent = JSON.parse(repost);
const res = await commands.getEventMeta(nostrEvent.content);
if (res.status === "ok") {
nostrEvent.meta = res.data;
}
return new LumeEvent(nostrEvent);
}
// Validate ID
let normalizeId: string = id
.replace("nostr:", "")
@@ -27,8 +38,6 @@ export function useEvent(id: string) {
}
}
console.log(relayHint);
// Build query
if (relayHint?.length) {
query = await commands.getEventFrom(normalizeId, relayHint);
@@ -56,7 +65,7 @@ export function useEvent(id: string) {
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
retry: 2,
});
return { isLoading, isError, error, data };
+1 -4
View File
@@ -17,18 +17,15 @@ export function useProfile(pubkey: string, embed?: string) {
}
let normalizeId = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
let relayHint: string;
if (normalizeId.startsWith("nprofile")) {
const decoded = nip19.decode(normalizeId);
if (decoded.type === "nprofile") {
relayHint = decoded.data.relays[0];
normalizeId = decoded.data.pubkey;
}
}
console.log(relayHint);
const query = await commands.getProfile(normalizeId);
if (query.status === "ok") {
@@ -41,7 +38,7 @@ export function useProfile(pubkey: string, embed?: string) {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
retry: 2,
});
return { isLoading, isError, profile };
+24 -9
View File
@@ -1,6 +1,7 @@
import { commands } from "@/commands.gen";
import type { LumeColumn, NostrEvent } from "@/types";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { nanoid } from "nanoid";
import type { LumeEvent } from "./event";
export const LumeWindow = {
@@ -14,9 +15,9 @@ export const LumeWindow = {
await getCurrentWindow().emit("columns", {
type: "add",
column: {
label: "columns_gallery",
name: "Columns Gallery",
url: "/columns/gallery",
label: "launchpad",
name: "Launchpad",
url: "/columns/launchpad",
},
});
},
@@ -24,8 +25,8 @@ export const LumeWindow = {
await getCurrentWindow().emit("columns", {
type: "add",
column: {
label: "local_feeds",
name: "Local Feeds",
label: "newsfeed",
name: "Newsfeed",
url: "/columns/newsfeed",
},
});
@@ -58,7 +59,7 @@ export const LumeWindow = {
eTags.find((el) => el[3] === "reply")?.[1] ?? eTags[1]?.[1];
const url = `/columns/events/${root ?? reply ?? event.id}`;
const label = `event-${root ?? reply ?? event.id}`;
const label = `event-${root?.substring(0, 6) ?? reply?.substring(0, 6) ?? event.id.substring(0, 6)}`;
LumeWindow.openColumn({ label, url, name: "Thread" });
},
@@ -150,8 +151,22 @@ export const LumeWindow = {
throw new Error(query.error);
}
},
openMainWindow: async () => {
const query = await commands.reopenLume();
return query;
openPopup: async (title: string, url: string) => {
const query = await commands.openWindow({
label: `popup-${nanoid()}`,
url,
title,
width: 400,
height: 500,
maximizable: false,
minimizable: false,
hidden_title: false,
});
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
},
};

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