4 Commits

Author SHA1 Message Date
reya
f6ce53ef9c feat: revamp the chat panel ui (#7)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m40s
Reviewed-on: #7
2026-02-19 07:25:07 +00:00
reya
e327178161 chore: fix some performance issues (#6)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m14s
Reviewed-on: #6
2026-02-14 02:01:49 +00:00
reya
ecd7f6aa9b Continue redesign for the v1 stable release (#5)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m32s
Reviewed-on: #5
2026-02-12 08:32:17 +00:00
reya
32201554ec Redesign for the v1 stable release (#3)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Only half done. Will continue in another PR.

Reviewed-on: #3
2026-02-04 01:43:21 +00:00
194 changed files with 10571 additions and 20953 deletions

View File

@@ -21,7 +21,7 @@ jobs:
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-15-intel
os: macos-13
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
@@ -45,7 +45,7 @@ jobs:
# Windows and macOS builds using cargo-packager
- name: Build with cargo-packager (Windows/macOS)
if: runner.os != 'Linux'
working-directory: desktop
working-directory: crates/coop
run: |
cargo install cargo-packager --locked
cargo packager --release
@@ -130,7 +130,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Make get-crate-version executable
run: chmod +x script/get-crate-version
@@ -154,15 +154,17 @@ jobs:
- name: Create draft release
id: create_release
uses: akkuman/gitea-release-action@v1
uses: softprops/action-gh-release@v2
with:
server_url: "https://git.reya.su/"
repository: "reya/coop"
token: ${{ secrets.GITEA_TOKEN }}
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
draft: true
prerelease: false
generate_release_notes: true
files: |
artifacts/**/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Output release info
run: |

View File

@@ -13,7 +13,7 @@ jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest]
rustup: [stable]
runs-on: ${{ matrix.os }}

3
.gitignore vendored
View File

@@ -21,6 +21,3 @@ dist/
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o
.cargo/
vendor/

3023
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,26 @@
[workspace]
resolver = "2"
members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "1.0.0-beta5"
edition = "2024"
version = "0.3.0"
edition = "2021"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
# Others
anyhow = "1.0.44"
@@ -33,6 +29,7 @@ futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0"
serde = { version = "1.0", features = ["derive"] }
@@ -42,7 +39,6 @@ smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
[profile.release]
strip = true

View File

@@ -1,12 +1,12 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
@@ -36,7 +36,7 @@ To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Visit the [Coop Releases page on GitHub](https://github.com/lumehq/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
@@ -65,7 +65,7 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
git clone https://github.com/lumehq/coop.git
cd coop
```
@@ -118,7 +118,7 @@ For more information, see the [Contributing](#contributing) section.
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
- [Coop Issue Tracker](https://github.com/lumehq/coop/issues/)
### License

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 866 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M8.75 8.75V4C8.75 3.30964 9.30964 2.75 10 2.75H20C20.6904 2.75 21.25 3.30964 21.25 4V14C21.25 14.6904 20.6904 15.25 20 15.25H15.25M14 8.75H4C3.30964 8.75 2.75 9.30964 2.75 10V20C2.75 20.6904 3.30964 21.25 4 21.25H14C14.6904 21.25 15.25 20.6904 15.25 20V10C15.25 9.30964 14.6904 8.75 14 8.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10352 4C7.42998 2.84575 8.49122 2 9.75 2H14.25C15.5088 2 16.57 2.84575 16.8965 4H18.25C19.7688 4 21 5.23122 21 6.75V19.25C21 20.7688 19.7688 22 18.25 22H5.75C4.23122 22 3 20.7688 3 19.25V6.75C3 5.23122 4.23122 4 5.75 4H7.10352ZM8.5 4.75V6.25C8.5 6.38807 8.61193 6.5 8.75 6.5H15.25C15.3881 6.5 15.5 6.38807 15.5 6.25V4.75C15.5 4.05964 14.9404 3.5 14.25 3.5H9.75C9.05964 3.5 8.5 4.05964 8.5 4.75Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 472 B

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M14.25 10.75C14.25 9.64543 15.1454 8.75 16.25 8.75H20.25C21.3546 8.75 22.25 9.64543 22.25 10.75V19.25C22.25 20.3546 21.3546 21.25 20.25 21.25H16.25C15.1454 21.25 14.25 20.3546 14.25 19.25V10.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 18.25H19.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.25 8.75V5.75C20.25 4.64543 19.3546 3.75 18.25 3.75H5.75C4.64543 3.75 3.75 4.64543 3.75 5.75V14.75C3.75 15.8546 2.85457 16.75 1.75 16.75V18.25C1.75 19.3546 2.64543 20.25 3.75 20.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 16.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 604 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.25 4C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75C2 5.23122 3.23122 4 4.75 4H19.25ZM6.25 7.5C5.83579 7.5 5.5 7.83579 5.5 8.25V15.75C5.5 16.1642 5.83579 16.5 6.25 16.5C6.66421 16.5 7 16.1642 7 15.75V8.25C7 7.83579 6.66421 7.5 6.25 7.5Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 451 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6.25 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 5.5V18.5H4.75C4.05964 18.5 3.5 17.9404 3.5 17.25V6.75C3.5 6.05964 4.05964 5.5 4.75 5.5H15ZM22 6.75C22 5.23122 20.7688 4 19.25 4H4.75C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM17.75 7.5C18.1642 7.5 18.5 7.83579 18.5 8.25V15.75C18.5 16.1642 18.1642 16.5 17.75 16.5C17.3358 16.5 17 16.1642 17 15.75V8.25C17 7.83579 17.3358 7.5 17.75 7.5Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 459 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M15.75 5V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M17.75 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 21C13.5523 21 14 20.5523 14 20C14 19.4477 13.5523 19 13 19C12.4477 19 12 19.4477 12 20C12 20.5523 12.4477 21 13 21Z" fill="currentColor"/><path d="M21 11C21 10.4477 20.5523 9.99999 20 9.99999C19.4477 9.99999 19 10.4477 19 11C19 11.5523 19.4477 12 20 12C20.5523 12 21 11.5523 21 11Z" fill="currentColor"/><path d="M19.9295 14.2679C20.4078 14.5441 20.5716 15.1557 20.2955 15.634C20.0193 16.1123 19.4078 16.2761 18.9295 16C18.4512 15.7238 18.2873 15.1123 18.5634 14.634C18.8396 14.1557 19.4512 13.9918 19.9295 14.2679Z" fill="currentColor"/><path d="M17.3676 19.2942C17.8459 19.0181 18.0098 18.4065 17.7336 17.9282C17.4575 17.4499 16.8459 17.286 16.3676 17.5621C15.8893 17.8383 15.7254 18.4499 16.0016 18.9282C16.2777 19.4065 16.8893 19.5703 17.3676 19.2942Z" fill="currentColor"/><path d="M18.9269 7.99998C18.4487 8.27612 17.8371 8.11225 17.5609 7.63396C17.2848 7.15566 17.4487 6.54407 17.9269 6.26793C18.4052 5.99179 19.0168 6.15566 19.293 6.63396C19.5691 7.11225 19.4052 7.72384 18.9269 7.99998Z" fill="currentColor"/><path d="M9.25 14.75V20.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.2493 4.41452C14.2521 3.98683 13.1537 3.75 12 3.75C7.44365 3.75 3.75 7.44365 3.75 12C3.75 15.498 5.92698 18.4875 9 19.6876" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 17.25C16.4518 17.25 19.25 14.4518 19.25 11V10.5C19.25 6.77208 16.2279 3.75 12.5 3.75C8.77208 3.75 5.75 6.77208 5.75 10.5V20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 16.5L5.75 20.25L9.5 16.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 435 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V9.25M16.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V9.25M21.25 14.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.75M7.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V14.75M7.75 9.75V14.25M16.25 9.75V14.25M12 9.75V12.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 468 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12.9996 12.8145C12.675 12.7719 12.3415 12.75 11.9996 12.75C8.55174 12.75 5.94978 14.981 4.9305 18.114C4.56744 19.23 5.50919 20.25 6.68275 20.25H13.9996M15.7496 6.5C15.7496 8.57107 14.0706 10.25 11.9996 10.25C9.92851 10.25 8.24958 8.57107 8.24958 6.5C8.24958 4.42893 9.92851 2.75 11.9996 2.75C14.0706 2.75 15.7496 4.42893 15.7496 6.5ZM15.7496 14C15.7496 12.7574 16.7569 11.75 17.9996 11.75C19.2422 11.75 20.2496 12.7574 20.2496 14C20.2496 14.7801 19.8526 15.4675 19.2496 15.8711V17L18.7496 17.9356L19.2496 18.9678V20L17.9996 21L16.7496 20V15.8711C16.1466 15.4675 15.7496 14.7801 15.7496 14Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 771 B

0
assets/themes/.keep Normal file
View File

View File

@@ -1,144 +0,0 @@
{
"id": "aurora",
"name": "Aurora",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"light": {
"background": "#fdfcfeff",
"surface_background": "#f8f8ffff",
"elevated_surface_background": "#f0f1feff",
"panel_background": "#fdfcfeff",
"overlay": "#211f4300",
"title_bar": "#f0f1feff",
"title_bar_inactive": "#fdfcfeff",
"window_border": "#dadcffff",
"border": "#dadcffff",
"border_variant": "#cbcdffff",
"border_focused": "#5b5bd6ff",
"border_selected": "#5b5bd6ff",
"border_transparent": "#00000000",
"border_disabled": "#e6e7ffff",
"ring": "#5151cdff",
"text": "#1f2d5cff",
"text_muted": "#5753c6ff",
"text_placeholder": "#9b9ef0ff",
"text_accent": "#5b5bd6ff",
"text_danger": "#e54d2eff",
"text_warning": "#f76b15ff",
"icon": "#5753c6ff",
"icon_muted": "#9b9ef0ff",
"icon_accent": "#5151cdff",
"element_foreground": "#ffffffff",
"element_background": "#5b5bd6ff",
"element_hover": "#5151cdff",
"element_active": "#6e56cfff",
"element_selected": "#654dc4ff",
"element_disabled": "#5b5bd64d",
"secondary_foreground": "#1f2d5cff",
"secondary_background": "#f0f1feff",
"secondary_hover": "#e6e7ffff",
"secondary_active": "#dadcffff",
"secondary_selected": "#dadcffff",
"secondary_disabled": "#5b5bd64d",
"danger_foreground": "#ffffffff",
"danger_background": "#feebe7ff",
"danger_hover": "#ffcdc2ff",
"danger_active": "#fdbdafff",
"danger_selected": "#fdbdafff",
"danger_disabled": "#e54d2e4d",
"warning_foreground": "#ffffffff",
"warning_background": "#fff7edff",
"warning_hover": "#ffd19aff",
"warning_active": "#ffc182ff",
"warning_selected": "#ffc182ff",
"warning_disabled": "#f76b154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f0f1feff",
"ghost_element_hover": "#211f430d",
"ghost_element_active": "#211f431a",
"ghost_element_selected": "#211f431a",
"ghost_element_disabled": "#211f4305",
"tab_background": "#f0f1feff",
"tab_foreground": "#5753c6ff",
"tab_hover_background": "#211f430d",
"tab_active_background": "#fdfcfeff",
"tab_active_foreground": "#1f2d5cff",
"scrollbar_thumb_background": "#211f431a",
"scrollbar_thumb_hover_background": "#211f4326",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#5b5bd61a",
"cursor": "#5b5bd6ff",
"selection": "#5b5bd640"
},
"dark": {
"background": "#14121fff",
"surface_background": "#1b1525ff",
"elevated_surface_background": "#291f43ff",
"panel_background": "#14121fff",
"overlay": "#baa7ff1a",
"title_bar": "#291f43ff",
"title_bar_inactive": "#14121fff",
"window_border": "#473876ff",
"border": "#473876ff",
"border_variant": "#3c2e69ff",
"border_focused": "#7d66d9ff",
"border_selected": "#7d66d9ff",
"border_transparent": "#00000000",
"border_disabled": "#33255bff",
"ring": "#6e56cfff",
"text": "#e2ddfeff",
"text_muted": "#baa7ffff",
"text_placeholder": "#6958adff",
"text_accent": "#baa7ffff",
"text_danger": "#ff977dff",
"text_warning": "#ffa057ff",
"icon": "#baa7ffff",
"icon_muted": "#6958adff",
"icon_accent": "#6e56cfff",
"element_foreground": "#14121fff",
"element_background": "#7d66d9ff",
"element_hover": "#baa7ffff",
"element_active": "#6e56cfff",
"element_selected": "#654dc4ff",
"element_disabled": "#7d66d94d",
"secondary_foreground": "#e2ddfeff",
"secondary_background": "#291f43ff",
"secondary_hover": "#33255bff",
"secondary_active": "#3c2e69ff",
"secondary_selected": "#3c2e69ff",
"secondary_disabled": "#7d66d94d",
"danger_foreground": "#181111ff",
"danger_background": "#391714ff",
"danger_hover": "#5e1c16ff",
"danger_active": "#6e2920ff",
"danger_selected": "#6e2920ff",
"danger_disabled": "#ff977d4d",
"warning_foreground": "#17120eff",
"warning_background": "#331e0bff",
"warning_hover": "#562800ff",
"warning_active": "#66350cff",
"warning_selected": "#66350cff",
"warning_disabled": "#ffa0574d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#291f43ff",
"ghost_element_hover": "#baa7ff0d",
"ghost_element_active": "#baa7ff1a",
"ghost_element_selected": "#baa7ff1a",
"ghost_element_disabled": "#baa7ff05",
"tab_background": "#291f43ff",
"tab_foreground": "#baa7ffff",
"tab_hover_background": "#baa7ff0d",
"tab_active_background": "#14121fff",
"tab_active_foreground": "#e2ddfeff",
"scrollbar_thumb_background": "#baa7ff1a",
"scrollbar_thumb_hover_background": "#baa7ff26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#baa7ff1a",
"cursor": "#baa7ffff",
"selection": "#baa7ff40"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "catppuccin-frappe",
"name": "Catppuccin Frappé",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#232634",
"title_bar_inactive": "#303446",
"window_border": "#51576d",
"border": "#51576d",
"border_variant": "#414559",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#c6d0f500",
"border_disabled": "#292c3c",
"ring": "#babbf1",
"text": "#c6d0f5",
"text_muted": "#a5adce",
"text_placeholder": "#838ba7",
"text_accent": "#8caaee",
"text_danger": "#e78284",
"text_warning": "#ef9f76",
"icon": "#a5adce",
"icon_muted": "#838ba7",
"icon_accent": "#babbf1",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#babbf1",
"element_active": "#99d1db",
"element_selected": "#85c1dc",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#c6d0f5",
"secondary_background": "#414559",
"secondary_hover": "#51576d",
"secondary_active": "#626880",
"secondary_selected": "#626880",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#ea999c",
"danger_active": "#ef9f76",
"danger_selected": "#e5c890",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#ef9f76",
"warning_hover": "#e5c890",
"warning_active": "#a6d189",
"warning_selected": "#81c8be",
"warning_disabled": "#ef9f764d",
"ghost_element_background": "#c6d0f500",
"ghost_element_background_alt": "#292c3c",
"ghost_element_hover": "#c6d0f50d",
"ghost_element_active": "#c6d0f51a",
"ghost_element_selected": "#c6d0f51a",
"ghost_element_disabled": "#c6d0f505",
"tab_background": "#232634",
"tab_foreground": "#a5adce",
"tab_hover_background": "#c6d0f50d",
"tab_active_background": "#303446",
"tab_active_foreground": "#c6d0f5",
"scrollbar_thumb_background": "#c6d0f51a",
"scrollbar_thumb_hover_background": "#c6d0f526",
"scrollbar_thumb_border": "#c6d0f500",
"scrollbar_track_background": "#c6d0f500",
"scrollbar_track_border": "#c6d0f500",
"drop_target_background": "#8caaee1a",
"cursor": "#8caaee",
"selection": "#8caaee40"
},
"dark": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#232634",
"title_bar_inactive": "#303446",
"window_border": "#51576d",
"border": "#51576d",
"border_variant": "#414559",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#c6d0f500",
"border_disabled": "#292c3c",
"ring": "#babbf1",
"text": "#c6d0f5",
"text_muted": "#a5adce",
"text_placeholder": "#838ba7",
"text_accent": "#8caaee",
"text_danger": "#e78284",
"text_warning": "#ef9f76",
"icon": "#a5adce",
"icon_muted": "#838ba7",
"icon_accent": "#babbf1",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#babbf1",
"element_active": "#99d1db",
"element_selected": "#85c1dc",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#c6d0f5",
"secondary_background": "#414559",
"secondary_hover": "#51576d",
"secondary_active": "#626880",
"secondary_selected": "#626880",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#ea999c",
"danger_active": "#ef9f76",
"danger_selected": "#e5c890",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#ef9f76",
"warning_hover": "#e5c890",
"warning_active": "#a6d189",
"warning_selected": "#81c8be",
"warning_disabled": "#ef9f764d",
"ghost_element_background": "#c6d0f500",
"ghost_element_background_alt": "#292c3c",
"ghost_element_hover": "#c6d0f50d",
"ghost_element_active": "#c6d0f51a",
"ghost_element_selected": "#c6d0f51a",
"ghost_element_disabled": "#c6d0f505",
"tab_background": "#232634",
"tab_foreground": "#a5adce",
"tab_hover_background": "#c6d0f50d",
"tab_active_background": "#303446",
"tab_active_foreground": "#c6d0f5",
"scrollbar_thumb_background": "#c6d0f51a",
"scrollbar_thumb_hover_background": "#c6d0f526",
"scrollbar_thumb_border": "#c6d0f500",
"scrollbar_track_background": "#c6d0f500",
"scrollbar_track_border": "#c6d0f500",
"drop_target_background": "#8caaee1a",
"cursor": "#8caaee",
"selection": "#8caaee40"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#dce0e8",
"title_bar_inactive": "#eff1f5",
"window_border": "#bcc0cc",
"border": "#bcc0cc",
"border_variant": "#ccd0da",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#4c4f6900",
"border_disabled": "#e6e9ef",
"ring": "#7287fd",
"text": "#4c4f69",
"text_muted": "#6c6f85",
"text_placeholder": "#8c8fa1",
"text_accent": "#1e66f5",
"text_danger": "#d20f39",
"text_warning": "#fe640b",
"icon": "#6c6f85",
"icon_muted": "#8c8fa1",
"icon_accent": "#7287fd",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#7287fd",
"element_active": "#04a5e5",
"element_selected": "#209fb5",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#4c4f69",
"secondary_background": "#ccd0da",
"secondary_hover": "#bcc0cc",
"secondary_active": "#acb0be",
"secondary_selected": "#acb0be",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#e64553",
"danger_active": "#fe640b",
"danger_selected": "#df8e1d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#eff1f5",
"warning_background": "#fe640b",
"warning_hover": "#df8e1d",
"warning_active": "#40a02b",
"warning_selected": "#179299",
"warning_disabled": "#fe640b4d",
"ghost_element_background": "#4c4f6900",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f690d",
"ghost_element_active": "#4c4f691a",
"ghost_element_selected": "#4c4f691a",
"ghost_element_disabled": "#4c4f6905",
"tab_background": "#e6e9ef",
"tab_foreground": "#6c6f85",
"tab_hover_background": "#4c4f690d",
"tab_active_background": "#eff1f5",
"tab_active_foreground": "#4c4f69",
"scrollbar_thumb_background": "#4c4f691a",
"scrollbar_thumb_hover_background": "#4c4f6926",
"scrollbar_thumb_border": "#4c4f6900",
"scrollbar_track_background": "#4c4f6900",
"scrollbar_track_border": "#4c4f6900",
"drop_target_background": "#1e66f51a",
"cursor": "#1e66f5",
"selection": "#1e66f540"
},
"dark": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#dce0e8",
"title_bar_inactive": "#eff1f5",
"window_border": "#bcc0cc",
"border": "#bcc0cc",
"border_variant": "#ccd0da",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#4c4f6900",
"border_disabled": "#e6e9ef",
"ring": "#7287fd",
"text": "#4c4f69",
"text_muted": "#6c6f85",
"text_placeholder": "#8c8fa1",
"text_accent": "#1e66f5",
"text_danger": "#d20f39",
"text_warning": "#fe640b",
"icon": "#6c6f85",
"icon_muted": "#8c8fa1",
"icon_accent": "#7287fd",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#7287fd",
"element_active": "#04a5e5",
"element_selected": "#209fb5",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#4c4f69",
"secondary_background": "#ccd0da",
"secondary_hover": "#bcc0cc",
"secondary_active": "#acb0be",
"secondary_selected": "#acb0be",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#e64553",
"danger_active": "#fe640b",
"danger_selected": "#df8e1d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#eff1f5",
"warning_background": "#fe640b",
"warning_hover": "#df8e1d",
"warning_active": "#40a02b",
"warning_selected": "#179299",
"warning_disabled": "#fe640b4d",
"ghost_element_background": "#4c4f6900",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f690d",
"ghost_element_active": "#4c4f691a",
"ghost_element_selected": "#4c4f691a",
"ghost_element_disabled": "#4c4f6905",
"tab_background": "#e6e9ef",
"tab_foreground": "#6c6f85",
"tab_hover_background": "#4c4f690d",
"tab_active_background": "#eff1f5",
"tab_active_foreground": "#4c4f69",
"scrollbar_thumb_background": "#4c4f691a",
"scrollbar_thumb_hover_background": "#4c4f6926",
"scrollbar_thumb_border": "#4c4f6900",
"scrollbar_track_background": "#4c4f6900",
"scrollbar_track_border": "#4c4f6900",
"drop_target_background": "#1e66f51a",
"cursor": "#1e66f5",
"selection": "#1e66f540"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "catppuccin-macchiato",
"name": "Catppuccin Macchiato",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#181926",
"title_bar_inactive": "#24273a",
"window_border": "#494d64",
"border": "#494d64",
"border_variant": "#363a4f",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#cad3f500",
"border_disabled": "#1e2030",
"ring": "#b7bdf8",
"text": "#cad3f5",
"text_muted": "#a5adcb",
"text_placeholder": "#8087a2",
"text_accent": "#8aadf4",
"text_danger": "#ed8796",
"text_warning": "#f5a97f",
"icon": "#a5adcb",
"icon_muted": "#8087a2",
"icon_accent": "#b7bdf8",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#b7bdf8",
"element_active": "#91d7e3",
"element_selected": "#7dc4e4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#cad3f5",
"secondary_background": "#363a4f",
"secondary_hover": "#494d64",
"secondary_active": "#5b6078",
"secondary_selected": "#5b6078",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ee99a0",
"danger_active": "#f5a97f",
"danger_selected": "#eed49f",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#f5a97f",
"warning_hover": "#eed49f",
"warning_active": "#a6da95",
"warning_selected": "#8bd5ca",
"warning_disabled": "#f5a97f4d",
"ghost_element_background": "#cad3f500",
"ghost_element_background_alt": "#1e2030",
"ghost_element_hover": "#cad3f50d",
"ghost_element_active": "#cad3f51a",
"ghost_element_selected": "#cad3f51a",
"ghost_element_disabled": "#cad3f505",
"tab_background": "#181926",
"tab_foreground": "#a5adcb",
"tab_hover_background": "#cad3f50d",
"tab_active_background": "#24273a",
"tab_active_foreground": "#cad3f5",
"scrollbar_thumb_background": "#cad3f51a",
"scrollbar_thumb_hover_background": "#cad3f526",
"scrollbar_thumb_border": "#cad3f500",
"scrollbar_track_background": "#cad3f500",
"scrollbar_track_border": "#cad3f500",
"drop_target_background": "#8aadf41a",
"cursor": "#8aadf4",
"selection": "#8aadf440"
},
"dark": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#181926",
"title_bar_inactive": "#24273a",
"window_border": "#494d64",
"border": "#494d64",
"border_variant": "#363a4f",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#cad3f500",
"border_disabled": "#1e2030",
"ring": "#b7bdf8",
"text": "#cad3f5",
"text_muted": "#a5adcb",
"text_placeholder": "#8087a2",
"text_accent": "#8aadf4",
"text_danger": "#ed8796",
"text_warning": "#f5a97f",
"icon": "#a5adcb",
"icon_muted": "#8087a2",
"icon_accent": "#b7bdf8",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#b7bdf8",
"element_active": "#91d7e3",
"element_selected": "#7dc4e4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#cad3f5",
"secondary_background": "#363a4f",
"secondary_hover": "#494d64",
"secondary_active": "#5b6078",
"secondary_selected": "#5b6078",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ee99a0",
"danger_active": "#f5a97f",
"danger_selected": "#eed49f",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#f5a97f",
"warning_hover": "#eed49f",
"warning_active": "#a6da95",
"warning_selected": "#8bd5ca",
"warning_disabled": "#f5a97f4d",
"ghost_element_background": "#cad3f500",
"ghost_element_background_alt": "#1e2030",
"ghost_element_hover": "#cad3f50d",
"ghost_element_active": "#cad3f51a",
"ghost_element_selected": "#cad3f51a",
"ghost_element_disabled": "#cad3f505",
"tab_background": "#181926",
"tab_foreground": "#a5adcb",
"tab_hover_background": "#cad3f50d",
"tab_active_background": "#24273a",
"tab_active_foreground": "#cad3f5",
"scrollbar_thumb_background": "#cad3f51a",
"scrollbar_thumb_hover_background": "#cad3f526",
"scrollbar_thumb_border": "#cad3f500",
"scrollbar_track_background": "#cad3f500",
"scrollbar_track_border": "#cad3f500",
"drop_target_background": "#8aadf41a",
"cursor": "#8aadf4",
"selection": "#8aadf440"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "catppuccin-mocha",
"name": "Catppuccin Mocha",
"author": "Catppuccin Org (ported by Coop)",
"url": "https://catppuccin.com",
"light": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#11111b",
"title_bar_inactive": "#1e1e2e",
"window_border": "#45475a",
"border": "#45475a",
"border_variant": "#313244",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#cdd6f400",
"border_disabled": "#181825",
"ring": "#b4befe",
"text": "#cdd6f4",
"text_muted": "#a6adc8",
"text_placeholder": "#7f849c",
"text_accent": "#89b4fa",
"text_danger": "#f38ba8",
"text_warning": "#fab387",
"icon": "#a6adc8",
"icon_muted": "#7f849c",
"icon_accent": "#b4befe",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#b4befe",
"element_active": "#89dceb",
"element_selected": "#74c7ec",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#cdd6f4",
"secondary_background": "#313244",
"secondary_hover": "#45475a",
"secondary_active": "#585b70",
"secondary_selected": "#585b70",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#eba0ac",
"danger_active": "#fab387",
"danger_selected": "#f9e2af",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#fab387",
"warning_hover": "#f9e2af",
"warning_active": "#a6e3a1",
"warning_selected": "#94e2d5",
"warning_disabled": "#fab3874d",
"ghost_element_background": "#cdd6f400",
"ghost_element_background_alt": "#181825",
"ghost_element_hover": "#cdd6f40d",
"ghost_element_active": "#cdd6f41a",
"ghost_element_selected": "#cdd6f41a",
"ghost_element_disabled": "#cdd6f405",
"tab_background": "#11111b",
"tab_foreground": "#a6adc8",
"tab_hover_background": "#cdd6f40d",
"tab_active_background": "#1e1e2e",
"tab_active_foreground": "#cdd6f4",
"scrollbar_thumb_background": "#cdd6f41a",
"scrollbar_thumb_hover_background": "#cdd6f426",
"scrollbar_thumb_border": "#cdd6f400",
"scrollbar_track_background": "#cdd6f400",
"scrollbar_track_border": "#cdd6f400",
"drop_target_background": "#89b4fa1a",
"cursor": "#89b4fa",
"selection": "#89b4fa40"
},
"dark": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#11111b",
"title_bar_inactive": "#1e1e2e",
"window_border": "#45475a",
"border": "#45475a",
"border_variant": "#313244",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#cdd6f400",
"border_disabled": "#181825",
"ring": "#b4befe",
"text": "#cdd6f4",
"text_muted": "#a6adc8",
"text_placeholder": "#7f849c",
"text_accent": "#89b4fa",
"text_danger": "#f38ba8",
"text_warning": "#fab387",
"icon": "#a6adc8",
"icon_muted": "#7f849c",
"icon_accent": "#b4befe",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#b4befe",
"element_active": "#89dceb",
"element_selected": "#74c7ec",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#cdd6f4",
"secondary_background": "#313244",
"secondary_hover": "#45475a",
"secondary_active": "#585b70",
"secondary_selected": "#585b70",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#eba0ac",
"danger_active": "#fab387",
"danger_selected": "#f9e2af",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#fab387",
"warning_hover": "#f9e2af",
"warning_active": "#a6e3a1",
"warning_selected": "#94e2d5",
"warning_disabled": "#fab3874d",
"ghost_element_background": "#cdd6f400",
"ghost_element_background_alt": "#181825",
"ghost_element_hover": "#cdd6f40d",
"ghost_element_active": "#cdd6f41a",
"ghost_element_selected": "#cdd6f41a",
"ghost_element_disabled": "#cdd6f405",
"tab_background": "#11111b",
"tab_foreground": "#a6adc8",
"tab_hover_background": "#cdd6f40d",
"tab_active_background": "#1e1e2e",
"tab_active_foreground": "#cdd6f4",
"scrollbar_thumb_background": "#cdd6f41a",
"scrollbar_thumb_hover_background": "#cdd6f426",
"scrollbar_thumb_border": "#cdd6f400",
"scrollbar_track_background": "#cdd6f400",
"scrollbar_track_border": "#cdd6f400",
"drop_target_background": "#89b4fa1a",
"cursor": "#89b4fa",
"selection": "#89b4fa40"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "flexoki",
"name": "Flexoki",
"author": "Steph Ango (ported by Coop)",
"url": "https://stephango.com/flexoki",
"light": {
"background": "#FFFCF0",
"surface_background": "#F2F0E5",
"elevated_surface_background": "#E6E4D9",
"panel_background": "#FFFCF0",
"overlay": "#100F0F1A",
"title_bar": "#E6E4D9",
"title_bar_inactive": "#FFFCF0",
"window_border": "#CECDC3",
"border": "#CECDC3",
"border_variant": "#DAD8CE",
"border_focused": "#24837B",
"border_selected": "#24837B",
"border_transparent": "#100F0F00",
"border_disabled": "#E6E4D9",
"ring": "#3AA99F",
"text": "#100F0F",
"text_muted": "#6F6E69",
"text_placeholder": "#B7B5AC",
"text_accent": "#24837B",
"text_danger": "#AF3029",
"text_warning": "#BC5215",
"icon": "#6F6E69",
"icon_muted": "#B7B5AC",
"icon_accent": "#3AA99F",
"element_foreground": "#FFFCF0",
"element_background": "#24837B",
"element_hover": "#3AA99F",
"element_active": "#1C1B1A",
"element_selected": "#100F0F",
"element_disabled": "#24837B4D",
"secondary_foreground": "#100F0F",
"secondary_background": "#E6E4D9",
"secondary_hover": "#DAD8CE",
"secondary_active": "#CECDC3",
"secondary_selected": "#CECDC3",
"secondary_disabled": "#24837B4D",
"danger_foreground": "#FFFCF0",
"danger_background": "#AF3029",
"danger_hover": "#D14D41",
"danger_active": "#1C1B1A",
"danger_selected": "#100F0F",
"danger_disabled": "#AF30294D",
"warning_foreground": "#FFFCF0",
"warning_background": "#BC5215",
"warning_hover": "#DA702C",
"warning_active": "#1C1B1A",
"warning_selected": "#100F0F",
"warning_disabled": "#BC52154D",
"ghost_element_background": "#100F0F00",
"ghost_element_background_alt": "#F2F0E5",
"ghost_element_hover": "#100F0F0D",
"ghost_element_active": "#100F0F1A",
"ghost_element_selected": "#100F0F1A",
"ghost_element_disabled": "#100F0F05",
"tab_background": "#E6E4D9",
"tab_foreground": "#6F6E69",
"tab_hover_background": "#100F0F0D",
"tab_active_background": "#FFFCF0",
"tab_active_foreground": "#100F0F",
"scrollbar_thumb_background": "#100F0F1A",
"scrollbar_thumb_hover_background": "#100F0F26",
"scrollbar_thumb_border": "#100F0F00",
"scrollbar_track_background": "#100F0F00",
"scrollbar_track_border": "#100F0F00",
"drop_target_background": "#24837B1A",
"cursor": "#24837B",
"selection": "#24837B40"
},
"dark": {
"background": "#100F0F",
"surface_background": "#1C1B1A",
"elevated_surface_background": "#282726",
"panel_background": "#100F0F",
"overlay": "#FFFCF01A",
"title_bar": "#282726",
"title_bar_inactive": "#100F0F",
"window_border": "#403E3C",
"border": "#403E3C",
"border_variant": "#343331",
"border_focused": "#3AA99F",
"border_selected": "#3AA99F",
"border_transparent": "#100F0F00",
"border_disabled": "#282726",
"ring": "#24837B",
"text": "#CECDC3",
"text_muted": "#878580",
"text_placeholder": "#575653",
"text_accent": "#3AA99F",
"text_danger": "#D14D41",
"text_warning": "#DA702C",
"icon": "#878580",
"icon_muted": "#575653",
"icon_accent": "#24837B",
"element_foreground": "#100F0F",
"element_background": "#3AA99F",
"element_hover": "#24837B",
"element_active": "#CECDC3",
"element_selected": "#F2F0E5",
"element_disabled": "#3AA99F4D",
"secondary_foreground": "#CECDC3",
"secondary_background": "#1C1B1A",
"secondary_hover": "#282726",
"secondary_active": "#343331",
"secondary_selected": "#343331",
"secondary_disabled": "#3AA99F4D",
"danger_foreground": "#100F0F",
"danger_background": "#D14D41",
"danger_hover": "#AF3029",
"danger_active": "#CECDC3",
"danger_selected": "#F2F0E5",
"danger_disabled": "#D14D414D",
"warning_foreground": "#100F0F",
"warning_background": "#DA702C",
"warning_hover": "#BC5215",
"warning_active": "#CECDC3",
"warning_selected": "#F2F0E5",
"warning_disabled": "#DA702C4D",
"ghost_element_background": "#100F0F00",
"ghost_element_background_alt": "#1C1B1A",
"ghost_element_hover": "#FFFCF00D",
"ghost_element_active": "#FFFCF01A",
"ghost_element_selected": "#FFFCF01A",
"ghost_element_disabled": "#FFFCF005",
"tab_background": "#282726",
"tab_foreground": "#878580",
"tab_hover_background": "#FFFCF00D",
"tab_active_background": "#100F0F",
"tab_active_foreground": "#CECDC3",
"scrollbar_thumb_background": "#FFFCF01A",
"scrollbar_thumb_hover_background": "#FFFCF026",
"scrollbar_thumb_border": "#100F0F00",
"scrollbar_track_background": "#100F0F00",
"scrollbar_track_border": "#100F0F00",
"drop_target_background": "#3AA99F1A",
"cursor": "#3AA99F",
"selection": "#3AA99F40"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "forest",
"name": "Forest",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"light": {
"background": "#fbfefcff",
"surface_background": "#f4fbf6ff",
"elevated_surface_background": "#e9f6e9ff",
"panel_background": "#fbfefcff",
"overlay": "#193b2d1a",
"title_bar": "#e9f6e9ff",
"title_bar_inactive": "#fbfefcff",
"window_border": "#c4e8d1ff",
"border": "#c4e8d1ff",
"border_variant": "#b2ddb5ff",
"border_focused": "#30a46cff",
"border_selected": "#30a46cff",
"border_transparent": "#00000000",
"border_disabled": "#e0f3e6ff",
"ring": "#2b9a66ff",
"text": "#193b2dff",
"text_muted": "#2f7c57ff",
"text_placeholder": "#8eceaaff",
"text_accent": "#30a46cff",
"text_danger": "#e54d2eff",
"text_warning": "#f76b15ff",
"icon": "#2f7c57ff",
"icon_muted": "#8eceaaff",
"icon_accent": "#2b9a66ff",
"element_foreground": "#ffffffff",
"element_background": "#30a46cff",
"element_hover": "#2b9a66ff",
"element_active": "#2a7e3bff",
"element_selected": "#218358ff",
"element_disabled": "#30a46c4d",
"secondary_foreground": "#193b2dff",
"secondary_background": "#e9f6e9ff",
"secondary_hover": "#daf1dbff",
"secondary_active": "#c4e8d1ff",
"secondary_selected": "#c4e8d1ff",
"secondary_disabled": "#30a46c4d",
"danger_foreground": "#ffffffff",
"danger_background": "#feebe7ff",
"danger_hover": "#ffcdc2ff",
"danger_active": "#fdbdafff",
"danger_selected": "#fdbdafff",
"danger_disabled": "#e54d2e4d",
"warning_foreground": "#ffffffff",
"warning_background": "#fff7edff",
"warning_hover": "#ffd19aff",
"warning_active": "#ffc182ff",
"warning_selected": "#ffc182ff",
"warning_disabled": "#f76b154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e9f6e9ff",
"ghost_element_hover": "#193b2d0d",
"ghost_element_active": "#193b2d1a",
"ghost_element_selected": "#193b2d1a",
"ghost_element_disabled": "#193b2d05",
"tab_background": "#e9f6e9ff",
"tab_foreground": "#2f7c57ff",
"tab_hover_background": "#193b2d0d",
"tab_active_background": "#fbfefcff",
"tab_active_foreground": "#193b2dff",
"scrollbar_thumb_background": "#193b2d1a",
"scrollbar_thumb_hover_background": "#193b2d26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#30a46c1a",
"cursor": "#30a46cff",
"selection": "#30a46c40"
},
"dark": {
"background": "#0e1512ff",
"surface_background": "#121b17ff",
"elevated_surface_background": "#132d21ff",
"panel_background": "#0e1512ff",
"overlay": "#b1f1cb1a",
"title_bar": "#132d21ff",
"title_bar_inactive": "#0e1512ff",
"window_border": "#20573eff",
"border": "#20573eff",
"border_variant": "#174933ff",
"border_focused": "#33b074ff",
"border_selected": "#33b074ff",
"border_transparent": "#00000000",
"border_disabled": "#113b29ff",
"ring": "#33b074ff",
"text": "#b1f1cbff",
"text_muted": "#71d083ff",
"text_placeholder": "#2f7c57ff",
"text_accent": "#3dd68cff",
"text_danger": "#ff977dff",
"text_warning": "#ffa057ff",
"icon": "#71d083ff",
"icon_muted": "#2f7c57ff",
"icon_accent": "#33b074ff",
"element_foreground": "#0e1512ff",
"element_background": "#3dd68cff",
"element_hover": "#71d083ff",
"element_active": "#33b074ff",
"element_selected": "#30a46cff",
"element_disabled": "#3dd68c4d",
"secondary_foreground": "#b1f1cbff",
"secondary_background": "#132d21ff",
"secondary_hover": "#113b29ff",
"secondary_active": "#174933ff",
"secondary_selected": "#174933ff",
"secondary_disabled": "#3dd68c4d",
"danger_foreground": "#181111ff",
"danger_background": "#391714ff",
"danger_hover": "#5e1c16ff",
"danger_active": "#6e2920ff",
"danger_selected": "#6e2920ff",
"danger_disabled": "#ff977d4d",
"warning_foreground": "#17120eff",
"warning_background": "#331e0bff",
"warning_hover": "#562800ff",
"warning_active": "#66350cff",
"warning_selected": "#66350cff",
"warning_disabled": "#ffa0574d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#132d21ff",
"ghost_element_hover": "#b1f1cb0d",
"ghost_element_active": "#b1f1cb1a",
"ghost_element_selected": "#b1f1cb1a",
"ghost_element_disabled": "#b1f1cb05",
"tab_background": "#132d21ff",
"tab_foreground": "#71d083ff",
"tab_hover_background": "#b1f1cb0d",
"tab_active_background": "#0e1512ff",
"tab_active_foreground": "#b1f1cbff",
"scrollbar_thumb_background": "#b1f1cb1a",
"scrollbar_thumb_hover_background": "#b1f1cb26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#3dd68c1a",
"cursor": "#3dd68cff",
"selection": "#3dd68c40"
}
}

View File

@@ -1,144 +0,0 @@
{
"id": "ocean",
"name": "Ocean",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"light": {
"background": "#fafefeff",
"surface_background": "#f2fbfaff",
"elevated_surface_background": "#e6f7f7ff",
"panel_background": "#fafefeff",
"overlay": "#00333f1a",
"title_bar": "#e6f7f7ff",
"title_bar_inactive": "#fafefeff",
"window_border": "#cce5e9ff",
"border": "#cce5e9ff",
"border_variant": "#b8dde3ff",
"border_focused": "#00a2c7ff",
"border_selected": "#00a2c7ff",
"border_transparent": "#00000000",
"border_disabled": "#e0f0f2ff",
"ring": "#0797b9ff",
"text": "#0d3c48ff",
"text_muted": "#107d98ff",
"text_placeholder": "#60b3d7ff",
"text_accent": "#00a2c7ff",
"text_danger": "#e54d2eff",
"text_warning": "#f76b15ff",
"icon": "#107d98ff",
"icon_muted": "#60b3d7ff",
"icon_accent": "#0797b9ff",
"element_foreground": "#ffffffff",
"element_background": "#00a2c7ff",
"element_hover": "#0797b9ff",
"element_active": "#12667eff",
"element_selected": "#0d4a5cff",
"element_disabled": "#00a2c74d",
"secondary_foreground": "#0d4a5cff",
"secondary_background": "#ddf9f2ff",
"secondary_hover": "#c8f4e9ff",
"secondary_active": "#b3ecdeff",
"secondary_selected": "#b3ecdeff",
"secondary_disabled": "#00a2c74d",
"danger_foreground": "#ffffffff",
"danger_background": "#feebe7ff",
"danger_hover": "#ffcdc2ff",
"danger_active": "#fdbdafff",
"danger_selected": "#fdbdafff",
"danger_disabled": "#e54d2e4d",
"warning_foreground": "#ffffffff",
"warning_background": "#fff7edff",
"warning_hover": "#ffd19aff",
"warning_active": "#ffc182ff",
"warning_selected": "#ffc182ff",
"warning_disabled": "#f76b154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6f7f7ff",
"ghost_element_hover": "#00333f0d",
"ghost_element_active": "#00333f1a",
"ghost_element_selected": "#00333f1a",
"ghost_element_disabled": "#00333f05",
"tab_background": "#e6f7f7ff",
"tab_foreground": "#107d98ff",
"tab_hover_background": "#00333f0d",
"tab_active_background": "#fafefeff",
"tab_active_foreground": "#0d3c48ff",
"scrollbar_thumb_background": "#00333f1a",
"scrollbar_thumb_hover_background": "#00333f26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#00a2c71a",
"cursor": "#00a2c7ff",
"selection": "#00a2c740"
},
"dark": {
"background": "#0b161aff",
"surface_background": "#101b20ff",
"elevated_surface_background": "#082c36ff",
"panel_background": "#0b161aff",
"overlay": "#c2f3ff1a",
"title_bar": "#082c36ff",
"title_bar_inactive": "#0b161aff",
"window_border": "#1b537bff",
"border": "#1b537bff",
"border_variant": "#154467ff",
"border_focused": "#00a2c7ff",
"border_selected": "#00a2c7ff",
"border_transparent": "#00000000",
"border_disabled": "#112840ff",
"ring": "#23afd0ff",
"text": "#b6ecf7ff",
"text_muted": "#4ccce6ff",
"text_placeholder": "#197caeff",
"text_accent": "#7ce2feff",
"text_danger": "#ff977dff",
"text_warning": "#ffa057ff",
"icon": "#4ccce6ff",
"icon_muted": "#197caeff",
"icon_accent": "#23afd0ff",
"element_foreground": "#0b161aff",
"element_background": "#7ce2feff",
"element_hover": "#a8eeffff",
"element_active": "#23afd0ff",
"element_selected": "#00a2c7ff",
"element_disabled": "#7ce2fe4d",
"secondary_foreground": "#adf0ddff",
"secondary_background": "#0d2d2aff",
"secondary_hover": "#023b37ff",
"secondary_active": "#084843ff",
"secondary_selected": "#084843ff",
"secondary_disabled": "#7ce2fe4d",
"danger_foreground": "#181111ff",
"danger_background": "#391714ff",
"danger_hover": "#5e1c16ff",
"danger_active": "#6e2920ff",
"danger_selected": "#6e2920ff",
"danger_disabled": "#ff977d4d",
"warning_foreground": "#17120eff",
"warning_background": "#331e0bff",
"warning_hover": "#562800ff",
"warning_active": "#66350cff",
"warning_selected": "#66350cff",
"warning_disabled": "#ffa0574d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#082c36ff",
"ghost_element_hover": "#c2f3ff0d",
"ghost_element_active": "#c2f3ff1a",
"ghost_element_selected": "#c2f3ff1a",
"ghost_element_disabled": "#c2f3ff05",
"tab_background": "#082c36ff",
"tab_foreground": "#4ccce6ff",
"tab_hover_background": "#c2f3ff0d",
"tab_active_background": "#0b161aff",
"tab_active_foreground": "#b6ecf7ff",
"scrollbar_thumb_background": "#c2f3ff1a",
"scrollbar_thumb_hover_background": "#c2f3ff26",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#00000000",
"drop_target_background": "#7ce2fe1a",
"cursor": "#7ce2feff",
"selection": "#7ce2fe40"
}
}

View File

@@ -6,16 +6,16 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
gpui_tokio.workspace = true
reqwest.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"
futures.workspace = true

View File

@@ -3,43 +3,22 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use nostr_sdk::prelude::*;
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use smallvec::{smallvec, SmallVec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
use state::NostrRegistry;
const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
fn get_github_repo_owner() -> String {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
}
fn get_github_repo_name() -> String {
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
}
fn is_flatpak_installation() -> bool {
// Check if app is installed via Flatpak
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
}
pub fn init(window: &mut Window, cx: &mut App) {
// Skip auto-update initialization if installed via Flatpak
if is_flatpak_installation() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
return;
}
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
pub fn init(cx: &mut App) {
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
}
struct GlobalAutoUpdater(Entity<AutoUpdater>);
@@ -129,7 +108,7 @@ impl Drop for MacOsUnmounter<'_> {
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { download_url: String },
Checked { files: Vec<EventId> },
Installing,
Updated,
Errored { msg: Box<String> },
@@ -150,8 +129,8 @@ impl AutoUpdateStatus {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
pub fn checked(files: Vec<EventId>) -> Self {
Self::Checked { files }
}
pub fn error(e: String) -> Self {
@@ -159,18 +138,6 @@ impl AutoUpdateStatus {
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
pub struct AutoUpdater {
/// Current status of the auto updater
@@ -183,7 +150,7 @@ pub struct AutoUpdater {
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
_tasks: SmallVec<[Task<()>; 2]>,
}
impl AutoUpdater {
@@ -197,29 +164,63 @@ impl AutoUpdater {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
let async_version = version.clone();
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
this.download_and_install(&download_url, cx);
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Subscribe to get the new update event in the bootstrap relays
Self::subscribe_to_updates(cx),
);
tasks.push(
// Subscribe to get the new update event in the bootstrap relays
cx.spawn(async move |this, cx| {
// Check for updates after 2 minutes
cx.background_executor()
.timer(Duration::from_secs(120))
.await;
// Update the status to checking
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
});
match Self::check_for_updates(async_version, cx).await {
Ok(ids) => {
// Update the status to downloading
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(ids), cx);
});
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
});
log::warn!("{e}");
}
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
});
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
this.get_latest_release(&files, cx);
}
}),
);
Self {
status: AutoUpdateStatus::Idle,
version,
tasks: vec![],
_subscriptions: subscriptions,
_tasks: tasks,
}
}
@@ -228,141 +229,135 @@ impl AutoUpdater {
cx.notify();
}
fn check(&mut self, cx: &mut Context<Self>) {
let version = self.version.clone();
let duration = Duration::from_secs(120);
let task = self.check_for_updates(version, cx);
// Check for updates after 2 minutes
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
// Update the status to checking
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
})?;
match task.await {
Ok(download_url) => {
// Update the status to checked with download URL
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(download_url), cx);
})?;
}
Err(e) => {
log::warn!("Failed to check for updates: {e}");
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})?;
}
}
Ok(())
}));
}
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
let http_client = cx.http_client();
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
fn subscribe_to_updates(cx: &App) -> Task<()> {
let nostr = NostrRegistry::global(cx);
let _client = nostr.read(cx).client();
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let _opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
let _filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.author(app_pubkey)
.limit(1);
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
// TODO
})
}
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
}
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
let client = cx.update(|cx| {
let nostr = NostrRegistry::global(cx);
nostr.read(cx).client()
});
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
cx.background_spawn(async move {
let _opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
// Parse version from tag (remove 'v' prefix if present)
let tag_version = release.tag_name.trim_start_matches('v');
let new_version = Version::parse(tag_version).context(format!(
"Failed to parse version from tag: {}",
release.tag_name
))?;
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.author(app_pubkey)
.limit(1);
if new_version > version {
// Find the appropriate asset for the current platform
let current_os = std::env::consts::OS;
let asset_name = match current_os {
"macos" => "Coop.dmg",
"linux" => "coop.tar.gz",
"windows" => "Coop.exe",
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
};
if let Some(event) = client.database().query(filter).await?.first_owned() {
let new_version: Version = event
.tags
.find(TagKind::d())
.and_then(|tag| tag.content())
.and_then(|content| content.split("@").last())
.and_then(|content| Version::parse(content).ok())
.context("Failed to parse version")?;
let download_url = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.browser_download_url.clone())
.context(format!(
"No {} asset found in release {}",
asset_name, release.tag_name
))?;
if new_version > version {
// Get all file metadata event ids
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
Ok(download_url)
let _filter = Filter::new()
.kind(Kind::FileMetadata)
.author(app_pubkey)
.ids(ids.clone());
// TODO
Ok(ids)
} else {
Err(anyhow!("No update available"))
}
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
Err(anyhow!("No update available"))
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let http_client = cx.http_client();
let download_url = download_url.to_string();
let ids = ids.to_vec();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let os = std::env::consts::OS;
// Download the release
download(&download_url, &target_path, http_client).await?;
let filter = Filter::new()
.kind(Kind::FileMetadata)
.author(app_pubkey)
.ids(ids);
Ok((installer_dir, target_path))
// Get all urls for this release
let events = client.database().query(filter).await?;
for event in events.into_iter() {
// Only process events that match current platform
if event.content != os {
continue;
}
// Parse the url
let url = event
.tags
.find(TagKind::Url)
.and_then(|tag| tag.content())
.and_then(|content| Url::parse(content).ok())
.context("Failed to parse url")?;
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(url.as_str(), &target_path, http_client).await?;
return Ok((installer_dir, target_path));
}
Err(anyhow!("Failed to get latest release"))
});
self.tasks.push(
self._tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
this.update(cx, |this, cx| {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
});
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
this.update(cx, |this, cx| {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})?;
});
}
}
Err(e) => {
// Update the status to error including the error message
this.update(cx, |this, cx| {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})?;
});
}
}
Ok(())
}),
);
}
@@ -370,7 +365,6 @@ impl AutoUpdater {
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"linux" => Ok("coop.tar.gz"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
@@ -385,7 +379,6 @@ impl AutoUpdater {
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
}
@@ -458,75 +451,6 @@ async fn install_release_macos(
Ok(())
}
async fn install_release_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
// Extract the tar.gz file
let extracted = temp_dir.path().join("coop");
smol::fs::create_dir_all(&extracted)
.await
.context("failed to create directory to extract update")?;
let output = Command::new("tar")
.arg("-xzf")
.arg(&downloaded_tar_gz)
.arg("-C")
.arg(&extracted)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to extract {:?} to {:?}: {:?}",
downloaded_tar_gz,
extracted,
String::from_utf8_lossy(&output.stderr)
);
// Find the extracted app directory
let mut entries = smol::fs::read_dir(&extracted).await?;
let mut app_dir = None;
use smol::stream::StreamExt;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
app_dir = Some(path);
break;
}
}
let from = app_dir.context("No app directory found in archive")?;
// Copy to the current installation directory
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&from)
.arg(
running_app_path
.parent()
.context("No parent directory for app")?,
)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app from {:?} to {:?}: {:?}",
from,
running_app_path.parent(),
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;

View File

@@ -1,22 +1,20 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::EventExt;
use fuzzy_matcher::FuzzyMatcher;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::EventUtils;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity, Window,
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use smol::lock::RwLock;
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, DEVICE_GIFTWRAP, USER_GIFTWRAP};
mod message;
mod room;
@@ -41,84 +39,31 @@ pub enum ChatEvent {
CloseRoom(u64),
/// An event to notify UI about a new chat request
Ping,
/// No Inbox Relays found, the app is not ready to subscribe messages
InboxRelayNotFound,
/// An error occurred
Error(SharedString),
}
/// Channel signal.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Signal {
/// Inbox Relays found, the app is ready to subscribe messages
InboxReady(Box<Event>),
/// No Inbox Relays found, the app is not ready to subscribe messages
InboxRelayNotFound,
/// Message received from relay pool
Message(NewMessage),
/// Eose received from relay pool
Eose,
/// An error occurred
Error(FailedMessage),
}
impl Signal {
pub fn message(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
Self::Message(NewMessage::new(gift_wrap, rumor))
}
pub fn inbox_ready(event: &Event) -> Self {
Self::InboxReady(Box::new(event.to_owned()))
}
pub fn inbox_relay_not_found() -> Self {
Self::InboxRelayNotFound
}
pub fn eose() -> Self {
Self::Eose
}
pub fn error<T>(event: &Event, reason: T) -> Self
where
T: Into<SharedString>,
{
Self::Error(FailedMessage::new(event, reason))
}
}
/// Chat Registry
#[derive(Debug)]
pub struct ChatRegistry {
/// Chat rooms
/// Collection of all chat rooms
rooms: Vec<Entity<Room>>,
/// Events that failed to unwrap for any reason
trashes: Entity<BTreeSet<FailedMessage>>,
/// Tracking events seen on which relays in the current session
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
/// Mapping of unwrapped event ids to their gift wrap event ids
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
/// Tracking the status of unwrapping gift wrap events.
tracking: Arc<AtomicBool>,
/// Whether the messaging relays have been found.
msg_relays_existed: Arc<AtomicBool>,
/// Channel for sending signals to the UI.
signal_tx: flume::Sender<Signal>,
/// Channel for receiving signals from the UI.
signal_rx: flume::Receiver<Signal>,
tracking_flag: Arc<AtomicBool>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl EventEmitter<ChatEvent> for ChatRegistry {}
@@ -137,138 +82,101 @@ impl ChatRegistry {
/// Create a new chat registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let user_signer = nostr.read(cx).signer.clone();
let nip65 = nostr.read(cx).nip65_state();
let nip17 = nostr.read(cx).nip17_state();
let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to the signer event
cx.observe(&user_signer, |this, signer, cx| {
if let Some(keys) = signer.read(cx).clone() {
// Observe the nip65 state and load chat rooms on every state change
cx.observe(&nip65, |this, state, cx| {
if state.read(cx).idle() {
this.reset(cx);
this.handle_notifications(keys, cx);
this.get_metadata(cx);
this.get_rooms(cx);
};
}
}),
);
subscriptions.push(
// Observe the nip17 state and load chat rooms on every state change
cx.observe(&nip17, |this, _state, cx| {
this.get_rooms(cx);
}),
);
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| {
this.handle_notifications(cx);
this.tracking(cx);
this.get_rooms(cx);
});
Self {
rooms: vec![],
trashes: cx.new(|_| BTreeSet::default()),
seens: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
tracking: Arc::new(AtomicBool::new(false)),
msg_relays_existed: Arc::new(AtomicBool::new(false)),
signal_rx: rx,
signal_tx: tx,
tracking_flag: Arc::new(AtomicBool::new(false)),
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, signer: Keys, cx: &mut Context<Self>) {
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let tracking = self.tracking.clone();
let msg_relays_existed = self.msg_relays_existed.clone();
let seens = self.seens.clone();
let event_map = self.event_map.clone();
let trashes = self.trashes.downgrade();
let signer = nostr.read(cx).signer();
let status = self.tracking_flag.clone();
let initialized_at = Timestamp::now();
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
// Channel for communication between nostr and gpui
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
let (tx, rx) = flume::bounded::<Signal>(1024);
self.tasks.push(cx.background_spawn(async move {
let device_signer = signer.get_encryption_signer().await;
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, relay_url } = notification else {
let ClientNotification::Message { message, .. } = notification else {
// Skip non-message notifications
continue;
};
match *message {
match message {
RelayMessage::Event { event, .. } => {
// Keep track of which relays have seen this event
{
let mut seens = seens.write().await;
seens.entry(event.id).or_default().insert(relay_url);
}
// De-duplicate events by their ID
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
// Handle msg relays event to determine when the app is ready to subscribe
if event.kind == Kind::InboxRelays {
let current_user = signer.get_public_key_async().await?;
if event.pubkey == current_user {
// Mark that the msg relays have been found
msg_relays_existed.store(true, Ordering::Release);
// Emit the inbox ready signal
tx.send_async(Signal::inbox_ready(&event)).await?;
}
}
// Skip non-gift wrap events
if event.kind != Kind::GiftWrap {
// Skip non-gift wrap events
continue;
}
log::info!("Received gift wrap event: {:?}", event);
// Extract the rumor from the gift wrap event
match extract_rumor(&client, &signer, event.as_ref()).await {
Ok(rumor) => {
// Map the rumor id to the gift wrap event id for later lookup
{
let mut event_map = event_map.write().await;
event_map.insert(rumor.id.unwrap(), event.id);
}
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
Ok(rumor) => match rumor.created_at >= initialized_at {
true => {
let new_message = NewMessage::new(event.id, rumor);
let signal = Signal::Message(new_message);
// Check if the rumor has a recipient
if rumor.tags.is_empty() {
let signal = Signal::error(&event, "Recipient is missing");
tx.send_async(signal).await?;
}
// Check if the rumor was created after the chat was initialized (for detecting new messages)
if rumor.created_at >= initialized_at {
let signal = Signal::message(event.id, rumor);
tx.send_async(signal).await?;
} else {
// Mark the chat still processing new messages
tracking.store(true, Ordering::Release);
false => {
status.store(true, Ordering::Release);
}
}
},
Err(e) => {
let reason = format!("Failed to extract rumor: {e}");
let signal = Signal::error(event.as_ref(), reason);
tx.send_async(signal).await?;
log::warn!("Failed to unwrap the gift wrap event: {e}");
}
}
}
RelayMessage::EndOfStoredEvents(id)
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
{
tx.send_async(Signal::eose()).await?;
RelayMessage::EndOfStoredEvents(id) => {
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
tx.send_async(Signal::Eose).await?;
}
}
_ => {}
}
@@ -285,27 +193,11 @@ impl ChatRegistry {
this.new_message(message, cx);
})?;
}
Signal::InboxReady(event) => {
this.update(cx, |this, cx| {
this.get_messages(&event, cx);
})?;
}
Signal::InboxRelayNotFound => {
this.update(cx, |_this, cx| {
cx.emit(ChatEvent::InboxRelayNotFound);
})?;
}
Signal::Eose => {
this.update(cx, |this, cx| {
this.get_rooms(cx);
})?;
}
Signal::Error(trash) => {
trashes.update(cx, |this, cx| {
this.insert(trash);
cx.notify();
})?;
}
};
}
@@ -315,133 +207,23 @@ impl ChatRegistry {
/// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) {
let status = self.tracking.clone();
let tx = self.signal_tx.clone();
let status = self.tracking_flag.clone();
self.tasks.push(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(15);
let loop_duration = Duration::from_secs(10);
loop {
if status.load(Ordering::Acquire) {
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
_ = tx.send_async(Signal::Eose).await;
} else {
_ = tx.send_async(Signal::Eose).await;
}
smol::Timer::after(loop_duration).await;
}
}));
}
/// Get all necessary metadata from relays for current user
pub fn get_metadata(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
self.tasks.push(cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Construct filter for msg relays
let msg_relays = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Construct filter for contact list
let contact_list = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Subscribe
client
.subscribe(vec![msg_relays, contact_list])
.close_on(opts)
.await?;
Ok(())
}));
let tx = self.signal_tx.clone();
let msg_relays_existed = self.msg_relays_existed.clone();
// Reset the status flag
msg_relays_existed.store(false, Ordering::Release);
// Wait for the msg relays to be found or timeout
self.tasks.push(cx.background_spawn(async move {
// Wait for 5 seconds
smol::Timer::after(Duration::from_secs(5)).await;
// Then check if the msg relays have been found
if !msg_relays_existed.load(Ordering::Acquire) {
tx.send_async(Signal::inbox_relay_not_found()).await?;
}
Ok(())
}));
}
/// Get all messages for the provided signer
fn get_messages(&mut self, msg_relays: &Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let urls: Vec<RelayUrl> = nip17::extract_relay_list(msg_relays).collect();
let Some(signer) = nostr.read(cx).signer(cx) else {
return;
};
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
// Ensure relay connections
for url in urls.iter() {
client.add_relay(url).and_connect().await?;
}
// Construct target for subscription
let target: HashMap<RelayUrl, Filter> = urls
.into_iter()
.map(|relay| (relay, filter.clone()))
.collect();
let output = client.subscribe(target).with_id(id).await?;
log::info!(
"Successfully subscribed to gift-wrap messages on: {:?}",
output.success
);
Ok(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
})?;
}
Ok(())
}));
}
/// Refresh the chat registry, fetching messages and contact list from relays.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self.reset(cx);
self.get_metadata(cx);
self.get_rooms(cx);
}
/// Get the loading status of the chat registry
pub fn loading(&self) -> bool {
self.tracking.load(Ordering::Acquire)
self.tracking_flag.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID.
@@ -469,54 +251,17 @@ impl ChatRegistry {
.count()
}
/// Count the number of messages seen by a given relay.
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
self.seens
.read_blocking()
.values()
.filter(|seen| seen.contains(relay_url))
.count()
}
/// Count the number of trash messages.
pub fn count_trash_messages(&self, cx: &App) -> usize {
self.trashes.read(cx).len()
}
/// Get the trash messages entity.
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
self.trashes.clone()
}
/// Get the relays that have seen a given rumor id.
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
self.event_map
.read_blocking()
.get(id)
.map(|id| self.seen_on(id))
}
/// Get the relays that have seen a given gift wrap id.
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
self.seens
.read_blocking()
.get(id)
.cloned()
.unwrap_or_default()
}
/// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where
I: Into<Room> + 'static,
{
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
let client = nostr.read(cx).client();
cx.spawn(async move |this, cx| {
let signer = client.signer()?;
let public_key = signer.get_public_key().await.ok()?;
let room: Room = room.into().organize(&public_key);
this.update(cx, |this, cx| {
@@ -584,10 +329,6 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.trashes.update(cx, |this, cx| {
this.clear();
cx.notify();
});
cx.notify();
}
@@ -621,47 +362,31 @@ impl ChatRegistry {
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = self.get_rooms_from_database(cx);
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
cx.spawn(async move |this, cx| {
let rooms = task.await.ok()?;
let task = self.get_rooms_from_database(public_key, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(rooms) => {
this.update(cx, |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})?;
}
Err(e) => {
log::error!("Failed to load rooms: {}", e);
}
};
Ok(())
}));
this.update(cx, move |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.ok()
})
.detach();
}
/// Create a task to load rooms from the database
fn get_rooms_from_database(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<HashSet<Room>, Error>> {
fn get_rooms_from_database(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Get contacts
let contacts = client
.database()
.contacts_public_keys(public_key)
.await
.unwrap_or_default();
let contacts = client.database().contacts_public_keys(public_key).await?;
// Construct authored filter
let authored_filter = Filter::new()
@@ -688,10 +413,10 @@ impl ChatRegistry {
// Process each event and group by room hash
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().peekable().peek().is_some()
{
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
if rumor.tags.public_keys().peekable().peek().is_some() {
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
}
@@ -731,21 +456,11 @@ impl ChatRegistry {
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
this.set_ongoing(cx);
}
this.push_message(message, cx);
});
self.sort(cx);
}
None => {
// Push the new room to the front of the list
@@ -755,148 +470,158 @@ impl ChatRegistry {
}
/// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
if let Some(ids) = ids {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
}
}
}
}
}
/// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor(
client: &Client,
signer: &Keys,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
return Ok(rumor);
/// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
return Ok(event);
}
// Try to unwrap with the available signer
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
let mut rumor_unsigned = unwrapped.rumor;
// Generate event id for the rumor if it doesn't have one
rumor_unsigned.ensure_id();
// Cache the rumor
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
Ok(rumor_unsigned)
}
// Try to unwrap with the available signer
let unwrapped = try_unwrap(signer, gift_wrap).await?;
let mut rumor = unwrapped.rumor;
/// Helper method to try unwrapping with different signers
async fn try_unwrap(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
// Try with the device signer first
if let Some(signer) = device_signer {
if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await {
return Ok(unwrapped);
};
};
// Generate event id for the rumor if it doesn't have one
rumor.ensure_id();
// Try with the user's signer
let user_signer = client.signer().context("Signer not found")?;
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
// Cache the rumor
if let Err(e) = set_rumor(client, gift_wrap.id, &rumor).await {
log::error!("Failed to cache rumor: {e:?}");
Ok(unwrapped)
}
Ok(rumor)
}
/// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with(
gift_wrap: &Event,
signer: &Arc<dyn NostrSigner>,
) -> Result<UnwrappedGift, Error> {
// Get the sealed event
let seal = signer
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
.await?;
/// Helper method to try unwrapping with different signers
async fn try_unwrap(signer: &Keys, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
/*
* // Try with the device signer first
if let Some(signer) = signer.get_encryption_signer().await {
log::info!("trying with encryption key");
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
return Ok(unwrapped);
// Verify the sealed event
let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?;
// Get the rumor event
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
let rumor = UnsignedEvent::from_json(rumor)?;
Ok(UnwrappedGift {
sender: seal.pubkey,
rumor,
})
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = Self::conversation_id(rumor);
let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[author],
));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation.to_string()],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[receiver],
));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(tags)
.sign(&Keys::generate())
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
}
}
// Fallback to the user's signer
let user_signer = signer.get().await;
*/
let unwrapped = try_unwrap_with(gift_wrap, signer).await?;
/// Get the conversation ID for a given rumor (message).
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
Ok(unwrapped)
}
/// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with(gift_wrap: &Event, signer: &Keys) -> Result<UnwrappedGift, Error> {
// Get the sealed event
let seal = signer
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
.await?;
// Verify the sealed event
let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?;
// Get the rumor event
let rumor = signer
.nip44_decrypt_async(&seal.pubkey, &seal.content)
.await?;
let rumor = UnsignedEvent::from_json(rumor)?;
Ok(UnwrappedGift {
sender: seal.pubkey,
rumor,
})
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = conversation_id(rumor);
let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom("a", [author]));
// Add a conversation id
tags.push(Tag::custom("c", [conversation.to_string()]));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys() {
tags.push(Tag::custom("P", [receiver]));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(tags)
.finalize_async(&Keys::generate())
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
hasher.finish()
}
}
/// Get the conversation ID for a given rumor (message).
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}

View File

@@ -1,110 +1,8 @@
use std::hash::Hash;
use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri};
use common::EventUtils;
use nostr_sdk::prelude::*;
/// Rendered message.
#[derive(Debug, Clone)]
pub struct Message {
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: String,
/// List of media URLs in the message
pub media: Vec<SharedUri>,
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
impl From<&Event> for Message {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
id: val.id,
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: string,
media,
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
impl Eq for Message {}
impl PartialEq for Message {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for Message {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
@@ -125,62 +23,194 @@ impl NewMessage {
}
}
/// Trash message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FailedMessage {
pub raw_event: SharedString,
pub reason: SharedString,
/// Message.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message {
User(RenderedMessage),
Warning(String, Timestamp),
System(Timestamp),
}
impl FailedMessage {
pub fn new<T>(event: &Event, reason: T) -> Self
impl Message {
pub fn user<I>(user: I) -> Self
where
T: Into<SharedString>,
I: Into<RenderedMessage>,
{
Self {
raw_event: SharedString::from(event.as_json()),
reason: reason.into(),
Self::User(user.into())
}
pub fn warning<I>(content: I) -> Self
where
I: Into<String>,
{
Self::Warning(content.into(), Timestamp::now())
}
pub fn system() -> Self {
Self::System(Timestamp::default())
}
fn timestamp(&self) -> &Timestamp {
match self {
Message::User(msg) => &msg.created_at,
Message::Warning(_, ts) => ts,
Message::System(ts) => ts,
}
}
}
#[derive(Debug, Clone)]
pub struct Mention {
pub public_key: PublicKey,
pub range: Range<usize>,
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
Self::User(val.into())
}
}
impl Mention {
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
Self { public_key, range }
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
Self::User(val.into())
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
// System always comes first
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
(Message::System(_), _) => std::cmp::Ordering::Less,
(_, Message::System(_)) => std::cmp::Ordering::Greater,
// For non-system messages, compare by timestamp
_ => self.timestamp().cmp(other.timestamp()),
}
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Rendered message.
#[derive(Debug, Clone)]
pub struct RenderedMessage {
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: String,
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<PublicKey>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
impl From<&Event> for RenderedMessage {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
Self {
id: val.id,
author: val.pubkey,
content: val.content.clone(),
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for RenderedMessage {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
Self {
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: val.content.clone(),
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for RenderedMessage {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
Self {
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: val.rumor.content.clone(),
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
impl Eq for RenderedMessage {}
impl PartialEq for RenderedMessage {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for RenderedMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for RenderedMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for RenderedMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<Mention> {
fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token.value {
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect()
.collect::<Vec<_>>()
}
/// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
for tag in inner.iter().filter(|tag| tag.kind() == "e") {
for tag in inner.filter(TagKind::e()) {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
for tag in inner.iter().filter(|tag| tag.kind() == "q") {
for tag in inner.filter(TagKind::q()) {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}

View File

@@ -2,9 +2,8 @@ use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use anyhow::{Context as AnyhowContext, Error};
use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
@@ -12,17 +11,14 @@ use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT};
use crate::NewMessage;
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
use crate::{ChatRegistry, NewMessage};
#[derive(Debug, Clone)]
pub struct SendReport {
pub receiver: PublicKey,
pub gift_wrap_id: Option<EventId>,
pub error: Option<SharedString>,
pub output: Option<Output<EventId, EventSendStatus>>,
pub output: Option<Output<EventId>>,
}
impl SendReport {
@@ -42,7 +38,7 @@ impl SendReport {
}
/// Set the output.
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
pub fn output(mut self, output: Output<EventId>) -> Self {
self.output = Some(output);
self
}
@@ -58,44 +54,16 @@ impl SendReport {
/// Returns true if the send is pending.
pub fn pending(&self) -> bool {
self.error.is_none()
&& self
.output
.as_ref()
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
self.output.is_none() && self.error.is_none()
}
/// Returns true if the send was successful.
pub fn success(&self) -> bool {
self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
}
/// Returns true if the send failed.
pub fn failed(&self) -> bool {
self.error.is_some() && self.output.as_ref().is_some_and(|o| !o.failed.is_empty())
}
}
#[derive(Debug, Clone)]
pub enum SendStatus {
Ok {
id: EventId,
relay: RelayUrl,
},
Failed {
id: EventId,
relay: RelayUrl,
message: String,
},
}
impl SendStatus {
pub fn ok(id: EventId, relay: RelayUrl) -> Self {
Self::Ok { id, relay }
}
pub fn failed(id: EventId, relay: RelayUrl, message: String) -> Self {
Self::Failed { id, relay, message }
if let Some(output) = self.output.as_ref() {
!output.failed.is_empty()
} else {
false
}
}
}
@@ -172,8 +140,7 @@ impl From<&UnsignedEvent> for Room {
let members = val.extract_public_keys();
let subject = val
.tags
.iter()
.find(|tag| tag.kind() == "subject")
.find(TagKind::Subject)
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
Room {
@@ -182,7 +149,7 @@ impl From<&UnsignedEvent> for Room {
subject,
members,
kind: RoomKind::default(),
config: RoomConfig::new(),
config: RoomConfig::default(),
}
}
}
@@ -207,7 +174,7 @@ impl Room {
// WARNING: never sign this event
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags)
.finalize_unsigned(author);
.build(author);
// Ensure that the ID is set
event.ensure_id();
@@ -234,8 +201,10 @@ impl Room {
/// Sets this room is ongoing conversation
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
self.kind = RoomKind::Ongoing;
cx.notify();
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing;
cx.notify();
}
}
/// Updates the creation timestamp of the room
@@ -253,23 +222,6 @@ impl Room {
cx.notify();
}
/// Updates the signer kind config for the room
pub fn set_signer_kind(&mut self, kind: &SignerKind, cx: &mut Context<Self>) {
self.config.set_signer_kind(kind);
cx.notify();
}
/// Updates the backup config for the room
pub fn set_backup(&mut self, cx: &mut Context<Self>) {
self.config.toggle_backup();
cx.notify();
}
/// Returns the config of the room
pub fn config(&self) -> &RoomConfig {
&self.config
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
@@ -344,6 +296,10 @@ impl Room {
if new_message {
self.set_created_at(created_at, cx);
// Sort chats after emitting a new message
ChatRegistry::global(cx).update(cx, |this, cx| {
this.sort(cx);
});
}
}
@@ -353,31 +309,43 @@ impl Room {
}
/// Get gossip relays for each member
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
pub fn early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let subscription_id = SubscriptionId::new(format!("room-{}", self.id));
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
for public_key in members.into_iter() {
for member in members.into_iter() {
if member == public_key {
continue;
};
// Construct a filter for messaging relays
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
// Construct a filter for announcement
let announcement = Filter::new()
.author(public_key)
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to the target
// Subscribe to get member's gossip relays
client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.with_id(subscription_id.clone())
.close_on(
SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE),
)
.await?;
}
@@ -402,10 +370,6 @@ impl Room {
.await?
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.filter(|event| {
// Only process private direct messages and file messages
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
})
.sorted_by_key(|message| message.created_at)
.collect();
@@ -427,9 +391,9 @@ impl Room {
let nostr = NostrRegistry::global(cx);
// Get current user's public key
let sender = nostr.read(cx).signer_pubkey(cx)?;
let sender = nostr.read(cx).signer().public_key()?;
// Get all members, excluding the sender
// Get all members
let members: Vec<Person> = self
.members
.iter()
@@ -442,7 +406,9 @@ impl Room {
// Add subject tag if present
if let Some(value) = self.subject.as_ref() {
tags.push(Tag::custom("subject", vec![value.to_string()]));
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add all reply tags
@@ -452,20 +418,24 @@ impl Room {
// Add all receiver tags
for member in members.into_iter() {
tags.push(
Nip01Tag::PublicKey {
// Skip current user
if member.public_key() == sender {
continue;
}
tags.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}
.to_tag(),
);
relay_url: member.messaging_relay_hint(),
alias: None,
uppercase: false,
},
));
}
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
// Ensure that the ID is set
event.ensure_id();
@@ -475,116 +445,116 @@ impl Room {
/// Send rumor event to all members's messaging relays
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let encryption_signer = device.read(cx).signer(cx);
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get room's config
let config = self.config.clone();
// Get current user's public key
let user_signer = nostr.read(cx).signer(cx)?;
let public_key = nostr.read(cx).signer_pubkey(cx)?;
let persons = PersonRegistry::global(cx);
let sender = persons.read(cx).get(&public_key, cx);
let sender = nostr.read(cx).signer().public_key()?;
// Get all members (excluding sender)
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender.public_key())
.filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx))
.collect();
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
let backup = config.backup();
let user_signer = signer.get().await;
let encryption_signer = signer.get_encryption_signer().await;
let mut sents = 0;
let mut reports = Vec::new();
// Process each member
for member in members {
let relays = member.messaging_relays();
let announcement = member.announcement();
let public_key = member.public_key();
// Handle encryption signer requirements
if signer_kind.encryption() {
// Receiver didn't set up a decoupled encryption key
if announcement.is_none() {
reports.push(SendReport::new(public_key).error(NO_DEKEY));
continue;
}
// Sender didn't set up a decoupled encryption key
if encryption_signer.is_none() {
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
continue;
}
// Skip if member has no messaging relays
if relays.is_empty() {
reports.push(SendReport::new(member.public_key()).error("No messaging relays"));
continue;
}
// Determine the signer to use
let signer = match signer_kind {
// Ensure relay connections
for url in relays.iter() {
client
.add_relay(url)
.and_connect()
.capabilities(RelayCapabilities::GOSSIP)
.await
.ok();
}
// When forced to use encryption signer, skip if receiver has no announcement
if signer_kind.encryption() && announcement.is_none() {
reports
.push(SendReport::new(member.public_key()).error("Encryption not found"));
continue;
}
// Determine receiver and signer based on signer kind
let (receiver, signer_to_use) = match signer_kind {
SignerKind::Auto => {
if announcement.is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
{
// Safe to unwrap due to earlier checks
encryption_signer
if let Some(announcement) = announcement {
if let Some(enc_signer) = encryption_signer.as_ref() {
(announcement.public_key(), enc_signer.clone())
} else {
(member.public_key(), user_signer.clone())
}
} else {
user_signer.clone()
(member.public_key(), user_signer.clone())
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
let Some(encryption_signer) = encryption_signer.as_ref() else {
reports.push(
SendReport::new(member.public_key()).error("Encryption not found"),
);
continue;
};
let Some(announcement) = announcement else {
reports.push(
SendReport::new(member.public_key())
.error("Announcement not found"),
);
continue;
};
(announcement.public_key(), encryption_signer.clone())
}
SignerKind::User => user_signer.clone(),
SignerKind::User => (member.public_key(), user_signer.clone()),
};
// Send the gift wrap event and collect the report
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
Ok(report) => {
reports.push(report);
sents += 1;
}
Err(error) => {
let report = SendReport::new(public_key).error(error.to_string());
reports.push(report);
}
}
}
// Send backup to current user if needed
if backup && sents >= 1 {
let public_key = sender.public_key();
// Determine the signer to use
let signer = match signer_kind {
SignerKind::Auto => {
if sender.announcement().is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
// Create and send gift-wrapped event
match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await {
Ok(event) => {
match client
.send_event(&event)
.to(relays)
.ack_policy(AckPolicy::none())
.await
{
// Safe to unwrap due to earlier checks
encryption_signer
} else {
user_signer.clone()
Ok(output) => {
reports.push(
SendReport::new(member.public_key())
.gift_wrap_id(event.id)
.output(output),
);
}
Err(e) => {
reports.push(
SendReport::new(member.public_key()).error(e.to_string()),
);
}
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
}
SignerKind::User => user_signer.clone(),
};
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
Ok(report) => reports.push(report),
Err(error) => {
let report = SendReport::new(public_key).error(error.to_string());
reports.push(report);
Err(e) => {
reports.push(SendReport::new(member.public_key()).error(e.to_string()));
}
}
}
@@ -592,57 +562,222 @@ impl Room {
reports
}))
}
}
// Helper function to send a gift-wrapped event
async fn send_gift_wrap(
client: &Client,
signer: &Keys,
receiver: &Person,
rumor: &UnsignedEvent,
config: &SignerKind,
) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", vec!["14"]);
let mut extra_tags = vec![k_tag];
/*
* /// Create a new unsigned message event
pub fn create_message(
&self,
content: &str,
replies: Vec<EventId>,
cx: &App,
) -> Task<Result<UnsignedEvent, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Determine the receiver public key based on the config
let receiver = match config {
SignerKind::Auto => {
if let Some(announcement) = receiver.announcement().as_ref() {
extra_tags.push(Tag::public_key(receiver.public_key()));
announcement.public_key()
} else {
receiver.public_key()
}
let subject = self.subject.clone();
let content = content.to_string();
let mut member_and_relay_hints = HashMap::new();
// Populate the hashmap with member and relay hint tasks
for member in self.members.iter() {
let hint = nostr.read(cx).relay_hint(member, cx);
member_and_relay_hints.insert(member.to_owned(), hint);
}
SignerKind::Encryption => {
if let Some(announcement) = receiver.announcement().as_ref() {
extra_tags.push(Tag::public_key(receiver.public_key()));
announcement.public_key()
} else {
return Err(anyhow!("User has no encryption announcement"));
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// List of event tags for each receiver
let mut tags = vec![];
for (member, task) in member_and_relay_hints.into_iter() {
// Skip current user
if member == public_key {
continue;
}
// Get relay hint if available
let relay_url = task.await;
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member,
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
}
}
SignerKind::User => receiver.public_key(),
};
// Construct the gift wrap event
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
.extra_tags(extra_tags)
.finalize_async(signer)
.await?;
// Add subject tag if present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Send the gift wrap event and collect the report
let report = client
.send_event(&event)
.to_nip17()
.ack_policy(AckPolicy::none())
.await
.map(|output| {
SendReport::new(receiver)
.gift_wrap_id(event.id)
.output(output)
})?;
// Add all reply tags
for id in replies {
tags.push(Tag::event(id))
}
Ok(report)
// Construct a direct message event
//
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tags(tags)
.build(public_key);
// Ensure the event ID has been generated
event.ensure_id();
Ok(event)
})
}
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: &UnsignedEvent,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut members = self.members();
let rumor = rumor.to_owned();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let current_user = signer.get_public_key().await?;
// Remove the current user's public key from the list of receivers
// the current user will be handled separately
members.retain(|this| this != &current_user);
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for receiver in members.into_iter() {
// Construct the gift wrap event
let event =
EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
// Send the gift wrap event to the messaging relays
match client.send_event(&event).to_nip17().await {
Ok(output) => {
let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
let tracker = tracker().read().await;
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
// Check if event was successfully resent
if tracker.is_sent_by_coop(&id) {
let output = Output::new(id);
let report = SendReport::new(receiver).status(output);
reports.push(report);
break;
}
// Check if retry limit exceeded
if attempt == SEND_RETRY {
reports.push(report);
break;
}
smol::Timer::after(Duration::from_millis(1200)).await;
}
} else {
reports.push(report);
}
}
Err(e) => {
reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
// Construct the gift-wrapped event
let event =
EventBuilder::gift_wrap(signer, &current_user, rumor.clone(), vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) {
// Send the event to the messaging relays
match client.send_event(&event).to_nip17().await {
Ok(output) => {
reports.push(SendReport::new(current_user).status(output));
}
Err(e) => {
reports.push(SendReport::new(current_user).error(e.to_string()));
}
}
} else {
reports.push(SendReport::new(current_user).on_hold(event));
}
Ok(reports)
})
}
/// Create a task to resend a failed message
pub fn resend_message(
&self,
reports: Vec<SendReport>,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let mut resend_reports = vec![];
for report in reports.into_iter() {
let receiver = report.receiver;
// Process failed events
if let Some(output) = report.status {
let id = output.id();
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
if let Some(event) = client.database().event_by_id(id).await? {
for url in urls.into_iter() {
let relay = client.relay(url).await?.context("Relay not found")?;
let id = relay.send_event(&event).await?;
let resent: Output<EventId> = Output {
val: id,
success: HashSet::from([url.to_owned()]),
failed: HashMap::new(),
};
resend_reports.push(SendReport::new(receiver).status(resent));
}
}
}
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
// Send the event to the messaging relays
match client.send_event(&event).await {
Ok(output) => {
resend_reports.push(SendReport::new(receiver).status(output));
}
Err(e) => {
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
}
Ok(resend_reports)
})
}
*/
}

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
ui = { path = "../ui" }
dock = { path = "../dock" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
@@ -28,5 +29,3 @@ serde_json.workspace = true
once_cell = "1.19.0"
regex = "1"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"

View File

@@ -1,17 +1,24 @@
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::SignerKind;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub enum Command {
Insert(&'static str),
ChangeSubject(String),
ChangeSigner(SignerKind),
ToggleBackup,
Copy(PublicKey),
Relays(PublicKey),
Njump(PublicKey),
Trace(EventId),
ChangeSubject(&'static str),
}
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct SeenOn(pub EventId);
/// Define a open public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct OpenPublicKey(pub PublicKey);
/// Define a copy inline public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct CopyPublicKey(pub PublicKey);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
use gpui::{
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Window,
};
use theme::ActiveTheme;
use ui::input::{InputState, TextInput};
use ui::{v_flex, Sizable};
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
cx.new(|cx| Subject::new(subject, window, cx))
}
pub struct Subject {
input: Entity<InputState>,
}
impl Subject {
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
if let Some(value) = subject {
input.update(cx, |this, cx| {
this.set_value(value, window, cx);
});
};
Self { input }
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value()
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Subject:")),
)
.child(TextInput::new(&self.input).small()),
)
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from(
"Subject will be updated when you send a new message.",
)),
)
}
}

View File

@@ -1,29 +1,29 @@
use std::ops::Range;
use std::sync::Arc;
use chat::Mention;
use common::RangeExt;
use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
StyledText, UnderlineStyle, Window,
};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme;
#[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Code,
InlineCode(bool),
Highlight(HighlightStyle),
Mention,
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
}
Link,
Nostr,
}
#[derive(Default)]
@@ -35,12 +35,7 @@ pub struct RenderedText {
}
impl RenderedText {
pub fn new(
content: &str,
mentions: &[Mention],
persons: &Entity<PersonRegistry>,
cx: &App,
) -> Self {
pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
@@ -48,12 +43,10 @@ impl RenderedText {
render_plain_text_mut(
content,
mentions,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
persons,
cx,
);
@@ -68,8 +61,7 @@ impl RenderedText {
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
let link_color = cx.theme().text_accent;
InteractiveText::new(
id,
@@ -79,36 +71,15 @@ impl RenderedText {
(
range.clone(),
match highlight {
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
Highlight::Link => HighlightStyle {
color: Some(link_color),
underline: Some(UnderlineStyle::default()),
..Default::default()
},
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
}
}
Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
..Default::default()
},
Highlight::Highlight(highlight) => *highlight,
},
)
}),
@@ -116,10 +87,22 @@ impl RenderedText {
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
cx.open_url(url);
move |ix, window, cx| {
let token = link_urls[ix].as_str();
if let Some(clean_url) = token.strip_prefix("nostr:") {
if let Ok(public_key) = PublicKey::parse(clean_url) {
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
}
} else if is_url(token) {
let url = if token.starts_with("http") {
token.to_string()
} else {
format!("https://{token}")
};
cx.open_url(&url);
} else {
log::warn!("Unrecognized token {token}")
}
}
})
@@ -127,273 +110,214 @@ impl RenderedText {
}
}
#[allow(clippy::too_many_arguments)]
fn render_plain_text_mut(
block: &str,
mut mentions: &[Mention],
content: &str,
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
persons: &Entity<PersonRegistry>,
cx: &App,
) {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
// Copy the content directly
text.push_str(content);
let mut bold_depth = 0;
let mut italic_depth = 0;
let mut strikethrough_depth = 0;
let mut link_url = None;
let mut list_stack = Vec::new();
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
for link in URL_REGEX.find_iter(content) {
let range = link.start()..link.end();
let url = link.as_str().to_string();
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
let prev_len = text.len();
url_matches.push((range, url));
}
match event {
Event::Text(t) => {
// Process text with mention replacements
let t_str = t.as_ref();
let mut last_processed = 0;
// Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
while let Some(mention) = mentions.first() {
if !source_range.contains_inclusive(&mention.range) {
break;
}
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let range = nostr_match.start()..nostr_match.end();
let nostr_uri = nostr_match.as_str().to_string();
// Calculate positions within the current text
let mention_start_in_text = mention.range.start - source_range.start;
let mention_end_in_text = mention.range.end - source_range.start;
// Check if this nostr URI overlaps with any already processed URL
if !url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
{
nostr_matches.push((range, nostr_uri));
}
}
// Add text before this mention
if mention_start_in_text > last_processed {
let before_mention = &t_str[last_processed..mention_start_in_text];
process_text_segment(
before_mention,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
link_url.clone(),
text,
highlights,
link_ranges,
link_urls,
);
}
// Combine all matches for processing from end to start
let mut all_matches = Vec::new();
all_matches.extend(url_matches);
all_matches.extend(nostr_matches);
// Process the mention replacement
let profile = persons.read(cx).get(&mention.public_key, cx);
let replacement_text = format!("@{}", profile.name());
// Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
let replacement_start = text.len();
text.push_str(&replacement_text);
let replacement_end = text.len();
// Process all matches
for (range, entity) in all_matches {
// Handle URL token
if is_url(&entity) {
highlights.push((range.clone(), Highlight::Link));
link_ranges.push(range);
link_urls.push(entity);
continue;
};
highlights.push((replacement_start..replacement_end, Highlight::Mention));
last_processed = mention_end_in_text;
mentions = &mentions[1..];
}
// Add any remaining text after the last mention
if last_processed < t_str.len() {
let remaining_text = &t_str[last_processed..];
process_text_segment(
remaining_text,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
link_url.clone(),
if let Ok(nip21) = Nip21::parse(&entity) {
match nip21 {
Nip21::Pubkey(public_key) => {
render_pubkey(
public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::EventId(event_id) => {
render_bech32(
event_id.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Event(nip19_event) => {
render_bech32(
nip19_event.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Coordinate(nip19_coordinate) => {
render_bech32(
nip19_coordinate.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
}
Event::Code(t) => {
text.push_str(t.as_ref());
let is_link = link_url.is_some();
if let Some(link_url) = link_url.clone() {
link_ranges.push(prev_len..text.len());
link_urls.push(link_url);
}
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
}
Event::Start(tag) => match tag {
Tag::Paragraph => new_paragraph(text, &mut list_stack),
Tag::Heading { .. } => {
new_paragraph(text, &mut list_stack);
bold_depth += 1;
}
Tag::CodeBlock(_kind) => {
new_paragraph(text, &mut list_stack);
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Strikethrough => strikethrough_depth += 1,
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
Tag::List(number) => {
list_stack.push((number, false));
}
Tag::Item => {
let len = list_stack.len();
if let Some((list_number, has_content)) = list_stack.last_mut() {
*has_content = false;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
for _ in 0..len - 1 {
text.push_str(" ");
}
if let Some(number) = list_number {
text.push_str(&format!("{}. ", number));
*number += 1;
*has_content = false;
} else {
text.push_str("- ");
}
}
}
_ => {}
},
Event::End(tag) => match tag {
TagEnd::Heading(_) => bold_depth -= 1,
TagEnd::Emphasis => italic_depth -= 1,
TagEnd::Strong => bold_depth -= 1,
TagEnd::Strikethrough => strikethrough_depth -= 1,
TagEnd::Link => link_url = None,
TagEnd::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push('\n'),
_ => {}
}
}
}
#[allow(clippy::too_many_arguments)]
fn process_text_segment(
segment: &str,
segment_start: usize,
bold_depth: i32,
italic_depth: i32,
strikethrough_depth: i32,
link_url: Option<String>,
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
}
/// Format a bech32 entity with ellipsis and last 4 characters
fn format_shortened_entity(entity: &str) -> String {
let prefix_end = entity.find('1').unwrap_or(0);
if prefix_end > 0 && entity.len() > prefix_end + 5 {
let prefix = &entity[0..=prefix_end]; // Include the '1'
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
format!("{prefix}...{suffix}")
} else {
entity.to_string()
}
}
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let display_name = format!("@{}", profile.name());
text.replace_range(range.clone(), &display_name);
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
highlights.push((new_range.clone(), Highlight::Nostr));
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
// Build the style for this segment
let mut style = HighlightStyle::default();
if bold_depth > 0 {
style.font_weight = Some(FontWeight::BOLD);
}
if italic_depth > 0 {
style.font_style = Some(FontStyle::Italic);
}
if strikethrough_depth > 0 {
style.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
}
let njump_url = format!("https://njump.me/{bech32}");
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
// Add the text
text.push_str(segment);
let text_end = text.len();
text.replace_range(range.clone(), &display_text);
if let Some(link_url) = link_url {
// Handle as a markdown link
link_ranges.push(segment_start..text_end);
link_urls.push(link_url);
style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
// Add highlight for the entire linked segment
if style != HighlightStyle::default() {
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
}
} else {
// Handle link detection within the segment
let mut finder = linkify::LinkFinder::new();
finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0;
highlights.push((new_range.clone(), Highlight::Link));
link_ranges.push(new_range);
link_urls.push(njump_url);
for link in finder.links(segment) {
let start = link.start();
let end = link.end();
// Add non-link text before this link
if start > last_link_pos {
let non_link_start = segment_start + last_link_pos;
let non_link_end = segment_start + start;
if style != HighlightStyle::default() {
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
}
}
// Add the link
let range = (segment_start + start)..(segment_start + end);
link_ranges.push(range.clone());
link_urls.push(link.as_str().to_string());
// Apply link styling (underline + existing style)
let mut link_style = style;
link_style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
highlights.push((range, Highlight::Highlight(link_style)));
last_link_pos = end;
}
// Add any remaining text after the last link
if last_link_pos < segment.len() {
let remaining_start = segment_start + last_link_pos;
let remaining_end = segment_start + segment.len();
if style != HighlightStyle::default() {
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
}
}
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
} else {
*has_content = true;
return;
// Helper function to adjust ranges when text length changes
fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)],
link_ranges: &mut [Range<usize>],
position: usize,
length_diff: isize,
) {
// Adjust highlight ranges
for (range, _) in highlights.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
if !text.is_empty() {
if !text.ends_with('\n') {
text.push('\n');
// Adjust link ranges
for range in link_ranges.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
}

View File

@@ -15,9 +15,8 @@ chrono.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
reqwest.workspace = true
log.workspace = true
dirs = "5.0"
qrcode = "0.14.1"
bech32 = "0.11.1"
regex = "1.10"

View File

@@ -1,135 +0,0 @@
use std::collections::{HashMap, VecDeque};
use std::mem::take;
use futures::FutureExt;
use gpui::{
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
};
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
CoopImageCacheProvider {
id: id.into(),
max_items,
}
}
pub struct CoopImageCacheProvider {
id: ElementId,
max_items: usize,
}
impl ImageCacheProvider for CoopImageCacheProvider {
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
window
.with_global_id(self.id.clone(), |id, window| {
window.with_element_state(id, |cache, _| {
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
(cache.clone(), cache)
})
})
.into()
}
}
pub struct CoopImageCache {
max_items: usize,
usage_list: VecDeque<u64>,
cache: HashMap<u64, (ImageCacheItem, Resource)>,
}
impl CoopImageCache {
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
log::info!("Creating CoopImageCache");
cx.on_release(|this: &mut Self, cx| {
for (ix, (mut image, resource)) in take(&mut this.cache) {
if let Some(Ok(image)) = image.get() {
log::info!("Dropping image {ix}");
cx.drop_image(image, None);
}
ImageSource::Resource(resource).remove_asset(cx);
}
})
.detach();
CoopImageCache {
max_items,
usage_list: VecDeque::with_capacity(max_items),
cache: HashMap::with_capacity(max_items),
}
})
}
}
impl ImageCache for CoopImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
let hash = hash(resource);
if let Some(item) = self.cache.get_mut(&hash) {
let current_idx = self
.usage_list
.iter()
.position(|item| *item == hash)
.expect("cache has an item usage_list doesn't");
self.usage_list.remove(current_idx);
self.usage_list.push_front(hash);
return item.0.get();
}
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(load_future).shared();
if self.usage_list.len() >= self.max_items {
log::info!("Image cache is full, evicting oldest item");
if let Some(oldest) = self.usage_list.pop_back() {
let mut image = self
.cache
.remove(&oldest)
.expect("usage_list has an item cache doesn't");
if let Some(Ok(image)) = image.0.get() {
log::info!("requesting image to be dropped");
cx.drop_image(image, Some(window));
}
ImageSource::Resource(image.1).remove_asset(cx);
}
}
self.cache.insert(
hash,
(
gpui::ImageCacheItem::Loading(task.clone()),
resource.clone(),
),
);
self.usage_list.push_front(hash);
let entity = window.current_view();
window
.spawn(cx, async move |cx| {
let result = task.await;
if let Err(err) = result {
log::error!("error loading image into cache: {:?}", err);
}
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
})
.detach();
None
}
}

View File

@@ -1,10 +1,11 @@
use std::sync::Arc;
use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone};
use gpui::{Image, ImageFormat, SharedString};
use nostr_sdk::prelude::*;
use qrcode::QrCode;
use qrcode::render::svg;
use qrcode::QrCode;
const NOW: &str = "now";
const SECONDS_IN_MINUTE: i64 = 60;
@@ -12,12 +13,12 @@ const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
pub trait TimestampExt {
pub trait RenderedTimestamp {
fn to_human_time(&self) -> SharedString;
fn to_ago(&self) -> SharedString;
}
impl TimestampExt for Timestamp {
impl RenderedTimestamp for Timestamp {
fn to_human_time(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
@@ -60,11 +61,23 @@ impl TimestampExt for Timestamp {
}
}
pub trait StringExt {
pub trait TextUtils {
fn to_public_key(&self) -> Result<PublicKey, Error>;
fn to_qr(&self) -> Option<Arc<Image>>;
}
impl<T: AsRef<str>> StringExt for T {
impl<T: AsRef<str>> TextUtils for T {
fn to_public_key(&self) -> Result<PublicKey, Error> {
let s = self.as_ref();
if s.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(s)?.public_key)
} else if s.starts_with("npub1") {
Ok(PublicKey::parse(s)?)
} else {
Err(anyhow!("Invalid public key"))
}
}
fn to_qr(&self) -> Option<Arc<Image>> {
let s = self.as_ref();
let code = QrCode::new(s).unwrap();
@@ -81,3 +94,13 @@ impl<T: AsRef<str>> StringExt for T {
)))
}
}
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
}

View File

@@ -3,12 +3,12 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools;
use nostr_sdk::prelude::*;
pub trait EventExt {
pub trait EventUtils {
fn uniq_id(&self) -> u64;
fn extract_public_keys(&self) -> Vec<PublicKey>;
}
impl EventExt for Event {
impl EventUtils for Event {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
@@ -18,14 +18,14 @@ impl EventExt for Event {
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().collect()
}
}
impl EventExt for UnsignedEvent {
impl EventUtils for UnsignedEvent {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = vec![];
@@ -46,7 +46,7 @@ impl EventExt for UnsignedEvent {
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect()
}

View File

@@ -1,17 +1,11 @@
pub use caching::*;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use media_extractor::*;
pub use parser::*;
pub use nip96::*;
pub use paths::*;
pub use range::*;
mod caching;
mod debounced_delay;
mod display;
mod event;
mod media_extractor;
mod parser;
mod nip96;
mod paths;
mod range;

View File

@@ -1,117 +0,0 @@
use gpui::SharedUri;
use regex::Regex;
/// Extracts media URLs from a string and returns both the extracted URLs
/// and the string with media URLs removed
pub struct MediaExtractor {
image_regex: Regex,
video_regex: Regex,
}
impl MediaExtractor {
/// Creates a new MediaExtractor with compiled regex patterns
pub fn new() -> Self {
MediaExtractor {
// Match common image extensions
image_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
// Match common video extensions
video_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
}
}
/// Extracts all media URLs from a string
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
let mut urls = Vec::new();
// Extract image URLs
for capture in self.image_regex.find_iter(text) {
urls.push(capture.as_str().to_string().into());
}
// Extract video URLs
// for capture in self.video_regex.find_iter(text) {
// urls.push(capture.as_str().to_string().into());
// }
urls
}
/// Removes all media URLs from a string and returns the cleaned text
pub fn remove_media_urls(&self, text: &str) -> String {
let mut result = text.to_string();
// Remove image URLs
result = self.image_regex.replace_all(&result, "").to_string();
// Remove video URLs
// result = self.video_regex.replace_all(&result, "").to_string();
// Clean up extra whitespace that might result from removal
self.cleanup_text(&result)
}
/// Extracts media URLs and removes them from the string, returning both
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
let urls = self.extract_media_urls(text);
let cleaned_text = self.remove_media_urls(text);
(urls, cleaned_text)
}
/// Helper function to clean up text after URL removal
fn cleanup_text(&self, text: &str) -> String {
let text = text.trim();
// Remove multiple consecutive spaces
let re = Regex::new(r"\s+").unwrap();
re.replace_all(text, " ").trim().to_string()
}
/// Validates if a URL is a valid media URL
pub fn is_media_url(&self, url: &str) -> bool {
self.image_regex.is_match(url) || self.video_regex.is_match(url)
}
/// Categorizes extracted URLs into images and videos
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
let mut images = Vec::new();
let mut videos = Vec::new();
for url in urls {
if self.image_regex.is_match(url) {
images.push(url.clone());
} else if self.video_regex.is_match(url) {
videos.push(url.clone());
}
}
(images, videos)
}
}
impl Default for MediaExtractor {
fn default() -> Self {
Self::new()
}
}
/// Convenience function for one-time extraction and removal
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
let extractor = MediaExtractor::new();
extractor.extract_and_remove(text)
}
/// Convenience function for just extracting media URLs
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
let extractor = MediaExtractor::new();
extractor.extract_media_urls(text)
}
/// Convenience function for just removing media URLs
pub fn remove_media_urls(text: &str) -> String {
let extractor = MediaExtractor::new();
extractor.remove_media_urls(text)
}

View File

@@ -0,0 +1,83 @@
use anyhow::anyhow;
use nostr::hashes::sha256::Hash as Sha256Hash;
use nostr::hashes::Hash;
use nostr::prelude::*;
use nostr_sdk::prelude::*;
use reqwest::{multipart, Client as ReqClient, Response};
pub(crate) fn make_multipart_form(
file_data: Vec<u8>,
mime_type: Option<&str>,
) -> Result<multipart::Form, anyhow::Error> {
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
// Set the part's MIME type, or leave it as is if mime_type is None
let part = match mime_type {
Some(mime) => form_file_part.mime_str(mime)?,
None => form_file_part,
};
Ok(multipart::Form::new().part("file", part))
}
pub(crate) async fn upload<T>(
signer: &T,
desc: &ServerConfig,
file_data: Vec<u8>,
mime_type: Option<&str>,
) -> Result<Url, anyhow::Error>
where
T: NostrSigner,
{
let payload: Sha256Hash = Sha256Hash::hash(&file_data);
let data: HttpData = HttpData::new(desc.api_url.clone(), HttpMethod::POST).payload(payload);
let nip98_auth: String = data.to_authorization(signer).await?;
// Make form
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
// Make req client
let req_client = ReqClient::new();
// Send
let response: Response = req_client
.post(desc.api_url.clone())
.header("Authorization", nip98_auth)
.multipart(form)
.send()
.await?;
// Parse response
let json: Value = response.json().await?;
let upload_response = nip96::UploadResponse::from_json(json.to_string())?;
if upload_response.status == UploadResponseStatus::Error {
return Err(anyhow!(upload_response.message));
}
Ok(upload_response.download_url()?.to_owned())
}
pub async fn nip96_upload(
client: &Client,
server: &Url,
file: Vec<u8>,
) -> Result<Url, anyhow::Error> {
let req_client = ReqClient::new();
let config_url = nip96::get_server_config_url(server)?;
// Get
let res = req_client.get(config_url.to_string()).send().await?;
let json: Value = res.json().await?;
let config = nip96::ServerConfig::from_json(json.to_string())?;
let signer = client
.signer()
.cloned()
.unwrap_or(Keys::generate().into_nostr_signer());
let url = upload(&signer, &config, file, None).await?;
Ok(url)
}

View File

@@ -1,210 +0,0 @@
use std::ops::Range;
use nostr::prelude::*;
const BECH32_SEPARATOR: u8 = b'1';
const SCHEME_WITH_COLON: &str = "nostr:";
/// Nostr parsed token with its range in the original text
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
/// The parsed NIP-21 URI
///
/// <https://github.com/nostr-protocol/nips/blob/master/21.md>
pub value: Nip21,
/// The range of this token in the original text
pub range: Range<usize>,
}
#[derive(Debug, Clone, Copy)]
struct Match {
start: usize,
end: usize,
}
/// Nostr parser
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NostrParser;
impl Default for NostrParser {
fn default() -> Self {
Self::new()
}
}
impl NostrParser {
/// Create new parser
pub const fn new() -> Self {
Self
}
/// Parse text
pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> {
NostrParserIter::new(text)
}
}
struct FindMatches<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> FindMatches<'a> {
fn new(text: &'a str) -> Self {
Self {
bytes: text.as_bytes(),
pos: 0,
}
}
fn try_parse_nostr_uri(&mut self) -> Option<Match> {
let start = self.pos;
let bytes = self.bytes;
let len = bytes.len();
// Check if we have "nostr:" prefix
if len - start < SCHEME_WITH_COLON.len() {
return None;
}
// Check for "nostr:" prefix (case-insensitive)
let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()];
if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) {
return None;
}
// Skip the scheme
let pos = start + SCHEME_WITH_COLON.len();
// Parse bech32 entity
let mut has_separator = false;
let mut end = pos;
while end < len {
let byte = bytes[end];
// Check for bech32 separator
if byte == BECH32_SEPARATOR && !has_separator {
has_separator = true;
end += 1;
continue;
}
// Check if character is valid for bech32
if !byte.is_ascii_alphanumeric() {
break;
}
end += 1;
}
// Must have at least one character after separator
if !has_separator || end <= pos + 1 {
return None;
}
// Update position
self.pos = end;
Some(Match { start, end })
}
}
impl Iterator for FindMatches<'_> {
type Item = Match;
fn next(&mut self) -> Option<Self::Item> {
while self.pos < self.bytes.len() {
// Try to parse nostr URI
if let Some(mat) = self.try_parse_nostr_uri() {
return Some(mat);
}
// Skip one character if no match found
self.pos += 1;
}
None
}
}
enum HandleMatch {
Token(Token),
Recursion,
}
pub struct NostrParserIter<'a> {
/// The original text
text: &'a str,
/// Matches found
matches: FindMatches<'a>,
/// A pending match
pending_match: Option<Match>,
/// Last match end index
last_match_end: usize,
}
impl<'a> NostrParserIter<'a> {
fn new(text: &'a str) -> Self {
Self {
text,
matches: FindMatches::new(text),
pending_match: None,
last_match_end: 0,
}
}
fn handle_match(&mut self, mat: Match) -> HandleMatch {
// Update last match end
self.last_match_end = mat.end;
// Extract the matched string
let data: &str = &self.text[mat.start..mat.end];
// Parse NIP-21 URI
match Nip21::parse(data) {
Ok(uri) => HandleMatch::Token(Token {
value: uri,
range: mat.start..mat.end,
}),
// If the nostr URI parsing is invalid, skip it
Err(_) => HandleMatch::Recursion,
}
}
}
impl<'a> Iterator for NostrParserIter<'a> {
type Item = Token;
fn next(&mut self) -> Option<Self::Item> {
// Handle a pending match
if let Some(pending_match) = self.pending_match.take() {
return match self.handle_match(pending_match) {
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(),
};
}
match self.matches.next() {
Some(mat) => {
// Skip any text before this match
if mat.start > self.last_match_end {
// Update pending match
// This will be handled at next iteration, in `handle_match` method.
self.pending_match = Some(mat);
// Skip the text before the match
self.last_match_end = mat.start;
return self.next();
}
// Handle match
match self.handle_match(mat) {
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(),
}
}
None => None,
}
}
}

View File

@@ -7,13 +7,6 @@ pub fn home_dir() -> &'static PathBuf {
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
}
/// Returns the path to the user's download directory.
pub fn download_dir() -> &'static PathBuf {
static DOWNLOAD_DIR: OnceLock<PathBuf> = OnceLock::new();
DOWNLOAD_DIR
.get_or_init(|| dirs::download_dir().expect("failed to determine download directory"))
}
/// Returns the path to the configuration directory used by Coop.
pub fn config_dir() -> &'static PathBuf {
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -63,3 +56,9 @@ pub fn support_dir() -> &'static PathBuf {
config_dir().clone()
})
}
/// Returns the path to the `nostr` file.
pub fn nostr_file() -> &'static PathBuf {
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
}

View File

@@ -1,45 +0,0 @@
use std::cmp::{self};
use std::ops::{Range, RangeInclusive};
pub trait RangeExt<T> {
fn sorted(&self) -> Self;
fn to_inclusive(&self) -> RangeInclusive<T>;
fn overlaps(&self, other: &Range<T>) -> bool;
fn contains_inclusive(&self, other: &Range<T>) -> bool;
}
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
fn sorted(&self) -> Self {
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
}
fn to_inclusive(&self) -> RangeInclusive<T> {
self.start.clone()..=self.end.clone()
}
fn overlaps(&self, other: &Range<T>) -> bool {
self.start < other.end && other.start < self.end
}
fn contains_inclusive(&self, other: &Range<T>) -> bool {
self.start <= other.start && other.end <= self.end
}
}
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
fn sorted(&self) -> Self {
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
}
fn to_inclusive(&self) -> RangeInclusive<T> {
self.clone()
}
fn overlaps(&self, other: &Range<T>) -> bool {
self.start() < &other.end && &other.start <= self.end()
}
fn contains_inclusive(&self, other: &Range<T>) -> bool {
self.start() <= &other.start && &other.end <= self.end()
}
}

63
crates/coop/Cargo.toml Normal file
View File

@@ -0,0 +1,63 @@
[package]
name = "coop"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "coop"
path = "src/main.rs"
[package.metadata.packager]
name = "Coop"
product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "0.3.0"
out-dir = "../../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
icons = [
"resources/32x32.png",
"resources/128x128.png",
"resources/128x128@2x.png",
"resources/icon.icns",
"resources/icon.ico",
]
[dependencies]
assets = { path = "../assets" }
ui = { path = "../ui" }
titlebar = { path = "../titlebar" }
dock = { path = "../dock" }
theme = { path = "../theme" }
common = { path = "../common" }
state = { path = "../state" }
device = { path = "../device" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
auto_update = { path = "../auto_update" }
person = { path = "../person" }
relay_auth = { path = "../relay_auth" }
gpui.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -50,7 +50,7 @@
},
{
"type": "dir",
"path": "./desktop/resources"
"path": "./crates/coop/resources"
}
]
}

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -0,0 +1 @@
pub mod screening;

View File

@@ -0,0 +1,266 @@
use std::time::Duration;
use anyhow::Error;
use common::shorten_pubkey;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{NostrAddress, NostrRegistry};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileDialog> {
cx.new(|cx| ProfileDialog::new(public_key, window, cx))
}
#[derive(Debug)]
pub struct ProfileDialog {
public_key: PublicKey,
/// Follow status
followed: bool,
/// Verification status
verified: bool,
/// Copy status
copied: bool,
/// Async operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl ProfileDialog {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let http_client = cx.http_client();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let mut tasks = smallvec![];
// Check if the user is following
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list.contains(&public_key))
});
// Verify the NIP05 address if available
let verify_nip05 = profile.metadata().nip05.and_then(|address| {
Nip05Address::parse(&address).ok().map(|addr| {
cx.background_spawn(async move { addr.verify(&http_client, &public_key).await })
})
});
tasks.push(
// Load user profile data
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await.unwrap_or(false);
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}),
);
Self {
public_key,
followed: false,
verified: false,
copied: false,
_tasks: tasks,
}
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&self.public_key, cx);
profile.metadata().nip05
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&self.public_key, cx);
let Ok(bech32) = profile.public_key().to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
if status {
self._tasks.push(
// Reset the copied state after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
}),
);
}
}
}
impl Render for ProfileDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&self.public_key, cx);
let bech32 = shorten_pubkey(profile.public_key(), 16);
let shared_bech32 = SharedString::from(bech32);
v_flex()
.gap_4()
.text_sm()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar()).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
h_flex()
.justify_center()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(address)
.when(self.verified, |this| {
this.child(
div()
.relative()
.text_color(cx.theme().text_accent)
.child(
Icon::new(IconName::CheckCircle)
.small()
.block(),
),
)
}),
)
}),
)
.when(!self.followed, |this| {
this.child(
div()
.flex_none()
.w_32()
.p_1()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_xs()
.font_semibold()
.child(SharedString::from("Unknown contact")),
)
}),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Bio:")),
)
.child(
div()
.p_2()
.min_h_16()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
profile
.metadata()
.about
.map(SharedString::from)
.unwrap_or(SharedString::from("No bio.")),
),
),
)
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.gap_2()
.w_full()
.h_12()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(shared_bech32)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.xsmall()
.ghost()
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx);
})),
),
),
)
}
}

View File

@@ -1,22 +1,22 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Error;
use common::TimestampExt;
use anyhow::{Context as AnyhowContext, Error};
use common::{shorten_pubkey, RenderedTimestamp};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use person::{Person, PersonRegistry};
use smallvec::{smallvec, SmallVec};
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -41,20 +41,10 @@ pub struct Screening {
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
this.tasks.clear();
window.close_all_modals(cx);
}));
cx.defer_in(window, move |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
@@ -69,7 +59,6 @@ impl Screening {
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
@@ -78,13 +67,12 @@ impl Screening {
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(current_user).await;
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
Ok(followed)
@@ -106,17 +94,16 @@ impl Screening {
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
mutual_contacts.push(event.pubkey);
}
}
@@ -150,10 +137,10 @@ impl Screening {
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
let target = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
.collect::<HashMap<_, _>>();
if let Ok(mut stream) = client
.stream_events(target)
@@ -226,20 +213,10 @@ impl Screening {
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(signer) = nostr.read(cx).signer(cx) else {
return;
};
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
}
.to_tag();
let event = EventBuilder::report(vec![tag], "")
.finalize_async(&signer)
.await?;
let tag = Tag::public_key_report(public_key, Report::Impersonation);
let builder = EventBuilder::report(vec![tag], "");
let event = client.sign_event_builder(builder).await?;
// Send the report to the public relays
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
@@ -266,7 +243,7 @@ impl Screening {
let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_2().child(
v_flex().gap_1().pb_4().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
@@ -286,7 +263,7 @@ impl Screening {
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).small())
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
.child(profile.name()),
);
}
@@ -302,21 +279,11 @@ impl Screening {
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const CONTACT: &str = "This person is one of your contacts.";
const NOT_CONTACT: &str = "This person is not one of your contacts.";
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let total_mutuals = self.mutual_contacts.len();
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
.gap_4()
@@ -326,7 +293,7 @@ impl Render for Screening {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar()).large())
.child(Avatar::new(profile.avatar()).size(rems(4.)))
.child(
div()
.font_semibold()
@@ -368,9 +335,8 @@ impl Render for Screening {
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Warning)
.small()
.warning()
.icon(IconName::Boom)
.danger()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
@@ -397,9 +363,9 @@ impl Render for Screening {
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
SharedString::from("This person is one of your contacts.")
} else {
SharedString::from(NOT_CONTACT)
SharedString::from("This person is not one of your contacts.")
}
}),
),
@@ -424,7 +390,7 @@ impl Render for Screening {
.xsmall()
.ghost()
.rounded()
.tooltip(RELAY_INFO),
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
),
)
.child(
@@ -433,13 +399,13 @@ impl Render for Screening {
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(t) = self.last_active {
if let Some(date) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
t.to_human_time()
date.to_human_time()
)))
} else {
this.child(SharedString::from(NO_ACTIVITY))
this.child(SharedString::from("This person hasn't had any activity."))
}
}),
),
@@ -457,9 +423,7 @@ impl Render for Screening {
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
} else {
SharedString::from(
"Friendly Address (NIP-05) validation",
)
SharedString::from("Friendly Address (NIP-05) validation")
}
})
.child(
@@ -469,12 +433,12 @@ impl Render for Screening {
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
SharedString::from("The address matches the user's public key.")
} else {
SharedString::from(NIP05_NOT_MATCH)
SharedString::from("The address does not match the user's public key.")
}
} else {
SharedString::from(NO_NIP05)
SharedString::from("This person has not set up their friendly address")
}
}),
),
@@ -484,7 +448,7 @@ impl Render for Screening {
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(status_badge(Some(total_mutuals > 0), cx))
.child(
v_flex()
.text_sm()
@@ -510,10 +474,13 @@ impl Render for Screening {
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if mutuals > 0 {
SharedString::from(mutuals_str)
if total_mutuals > 0 {
SharedString::from(format!(
"You have {} mutual contacts with this person.",
total_mutuals
))
} else {
SharedString::from(NO_MUTUAL)
SharedString::from("You don't have any mutual contacts with this person.")
}
}),
),

139
crates/coop/src/main.rs Normal file
View File

@@ -0,0 +1,139 @@
use std::sync::{Arc, Mutex};
use assets::Assets;
use gpui::{
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
WindowDecorations, WindowKind, WindowOptions,
};
use state::{APP_ID, CLIENT_NAME};
use ui::Root;
mod dialogs;
mod panels;
mod sidebar;
mod workspace;
actions!(coop, [Quit]);
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize the Application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
// Run application
app.run(move |cx| {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q (macOS)
#[cfg(target_os = "macos")]
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Register the `quit` function with Super+Q (others)
#[cfg(not(target_os = "macos"))]
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
// Set up the window options
let opts = WindowOptions {
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(Size::new(px(640.), px(480.))),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
..Default::default()
};
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Bring the app to the foreground
cx.activate(true);
cx.new(|cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(cx);
// Initialize the nostr client
state::init(window, cx);
// Initialize relay auth registry
relay_auth::init(window, cx);
// Initialize person registry
person::init(cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize auto update
auto_update::init(cx);
// Root Entity
Root::new(workspace::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
});
}
fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(vec![]);
let executor = cx.background_executor();
cx.foreground_executor().block_on(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
fn quit(_ev: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}

View File

@@ -0,0 +1,127 @@
use std::sync::Arc;
use common::TextUtils;
use dock::panel::{Panel, PanelEvent};
use dock::ClosePanel;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
Window,
};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::notification::Notification;
use ui::{v_flex, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
cx.new(|cx| ConnectPanel::new(window, cx))
}
pub struct ConnectPanel {
name: SharedString,
focus_handle: FocusHandle,
/// QR Code
qr_code: Option<Arc<Image>>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl ConnectPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let (signer, uri) = nostr.read(cx).client_connect(None);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
let mut tasks = smallvec![];
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
name: "Nostr Connect".into(),
focus_handle: cx.focus_handle(),
qr_code,
_tasks: tasks,
}
}
}
impl Panel for ConnectPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ConnectPanel {}
impl Focusable for ConnectPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ConnectPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Continue with Nostr Connect")),
)
.child(div().text_sm().text_color(cx.theme().text_muted).child(
SharedString::from("Use Nostr Connect apps to scan the code"),
)),
)
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
}
}

View File

@@ -0,0 +1,294 @@
use dock::dock::DockPlacement;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use state::{NostrRegistry, RelayState};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
use crate::workspace::Workspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
cx.new(|cx| GreeterPanel::new(window, cx))
}
pub struct GreeterPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl GreeterPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
}
}
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
Workspace::add_panel(
profile::init(public_key, window, cx),
DockPlacement::Center,
window,
cx,
);
})
.ok();
})
.detach();
}
}
}
impl Panel for GreeterPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
div()
.child(
svg()
.path("brand/coop.svg")
.size_4()
.text_color(cx.theme().text_muted),
)
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for GreeterPanel {}
impl Focusable for GreeterPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for GreeterPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const TITLE: &str = "Welcome to Coop!";
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
let nostr = NostrRegistry::global(cx);
let nip65_state = nostr.read(cx).nip65_state();
let nip17_state = nostr.read(cx).nip17_state();
let signer = nostr.read(cx).signer();
let owned = signer.owned();
let required_actions = nip65_state.read(cx) == &RelayState::NotConfigured
|| nip17_state.read(cx) == &RelayState::NotConfigured;
h_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.child(
v_flex()
.h_full()
.w_112()
.gap_6()
.items_center()
.justify_center()
.child(
h_flex()
.mb_4()
.gap_2()
.w_full()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().icon_muted),
)
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from(TITLE)),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.line_height(relative(1.25))
.child(SharedString::from(DESCRIPTION)),
),
),
)
.when(required_actions, |this| {
this.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_1()
.w_full()
.text_sm()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Required Actions"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap_2()
.w_full()
.when(nip65_state.read(cx).not_configured(), |this| {
this.child(
Button::new("relaylist")
.icon(Icon::new(IconName::Relay))
.label("Set up relay list")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
relay_list::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
})
.when(nip17_state.read(cx).not_configured(), |this| {
this.child(
Button::new("import")
.icon(Icon::new(IconName::Relay))
.label("Set up messaging relays")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
messaging_relays::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
}),
),
)
})
.when(!owned, |this| {
this.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_1()
.w_full()
.text_sm()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Use your own identity"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
),
),
)
})
.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_1()
.w_full()
.text_sm()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Get Started"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("backup")
.icon(Icon::new(IconName::Shield))
.label("Backup account")
.ghost()
.small()
.justify_start(),
)
.child(
Button::new("profile")
.icon(Icon::new(IconName::Profile))
.label("Update profile")
.ghost()
.small()
.justify_start()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.add_profile_panel(window, cx)
})),
)
.child(
Button::new("invite")
.icon(Icon::new(IconName::Invite))
.label("Invite friends")
.ghost()
.small()
.justify_start(),
),
),
),
)
}
}

View File

@@ -0,0 +1,371 @@
use std::time::Duration;
use anyhow::anyhow;
use dock::panel::{Panel, PanelEvent};
use dock::ClosePanel;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{CoopAuthUrlHandler, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
cx.new(|cx| ImportPanel::new(window, cx))
}
#[derive(Debug)]
pub struct ImportPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently logging in
logging_in: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl ImportPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
Self {
key_input,
pass_input,
error,
countdown,
name: "Import".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.login_with_bunker(&value, window, cx);
return;
}
if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, window, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
} else {
self.set_error("Invalid", cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let app_keys = nostr.read(cx).app_keys();
let timeout = Duration::from_secs(30);
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
pub fn login_with_password(
&mut self,
content: &str,
pwd: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if pwd.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
let password = pwd.to_owned();
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(keys) => {
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for ImportPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ImportPanel {}
impl Focusable for ImportPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ImportPanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
It will be cleared when you close the app. \
To persist your identity, please connect via Nostr Connect.";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
div()
.text_center()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Import a Secret Key or Bunker")),
)
.child(
v_flex()
.w_112()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
},
)
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
})
.child(
div()
.mt_2()
.italic()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(SECRET_WARN)),
),
)
}
}

View File

@@ -1,24 +1,21 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Error, anyhow};
use anyhow::{anyhow, Context as AnyhowContext, Error};
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems,
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, TextAlign, UniformList, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
const MSG: &str = "Messaging Relays are relays that hosted all your messages. \
Other users will find your relays and send messages to it.";
use ui::input::{InputEvent, InputState, TextInput};
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelayPanel> {
cx.new(|cx| MessagingRelayPanel::new(window, cx))
@@ -32,26 +29,44 @@ pub struct MessagingRelayPanel {
/// Relay URL input
input: Entity<InputState>,
/// Whether the panel is updating
updating: bool,
/// Error message
error: Option<SharedString>,
/// All relays
// All relays
relays: HashSet<RelayUrl>,
/// Event subscriptions
// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl MessagingRelayPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move { Self::load(&client).await })
.await;
if let Ok(relays) = result {
this.update(cx, |this, cx| {
this.relays.extend(relays);
cx.notify();
})
.ok();
}
}),
);
subscriptions.push(
// Subscribe to user's input events
@@ -62,55 +77,31 @@ impl MessagingRelayPanel {
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
name: "Update Messaging Relays".into(),
focus_handle: cx.focus_handle(),
input,
updating: false,
relays: HashSet::new(),
error: None,
_subscriptions: subscriptions,
tasks: vec![],
_tasks: tasks,
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip17::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let relays = task.await?;
// Update state
this.update(cx, |this, cx| {
this.relays.extend(relays);
cx.notify();
})?;
Ok(())
}));
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip17::extract_owned_relay_list(event).collect())
} else {
Err(anyhow!("Not found."))
}
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -122,7 +113,7 @@ impl MessagingRelayPanel {
}
if let Ok(url) = RelayUrl::parse(&value) {
if self.relays.insert(url) {
if !self.relays.insert(url) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
@@ -145,22 +136,16 @@ impl MessagingRelayPanel {
self.error = Some(error.into());
cx.notify();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})?;
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
self.updating = updating;
cx.notify();
})
.ok();
})
.detach();
}
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -172,26 +157,16 @@ impl MessagingRelayPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(signer) = nostr.read(cx).signer(cx) else {
return;
};
// Construct event tags
let tags: Vec<Tag> = self
.relays
.iter()
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
.map(|relay| Tag::relay(relay.clone()))
.collect();
// Set updating state
self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Construct nip17 event builder
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(tags)
.finalize_async(&signer)
.await?;
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let event = client.sign_event_builder(builder).await?;
// Set messaging relays
client.send_event(&event).to_nip65().await?;
@@ -199,67 +174,81 @@ impl MessagingRelayPanel {
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.load(window, cx);
window.push_notification("Update successful", cx);
})?;
// TODO
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.set_error(e.to_string(), window, cx);
})?;
})
.ok();
}
};
Ok(())
}));
})
.detach();
}
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let mut items = Vec::new();
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
let relays = self.relays.clone();
let total = relays.len();
for url in self.relays.iter() {
items.push(
h_flex()
.id(SharedString::from(url.to_string()))
.group("")
.flex_1()
.w_full()
.h_8()
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.child(div().text_sm().child(SharedString::from(url.to_string())))
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let url = url.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&url, cx);
})
}),
),
)
}
uniform_list(
"relays",
total,
cx.processor(move |_v, range, _window, cx| {
let mut items = Vec::new();
items
for ix in range {
let Some(url) = relays.iter().nth(ix) else {
continue;
};
items.push(
div()
.id(SharedString::from(url.to_string()))
.group("")
.w_full()
.h_9()
.py_0p5()
.child(
h_flex()
.px_2()
.flex()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
div().text_sm().child(SharedString::from(url.to_string())),
)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let url = url.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&url, cx);
})
}),
),
),
)
}
items
}),
)
.h_full()
}
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.mt_2()
.h_20()
.justify_center()
.border_2()
@@ -293,48 +282,36 @@ impl Focusable for MessagingRelayPanel {
impl Render for MessagingRelayPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.p_3()
.gap_3()
.w_full()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
.text_center()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Update Messaging Relays")),
)
.child(divider(cx))
.child(
v_flex()
.w_112()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Relays:")),
)
.child(
v_flex()
.gap_1()
.gap_1p5()
.child(
h_flex()
.gap_1()
.w_full()
.child(
Input::new(&self.input)
.small()
.bordered(false)
.cleanable(true),
)
.child(TextInput::new(&self.input).small())
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add relay")
.label("Add")
.ghost()
.size(rems(2.))
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
@@ -345,33 +322,23 @@ impl Render for MessagingRelayPanel {
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
.map(|this| {
if self.relays.is_empty() {
this.child(self.render_empty(window, cx))
if !self.relays.is_empty() {
this.child(self.render_list(window, cx))
} else {
this.child(
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
)
this.child(self.render_empty(window, cx))
}
})
.child(divider(cx))
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.set_relays(window, cx);
})),

View File

@@ -1,7 +1,6 @@
pub mod backup;
pub mod contact_list;
pub mod connect;
pub mod greeter;
pub mod import;
pub mod messaging_relays;
pub mod profile;
pub mod relay_list;
pub mod trash;

View File

@@ -1,23 +1,26 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::anyhow;
use common::{nip96_upload, shorten_pubkey};
use dock::panel::{Panel, PanelEvent};
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window, div,
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Window,
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use person::{Person, PersonRegistry};
use settings::AppSettings;
use state::{NostrRegistry, upload};
use smol::fs;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputState};
use ui::input::{InputState, TextInput};
use ui::notification::Notification;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
@@ -48,12 +51,6 @@ pub struct ProfilePanel {
/// Copied states
copied: bool,
/// Updating state
updating: bool,
/// Tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl ProfilePanel {
@@ -61,18 +58,20 @@ impl ProfilePanel {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
// Use multi-line input for bio
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line(true)
.multi_line()
.auto_grow(3, 8)
.placeholder("A short introduce about you.")
});
// Get user's profile and update inputs
cx.defer_in(window, move |this, window, cx| {
this.set_profile(window, cx);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
// Set all input's values with current profile
this.set_profile(profile, window, cx);
});
Self {
@@ -85,15 +84,11 @@ impl ProfilePanel {
website_input,
uploading: false,
copied: false,
updating: false,
tasks: vec![],
}
}
fn set_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&self.public_key, cx);
let metadata = profile.metadata();
fn set_profile(&mut self, person: Person, window: &mut Window, cx: &mut Context<Self>) {
let metadata = person.metadata();
self.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
@@ -148,87 +143,74 @@ impl ProfilePanel {
}
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
self.uploading(true, cx);
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_file_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
let task = Tokio::spawn(cx, async move {
match paths.await {
Ok(Ok(Some(mut paths))) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(&client, &nip96_server, file).await?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
_ => Err(anyhow!("Error")),
}
});
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
this.update_in(cx, |this, window, cx| {
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
this.set_uploading(false, cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
self.updating = updating;
cx.notify();
}
/// Set the metadata for the current user
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let metadata = metadata.clone();
let Some(signer) = nostr.read(cx).signer(cx) else {
return Task::ready(Err(anyhow!("Signer is required")));
};
cx.background_spawn(async move {
// Build and sign the metadata event
let event = EventBuilder::metadata(&metadata)
.finalize_async(&signer)
.await?;
// Send event to user's relays
client.send_event(&event).await?;
Ok(())
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
}
};
this.uploading(false, cx);
})
.expect("Entity has been released");
})
.detach();
}
fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let persons = PersonRegistry::global(cx);
fn set_metadata(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let public_key = self.public_key;
// Get the old metadata
let persons = PersonRegistry::global(cx);
let old_metadata = persons.read(cx).get(&public_key, cx).metadata();
// Extract all new metadata fields
@@ -254,39 +236,34 @@ impl ProfilePanel {
}
// Set the metadata
let task = self.publish(&new_metadata, cx);
let task = nostr.read(cx).set_metadata(&new_metadata, cx);
// Set the updating state
self.set_updating(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
// Update the registry
Ok(()) => {
cx.update(|window, cx| {
persons.update(cx, |this, cx| {
this.insert(Person::new(public_key, new_metadata), cx);
});
// Update current panel
this.set_updating(false, cx);
this.set_profile(window, cx);
this.update(cx, |this, cx| {
this.set_metadata(window, cx);
})
.ok();
window.push_notification("Profile updated successfully", cx);
})?;
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
Ok(())
}));
})
.detach();
}
}
@@ -310,126 +287,123 @@ impl Focusable for ProfilePanel {
impl Render for ProfilePanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar_input = self.avatar_input.read(cx).value();
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
// Get the avatar
let avatar_input = self.avatar_input.read(cx).value();
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
// Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
v_flex()
.p_3()
.gap_3()
.w_full()
.size_full()
.items_center()
.justify_center()
.p_2()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).large())
.gap_2()
.w_112()
.child(
Button::new("upload")
.icon(IconName::PlusCircle)
.label("Add an avatar")
.xsmall()
.ghost()
.rounded()
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).size(rems(4.25)))
.child(
Button::new("upload")
.icon(IconName::PlusCircle)
.label("Add an avatar")
.xsmall()
.ghost()
.rounded()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("What should people call you?"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("A short introduction about you:"))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Website:"))
.child(TextInput::new(&self.website_input).small()),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(
div()
.font_semibold()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.h_8()
.w_full()
.justify_center()
.gap_2()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.xsmall()
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.copy(
this.public_key.to_bech32().unwrap(),
window,
cx,
);
})),
),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label("Update")
.primary()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
.on_click(cx.listener(move |this, _ev, window, cx| {
this.set_metadata(window, cx);
})),
),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("What should people call you?")),
)
.child(Input::new(&self.name_input).bordered(false).small()),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("A short introduction about you:")),
)
.child(Input::new(&self.bio_input).bordered(false).small()),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Website:")),
)
.child(Input::new(&self.website_input).bordered(false).small()),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.h_8()
.w_full()
.justify_center()
.gap_3()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.xsmall()
.secondary()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.copy(this.public_key.to_bech32().unwrap(), window, cx);
})),
),
),
)
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
)
}
}

View File

@@ -0,0 +1,365 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, TextAlign, UniformList, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, BOOTSTRAP_RELAYS};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> {
cx.new(|cx| RelayListPanel::new(window, cx))
}
#[derive(Debug)]
pub struct RelayListPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Relay URL input
input: Entity<InputState>,
/// Relay metadata input
metadata: Entity<Option<RelayMetadata>>,
/// Error message
error: Option<SharedString>,
// All relays
relays: HashSet<(RelayUrl, Option<RelayMetadata>)>,
// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl RelayListPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let metadata = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move { Self::load(&client).await })
.await;
if let Ok(relays) = result {
this.update(cx, |this, cx| {
this.relays.extend(relays);
cx.notify();
})
.ok();
}
}),
);
subscriptions.push(
// Subscribe to user's input events
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
}),
);
Self {
name: "Update Relay List".into(),
focus_handle: cx.focus_handle(),
input,
metadata,
relays: HashSet::new(),
error: None,
_subscriptions: subscriptions,
_tasks: tasks,
}
}
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip65::extract_owned_relay_list(event).collect())
} else {
Err(anyhow!("Not found."))
}
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
let metadata = self.metadata.read(cx);
if !value.starts_with("ws") {
self.set_error("Relay URl is invalid", window, cx);
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
if !self.relays.insert((url, metadata.to_owned())) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
} else {
self.set_error("Relay URl is invalid", window, cx);
}
}
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
self.relays.retain(|(relay, _)| relay != url);
cx.notify();
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.detach();
}
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.relays.is_empty() {
self.set_error("You need to add at least 1 relay", window, cx);
return;
};
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let relays = self.relays.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let builder = EventBuilder::relay_list(relays);
let event = client.sign_event_builder(builder).await?;
// Set relay list for current user
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
// TODO
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
};
})
.detach();
}
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
let relays = self.relays.clone();
let total = relays.len();
uniform_list(
"relays",
total,
cx.processor(move |_v, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let Some((url, metadata)) = relays.iter().nth(ix) else {
continue;
};
items.push(
div()
.id(SharedString::from(url.to_string()))
.group("")
.w_full()
.h_9()
.py_0p5()
.child(
h_flex()
.px_2()
.flex()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
div().text_sm().child(SharedString::from(url.to_string())),
)
.child(
h_flex()
.gap_1()
.text_xs()
.map(|this| {
if let Some(metadata) = metadata {
this.child(SharedString::from(
metadata.to_string(),
))
} else {
this.child(SharedString::from("Read+Write"))
}
})
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let url = url.to_owned();
cx.listener(
move |this, _ev, _window, cx| {
this.remove(&url, cx);
},
)
}),
),
),
),
)
}
items
}),
)
.h_full()
}
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.mt_2()
.h_20()
.justify_center()
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
}
impl Panel for RelayListPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for RelayListPanel {}
impl Focusable for RelayListPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RelayListPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
div()
.text_center()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Update Relay List")),
)
.child(
v_flex()
.w_112()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1p5()
.child(
h_flex()
.gap_1()
.w_full()
.child(TextInput::new(&self.input).small())
.child(
Button::new("add")
.icon(IconName::Plus)
.label("Add")
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
.map(|this| {
if !self.relays.is_empty() {
this.child(self.render_list(window, cx))
} else {
this.child(self.render_empty(window, cx))
}
})
.child(divider(cx))
.child(
Button::new("submit")
.label("Update")
.primary()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.set_relays(window, cx);
})),
),
)
}
}

View File

@@ -1,18 +1,18 @@
use std::rc::Rc;
use chat::RoomKind;
use dock::ClosePanel;
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div,
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::dock::ClosePanel;
use ui::modal::ModalButtonProps;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex};
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
use crate::dialogs::screening;
@@ -106,7 +106,14 @@ impl RenderOnce for RoomEntry {
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.when_some(self.avatar, |this, avatar| {
this.child(Avatar::new(avatar).small().flex_shrink_0())
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(avatar).size(rems(1.5))),
)
})
})
.child(
@@ -148,27 +155,26 @@ impl RenderOnce for RoomEntry {
this.on_click(move |event, window, cx| {
handler(event, window, cx);
if let Some(public_key) = public_key
&& self.kind != Some(RoomKind::Ongoing)
&& screening
{
let screening = screening::init(public_key, window, cx);
if let Some(public_key) = public_key {
if self.kind != Some(RoomKind::Ongoing) && screening {
let screening = screening::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.confirm()
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
// Prevent closing the modal on click
// modal will be automatically closed after closing panel
false
})
});
window.open_modal(cx, move |this, _window, _cx| {
this.confirm()
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
// Prevent closing the modal on click
// modal will be automatically closed after closing panel
false
})
});
}
}
})
})

View File

@@ -4,26 +4,29 @@ use std::time::Duration;
use anyhow::Error;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, TimestampExt, coop_cache};
use common::{DebouncedDelay, RenderedTimestamp};
use dock::panel::{Panel, PanelEvent};
use entry::RoomEntry;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
Window, div, uniform_list,
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
Task, UniformListScrollHandle, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::divider::Divider;
use ui::indicator::Indicator;
use ui::input::{Input, InputEvent, InputState};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{
h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
};
mod entry;
@@ -39,6 +42,9 @@ pub struct Sidebar {
focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Find input state
find_input: Entity<InputState>,
@@ -116,10 +122,12 @@ impl Sidebar {
}
}
InputEvent::Focus => {
this.set_input_focus(true, window, cx);
this.set_input_focus(window, cx);
this.get_contact_list(window, cx);
}
_ => {}
InputEvent::Blur => {
this.set_input_focus(window, cx);
}
};
}),
);
@@ -138,6 +146,7 @@ impl Sidebar {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(),
image_cache: RetainAllImageCache::new(cx),
find_input,
find_debouncer: DebouncedDelay::new(),
find_results,
@@ -157,16 +166,7 @@ impl Sidebar {
/// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contacts = client.database().contacts_public_keys(public_key).await?;
Ok(contacts)
});
let task = nostr.read(cx).get_contact_list(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
@@ -177,10 +177,7 @@ impl Sidebar {
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
window.push_notification(Notification::error(e.to_string()), cx);
})?;
}
};
@@ -241,7 +238,6 @@ impl Sidebar {
}));
}
/// Set the results of the search
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
@@ -249,10 +245,10 @@ impl Sidebar {
});
}
/// Set the finding status
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
// Set the search status
@@ -260,14 +256,13 @@ impl Sidebar {
cx.notify();
}
/// Set the focus status of the input element.
fn set_input_focus(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.find_focused = status;
fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.find_focused = !self.find_focused;
cx.notify();
// Focus to the input element
if !status {
window.focus_prev(cx);
// Reset the find panel
if !self.find_focused {
self.reset(window, cx);
}
}
@@ -320,14 +315,14 @@ impl Sidebar {
let async_chat = chat.downgrade();
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
return;
};
let signer = nostr.read(cx).signer();
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_key = signer.get_public_key().await?;
// Create a new room and emit it
async_chat.update_in(cx, |this, _window, cx| {
let room = cx.new(|_| {
@@ -353,8 +348,7 @@ impl Sidebar {
}
/// Set the active filter for the sidebar.
fn set_filter(&mut self, kind: RoomKind, window: &mut Window, cx: &mut Context<Self>) {
self.set_input_focus(false, window, cx);
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
self.filter.update(cx, |this, cx| {
*this = kind;
cx.notify();
@@ -362,11 +356,7 @@ impl Sidebar {
self.new_requests = false;
}
fn render_list_items(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
fn render_list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).rooms(self.filter.read(cx), cx);
@@ -398,11 +388,7 @@ impl Sidebar {
}
/// Render the contact list
fn render_results(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
// Get the contact list
@@ -435,11 +421,7 @@ impl Sidebar {
}
/// Render the contact list
fn render_contacts(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
// Get the contact list
@@ -503,17 +485,16 @@ impl Render for Sidebar {
};
v_flex()
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
.image_cache(self.image_cache.clone())
.size_full()
.gap_2()
.relative()
.child(
h_flex()
.h(TABBAR_HEIGHT)
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.child(
Input::new(&self.find_input)
TextInput::new(&self.find_input)
.appearance(false)
.bordered(false)
.small()
@@ -531,17 +512,22 @@ impl Render for Sidebar {
)
.child(
h_flex()
.px_2()
.gap_2()
.h(TABBAR_HEIGHT)
.justify_center()
.border_b_1()
.border_color(cx.theme().border)
.when(show_find_panel, |this| {
this.child(
Button::new("search-results")
.icon(IconName::Search)
.label("Search")
.tooltip("All search results")
.small()
.ghost_alt()
.underline()
.ghost()
.font_semibold()
.rounded_none()
.h_full()
.flex_1()
.selected(true),
)
@@ -558,16 +544,21 @@ impl Render for Sidebar {
.when(!show_find_panel, |this| this.label("Inbox"))
.tooltip("All ongoing conversations")
.small()
.ghost_alt()
.underline()
.ghost()
.font_semibold()
.rounded_none()
.h_full()
.flex_1()
.disabled(show_find_panel)
.selected(
!show_find_panel && self.current_filter(&RoomKind::Ongoing, cx),
)
.on_click(cx.listener(|this, _ev, window, cx| {
this.set_filter(RoomKind::Ongoing, window, cx);
.on_click(cx.listener(|this, _ev, _window, cx| {
this.set_filter(RoomKind::Ongoing, cx);
})),
)
.child(Divider::vertical())
.child(
Button::new("requests")
.map(|this| {
@@ -580,23 +571,27 @@ impl Render for Sidebar {
.when(!show_find_panel, |this| this.label("Requests"))
.tooltip("Incoming new conversations")
.small()
.ghost_alt()
.ghost()
.underline()
.font_semibold()
.rounded_none()
.h_full()
.flex_1()
.disabled(show_find_panel)
.selected(
!show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx),
)
.when(self.new_requests, |this| {
this.child(div().size_1().rounded_full().bg(cx.theme().cursor))
})
.on_click(cx.listener(|this, _ev, window, cx| {
this.set_filter(RoomKind::default(), window, cx);
.on_click(cx.listener(|this, _ev, _window, cx| {
this.set_filter(RoomKind::default(), cx);
})),
),
)
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
this.child(
div().w(SIDEBAR_WIDTH).px_2().child(
div().mt_2().px_2().child(
v_flex()
.p_3()
.h_24()
@@ -624,9 +619,12 @@ impl Render for Sidebar {
})
.child(
v_flex()
.size_full()
.h_full()
.px_1p5()
.mt_2()
.flex_1()
.gap_1()
.overflow_y_hidden()
.when(show_find_panel, |this| {
this.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
@@ -649,7 +647,7 @@ impl Render for Sidebar {
uniform_list(
"rooms",
results.len(),
cx.processor(move |this, range, _window, cx| {
cx.processor(|this, range, _window, cx| {
this.render_results(range, cx)
}),
)
@@ -669,14 +667,14 @@ impl Render for Sidebar {
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small())
.child(Icon::new(IconName::ChevronDown))
.child(SharedString::from("Suggestions")),
)
.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(|this, range, _window, cx| {
cx.processor(move |this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
@@ -697,8 +695,7 @@ impl Render for Sidebar {
)
.track_scroll(&self.scroll_handle)
.flex_1()
.h_full()
.px_2(),
.h_full(),
)
.child(Scrollbar::vertical(&self.scroll_handle))
}),
@@ -742,7 +739,7 @@ impl Render for Sidebar {
.bg(cx.theme().background.opacity(0.85))
.border_color(cx.theme().border_disabled)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded_full()
.text_xs()
.font_semibold()

View File

@@ -0,0 +1,290 @@
use std::sync::Arc;
use chat::{ChatEvent, ChatRegistry};
use dock::dock::DockPlacement;
use dock::panel::{PanelStyle, PanelView};
use dock::{ClosePanel, DockArea, DockItem};
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, RelayState};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use titlebar::TitleBar;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::menu::DropdownMenu;
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
use crate::panels::greeter;
use crate::sidebar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
pub struct Workspace {
/// App's Title Bar
titlebar: Entity<TitleBar>,
/// App's Dock Area
dock: Entity<DockArea>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx).style(PanelStyle::TabBar));
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
match ev {
ChatEvent::OpenRoom(id) => {
if let Some(room) = chat.read(cx).room(id, cx) {
this.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(chat_ui::init(room, window, cx)),
DockPlacement::Center,
window,
cx,
);
});
}
}
ChatEvent::CloseRoom(..) => {
this.dock.update(cx, |this, cx| {
// Force focus to the tab panel
this.focus_tab_panel(window, cx);
// Dispatch the close panel action
cx.defer_in(window, |_, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
window.close_all_modals(cx);
});
});
}
_ => {}
};
}),
);
subscriptions.push(
// Observe the chat registry
cx.observe(&chat, move |this, chat, cx| {
let ids = this.panel_ids(cx);
chat.update(cx, |this, cx| {
this.refresh_rooms(ids, cx);
});
}),
);
// Set the default layout for app's dock
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
});
Self {
titlebar,
dock,
_subscriptions: subscriptions,
}
}
/// Add panel to the dock
pub fn add_panel<P>(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App)
where
P: PanelView,
{
if let Some(root) = window.root::<Root>().flatten() {
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
workspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.add_panel(Arc::new(panel), placement, window, cx);
});
});
}
}
}
/// Get all panel ids
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
let ids: Vec<u64> = self
.dock
.read(cx)
.items
.panel_ids(cx)
.into_iter()
.filter_map(|panel| panel.parse::<u64>().ok())
.collect();
Some(ids)
}
/// Set the dock layout
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
// Sidebar
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Main workspace
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(greeter::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
// Update the dock layout
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
this.set_center(center, window, cx);
});
}
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).nip65_state();
let nip17 = nostr.read(cx).nip17_state();
let signer = nostr.read(cx).signer();
let current_user = signer.public_key();
h_flex()
.h(TITLEBAR_HEIGHT)
.flex_shrink_0()
.justify_between()
.gap_2()
.when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx);
this.child(
Button::new("current-user")
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
this.label(profile.name())
.separator()
.menu("Profile", Box::new(ClosePanel))
.menu("Backup", Box::new(ClosePanel))
.menu("Themes", Box::new(ClosePanel))
.menu("Settings", Box::new(ClosePanel))
}),
)
})
.when(nostr.read(cx).creating_signer(), |this| {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Coop is creating a new identity for you..."),
))
})
.when(!nostr.read(cx).connected(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Connecting...")),
)
})
.map(|this| match nip65.read(cx) {
RelayState::Checking => this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Fetching user's relay list...")),
),
RelayState::NotConfigured => this.child(
h_flex()
.h_6()
.w_full()
.px_1()
.text_xs()
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_background)
.rounded_sm()
.child(SharedString::from("User hasn't configured a relay list")),
),
_ => this,
})
.map(|this| match nip17.read(cx) {
RelayState::Checking => {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Fetching user's messaging relay list..."),
))
}
RelayState::NotConfigured => this.child(
h_flex()
.h_6()
.w_full()
.px_1()
.text_xs()
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_background)
.rounded_sm()
.child(SharedString::from(
"User hasn't configured a messaging relay list",
)),
),
_ => this,
})
}
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
}
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
// Titlebar elements
let left = self.titlebar_left(window, cx).into_any_element();
let right = self.titlebar_right(window, cx).into_any_element();
// Update title bar children
self.titlebar.update(cx, |this, _cx| {
this.set_children(vec![left, right]);
});
div()
.id(SharedString::from("workspace"))
.relative()
.size_full()
.child(
v_flex()
.relative()
.size_full()
// Title Bar
.child(self.titlebar.clone())
// Dock
.child(self.dock.clone()),
)
// Notifications
.children(notification_layer)
// Modals
.children(modal_layer)
}
}

View File

@@ -7,10 +7,6 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
person = { path = "../person" }
ui = { path = "../ui" }
theme = { path = "../theme" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true

View File

@@ -1,6 +1,28 @@
use gpui::SharedString;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum DeviceState {
#[default]
Initial,
Requesting,
Set,
}
impl DeviceState {
pub fn initial(&self) -> bool {
matches!(self, DeviceState::Initial)
}
pub fn requesting(&self) -> bool {
matches!(self, DeviceState::Requesting)
}
pub fn set(&self) -> bool {
matches!(self, DeviceState::Set)
}
}
/// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement {
@@ -16,15 +38,14 @@ impl From<&Event> for Announcement {
let public_key = val
.tags
.iter()
.find(|tag| tag.kind() == "n")
.find(|tag| tag.kind().as_str() == "n")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.unwrap_or(val.pubkey);
let client_name = val
.tags
.iter()
.find(|tag| tag.kind() == "client")
.find(TagKind::Client)
.and_then(|tag| tag.content())
.map(|c| c.to_string());

File diff suppressed because it is too large Load Diff

18
crates/dock/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "dock"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
gpui.workspace = true
smallvec.workspace = true
anyhow.workspace = true
log.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"

View File

@@ -3,26 +3,20 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
Window,
};
use ui::StyledExt;
use super::{DockArea, DockItem};
use crate::StyledExt;
use crate::dock::panel::PanelView;
use crate::dock::tab_panel::TabPanel;
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
use crate::panel::PanelView;
use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
use crate::tab_panel::TabPanel;
#[derive(Clone)]
#[derive(Clone, Render)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockPlacement {
Center,
@@ -271,22 +265,22 @@ impl Dock {
let mut right_dock_size = px(0.0);
// Get the size of the left dock if it's open and not the current dock
if let Some(left_dock) = &dock_area.left_dock
&& left_dock.entity_id() != cx.entity().entity_id()
{
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
if let Some(left_dock) = &dock_area.left_dock {
if left_dock.entity_id() != cx.entity().entity_id() {
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
}
}
// Get the size of the right dock if it's open and not the current dock
if let Some(right_dock) = &dock_area.right_dock
&& right_dock.entity_id() != cx.entity().entity_id()
{
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
if let Some(right_dock) = &dock_area.right_dock {
if right_dock.entity_id() != cx.entity().entity_id() {
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
}
}
@@ -327,8 +321,6 @@ impl Render for Dock {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
@@ -344,7 +336,7 @@ impl Render for Dock {
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
DockItem::Panel { view, .. } => this.child(view.clone().view()),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {

View File

@@ -2,24 +2,23 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use ui::ElementExt;
use crate::ElementExt;
use crate::dock::{Dock, DockPlacement};
use crate::panel::{Panel, PanelEvent, PanelStyle, PanelView};
use crate::stack_panel::StackPanel;
use crate::tab_panel::TabPanel;
#[allow(clippy::module_inception)]
mod dock;
mod panel;
mod stack_panel;
mod tab_panel;
pub use dock::*;
pub use panel::*;
pub use stack_panel::*;
pub use tab_panel::*;
pub mod dock;
pub mod panel;
pub mod resizable;
pub mod stack_panel;
pub mod tab;
pub mod tab_panel;
actions!(dock, [ToggleZoom, ClosePanel]);
@@ -205,16 +204,19 @@ impl DockItem {
/// Returns all panel ids
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
match self {
Self::Panel { .. } => vec![],
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
Self::Split { items, .. } => items
.iter()
.filter_map(|item| match item {
DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)),
_ => None,
})
.flatten()
.collect(),
Self::Split { items, .. } => {
let mut total = vec![];
for item in items.iter() {
if let DockItem::Tabs { view, .. } = item {
total.extend(view.read(cx).panel_ids(cx));
}
}
total
}
Self::Panel { .. } => vec![],
}
}
@@ -590,13 +592,17 @@ impl DockArea {
}
}
DockPlacement::Right => {
self.set_right_dock(
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
Some(px(320.)),
true,
window,
cx,
);
if let Some(dock) = self.right_dock.as_ref() {
dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx))
} else {
self.set_right_dock(
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
Some(px(320.)),
true,
window,
cx,
);
}
}
DockPlacement::Center => {
self.items
@@ -745,7 +751,6 @@ impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone();
let decorations = window.window_decorations();
div()
.id("dock-area")
@@ -755,17 +760,7 @@ impl Render for DockArea {
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
this.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
this.child(zoom_view)
} else {
// render dock
this.child(

View File

@@ -1,10 +1,9 @@
use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render,
SharedString, Window,
};
use crate::button::Button;
use crate::menu::PopupMenu;
use ui::button::Button;
use ui::menu::PopupMenu;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent {
@@ -21,6 +20,12 @@ pub enum PanelStyle {
TabBar,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TitleStyle {
pub background: Hsla,
pub foreground: Hsla,
}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
///

View File

@@ -1,7 +1,7 @@
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
px, Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window,
};
mod panel;
@@ -142,10 +142,10 @@ impl ResizableState {
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix
&& resizing_panel_ix > panel_ix
{
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
if resizing_panel_ix > panel_ix {
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
}
self.adjust_to_container_size(cx);
}

View File

@@ -3,15 +3,14 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent,
ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, div,
div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty,
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window,
};
use theme::AxisExt;
use ui::{h_flex, v_flex, AxisExt, ElementExt};
use super::{ResizableState, resizable_panel, resize_handle};
use super::{resizable_panel, resize_handle, ResizableState};
use crate::resizable::PANEL_MIN_SIZE;
use crate::{ElementExt, h_flex, v_flex};
pub enum ResizablePanelEvent {
Resized,
@@ -241,19 +240,17 @@ impl RenderOnce for ResizablePanel {
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.flex_grow()
.size_full()
.relative()
.when(self.axis.is_vertical(), |this| {
@@ -265,7 +262,7 @@ impl RenderOnce for ResizablePanel {
// 1. initial_size is None, to use auto size.
// 2. initial_size is Some and size is none, to use the initial size of the panel for first time render.
// 3. initial_size is Some and size is Some, use `size`.
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when(self.initial_size.is_none(), |this| this.flex_shrink())
.when_some(self.initial_size, |this, initial_size| {
// The `self.size` is None, that mean the initial size for the panel,
// so we need set `flex_shrink_0` To let it keep the initial size.

View File

@@ -3,11 +3,12 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, px,
div, px, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId,
InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels,
Point, Render, StatefulInteractiveElement, Styled as _, Window,
};
use theme::{ActiveTheme, AxisExt};
use theme::ActiveTheme;
use ui::AxisExt;
use crate::dock::DockPlacement;
@@ -112,7 +113,7 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
let state = state.unwrap_or(ResizeHandleState::default());
let bg_color = if state.is_active() {
cx.theme().border_selected
cx.theme().border_variant
} else {
cx.theme().border
};

View File

@@ -7,16 +7,16 @@ use gpui::{
Window,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
use ui::{h_flex, AxisExt as _, Placement};
use super::{DockArea, PanelEvent};
use crate::dock::panel::{Panel, PanelView};
use crate::dock::tab_panel::TabPanel;
use crate::h_flex;
use crate::panel::{Panel, PanelView};
use crate::resizable::{
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
resizable_panel,
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
PANEL_MIN_SIZE,
};
use crate::tab_panel::TabPanel;
pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>,
@@ -70,10 +70,10 @@ impl StackPanel {
return false;
}
if let Some(parent) = &self.parent
&& let Some(parent) = parent.upgrade()
{
return parent.read(cx).is_last_panel(cx);
if let Some(parent) = &self.parent {
if let Some(parent) = parent.upgrade() {
return parent.read(cx).is_last_panel(cx);
}
}
true
@@ -297,11 +297,12 @@ impl StackPanel {
/// Find the first top left in the stack.
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
{
return Some(panel);
if check_parent {
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
return Some(panel);
}
}
}
let first_panel = self.panels.first();
@@ -320,11 +321,12 @@ impl StackPanel {
/// Find the first top right in the stack.
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
{
return Some(panel);
if check_parent {
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
return Some(panel);
}
}
}
let panel = if self.axis.is_vertical() {
@@ -369,6 +371,7 @@ impl Focusable for StackPanel {
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {

165
crates/dock/src/tab/mod.rs Normal file
View File

@@ -0,0 +1,165 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, StatefulInteractiveElement, Styled, Window,
};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::{Selectable, Sizable, Size};
pub mod tab_bar;
#[derive(IntoElement)]
pub struct Tab {
ix: usize,
base: Div,
label: Option<AnyElement>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
disabled: bool,
selected: bool,
size: Size,
}
impl Tab {
pub fn new() -> Self {
Self {
ix: 0,
base: div(),
label: None,
disabled: false,
selected: false,
prefix: None,
suffix: None,
size: Size::default(),
}
}
/// Set label for the tab.
pub fn label(mut self, label: impl Into<AnyElement>) -> Self {
self.label = Some(label.into());
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl Into<AnyElement>) -> Self {
self.prefix = Some(prefix.into());
self
}
/// Set the right side of the tab
pub fn suffix(mut self, suffix: impl Into<AnyElement>) -> Self {
self.suffix = Some(suffix.into());
self
}
/// Set disabled state to the tab
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set index to the tab.
pub fn ix(mut self, ix: usize) -> Self {
self.ix = ix;
self
}
}
impl Default for Tab {
fn default() -> Self {
Self::new()
}
}
impl Selectable for Tab {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Tab {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Tab {}
impl Styled for Tab {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl Sizable for Tab {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Tab {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let (text_color, hover_text_color, bg_color, border_color) =
match (self.selected, self.disabled) {
(true, false) => (
cx.theme().tab_active_foreground,
cx.theme().tab_hover_foreground,
cx.theme().tab_active_background,
cx.theme().border,
),
(false, false) => (
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().border_transparent,
),
(true, true) => (
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().border_disabled,
),
(false, true) => (
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().border_disabled,
),
};
self.base
.id(self.ix)
.h(TABBAR_HEIGHT)
.px_4()
.relative()
.flex()
.items_center()
.flex_shrink_0()
.cursor_pointer()
.overflow_hidden()
.text_xs()
.text_ellipsis()
.text_color(text_color)
.bg(bg_color)
.border_l(px(1.))
.border_r(px(1.))
.border_color(border_color)
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| this.text_color(hover_text_color))
})
.when_some(self.prefix, |this, prefix| {
this.child(prefix).text_color(text_color)
})
.when_some(self.label, |this, label| this.child(label))
.when_some(self.suffix, |this, suffix| this.child(suffix))
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
cx.stop_propagation();
})
}
}

View File

@@ -0,0 +1,127 @@
use gpui::prelude::FluentBuilder as _;
#[cfg(not(target_os = "windows"))]
use gpui::Pixels;
use gpui::{
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use ui::{h_flex, Sizable, Size, StyledExt};
#[derive(IntoElement)]
pub struct TabBar {
base: Div,
style: StyleRefinement,
scroll_handle: Option<ScrollHandle>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
last_empty_space: AnyElement,
children: SmallVec<[AnyElement; 2]>,
size: Size,
}
impl TabBar {
pub fn new() -> Self {
Self {
base: h_flex().px(px(-1.)),
style: StyleRefinement::default(),
scroll_handle: None,
children: SmallVec::new(),
prefix: None,
suffix: None,
size: Size::default(),
last_empty_space: div().w_3().into_any_element(),
}
}
/// Track the scroll of the TabBar.
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle.clone());
self
}
/// Set the prefix element of the TabBar
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the suffix element of the TabBar
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set the last empty space element of the TabBar.
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
self.last_empty_space = last_empty_space.into_any_element();
self
}
#[cfg(not(target_os = "windows"))]
pub fn height(window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(36.))
}
}
impl Default for TabBar {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for TabBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Styled for TabBar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Sizable for TabBar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for TabBar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
self.base
.group("tab-bar")
.relative()
.refine_style(&self.style)
.bg(cx.theme().surface_background)
.child(
div()
.id("border-bottom")
.absolute()
.left_0()
.bottom_0()
.size_full()
.border_b_1()
.border_color(cx.theme().border),
)
.text_color(cx.theme().text)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.id("tabs")
.flex_grow()
.overflow_x_scroll()
.when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.children(self.children)
.when(self.suffix.is_some(), |this| {
this.child(self.last_empty_space)
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix))
}
}

View File

@@ -2,22 +2,22 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, App, AppContext, Context, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton,
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
WeakEntity, Window, div, px, rems,
div, px, rems, App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent,
Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement,
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::{ActiveTheme, AxisExt, CLIENT_SIDE_DECORATION_ROUNDING, Placement, TABBAR_HEIGHT};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants as _};
use ui::menu::{DropdownMenu, PopupMenu};
use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
use crate::button::{Button, ButtonVariants as _};
use crate::dock::dock::DockPlacement;
use crate::dock::panel::{Panel, PanelView};
use crate::dock::stack_panel::StackPanel;
use crate::dock::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
use crate::menu::{DropdownMenu, PopupMenu};
use crate::tab::Tab;
use crate::dock::DockPlacement;
use crate::panel::{Panel, PanelView};
use crate::stack_panel::StackPanel;
use crate::tab::tab_bar::TabBar;
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
use crate::tab::Tab;
use crate::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
#[derive(Clone)]
struct TabState {
@@ -42,20 +42,22 @@ impl DragPanel {
impl Render for DragPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
div()
.id("drag-panel")
.cursor_grab()
.p_2()
.min_w_24()
.py_1()
.px_2()
.w_24()
.flex()
.items_center()
.justify_center()
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.rounded(cx.theme().radius_lg)
.text_xs()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(cx.theme().shadow, |this| this.shadow_lg())
.bg(cx.theme().background)
.text_color(cx.theme().text_accent)
.child(self.panel.title(cx))
}
}
@@ -232,13 +234,14 @@ impl TabPanel {
.any(|p| p.panel_id(cx) == panel.panel_id(cx))
{
// Set the active panel to the matched panel
if active
&& let Some(ix) = self
if active {
if let Some(ix) = self
.panels
.iter()
.position(|p| p.panel_id(cx) == panel.panel_id(cx))
{
self.set_active_ix(ix, window, cx);
{
self.set_active_ix(ix, window, cx);
}
}
return;
@@ -371,11 +374,12 @@ impl TabPanel {
/// Return true if self or parent only have last panel.
fn is_last_panel(&self, cx: &App) -> bool {
if let Some(parent) = &self.stack_panel
&& let Some(stack_panel) = parent.upgrade()
&& !stack_panel.read(cx).is_last_panel(cx)
{
return false;
if let Some(parent) = &self.stack_panel {
if let Some(stack_panel) = parent.upgrade() {
if !stack_panel.read(cx).is_last_panel(cx) {
return false;
}
}
}
self.panels.len() <= 1
@@ -421,13 +425,14 @@ impl TabPanel {
let view = cx.entity().clone();
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
let toolbar = self.toolbar_buttons(window, cx);
let has_toolbar = !toolbar.is_empty();
h_flex()
.p_0p5()
.gap_1p5()
.gap_1()
.occlude()
.rounded_full()
.children(toolbar.into_iter().map(|btn| btn.small().ghost()))
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
.when(self.zoomed, |this| {
this.child(
Button::new("zoom")
@@ -440,11 +445,15 @@ impl TabPanel {
})),
)
})
.when(has_toolbar, |this| {
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.small()
.ghost()
.rounded()
.dropdown_menu({
let zoomable = state.zoomable;
let closable = state.closable;
@@ -460,7 +469,7 @@ impl TabPanel {
})
}
})
.anchor(Anchor::TopRight),
.anchor(Corner::TopRight),
)
}
@@ -470,12 +479,12 @@ impl TabPanel {
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Button> {
let dock_area = self.dock_area.upgrade()?.read(cx);
if self.zoomed {
return None;
}
let dock_area = self.dock_area.upgrade()?.read(cx);
if !dock_area.toggle_button_visible {
return None;
}
@@ -569,7 +578,6 @@ impl TabPanel {
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
let tabs_count = self.panels.len();
let is_bottom_dock = bottom_dock_button.is_some();
if tabs_count == 1 && dock_area.read(cx).panel_style == PanelStyle::Default {
let panel = self.panels.first().unwrap();
@@ -582,11 +590,10 @@ impl TabPanel {
.justify_between()
.items_center()
.line_height(rems(1.0))
.h(TABBAR_HEIGHT)
.h(px(30.))
.py_2()
.pl_3()
.pr_2()
.bg(cx.theme().panel_background)
.when(left_dock_button.is_some(), |this| this.pl_2())
.when(right_dock_button.is_some(), |this| this.pr_2())
.when(has_extend_dock_button, |this| {
@@ -603,7 +610,6 @@ impl TabPanel {
div()
.id("tab")
.flex_1()
.px_2()
.min_w_16()
.overflow_hidden()
.whitespace_nowrap()
@@ -632,13 +638,12 @@ impl TabPanel {
.flex_shrink_0()
.ml_1()
.gap_1()
.child(self.render_toolbar(state, window, cx))
.children(right_dock_button),
.child(self.render_toolbar(state, window, cx)),
)
.into_any_element();
}
TabBar::new("tab-bar")
TabBar::new()
.track_scroll(&self.tab_bar_scroll_handle)
.h(TABBAR_HEIGHT)
.when(has_extend_dock_button, |this| {
@@ -651,9 +656,8 @@ impl TabPanel {
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.pl_0p5()
.pr_1()
.bg(cx.theme().surface_background)
.px_2()
.children(left_dock_button)
.children(bottom_dock_button),
)
@@ -675,43 +679,16 @@ impl TabPanel {
Some(
Tab::new()
.ix(ix)
.tab_bar_prefix(has_extend_dock_button)
.child(panel.title(cx))
.label(panel.title(cx))
.py_2()
.selected(active)
.disabled(disabled)
.suffix(
Button::new("close-{ix}")
.icon(IconName::Close)
.tooltip("Close panel")
.ghost()
.xsmall()
.on_click(cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
view.remove_panel(&panel, window, cx);
}
})),
)
.on_click(cx.listener({
let is_collapsed = self.collapsed;
let dock_area = self.dock_area.clone();
move |view, _, window, cx| {
view.set_active_ix(ix, window, cx);
// Open dock if clicked on the collapsed bottom dock
if is_bottom_dock && is_collapsed {
_ = dock_area.update(cx, |dock_area, cx| {
dock_area.toggle_dock(DockPlacement::Bottom, window, cx);
});
}
}
}))
.when(!disabled, |this| {
this.on_mouse_down(
MouseButton::Middle,
cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
move |view, _, window, cx| {
view.remove_panel(&panel, window, cx);
}
}),
@@ -750,7 +727,7 @@ impl TabPanel {
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.flex_grow()
.min_w_16()
.when(state.droppable, |this| {
let view = cx.entity();
@@ -777,15 +754,14 @@ impl TabPanel {
this.suffix(
h_flex()
.items_center()
.px_2()
.gap_1()
.top_0()
.right_0()
.h_full()
.border_color(cx.theme().border)
.border_l_1()
.border_b_1()
.px_0p5()
.gap_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.child(self.render_toolbar(state, window, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
)
@@ -1101,9 +1077,7 @@ impl TabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.panels.len() > 1
&& let Some(panel) = self.active_panel(cx)
{
if let Some(panel) = self.active_panel(cx) {
self.remove_panel(&panel, window, cx);
}
}
@@ -1120,7 +1094,6 @@ impl Focusable for TabPanel {
}
impl EventEmitter<DismissEvent> for TabPanel {}
impl EventEmitter<PanelEvent> for TabPanel {}
impl Render for TabPanel {

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
device = { path = "../device" }
gpui.workspace = true
nostr-sdk.workspace = true
@@ -15,4 +16,3 @@ smallvec.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"

View File

@@ -3,19 +3,19 @@ use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use anyhow::{anyhow, Error};
use common::EventUtils;
use device::Announcement;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
pub use person::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
mod person;
pub use person::*;
pub fn init(window: &mut Window, cx: &mut App) {
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -36,13 +36,13 @@ pub struct PersonRegistry {
persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen
seens: Rc<RefCell<HashSet<PublicKey>>>,
seen: Rc<RefCell<HashSet<PublicKey>>>,
/// Sender for requesting metadata
sender: flume::Sender<PublicKey>,
/// Tasks for asynchronous operations
tasks: SmallVec<[Task<()>; 4]>,
_tasks: SmallVec<[Task<()>; 4]>,
}
impl PersonRegistry {
@@ -57,13 +57,13 @@ impl PersonRegistry {
}
/// Create a new person registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100);
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
let (mta_tx, mta_rx) = flume::bounded::<PublicKey>(100);
let mut tasks = smallvec![];
@@ -111,16 +111,33 @@ impl PersonRegistry {
}),
);
// Load all user profiles from the database
cx.defer_in(window, |this, _window, cx| {
this.load(cx);
});
tasks.push(
// Load all user profiles from the database
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.await_on_background(async move { Self::load_persons(&client).await })
.await;
match result {
Ok(persons) => {
this.update(cx, |this, cx| {
this.bulk_inserts(persons, cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to load all persons from the database: {e}");
}
};
}),
);
Self {
persons: HashMap::new(),
seens: Rc::new(RefCell::new(HashSet::new())),
seen: Rc::new(RefCell::new(HashSet::new())),
sender: mta_tx,
tasks,
_tasks: tasks,
}
}
@@ -135,7 +152,7 @@ impl PersonRegistry {
continue;
};
if let RelayMessage::Event { event, .. } = *message {
if let RelayMessage::Event { event, .. } = message {
// Skip if the event has already been processed
if !processed.insert(event.id) {
continue;
@@ -146,21 +163,25 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
let val = Box::new(person);
// Send
tx.send_async(Dispatch::Person(val)).await.ok();
}
Kind::ContactList => {
let public_keys = event.extract_public_keys();
// Get metadata for all public keys
get_metadata(client, public_keys).await.ok();
Self::get_metadata(client, public_keys).await.ok();
}
Kind::InboxRelays => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Relays(val)).await.ok();
}
Kind::Custom(10044) => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Announcement(val)).await.ok();
}
@@ -177,91 +198,104 @@ impl PersonRegistry {
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(TIMEOUT))
.wait_timeout(Duration::from_secs(2))
{
Ok(Some(public_key)) => {
batch.insert(public_key);
// Process the batch if it's full
if batch.len() >= 20 {
get_metadata(client, std::mem::take(&mut batch)).await.ok();
Self::get_metadata(client, std::mem::take(&mut batch))
.await
.ok();
}
}
_ => {
if !batch.is_empty() {
get_metadata(client, std::mem::take(&mut batch)).await.ok();
}
Self::get_metadata(client, std::mem::take(&mut batch))
.await
.ok();
}
}
}
}
/// Get metadata for all public keys in a event
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
where
I: IntoIterator<Item = PublicKey>,
{
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
let limit = authors.len();
if authors.is_empty() {
return Err(anyhow!("You need at least one public key"));
}
// Construct the subscription option
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(limit);
// Construct target for subscription
let target = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
/// Load all user profiles from the database
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
let persons = events
.into_iter()
.map(|event| {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
Person::new(event.pubkey, metadata)
})
.collect();
let mut persons = vec![];
Ok(persons)
});
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
persons.push(person);
}
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(persons) = task.await {
this.update(cx, |this, cx| {
this.bulk_inserts(persons, cx);
})
.ok();
}
}));
Ok(persons)
}
/// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
let announcement = Announcement::from(event);
if let Some(person) = self.persons.get(&event.pubkey) {
let announcement = Announcement::from(event);
person.update(cx, |person, cx| {
person.set_announcement(announcement);
cx.notify();
});
} else {
let person =
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
self.insert(person, cx);
}
}
/// Set messaging relays for a person
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
if let Some(person) = self.persons.get(&event.pubkey) {
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
person.update(cx, |person, cx| {
person.set_messaging_relays(urls);
person.set_messaging_relays(event.pubkey, urls);
cx.notify();
});
} else {
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
self.insert(person, cx);
}
}
/// Insert batch of persons
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() {
let public_key = person.public_key();
self.persons
.entry(public_key)
.or_insert_with(|| cx.new(|_| person));
self.persons.insert(person.public_key(), cx.new(|_| person));
}
cx.notify();
}
@@ -273,7 +307,7 @@ impl PersonRegistry {
match self.persons.get(&public_key) {
Some(this) => {
this.update(cx, |this, cx| {
this.set_metadata(person.metadata());
*this = person;
cx.notify();
});
}
@@ -290,7 +324,7 @@ impl PersonRegistry {
}
let public_key = *public_key;
let mut seen = self.seens.borrow_mut();
let mut seen = self.seen.borrow_mut();
if seen.insert(public_key) {
let sender = self.sender.clone();
@@ -308,37 +342,3 @@ impl PersonRegistry {
Person::new(public_key, Metadata::default())
}
}
/// Get metadata for all public keys in a event
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
where
I: IntoIterator<Item = PublicKey>,
{
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
let limit = authors.len();
if authors.is_empty() {
return Err(anyhow!("You need at least one public key"));
}
// Construct the subscription option
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(limit);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
client.subscribe(target).close_on(opts).await?;
Ok(())
}

View File

@@ -1,9 +1,9 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use device::Announcement;
use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
@@ -65,21 +65,6 @@ impl Person {
}
}
/// Build profile encryption keys announcement
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
self.announcement = Some(announcement);
self
}
/// Build profile messaging relays
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
self
}
/// Get profile public key
pub fn public_key(&self) -> PublicKey {
self.public_key
@@ -95,6 +80,12 @@ impl Person {
self.announcement.clone()
}
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
log::info!("Updated announcement for: {}", self.public_key());
}
/// Get profile messaging relays
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
&self.messaging_relays
@@ -105,6 +96,15 @@ impl Person {
self.messaging_relays.first().cloned()
}
/// Set profile messaging relays
pub fn set_messaging_relays<I>(&mut self, public_key: PublicKey, relays: I)
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
log::info!("Updated messaging relays for: {}", public_key);
}
/// Get profile avatar
pub fn avatar(&self) -> SharedString {
self.metadata()
@@ -112,9 +112,8 @@ impl Person {
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
@@ -123,38 +122,20 @@ impl Person {
/// Get profile name
pub fn name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref()
&& !display_name.is_empty()
{
return SharedString::from(display_name);
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return SharedString::from(display_name);
}
}
if let Some(name) = self.metadata().name.as_ref()
&& !name.is_empty()
{
return SharedString::from(name);
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return SharedString::from(name);
}
}
SharedString::from(shorten_pubkey(self.public_key(), 4))
}
/// Set profile metadata
pub fn set_metadata(&mut self, metadata: Metadata) {
self.metadata = metadata;
}
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
}
/// Set profile messaging relays
pub fn set_messaging_relays<I>(&mut self, relays: I)
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
}
}
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
@@ -164,7 +145,7 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}...{}",
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)

View File

@@ -5,19 +5,19 @@ use std::hash::Hash;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Task, Window, div, relative,
Task, Window,
};
use nostr_sdk::prelude::*;
use settings::{AppSettings, AuthMode};
use smallvec::{SmallVec, smallvec};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::Button;
use ui::notification::{Notification, NotificationKind};
use ui::{Disableable, WindowExtension, v_flex};
use ui::button::{Button, ButtonVariants};
use ui::notification::Notification;
use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension};
const AUTH_MESSAGE: &str =
"Approve the authentication request to allow Coop to continue sending or receiving events.";
@@ -34,10 +34,7 @@ struct AuthRequest {
}
impl AuthRequest {
pub fn new<S>(challenge: S, url: RelayUrl) -> Self
where
S: Into<String>,
{
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
url,
@@ -70,7 +67,7 @@ pub struct RelayAuth {
pending_events: HashSet<(EventId, RelayUrl)>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 2]>,
tasks: SmallVec<[Task<()>; 2]>,
}
impl RelayAuth {
@@ -86,21 +83,32 @@ impl RelayAuth {
/// Create a new relay auth instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
cx.defer_in(window, |this, window, cx| {
this.handle_notifications(window, cx);
});
Self {
pending_events: HashSet::default(),
tasks: smallvec![],
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut tasks = smallvec![];
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Signal>(256);
tasks.push(cx.background_spawn(async move {
self.tasks.push(cx.background_spawn(async move {
log::info!("Started handling nostr notifications");
let mut notifications = client.notifications();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { relay_url, message } = notification {
match *message {
match message {
RelayMessage::Auth { challenge } => {
if challenges.insert(challenge.clone()) {
let request = Arc::new(AuthRequest::new(challenge, relay_url));
@@ -126,7 +134,7 @@ impl RelayAuth {
}
}));
tasks.push(cx.spawn_in(window, async move |this, cx| {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Auth(req) => {
@@ -144,11 +152,6 @@ impl RelayAuth {
}
}
}));
Self {
pending_events: HashSet::default(),
_tasks: tasks,
}
}
/// Insert a pending event waiting for resend after authentication
@@ -159,12 +162,15 @@ impl RelayAuth {
/// Get all pending events for a specific relay,
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
self.pending_events
let pending_events: Vec<EventId> = self
.pending_events
.iter()
.filter(|(_, pending_relay)| pending_relay == relay)
.map(|(id, _relay)| id)
.cloned()
.collect()
.collect();
pending_events
}
/// Clear all pending events for a specific relay,
@@ -193,20 +199,15 @@ impl RelayAuth {
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(signer) = nostr.read(cx).signer(cx) else {
return Task::ready(Err(anyhow!("Signer is required")));
};
let req = req.clone();
// Get all pending events for the relay
let req = req.clone();
let pending_events = self.get_pending_events(req.url(), cx);
cx.background_spawn(async move {
// Construct event
let event = EventBuilder::auth(req.challenge(), req.url().clone())
.finalize_async(&signer)
.await?;
let builder = EventBuilder::auth(req.challenge(), req.url().clone());
let event = client.sign_event_builder(builder).await?;
// Get the event ID
let id = event.id;
@@ -222,33 +223,35 @@ impl RelayAuth {
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
.await?;
log::info!("Sending AUTH event");
while let Some(notification) = notifications.next().await {
match notification {
RelayNotification::Message { message } => {
if let RelayMessage::Ok { event_id, .. } = *message {
if id != event_id {
continue;
}
// Get all subscriptions
let subscriptions = relay.subscriptions().await;
// Re-subscribe to previous subscriptions
for (id, filters) in subscriptions.into_iter() {
if !filters.is_empty() {
relay.send_msg(ClientMessage::req(id, filters)).await?;
}
}
// Re-send pending events
for id in pending_events {
if let Some(event) = client.database().event_by_id(&id).await? {
relay.send_event(&event).await?;
}
}
return Ok(());
RelayNotification::Message {
message: RelayMessage::Ok { event_id, .. },
} => {
if id != event_id {
continue;
}
// Get all subscriptions
let subscriptions = relay.subscriptions().await;
// Re-subscribe to previous subscriptions
for (id, filters) in subscriptions.into_iter() {
if !filters.is_empty() {
relay.send_msg(ClientMessage::req(id, filters)).await?;
}
}
// Re-send pending events
for id in pending_events {
if let Some(event) = client.database().event_by_id(&id).await? {
relay.send_event(&event).await?;
}
}
return Ok(());
}
RelayNotification::AuthenticationFailed => break,
_ => {}
@@ -263,7 +266,7 @@ impl RelayAuth {
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let settings = AppSettings::global(cx);
let req = req.clone();
let challenge = SharedString::from(req.challenge().to_string());
let challenge = req.challenge().to_string();
// Create a task for authentication
let task = self.auth(&req, cx);
@@ -273,24 +276,21 @@ impl RelayAuth {
let url = req.url();
this.update_in(cx, |this, window, cx| {
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
window.clear_notification(challenge, cx);
if let Err(e) = result {
window
.push_notification(Notification::error(e.to_string()).autohide(false), cx);
} else {
// Clear pending events for the authenticated relay
this.clear_pending_events(url, cx);
let domain = url.domain().unwrap_or_default();
let msg = format!("Relay {} has been authenticated", domain);
window.push_notification(Notification::success(msg), cx);
// Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
match result {
Ok(_) => {
// Clear pending events for the authenticated relay
this.clear_pending_events(url, cx);
// Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
window.push_notification(format!("{} has been authenticated", url), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
}
})
.ok();
@@ -314,50 +314,49 @@ impl RelayAuth {
/// Build a notification for the authentication request.
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
let req = req.clone();
let challenge = SharedString::from(req.challenge.clone());
let url = SharedString::from(req.url().to_string());
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
Notification::new()
.type_id::<AuthNotification>(challenge)
.custom_id(SharedString::from(&req.challenge))
.autohide(false)
.with_kind(NotificationKind::Info)
.title("Authentication Required")
.content(move |_this, _window, cx| {
.icon(IconName::Info)
.title(SharedString::from("Authentication Required"))
.content(move |_window, cx| {
v_flex()
.gap_2()
.child(
div()
.text_sm()
.line_height(relative(1.25))
.child(SharedString::from(AUTH_MESSAGE)),
)
.text_sm()
.child(SharedString::from(AUTH_MESSAGE))
.child(
v_flex()
.py_1()
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text)
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(url.clone()),
)
.into_any_element()
})
.action(move |_this, _window, _cx| {
.action(move |_window, _cx| {
let view = entity.clone();
let req = req.clone();
Button::new("approve")
.label("Approve")
.small()
.primary()
.loading(loading.get())
.disabled(loading.get())
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
view.update(cx, |this, cx| {
this.response(&req, window, cx);
@@ -368,5 +367,3 @@ impl RelayAuth {
})
}
}
struct AuthNotification;

View File

@@ -5,7 +5,6 @@ edition.workspace = true
publish.workspace = true
[dependencies]
theme = { path = "../theme" }
common = { path = "../common" }
nostr-sdk.workspace = true

View File

@@ -1,16 +1,14 @@
use std::fmt::Display;
use std::rc::Rc;
use std::collections::{HashMap, HashSet};
use anyhow::{Error, anyhow};
use anyhow::{anyhow, Error};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode};
use smallvec::{smallvec, SmallVec};
pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
}
macro_rules! setting_accessors {
@@ -19,15 +17,13 @@ macro_rules! setting_accessors {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).inner.read(cx).$field.clone()
Self::global(cx).read(cx).values.$field.clone()
}
pub fn [<update_ $field>](value: $type, cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.inner.update(cx, |inner, cx| {
inner.$field = value;
cx.notify();
});
this.values.$field = value;
cx.notify();
});
}
}
@@ -37,13 +33,11 @@ macro_rules! setting_accessors {
}
setting_accessors! {
pub theme: Option<String>,
pub theme_mode: ThemeMode,
pub hide_avatar: bool,
pub screening: bool,
pub nip4e: bool,
pub auth_mode: AuthMode,
pub trusted_relays: Vec<String>,
pub trusted_relays: HashSet<RelayUrl>,
pub room_configs: HashMap<u64, RoomConfig>,
pub file_server: Url,
}
@@ -55,22 +49,13 @@ pub enum AuthMode {
Manual,
}
impl Display for AuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthMode::Auto => write!(f, "Auto"),
AuthMode::Manual => write!(f, "Ask every time"),
}
}
}
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
Auto,
Encryption,
#[default]
User,
Encryption,
}
impl SignerKind {
@@ -95,73 +80,46 @@ pub struct RoomConfig {
}
impl RoomConfig {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::default(),
}
}
/// Get backup config
pub fn backup(&self) -> bool {
self.backup
}
/// Set backup config
pub fn toggle_backup(&mut self) {
self.backup = !self.backup;
}
/// Get signer kind config
pub fn signer_kind(&self) -> &SignerKind {
&self.signer_kind
}
/// Set signer kind config
pub fn set_signer_kind(&mut self, kind: &SignerKind) {
self.signer_kind = kind.to_owned();
}
}
/// Settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// Theme
pub theme: Option<String>,
/// Theme mode
pub theme_mode: ThemeMode,
/// Hide user avatars
pub hide_avatar: bool,
/// Enable screening for unknown chat requests
pub screening: bool,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Authentication mode
pub auth_mode: AuthMode,
/// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: Vec<String>,
pub trusted_relays: HashSet<RelayUrl>,
/// Server for blossom media attachments
/// Configuration for each chat room
pub room_configs: HashMap<u64, RoomConfig>,
/// File server for NIP-96 media attachments
pub file_server: Url,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: None,
theme_mode: ThemeMode::default(),
hide_avatar: false,
screening: true,
nip4e: false,
auth_mode: AuthMode::default(),
trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(),
trusted_relays: HashSet::default(),
room_configs: HashMap::default(),
file_server: Url::parse("https://nostrmedia.com").unwrap(),
}
}
}
@@ -179,7 +137,7 @@ impl Global for GlobalAppSettings {}
/// Application settings
pub struct AppSettings {
/// Settings
inner: Entity<Settings>,
values: Settings,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
@@ -196,38 +154,38 @@ impl AppSettings {
cx.set_global(GlobalAppSettings(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let inner = cx.new(|_| Settings::default());
fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe and automatically save settings on changes
cx.observe(&inner, |this, _inner, cx| {
cx.observe_self(|this, cx| {
this.save(cx);
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
cx.defer(|cx| {
let settings = AppSettings::global(cx);
settings.update(cx, |this, cx| {
this.load(cx);
});
});
Self {
inner,
values: Settings::default(),
_subscriptions: subscriptions,
}
}
/// Update settings
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
*this = settings;
cx.notify();
});
self.values = settings;
cx.notify();
}
/// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings");
@@ -238,13 +196,12 @@ impl AppSettings {
}
});
cx.spawn_in(window, async move |this, cx| {
cx.spawn(async move |this, cx| {
let settings = task.await.unwrap_or(Settings::default());
// Update settings
this.update_in(cx, |this, window, cx| {
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
this.apply_theme(window, cx);
})
.ok();
})
@@ -253,84 +210,41 @@ impl AppSettings {
/// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) {
let settings = self.inner.read(cx);
let settings = self.values.clone();
if let Ok(content) = serde_json::to_string(&settings) {
cx.background_spawn(async move {
let path = config_dir().join(".settings");
// Write settings to file
smol::fs::write(&path, content).await.ok();
})
.detach();
}
}
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings");
let content = serde_json::to_string(&settings)?;
/// Set theme
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<String>,
{
// Update settings
self.inner.update(cx, |this, cx| {
this.theme = Some(theme.into());
cx.notify();
// Write settings to file
smol::fs::write(&path, content).await?;
Ok(())
});
// Apply the new theme
self.apply_theme(window, cx);
}
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
this.theme = None;
cx.notify();
});
self.apply_theme(window, cx);
}
/// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.inner.read(cx).theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
Theme::change(mode, Some(window), cx);
} else {
log::info!("Failed to load theme: {name}");
}
} else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
}
}
/// Check if decoupling encryption key is enabled
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
self.inner.read(cx).nip4e
task.detach();
}
/// Check if the given relay is already authenticated
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
self.inner
.read(cx)
.trusted_relays
.iter()
.any(|relay| relay == url.as_str_without_trailing_slash())
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.iter().any(|relay| {
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
})
}
/// Add a relay to the trusted list
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
if !this
.trusted_relays
.iter()
.any(|relay| relay == url.as_str_without_trailing_slash())
{
this.trusted_relays
.push(url.as_str_without_trailing_slash().to_string());
cx.notify();
}
});
self.values.trusted_relays.insert(url.clone());
cx.notify();
}
/// Add a room configuration
pub fn add_room_config(&mut self, id: u64, config: RoomConfig, cx: &mut Context<Self>) {
self.values
.room_configs
.entry(id)
.and_modify(|this| *this = config)
.or_default();
cx.notify();
}
}

View File

@@ -7,16 +7,15 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-gossip-memory.workspace = true
nostr-connect.workspace = true
nostr-blossom.workspace = true
nostr-gossip-memory.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
smol.workspace = true
reqwest.workspace = true
flume.workspace = true
log.workspace = true
anyhow.workspace = true
@@ -27,4 +26,3 @@ serde_json.workspace = true
rustls = "0.23"
petname = "2.0.2"
whoami = "1.6.1"
mime_guess = "2.0.4"

View File

@@ -1,27 +0,0 @@
use std::path::PathBuf;
use anyhow::{anyhow, Error};
use gpui::AsyncApp;
use gpui_tokio::Tokio;
use mime_guess::from_path;
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(path).await?;
let keys = Keys::generate();
// Construct the blossom client
let client = BlossomClient::new(server);
Tokio::spawn(cx, async move {
let blob = client
.upload_blob(data, Some(content_type), None, Some(&keys))
.await?;
Ok(blob.url)
})
.await
.map_err(|e| anyhow!("Upload error: {e}"))?
}

View File

@@ -1,8 +1,10 @@
use std::sync::OnceLock;
/// Client name (Application name)
pub const CLIENT_NAME: &str = "Coop";
/// COOP's public key
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x";
pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv";
/// App ID
pub const APP_ID: &str = "su.reya.coop";
@@ -13,35 +15,31 @@ pub const KEYRING: &str = "Coop Safe Storage";
/// Default timeout for subscription
pub const TIMEOUT: u64 = 2;
/// Default image cache size
pub const IMAGE_CACHE_SIZE: usize = 20;
/// Default delay for searching
pub const FIND_DELAY: u64 = 600;
/// Default limit for searching
pub const FIND_LIMIT: usize = 20;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default subscription id for device gift wrap events
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
/// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
@@ -49,3 +47,15 @@ pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.primal.net",
"wss://user.kindpag.es",
];
static APP_NAME: OnceLock<String> = OnceLock::new();
/// Get the app name
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = whoami::devicename();
let platform = whoami::platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}

File diff suppressed because it is too large Load Diff

155
crates/state/src/signer.rs Normal file
View File

@@ -0,0 +1,155 @@
use std::borrow::Cow;
use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
#[derive(Debug)]
pub struct CoopSigner {
/// User's signer
signer: RwLock<Arc<dyn NostrSigner>>,
/// User's signer public key
signer_pkey: RwLock<Option<PublicKey>>,
/// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
/// Whether coop is creating a new identity
creating: AtomicBool,
/// By default, Coop generates a new signer for new users.
///
/// This flag indicates whether the signer is user-owned or Coop-generated.
owned: AtomicBool,
}
impl CoopSigner {
pub fn new<T>(signer: T) -> Self
where
T: IntoNostrSigner,
{
Self {
signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None),
creating: AtomicBool::new(false),
owned: AtomicBool::new(false),
}
}
/// Get the current signer.
pub async fn get(&self) -> Arc<dyn NostrSigner> {
self.signer.read().await.clone()
}
/// Get the encryption signer.
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
self.encryption_signer.read().await.clone()
}
/// Get public key
pub fn public_key(&self) -> Option<PublicKey> {
self.signer_pkey.read_blocking().to_owned()
}
/// Get the flag indicating whether the signer is creating a new identity.
pub fn creating(&self) -> bool {
self.creating.load(Ordering::SeqCst)
}
/// Get the flag indicating whether the signer is user-owned.
pub fn owned(&self) -> bool {
self.owned.load(Ordering::SeqCst)
}
/// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T, owned: bool)
where
T: IntoNostrSigner,
{
let new_signer = new.into_nostr_signer();
let public_key = new_signer.get_public_key().await.ok();
let mut signer = self.signer.write().await;
let mut signer_pkey = self.signer_pkey.write().await;
let mut encryption_signer = self.encryption_signer.write().await;
// Switch to the new signer
*signer = new_signer;
// Update the public key
*signer_pkey = public_key;
// Reset the encryption signer
*encryption_signer = None;
// Update the owned flag
self.owned.store(owned, Ordering::SeqCst);
}
/// Set the encryption signer.
pub async fn set_encryption_signer<T>(&self, new: T)
where
T: IntoNostrSigner,
{
let mut encryption_signer = self.encryption_signer.write().await;
*encryption_signer = Some(new.into_nostr_signer());
}
}
impl NostrSigner for CoopSigner {
#[allow(mismatched_lifetime_syntaxes)]
fn backend(&self) -> SignerBackend {
SignerBackend::Custom(Cow::Borrowed("custom"))
}
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
Box::pin(async move { self.get().await.get_public_key().await })
}
fn sign_event<'a>(
&'a self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'a, Result<Event, SignerError>> {
Box::pin(async move { self.get().await.sign_event(unsigned).await })
}
fn nip04_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self.get()
.await
.nip04_decrypt(public_key, encrypted_content)
.await
})
}
fn nip44_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
}
}

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