76 Commits
Author SHA1 Message Date
reya a6e50cf3aa fix 2026-09-01 09:14:33 +07:00
reya 8f272f1fe8 fix 2026-09-01 09:14:26 +07:00
reya 6a7bf17e6e fix multithread 2026-09-01 08:18:26 +07:00
reya 3290f71fa4 update gpui 2026-09-01 07:58:40 +07:00
reya f7f1ea7438 fix web performance 2026-08-31 21:11:18 +07:00
reya 070c6a7e87 fix assets 2026-08-31 17:51:31 +07:00
reya 963a641f39 fix build 2026-08-31 17:23:59 +07:00
reya ff20e51729 fix build 2026-08-31 17:23:50 +07:00
reya 2f834a0bcc chore: update deps 2026-08-31 16:54:38 +07:00
reya 49cd5cb9a0 chore: clean up 2026-08-03 09:53:12 +07:00
reya d082f4fad9 chore: release version 1.0.0 2026-08-03 09:19:51 +07:00
reya 8c59eabba3 chore: migrate to gpui updater (#42)
Reviewed-on: https://git.reya.su/reya/coop/pulls/42
2026-08-03 01:54:25 +00:00
reya bc6bdb3c35 feat: add support for logging in via the web extension (#41)
Reviewed-on: https://git.reya.su/reya/coop/pulls/41
2026-08-03 00:31:03 +00:00
reya dbfee32d55 feat: add support for launch arguments (#40)
Reviewed-on: https://git.reya.su/reya/coop/pulls/40
2026-08-02 07:10:44 +00:00
reya 4f52fc52df feat: add native notification (#39)
Reviewed-on: https://git.reya.su/reya/coop/pulls/39
2026-08-02 04:13:11 +00:00
reya fbf06f2c81 chore: refine some ui components (#38)
Reviewed-on: https://git.reya.su/reya/coop/pulls/38
2026-08-01 10:38:08 +00:00
reya b0d1521c49 feat: add support for reaction (#37)
Reviewed-on: https://git.reya.su/reya/coop/pulls/37
2026-07-31 02:58:42 +00:00
reya 9dbac5308d chore: update deps 2026-07-31 07:34:12 +07:00
reya c8cabb7cd2 chore: release version 1.0.0-rc 2026-07-30 15:48:12 +07:00
reya 6d9284b37a chore: improve web support (#36)
Reviewed-on: https://git.reya.su/reya/coop/pulls/36
2026-07-30 08:47:30 +00:00
reya b518c729f6 feat: add initial support web via wasm (#35)
Reviewed-on: https://git.reya.su/reya/coop/pulls/35
2026-07-25 03:43:51 +00:00
reya 4b57a1d2a6 chore: update deps 2026-07-16 10:43:16 +07:00
reya 5ea6cdb34f chore: prepare for rc version (#34)
**TODOs:**

- [x] Fix all clippy issues
- [x] Make NIP-4e optional (disabled by default)
- [x] Remove support for bunker (Nostr Connect)
- [x] Group messages in the same timeframe
- [ ] ...

Reviewed-on: https://git.reya.su/reya/coop/pulls/34
2026-06-19 09:16:37 +00:00
reya 1f04a824d7 chore: release version 1.0.0-beta5 2026-06-03 20:20:30 +07:00
reya c78e0a5163 fix: chat input crashing when moving the cursor (#33)
Reviewed-on: https://git.reya.su/reya/coop/pulls/33
2026-06-03 13:18:03 +00:00
reya 5d4c8634ef chore: update gpui 2026-06-01 15:28:49 +07:00
reya 4efeec08c4 chore: connect to search relays only when needed 2026-06-01 15:07:11 +07:00
reya 2e451aae12 chore: fix ci and clean up 2026-05-30 07:51:04 +07:00
reya cdfcfdd782 chore: release version 1.0.0-beta4 2026-05-28 09:02:17 +07:00
reya 9817dd29a6 nip4e: remove app name in the client tag 2026-05-22 07:52:49 +07:00
reya f066cb8223 chore: force gossip ignore neg sync 2026-05-20 18:03:46 +07:00
reya 6d60726f27 chore: bump gpui 2026-04-25 07:01:14 +07:00
reya 80186a79e5 chore: update deps and remove mobile crate 2026-04-17 07:41:52 +07:00
reya cffcb4711d Merge pull request 'feat: add k tag to gift wraps and process only chat message rumors' (#30) from only-nip17 into master
Reviewed-on: https://git.reya.su/reya/coop/pulls/30
2026-04-10 02:41:20 +00:00
Ren Amamiya 44484d9992 . 2026-04-10 09:41:05 +07:00
Ren Amamiya 15ac8d6775 add k tag to gift wrap event 2026-04-10 09:34:24 +07:00
Ren Amamiya 6f0cefed33 only process nip17 rumor 2026-04-10 09:32:04 +07:00
Ren Amamiya c239e351b8 feat: add support for rendering images in chat messages (#29)
Reviewed-on: https://git.reya.su/reya/coop/pulls/29
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-04-10 02:00:18 +00:00
Ren Amamiya 9ff18aae35 chore: restruture 2026-04-07 11:46:08 +07:00
Ren Amamiya 6fef2ae1c6 chore: release version 1.0.0-beta3 2026-04-06 06:53:05 +07:00
Ren Amamiya c2a723faa8 chore: prepare mobile(android/ios) target 2026-04-04 09:32:41 +07:00
reya 6b872527ad chore: simplify codebase and prepare for multi-platforms (#28)
Reviewed-on: https://git.reya.su/reya/coop/pulls/28
2026-04-04 02:22:08 +00:00
Ren Amamiya d9b16aea9a feat: add tracking for which signer encrypted the message (#27)
Reviewed-on: https://git.reya.su/reya/coop/pulls/27
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-04-01 02:53:49 +00:00
Ren Amamiya 8345def015 feat: (re)add tracking for where messages have been seen (#26)
Reviewed-on: https://git.reya.su/reya/coop/pulls/26
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-31 05:26:04 +00:00
Ren Amamiya b0ba2549d7 chore: prepare for web target 2026-03-31 08:45:35 +07:00
Ren Amamiya c8034642c5 chore: fix issue where setting theme mode doesn't work 2026-03-31 08:13:10 +07:00
Ren Amamiya 8205c69e19 feat: add error log panel (#25)
Reviewed-on: https://git.reya.su/reya/coop/pulls/25
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-30 07:56:28 +00:00
Ren Amamiya d36364d60d chore: update nostr sdk 2026-03-25 13:52:21 +07:00
Ren Amamiya 99363475e0 chore: fix prepare flathub script 2026-03-20 13:22:58 +07:00
Ren Amamiya a52e1877fe chore: add prepare flathub script (#24)
Reviewed-on: https://git.reya.su/reya/coop/pulls/24
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-20 06:20:06 +00:00
Ren Amamiya b41de00c95 chore: release version 1.0.0-beta2 2026-03-18 15:22:04 +07:00
Ren Amamiya 94cbb4aa0e chore: fix some gossip and nip4e bugs (#23)
Reviewed-on: https://git.reya.su/reya/coop/pulls/23
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-18 08:21:39 +00:00
Ren Amamiya 40e7ca368b feat: add backup/restore for NIP-4e encryption key (#22)
Reviewed-on: https://git.reya.su/reya/coop/pulls/22
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-17 07:42:25 +00:00
Ren Amamiya b91697defc feat: add relay tracking for gift wrap events (#21)
Reviewed-on: https://git.reya.su/reya/coop/pulls/21
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-14 08:18:19 +00:00
Ren Amamiya 1d57a2deab chore: bump edition from 2021 to 2024 2026-03-13 09:13:04 +07:00
Ren Amamiya aa26c9ccba chore: release version 1.0.0-beta1 2026-03-13 08:44:55 +07:00
Ren Amamiya ff61c28a76 chore: update deps 2026-03-13 08:38:48 +07:00
Ren Amamiya 069eae8145 feat: refactor send report (#20)
Reviewed-on: https://git.reya.su/reya/coop/pulls/20
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-12 10:06:13 +00:00
Ren Amamiya ccbcc644db chore: make the ui consistent (#19)
Reviewed-on: https://git.reya.su/reya/coop/pulls/19
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-12 02:19:59 +00:00
Ren Amamiya 15c5ce7677 chore: remove ai stuffs 2026-03-10 17:28:51 +07:00
Ren Amamiya 40d726c986 feat: refactor to use gpui event instead of local state (#18)
Reviewed-on: https://git.reya.su/reya/coop/pulls/18
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-10 08:19:02 +00:00
Ren Amamiya fe4eb7df74 chore: re-add missing actions (#17)
Added:

- [x] Chage subject
- [x] Copy public key
- [x] View user's messaging relays
- [x] View user profile on njump

Reviewed-on: https://git.reya.su/reya/coop/pulls/17
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-06 08:25:31 +00:00
reya b5d6d91851 chore: config auto update and fix ci 2026-03-05 08:46:56 +07:00
reya d475d03d0c chore: fix ci 2026-03-05 08:12:45 +07:00
reya 0f00fed122 chore: add env-filter for tracing 2026-03-05 08:03:03 +07:00
reya ef73b3c629 chore: fix ci build for macos intel 2026-03-04 18:04:24 +07:00
reya bbf31baee5 chore: fix missing dep import for windows 2026-03-04 15:59:38 +07:00
reya 80227b3ed3 chore: fix build on windows 2026-03-04 15:46:55 +07:00
reya d00c5a1982 chore: fix ci 2026-03-04 15:32:54 +07:00
reya c054017d7e chore: prepare
Rust / build (ghcr.io/catthehacker/ubuntu:rust-latest, stable) (push) Has been cancelled
2026-03-04 15:23:06 +07:00
reya d065e70cd1 chore: some improvements (#16)
Rust / build (ubuntu-latest, stable) (push) Failing after 1m55s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: https://git.reya.su/reya/coop/pulls/16
2026-03-04 07:49:42 +00:00
reya 7a6b6feacc feat: refactor the text parser (#15)
Rust / build (ubuntu-latest, stable) (push) Failing after 1m44s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Fix: https://jumble.social/notes/nevent1qvzqqqqqqypzqwlsccluhy6xxsr6l9a9uhhxf75g85g8a709tprjcn4e42h053vaqyvhwumn8ghj7un9d3shjtnjv4ukztnnw5hkjmnzdauqzrnhwden5te0dehhxtnvdakz7qpqpj4awhj4ul6tztlne0v7efvqhthygt0myrlxslpsjh7t6x4esapq3lf5c0
Reviewed-on: https://git.reya.su/reya/coop/pulls/15
2026-03-03 08:55:36 +00:00
reya 55c5ebbf17 feat: multi-account switcher (#14)
Rust / build (ubuntu-latest, stable) (push) Failing after 1m56s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: https://git.reya.su/reya/coop/pulls/14
2026-03-02 08:08:04 +00:00
reya 3fecda175b feat: refactor encryption panel (#13)
Rust / build (ubuntu-latest, stable) (push) Failing after 1m52s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: https://git.reya.su/reya/coop/pulls/13
2026-02-28 11:25:02 +00:00
reya 2423cdca19 Merge pull request 'fix: build on macos' (#12) from fix-build-macos into master
Rust / build (ubuntu-latest, stable) (push) Failing after 1m50s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: https://git.reya.su/reya/coop/pulls/12
2026-02-28 06:28:13 +00:00
reya 4b021bef01 fix core-text-version
Rust / build (ubuntu-latest, stable) (push) Failing after 1m28s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 4m44s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Rust / build (macos-latest, stable) (pull_request) Has been cancelled
Rust / build (windows-latest, stable) (pull_request) Has been cancelled
2026-02-28 13:11:16 +07:00
206 changed files with 17535 additions and 13063 deletions
+7 -9
View File
@@ -21,7 +21,7 @@ jobs:
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-13
os: macos-15-intel
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: crates/coop
working-directory: desktop
run: |
cargo install cargo-packager --locked
cargo packager --release
@@ -130,7 +130,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Make get-crate-version executable
run: chmod +x script/get-crate-version
@@ -154,17 +154,15 @@ jobs:
- name: Create draft release
id: create_release
uses: softprops/action-gh-release@v2
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
server_url: "https://git.reya.su/"
repository: "reya/coop"
token: ${{ secrets.GITEA_TOKEN }}
draft: true
prerelease: false
generate_release_notes: true
files: |
artifacts/**/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Output release info
run: |
+5 -2
View File
@@ -19,5 +19,8 @@ dist/
# Useless stuffs
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o
.cargo/
vendor/
wasm/
node_modules/
Generated
+2433 -2108
View File
File diff suppressed because it is too large Load Diff
+17 -11
View File
@@ -1,18 +1,17 @@
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/coop"]
members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0-beta"
edition = "2021"
version = "1.0.0"
edition = "2024"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["screen-capture", "x11", "wayland", "runtime_shaders"] }
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" }
@@ -21,27 +20,34 @@ 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-memory = { 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-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0"
rust-embed = { version = "8.5", features = ["include-exclude"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1"
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
[profile.release]
strip = true
-120
View File
@@ -1,125 +1,5 @@
![Coop](/docs/coop.png)
<p>
<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/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.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- 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**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/lumehq/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [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/lumehq/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 898 B

+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 604 B

+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 468 B

+144
View File
@@ -0,0 +1,144 @@
{
"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"
}
}
+98 -94
View File
@@ -1,74 +1,76 @@
{
"id": "catppuccin-frappe",
"name": "Catppuccin Frappé",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"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": "#292c3c",
"title_bar_inactive": "#232634",
"window_border": "#737994",
"border": "#626880",
"border_variant": "#51576d",
"title_bar": "#232634",
"title_bar_inactive": "#303446",
"window_border": "#51576d",
"border": "#51576d",
"border_variant": "#414559",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#00000000",
"border_disabled": "#414559",
"ring": "#8caaee",
"border_transparent": "#c6d0f500",
"border_disabled": "#292c3c",
"ring": "#babbf1",
"text": "#c6d0f5",
"text_muted": "#b5bfe2",
"text_placeholder": "#a5adce",
"text_muted": "#a5adce",
"text_placeholder": "#838ba7",
"text_accent": "#8caaee",
"icon": "#b5bfe2",
"icon_muted": "#a5adce",
"icon_accent": "#8caaee",
"element_foreground": "#232634",
"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": "#7e99d6",
"element_selected": "#7088bf",
"element_active": "#99d1db",
"element_selected": "#85c1dc",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#7088bf",
"secondary_background": "#292c3c",
"secondary_hover": "#8caaee33",
"secondary_active": "#232634",
"secondary_selected": "#232634",
"secondary_foreground": "#c6d0f5",
"secondary_background": "#414559",
"secondary_hover": "#51576d",
"secondary_active": "#626880",
"secondary_selected": "#626880",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#232634",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#ea999c",
"danger_active": "#d07576",
"danger_selected": "#b96869",
"danger_active": "#ef9f76",
"danger_selected": "#e5c890",
"danger_disabled": "#e782844d",
"warning_foreground": "#232634",
"warning_background": "#e5c890",
"warning_hover": "#ef9f76",
"warning_active": "#ceb482",
"warning_selected": "#b7a074",
"warning_disabled": "#e5c8904d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#414559",
"ghost_element_hover": "#c6d0f533",
"ghost_element_active": "#51576d",
"ghost_element_selected": "#51576d",
"ghost_element_disabled": "#c6d0f50d",
"tab_inactive_background": "#292c3c",
"tab_inactive_foreground": "#b5bfe2",
"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",
"tab_hover_foreground": "#babbf1",
"scrollbar_thumb_background": "#c6d0f533",
"scrollbar_thumb_hover_background": "#c6d0f580",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#51576d",
"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": "#f2d5cf",
"selection": "#949cbb40"
"cursor": "#8caaee",
"selection": "#8caaee40"
},
"dark": {
"background": "#303446",
@@ -76,65 +78,67 @@
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#292c3c",
"title_bar_inactive": "#232634",
"window_border": "#737994",
"border": "#626880",
"border_variant": "#51576d",
"title_bar": "#232634",
"title_bar_inactive": "#303446",
"window_border": "#51576d",
"border": "#51576d",
"border_variant": "#414559",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#00000000",
"border_disabled": "#414559",
"ring": "#8caaee",
"border_transparent": "#c6d0f500",
"border_disabled": "#292c3c",
"ring": "#babbf1",
"text": "#c6d0f5",
"text_muted": "#b5bfe2",
"text_placeholder": "#a5adce",
"text_muted": "#a5adce",
"text_placeholder": "#838ba7",
"text_accent": "#8caaee",
"icon": "#b5bfe2",
"icon_muted": "#a5adce",
"icon_accent": "#8caaee",
"element_foreground": "#232634",
"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": "#7e99d6",
"element_selected": "#7088bf",
"element_active": "#99d1db",
"element_selected": "#85c1dc",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#7088bf",
"secondary_background": "#292c3c",
"secondary_hover": "#8caaee33",
"secondary_active": "#232634",
"secondary_selected": "#232634",
"secondary_foreground": "#c6d0f5",
"secondary_background": "#414559",
"secondary_hover": "#51576d",
"secondary_active": "#626880",
"secondary_selected": "#626880",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#232634",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#ea999c",
"danger_active": "#d07576",
"danger_selected": "#b96869",
"danger_active": "#ef9f76",
"danger_selected": "#e5c890",
"danger_disabled": "#e782844d",
"warning_foreground": "#232634",
"warning_background": "#e5c890",
"warning_hover": "#ef9f76",
"warning_active": "#ceb482",
"warning_selected": "#b7a074",
"warning_disabled": "#e5c8904d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#414559",
"ghost_element_hover": "#c6d0f533",
"ghost_element_active": "#51576d",
"ghost_element_selected": "#51576d",
"ghost_element_disabled": "#c6d0f50d",
"tab_inactive_background": "#292c3c",
"tab_inactive_foreground": "#b5bfe2",
"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",
"tab_hover_foreground": "#babbf1",
"scrollbar_thumb_background": "#c6d0f533",
"scrollbar_thumb_hover_background": "#c6d0f580",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#51576d",
"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": "#f2d5cf",
"selection": "#949cbb40"
"cursor": "#8caaee",
"selection": "#8caaee40"
}
}
+96 -92
View File
@@ -1,74 +1,76 @@
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"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": "#e6e9ef",
"title_bar_inactive": "#dce0e8",
"window_border": "#9ca0b0",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"title_bar": "#dce0e8",
"title_bar_inactive": "#eff1f5",
"window_border": "#bcc0cc",
"border": "#bcc0cc",
"border_variant": "#ccd0da",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"border_transparent": "#4c4f6900",
"border_disabled": "#e6e9ef",
"ring": "#7287fd",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_muted": "#6c6f85",
"text_placeholder": "#8c8fa1",
"text_accent": "#1e66f5",
"icon": "#5c5f77",
"icon_muted": "#6c6f85",
"icon_accent": "#1e66f5",
"text_danger": "#d20f39",
"text_warning": "#fe640b",
"icon": "#6c6f85",
"icon_muted": "#8c8fa1",
"icon_accent": "#7287fd",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#8839ef",
"element_active": "#1c5ce0",
"element_selected": "#1a52cc",
"element_hover": "#7287fd",
"element_active": "#04a5e5",
"element_selected": "#209fb5",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1a52cc",
"secondary_background": "#e6e9ef",
"secondary_hover": "#8839ef33",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"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": "#bd0d33",
"danger_selected": "#a80b2d",
"danger_active": "#fe640b",
"danger_selected": "#df8e1d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#fe640b",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#ccd0da",
"ghost_element_hover": "#4c4f6933",
"ghost_element_active": "#bcc0cc",
"ghost_element_selected": "#bcc0cc",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_inactive_foreground": "#5c5f77",
"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",
"tab_hover_foreground": "#8839ef",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f6980",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#bcc0cc",
"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": "#dc8a78",
"selection": "#7c7f9340"
"cursor": "#1e66f5",
"selection": "#1e66f540"
},
"dark": {
"background": "#eff1f5",
@@ -76,65 +78,67 @@
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#e6e9ef",
"title_bar_inactive": "#dce0e8",
"window_border": "#9ca0b0",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"title_bar": "#dce0e8",
"title_bar_inactive": "#eff1f5",
"window_border": "#bcc0cc",
"border": "#bcc0cc",
"border_variant": "#ccd0da",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"border_transparent": "#4c4f6900",
"border_disabled": "#e6e9ef",
"ring": "#7287fd",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_muted": "#6c6f85",
"text_placeholder": "#8c8fa1",
"text_accent": "#1e66f5",
"icon": "#5c5f77",
"icon_muted": "#6c6f85",
"icon_accent": "#1e66f5",
"text_danger": "#d20f39",
"text_warning": "#fe640b",
"icon": "#6c6f85",
"icon_muted": "#8c8fa1",
"icon_accent": "#7287fd",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#8839ef",
"element_active": "#1c5ce0",
"element_selected": "#1a52cc",
"element_hover": "#7287fd",
"element_active": "#04a5e5",
"element_selected": "#209fb5",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1a52cc",
"secondary_background": "#e6e9ef",
"secondary_hover": "#8839ef33",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"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": "#bd0d33",
"danger_selected": "#a80b2d",
"danger_active": "#fe640b",
"danger_selected": "#df8e1d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#fe640b",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#ccd0da",
"ghost_element_hover": "#4c4f6933",
"ghost_element_active": "#bcc0cc",
"ghost_element_selected": "#bcc0cc",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_inactive_foreground": "#5c5f77",
"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",
"tab_hover_foreground": "#8839ef",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f6980",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#bcc0cc",
"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": "#dc8a78",
"selection": "#7c7f9340"
"cursor": "#1e66f5",
"selection": "#1e66f540"
}
}
+98 -94
View File
@@ -1,74 +1,76 @@
{
"id": "catppuccin-macchiato",
"name": "Catppuccin Macchiato",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"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": "#1e2030",
"title_bar_inactive": "#181926",
"window_border": "#6e738d",
"border": "#5b6078",
"border_variant": "#494d64",
"title_bar": "#181926",
"title_bar_inactive": "#24273a",
"window_border": "#494d64",
"border": "#494d64",
"border_variant": "#363a4f",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#00000000",
"border_disabled": "#363a4f",
"ring": "#8aadf4",
"border_transparent": "#cad3f500",
"border_disabled": "#1e2030",
"ring": "#b7bdf8",
"text": "#cad3f5",
"text_muted": "#b8c0e0",
"text_placeholder": "#a5adcb",
"text_muted": "#a5adcb",
"text_placeholder": "#8087a2",
"text_accent": "#8aadf4",
"icon": "#b8c0e0",
"icon_muted": "#a5adcb",
"icon_accent": "#8aadf4",
"element_foreground": "#181926",
"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": "#7c9cdc",
"element_selected": "#6e8bc5",
"element_active": "#91d7e3",
"element_selected": "#7dc4e4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#6e8bc5",
"secondary_background": "#1e2030",
"secondary_hover": "#8aadf433",
"secondary_active": "#181926",
"secondary_selected": "#181926",
"secondary_foreground": "#cad3f5",
"secondary_background": "#363a4f",
"secondary_hover": "#494d64",
"secondary_active": "#5b6078",
"secondary_selected": "#5b6078",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#181926",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ee99a0",
"danger_active": "#d57a87",
"danger_selected": "#be6d78",
"danger_active": "#f5a97f",
"danger_selected": "#eed49f",
"danger_disabled": "#ed87964d",
"warning_foreground": "#181926",
"warning_background": "#eed49f",
"warning_hover": "#f5a97f",
"warning_active": "#d6bf8f",
"warning_selected": "#beaa7f",
"warning_disabled": "#eed49f4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#363a4f",
"ghost_element_hover": "#cad3f533",
"ghost_element_active": "#494d64",
"ghost_element_selected": "#494d64",
"ghost_element_disabled": "#cad3f50d",
"tab_inactive_background": "#1e2030",
"tab_inactive_foreground": "#b8c0e0",
"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",
"tab_hover_foreground": "#b7bdf8",
"scrollbar_thumb_background": "#cad3f533",
"scrollbar_thumb_hover_background": "#cad3f580",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#494d64",
"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": "#f4dbd6",
"selection": "#939ab740"
"cursor": "#8aadf4",
"selection": "#8aadf440"
},
"dark": {
"background": "#24273a",
@@ -76,65 +78,67 @@
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#1e2030",
"title_bar_inactive": "#181926",
"window_border": "#6e738d",
"border": "#5b6078",
"border_variant": "#494d64",
"title_bar": "#181926",
"title_bar_inactive": "#24273a",
"window_border": "#494d64",
"border": "#494d64",
"border_variant": "#363a4f",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#00000000",
"border_disabled": "#363a4f",
"ring": "#8aadf4",
"border_transparent": "#cad3f500",
"border_disabled": "#1e2030",
"ring": "#b7bdf8",
"text": "#cad3f5",
"text_muted": "#b8c0e0",
"text_placeholder": "#a5adcb",
"text_muted": "#a5adcb",
"text_placeholder": "#8087a2",
"text_accent": "#8aadf4",
"icon": "#b8c0e0",
"icon_muted": "#a5adcb",
"icon_accent": "#8aadf4",
"element_foreground": "#181926",
"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": "#7c9cdc",
"element_selected": "#6e8bc5",
"element_active": "#91d7e3",
"element_selected": "#7dc4e4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#6e8bc5",
"secondary_background": "#1e2030",
"secondary_hover": "#8aadf433",
"secondary_active": "#181926",
"secondary_selected": "#181926",
"secondary_foreground": "#cad3f5",
"secondary_background": "#363a4f",
"secondary_hover": "#494d64",
"secondary_active": "#5b6078",
"secondary_selected": "#5b6078",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#181926",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ee99a0",
"danger_active": "#d57a87",
"danger_selected": "#be6d78",
"danger_active": "#f5a97f",
"danger_selected": "#eed49f",
"danger_disabled": "#ed87964d",
"warning_foreground": "#181926",
"warning_background": "#eed49f",
"warning_hover": "#f5a97f",
"warning_active": "#d6bf8f",
"warning_selected": "#beaa7f",
"warning_disabled": "#eed49f4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#363a4f",
"ghost_element_hover": "#cad3f533",
"ghost_element_active": "#494d64",
"ghost_element_selected": "#494d64",
"ghost_element_disabled": "#cad3f50d",
"tab_inactive_background": "#1e2030",
"tab_inactive_foreground": "#b8c0e0",
"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",
"tab_hover_foreground": "#b7bdf8",
"scrollbar_thumb_background": "#cad3f533",
"scrollbar_thumb_hover_background": "#cad3f580",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#494d64",
"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": "#f4dbd6",
"selection": "#939ab740"
"cursor": "#8aadf4",
"selection": "#8aadf440"
}
}
+98 -94
View File
@@ -1,74 +1,76 @@
{
"id": "catppuccin-mocha",
"name": "Catppuccin Mocha",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"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": "#181825",
"title_bar_inactive": "#11111b",
"window_border": "#6c7086",
"border": "#585b70",
"border_variant": "#45475a",
"title_bar": "#11111b",
"title_bar_inactive": "#1e1e2e",
"window_border": "#45475a",
"border": "#45475a",
"border_variant": "#313244",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#00000000",
"border_disabled": "#313244",
"ring": "#89b4fa",
"border_transparent": "#cdd6f400",
"border_disabled": "#181825",
"ring": "#b4befe",
"text": "#cdd6f4",
"text_muted": "#bac2de",
"text_placeholder": "#a6adc8",
"text_muted": "#a6adc8",
"text_placeholder": "#7f849c",
"text_accent": "#89b4fa",
"icon": "#bac2de",
"icon_muted": "#a6adc8",
"icon_accent": "#89b4fa",
"element_foreground": "#11111b",
"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": "#7ba2e1",
"element_selected": "#6d90c9",
"element_active": "#89dceb",
"element_selected": "#74c7ec",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#6d90c9",
"secondary_background": "#181825",
"secondary_hover": "#89b4fa33",
"secondary_active": "#11111b",
"secondary_selected": "#11111b",
"secondary_foreground": "#cdd6f4",
"secondary_background": "#313244",
"secondary_hover": "#45475a",
"secondary_active": "#585b70",
"secondary_selected": "#585b70",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#11111b",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#eba0ac",
"danger_active": "#db7d98",
"danger_selected": "#c46f88",
"danger_active": "#fab387",
"danger_selected": "#f9e2af",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#11111b",
"warning_background": "#f9e2af",
"warning_hover": "#fab387",
"warning_active": "#e0cb9e",
"warning_selected": "#c8b48d",
"warning_disabled": "#f9e2af4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#313244",
"ghost_element_hover": "#cdd6f433",
"ghost_element_active": "#45475a",
"ghost_element_selected": "#45475a",
"ghost_element_disabled": "#cdd6f40d",
"tab_inactive_background": "#181825",
"tab_inactive_foreground": "#bac2de",
"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",
"tab_hover_foreground": "#b4befe",
"scrollbar_thumb_background": "#cdd6f433",
"scrollbar_thumb_hover_background": "#cdd6f580",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#45475a",
"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": "#f5e0dc",
"selection": "#9399b240"
"cursor": "#89b4fa",
"selection": "#89b4fa40"
},
"dark": {
"background": "#1e1e2e",
@@ -76,65 +78,67 @@
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#181825",
"title_bar_inactive": "#11111b",
"window_border": "#6c7086",
"border": "#585b70",
"border_variant": "#45475a",
"title_bar": "#11111b",
"title_bar_inactive": "#1e1e2e",
"window_border": "#45475a",
"border": "#45475a",
"border_variant": "#313244",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#00000000",
"border_disabled": "#313244",
"ring": "#89b4fa",
"border_transparent": "#cdd6f400",
"border_disabled": "#181825",
"ring": "#b4befe",
"text": "#cdd6f4",
"text_muted": "#bac2de",
"text_placeholder": "#a6adc8",
"text_muted": "#a6adc8",
"text_placeholder": "#7f849c",
"text_accent": "#89b4fa",
"icon": "#bac2de",
"icon_muted": "#a6adc8",
"icon_accent": "#89b4fa",
"element_foreground": "#11111b",
"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": "#7ba2e1",
"element_selected": "#6d90c9",
"element_active": "#89dceb",
"element_selected": "#74c7ec",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#6d90c9",
"secondary_background": "#181825",
"secondary_hover": "#89b4fa33",
"secondary_active": "#11111b",
"secondary_selected": "#11111b",
"secondary_foreground": "#cdd6f4",
"secondary_background": "#313244",
"secondary_hover": "#45475a",
"secondary_active": "#585b70",
"secondary_selected": "#585b70",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#11111b",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#eba0ac",
"danger_active": "#db7d98",
"danger_selected": "#c46f88",
"danger_active": "#fab387",
"danger_selected": "#f9e2af",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#11111b",
"warning_background": "#f9e2af",
"warning_hover": "#fab387",
"warning_active": "#e0cb9e",
"warning_selected": "#c8b48d",
"warning_disabled": "#f9e2af4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#313244",
"ghost_element_hover": "#cdd6f433",
"ghost_element_active": "#45475a",
"ghost_element_selected": "#45475a",
"ghost_element_disabled": "#cdd6f40d",
"tab_inactive_background": "#181825",
"tab_inactive_foreground": "#bac2de",
"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",
"tab_hover_foreground": "#b4befe",
"scrollbar_thumb_background": "#cdd6f433",
"scrollbar_thumb_hover_background": "#cdd6f580",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#45475a",
"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": "#f5e0dc",
"selection": "#9399b240"
"cursor": "#89b4fa",
"selection": "#89b4fa40"
}
}
+107 -103
View File
@@ -1,140 +1,144 @@
{
"id": "flexoki",
"name": "Flexoki",
"author": "Stephan Ango",
"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": "#F2F0E5",
"title_bar_inactive": "#E6E4D9",
"window_border": "#B7B5AC",
"overlay": "#100F0F1A",
"title_bar": "#E6E4D9",
"title_bar_inactive": "#FFFCF0",
"window_border": "#CECDC3",
"border": "#CECDC3",
"border_variant": "#DAD8CE",
"border_focused": "#205EA6",
"border_selected": "#205EA6",
"border_transparent": "#00000000",
"border_focused": "#24837B",
"border_selected": "#24837B",
"border_transparent": "#100F0F00",
"border_disabled": "#E6E4D9",
"ring": "#205EA6",
"ring": "#3AA99F",
"text": "#100F0F",
"text_muted": "#6F6E69",
"text_placeholder": "#9F9D96",
"text_accent": "#205EA6",
"text_placeholder": "#B7B5AC",
"text_accent": "#24837B",
"text_danger": "#AF3029",
"text_warning": "#BC5215",
"icon": "#6F6E69",
"icon_muted": "#9F9D96",
"icon_accent": "#205EA6",
"icon_muted": "#B7B5AC",
"icon_accent": "#3AA99F",
"element_foreground": "#FFFCF0",
"element_background": "#205EA6",
"element_hover": "#1A4F8C",
"element_active": "#163B66",
"element_selected": "#133051",
"element_disabled": "#205EA64d",
"secondary_foreground": "#163B66",
"secondary_background": "#F2F0E5",
"secondary_hover": "#205EA61a",
"secondary_active": "#E6E4D9",
"secondary_selected": "#E6E4D9",
"secondary_disabled": "#205EA64d",
"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": "#D14D41",
"danger_hover": "#C03E35",
"danger_active": "#AF3029",
"danger_selected": "#942822",
"danger_disabled": "#D14D414d",
"warning_foreground": "#100F0F",
"warning_background": "#D0A215",
"warning_hover": "#BE9207",
"warning_active": "#AD8301",
"warning_selected": "#8E6B01",
"warning_disabled": "#D0A2154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#E6E4D9",
"ghost_element_hover": "#100F0F1a",
"ghost_element_active": "#DAD8CE",
"ghost_element_selected": "#DAD8CE",
"ghost_element_disabled": "#100F0F0d",
"tab_inactive_background": "#F2F0E5",
"tab_inactive_foreground": "#6F6E69",
"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",
"tab_hover_foreground": "#205EA6",
"scrollbar_thumb_background": "#100F0F33",
"scrollbar_thumb_hover_background": "#100F0F4d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#DAD8CE",
"drop_target_background": "#205EA61a",
"cursor": "#205EA6",
"selection": "#205EA640"
"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": "#1C1B1A",
"title_bar_inactive": "#282726",
"window_border": "#575653",
"overlay": "#FFFCF01A",
"title_bar": "#282726",
"title_bar_inactive": "#100F0F",
"window_border": "#403E3C",
"border": "#403E3C",
"border_variant": "#343331",
"border_focused": "#4385BE",
"border_selected": "#4385BE",
"border_transparent": "#00000000",
"border_focused": "#3AA99F",
"border_selected": "#3AA99F",
"border_transparent": "#100F0F00",
"border_disabled": "#282726",
"ring": "#4385BE",
"text": "#FFFCF0",
"ring": "#24837B",
"text": "#CECDC3",
"text_muted": "#878580",
"text_placeholder": "#6F6E69",
"text_accent": "#4385BE",
"text_placeholder": "#575653",
"text_accent": "#3AA99F",
"text_danger": "#D14D41",
"text_warning": "#DA702C",
"icon": "#878580",
"icon_muted": "#6F6E69",
"icon_accent": "#4385BE",
"icon_muted": "#575653",
"icon_accent": "#24837B",
"element_foreground": "#100F0F",
"element_background": "#4385BE",
"element_hover": "#3171B2",
"element_active": "#205EA6",
"element_selected": "#1A4F8C",
"element_disabled": "#4385BE4d",
"secondary_foreground": "#205EA6",
"element_background": "#3AA99F",
"element_hover": "#24837B",
"element_active": "#CECDC3",
"element_selected": "#F2F0E5",
"element_disabled": "#3AA99F4D",
"secondary_foreground": "#CECDC3",
"secondary_background": "#1C1B1A",
"secondary_hover": "#4385BE1a",
"secondary_active": "#282726",
"secondary_selected": "#282726",
"secondary_disabled": "#4385BE4d",
"secondary_hover": "#282726",
"secondary_active": "#343331",
"secondary_selected": "#343331",
"secondary_disabled": "#3AA99F4D",
"danger_foreground": "#100F0F",
"danger_background": "#E8705F",
"danger_hover": "#D14D41",
"danger_active": "#C03E35",
"danger_selected": "#AF3029",
"danger_disabled": "#E8705F4d",
"danger_background": "#D14D41",
"danger_hover": "#AF3029",
"danger_active": "#CECDC3",
"danger_selected": "#F2F0E5",
"danger_disabled": "#D14D414D",
"warning_foreground": "#100F0F",
"warning_background": "#DFB431",
"warning_hover": "#D0A215",
"warning_active": "#BE9207",
"warning_selected": "#AD8301",
"warning_disabled": "#DFB4314d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#282726",
"ghost_element_hover": "#FFFCF01a",
"ghost_element_active": "#343331",
"ghost_element_selected": "#343331",
"ghost_element_disabled": "#FFFCF00d",
"tab_inactive_background": "#1C1B1A",
"tab_inactive_foreground": "#878580",
"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": "#FFFCF0",
"tab_hover_foreground": "#4385BE",
"scrollbar_thumb_background": "#FFFCF033",
"scrollbar_thumb_hover_background": "#FFFCF04d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#343331",
"drop_target_background": "#4385BE1a",
"cursor": "#4385BE",
"selection": "#4385BE40"
"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"
}
}
+144
View File
@@ -0,0 +1,144 @@
{
"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"
}
}
+144
View File
@@ -0,0 +1,144 @@
{
"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"
}
}
-140
View File
@@ -1,140 +0,0 @@
{
"id": "rose-pine-dawn",
"name": "Rosé Pine Dawn",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#faf4ed",
"surface_background": "#fffaf3",
"elevated_surface_background": "#f2e9e1",
"panel_background": "#faf4ed",
"overlay": "#5752791a",
"title_bar": "#fffaf3",
"title_bar_inactive": "#f2e9e1",
"window_border": "#cecacd",
"border": "#dfdad9",
"border_variant": "#f4ede8",
"border_focused": "#907aa9",
"border_selected": "#907aa9",
"border_transparent": "#00000000",
"border_disabled": "#f2e9e1",
"ring": "#907aa9",
"text": "#575279",
"text_muted": "#797593",
"text_placeholder": "#9893a5",
"text_accent": "#907aa9",
"icon": "#797593",
"icon_muted": "#9893a5",
"icon_accent": "#907aa9",
"element_foreground": "#faf4ed",
"element_background": "#907aa9",
"element_hover": "#907aa9e6",
"element_active": "#826b95",
"element_selected": "#745c81",
"element_disabled": "#907aa94d",
"secondary_foreground": "#745c81",
"secondary_background": "#fffaf3",
"secondary_hover": "#907aa91a",
"secondary_active": "#f2e9e1",
"secondary_selected": "#f2e9e1",
"secondary_disabled": "#907aa94d",
"danger_foreground": "#faf4ed",
"danger_background": "#b4637a",
"danger_hover": "#a7586e",
"danger_active": "#9a4d62",
"danger_selected": "#8d4256",
"danger_disabled": "#b4637a4d",
"warning_foreground": "#575279",
"warning_background": "#ea9d34",
"warning_hover": "#d98e2f",
"warning_active": "#c87f2a",
"warning_selected": "#b77025",
"warning_disabled": "#ea9d344d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f2e9e1",
"ghost_element_hover": "#5752791a",
"ghost_element_active": "#dfdad9",
"ghost_element_selected": "#dfdad9",
"ghost_element_disabled": "#5752790d",
"tab_inactive_background": "#fffaf3",
"tab_inactive_foreground": "#797593",
"tab_active_background": "#faf4ed",
"tab_active_foreground": "#575279",
"tab_hover_foreground": "#907aa9",
"scrollbar_thumb_background": "#57527933",
"scrollbar_thumb_hover_background": "#5752794d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dfdad9",
"drop_target_background": "#907aa91a",
"cursor": "#907aa9",
"selection": "#907aa940"
},
"dark": {
"background": "#faf4ed",
"surface_background": "#fffaf3",
"elevated_surface_background": "#f2e9e1",
"panel_background": "#faf4ed",
"overlay": "#5752791a",
"title_bar": "#fffaf3",
"title_bar_inactive": "#f2e9e1",
"window_border": "#cecacd",
"border": "#dfdad9",
"border_variant": "#f4ede8",
"border_focused": "#907aa9",
"border_selected": "#907aa9",
"border_transparent": "#00000000",
"border_disabled": "#f2e9e1",
"ring": "#907aa9",
"text": "#575279",
"text_muted": "#797593",
"text_placeholder": "#9893a5",
"text_accent": "#907aa9",
"icon": "#797593",
"icon_muted": "#9893a5",
"icon_accent": "#907aa9",
"element_foreground": "#faf4ed",
"element_background": "#907aa9",
"element_hover": "#907aa9e6",
"element_active": "#826b95",
"element_selected": "#745c81",
"element_disabled": "#907aa94d",
"secondary_foreground": "#745c81",
"secondary_background": "#fffaf3",
"secondary_hover": "#907aa91a",
"secondary_active": "#f2e9e1",
"secondary_selected": "#f2e9e1",
"secondary_disabled": "#907aa94d",
"danger_foreground": "#faf4ed",
"danger_background": "#b4637a",
"danger_hover": "#a7586e",
"danger_active": "#9a4d62",
"danger_selected": "#8d4256",
"danger_disabled": "#b4637a4d",
"warning_foreground": "#575279",
"warning_background": "#ea9d34",
"warning_hover": "#d98e2f",
"warning_active": "#c87f2a",
"warning_selected": "#b77025",
"warning_disabled": "#ea9d344d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f2e9e1",
"ghost_element_hover": "#5752791a",
"ghost_element_active": "#dfdad9",
"ghost_element_selected": "#dfdad9",
"ghost_element_disabled": "#5752790d",
"tab_inactive_background": "#fffaf3",
"tab_inactive_foreground": "#797593",
"tab_active_background": "#faf4ed",
"tab_active_foreground": "#575279",
"tab_hover_foreground": "#907aa9",
"scrollbar_thumb_background": "#57527933",
"scrollbar_thumb_hover_background": "#5752794d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dfdad9",
"drop_target_background": "#907aa91a",
"cursor": "#907aa9",
"selection": "#907aa940"
}
}
-140
View File
@@ -1,140 +0,0 @@
{
"id": "rose-pine-moon",
"name": "Rosé Pine Moon",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#232136",
"surface_background": "#2a273f",
"elevated_surface_background": "#393552",
"panel_background": "#232136",
"overlay": "#e0def41a",
"title_bar": "#2a273f",
"title_bar_inactive": "#393552",
"window_border": "#56526e",
"border": "#44415a",
"border_variant": "#393552",
"border_focused": "#c4a7e7",
"border_selected": "#c4a7e7",
"border_transparent": "#00000000",
"border_disabled": "#393552",
"ring": "#c4a7e7",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#908caa",
"icon_muted": "#6e6a86",
"icon_accent": "#c4a7e7",
"element_foreground": "#232136",
"element_background": "#c4a7e7",
"element_hover": "#c4a7e7e6",
"element_active": "#b296d6",
"element_selected": "#a085c5",
"element_disabled": "#c4a7e74d",
"secondary_foreground": "#a085c5",
"secondary_background": "#393552",
"secondary_hover": "#c4a7e71a",
"secondary_active": "#44415a",
"secondary_selected": "#44415a",
"secondary_disabled": "#c4a7e74d",
"danger_foreground": "#232136",
"danger_background": "#eb6f92",
"danger_hover": "#e55a82",
"danger_active": "#df4572",
"danger_selected": "#d93062",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#232136",
"warning_background": "#f6c177",
"warning_hover": "#f4b35e",
"warning_active": "#f2a545",
"warning_selected": "#f0972c",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#393552",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#44415a",
"ghost_element_selected": "#44415a",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#2a273f",
"tab_inactive_foreground": "#908caa",
"tab_active_background": "#232136",
"tab_active_foreground": "#e0def4",
"tab_hover_foreground": "#c4a7e7",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#44415a",
"drop_target_background": "#c4a7e71a",
"cursor": "#c4a7e7",
"selection": "#c4a7e740"
},
"dark": {
"background": "#232136",
"surface_background": "#2a273f",
"elevated_surface_background": "#393552",
"panel_background": "#232136",
"overlay": "#e0def41a",
"title_bar": "#2a273f",
"title_bar_inactive": "#393552",
"window_border": "#56526e",
"border": "#44415a",
"border_variant": "#393552",
"border_focused": "#c4a7e7",
"border_selected": "#c4a7e7",
"border_transparent": "#00000000",
"border_disabled": "#393552",
"ring": "#c4a7e7",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#908caa",
"icon_muted": "#6e6a86",
"icon_accent": "#c4a7e7",
"element_foreground": "#232136",
"element_background": "#c4a7e7",
"element_hover": "#c4a7e7e6",
"element_active": "#b296d6",
"element_selected": "#a085c5",
"element_disabled": "#c4a7e74d",
"secondary_foreground": "#a085c5",
"secondary_background": "#393552",
"secondary_hover": "#c4a7e71a",
"secondary_active": "#44415a",
"secondary_selected": "#44415a",
"secondary_disabled": "#c4a7e74d",
"danger_foreground": "#232136",
"danger_background": "#eb6f92",
"danger_hover": "#e55a82",
"danger_active": "#df4572",
"danger_selected": "#d93062",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#232136",
"warning_background": "#f6c177",
"warning_hover": "#f4b35e",
"warning_active": "#f2a545",
"warning_selected": "#f0972c",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#393552",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#44415a",
"ghost_element_selected": "#44415a",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#2a273f",
"tab_inactive_foreground": "#908caa",
"tab_active_background": "#232136",
"tab_active_foreground": "#e0def4",
"tab_hover_foreground": "#c4a7e7",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#44415a",
"drop_target_background": "#c4a7e71a",
"cursor": "#c4a7e7",
"selection": "#c4a7e740"
}
}
-140
View File
@@ -1,140 +0,0 @@
{
"id": "rose-pine",
"name": "Rosé Pine",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#191724",
"surface_background": "#1f1d2e",
"elevated_surface_background": "#26233a",
"panel_background": "#191724",
"overlay": "#e0def41a",
"title_bar": "#1f1d2e",
"title_bar_inactive": "#26233a",
"window_border": "#524f67",
"border": "#403d52",
"border_variant": "#26233a",
"border_focused": "#c4a7e7",
"border_selected": "#c4a7e7",
"border_transparent": "#00000000",
"border_disabled": "#26233a",
"ring": "#c4a7e7",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#908caa",
"icon_muted": "#6e6a86",
"icon_accent": "#c4a7e7",
"element_foreground": "#191724",
"element_background": "#c4a7e7",
"element_hover": "#c4a7e7e6",
"element_active": "#b296d6",
"element_selected": "#a085c5",
"element_disabled": "#c4a7e74d",
"secondary_foreground": "#a085c5",
"secondary_background": "#26233a",
"secondary_hover": "#c4a7e71a",
"secondary_active": "#403d52",
"secondary_selected": "#403d52",
"secondary_disabled": "#c4a7e74d",
"danger_foreground": "#191724",
"danger_background": "#eb6f92",
"danger_hover": "#e55a82",
"danger_active": "#df4572",
"danger_selected": "#d93062",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#191724",
"warning_background": "#f6c177",
"warning_hover": "#f4b35e",
"warning_active": "#f2a545",
"warning_selected": "#f0972c",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#26233a",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#403d52",
"ghost_element_selected": "#403d52",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#1f1d2e",
"tab_inactive_foreground": "#908caa",
"tab_active_background": "#191724",
"tab_active_foreground": "#e0def4",
"tab_hover_foreground": "#c4a7e7",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#403d52",
"drop_target_background": "#c4a7e71a",
"cursor": "#c4a7e7",
"selection": "#c4a7e740"
},
"dark": {
"background": "#191724",
"surface_background": "#1f1d2e",
"elevated_surface_background": "#26233a",
"panel_background": "#191724",
"overlay": "#e0def41a",
"title_bar": "#1f1d2e",
"title_bar_inactive": "#26233a",
"window_border": "#524f67",
"border": "#403d52",
"border_variant": "#26233a",
"border_focused": "#c4a7e7",
"border_selected": "#c4a7e7",
"border_transparent": "#00000000",
"border_disabled": "#26233a",
"ring": "#c4a7e7",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#908caa",
"icon_muted": "#6e6a86",
"icon_accent": "#c4a7e7",
"element_foreground": "#191724",
"element_background": "#c4a7e7",
"element_hover": "#c4a7e7e6",
"element_active": "#b296d6",
"element_selected": "#a085c5",
"element_disabled": "#c4a7e74d",
"secondary_foreground": "#a085c5",
"secondary_background": "#26233a",
"secondary_hover": "#c4a7e71a",
"secondary_active": "#403d52",
"secondary_selected": "#403d52",
"secondary_disabled": "#c4a7e74d",
"danger_foreground": "#191724",
"danger_background": "#eb6f92",
"danger_hover": "#e55a82",
"danger_active": "#df4572",
"danger_selected": "#d93062",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#191724",
"warning_background": "#f6c177",
"warning_hover": "#f4b35e",
"warning_active": "#f2a545",
"warning_selected": "#f0972c",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#26233a",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#403d52",
"ghost_element_selected": "#403d52",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#1f1d2e",
"tab_inactive_foreground": "#908caa",
"tab_active_background": "#191724",
"tab_active_foreground": "#e0def4",
"tab_hover_foreground": "#c4a7e7",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#403d52",
"drop_target_background": "#c4a7e71a",
"cursor": "#c4a7e7",
"selection": "#c4a7e740"
}
}
+8
View File
@@ -8,4 +8,12 @@ publish.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
[target.'cfg(not(target_family = "wasm"))'.dependencies]
rust-embed.workspace = true
[target.'cfg(target_family = "wasm")'.dependencies]
futures.workspace = true
reqwest = { version = "0.12", default-features = false }
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Location"] }
+58
View File
@@ -0,0 +1,58 @@
//! Generates a compile-time manifest of the asset files served on wasm, so
//! the web entrypoint can preload them before the first frame.
//!
//! `WASM_ASSETS` is emitted into `OUT_DIR` and included by
//! `src/wasm_assets.rs` on wasm targets. Native builds keep using
//! `rust-embed` and ignore it.
use std::path::Path;
use std::{env, fs};
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by cargo");
let assets_dir = Path::new(&manifest_dir).join("../../assets");
let mut paths = Vec::new();
for dir in ["icons", "brand"] {
let dir_path = assets_dir.join(dir);
let entries = fs::read_dir(&dir_path).unwrap_or_else(|error| {
panic!(
"expected asset directory {} to exist: {error}",
dir_path.display()
)
});
for entry in entries {
let entry = entry.expect("failed to read asset directory entry");
if entry.file_type().is_ok_and(|t| t.is_file()) {
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with('.') {
paths.push(format!("{dir}/{name}"));
}
}
}
}
paths.sort();
let manifest = format!(
"/// Asset files served by the wasm asset loader. Generated by build.rs.\npub const WASM_ASSETS: &[&str] = &[\n{}\n];\n",
paths
.iter()
.map(|path| format!(" \"{path}\","))
.collect::<Vec<_>>()
.join("\n")
);
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set by cargo");
fs::write(Path::new(&out_dir).join("wasm_assets.rs"), manifest)
.expect("failed to write wasm asset manifest");
// Rerun when the asset files change (adding/removing files updates the
// directory mtime).
for dir in ["icons", "brand"] {
if let Ok(canonical) = assets_dir.join(dir).canonicalize() {
println!("cargo:rerun-if-changed={}", canonical.display());
}
}
println!("cargo:rerun-if-changed=build.rs");
}
+16 -48
View File
@@ -1,51 +1,19 @@
use anyhow::Context;
use gpui::{App, AssetSource, Result, SharedString};
use rust_embed::RustEmbed;
//! Application assets for Coop.
//!
//! ## Platform differences
//!
//! - **Native (desktop)**: assets are embedded into the binary at compile time
//! with `rust-embed`.
//! - **WASM (web)**: assets are downloaded on demand from `{endpoint}/assets/{path}`
//! and cached in memory. This keeps the WASM bundle size small.
#[derive(RustEmbed)]
#[folder = "../../assets"]
#[include = "fonts/**/*"]
#[include = "brand/**/*"]
#[include = "icons/**/*"]
#[include = "themes/**/*"]
#[exclude = "*.DS_Store"]
pub struct Assets;
#[cfg(not(target_family = "wasm"))]
mod native_assets;
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
Self::get(path)
.map(|f| Some(f.data))
.with_context(|| format!("loading asset at path {path:?}"))
}
#[cfg(target_family = "wasm")]
mod wasm_assets;
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(Self::iter()
.filter_map(|p| {
if p.starts_with(path) {
Some(p.into())
} else {
None
}
})
.collect())
}
}
impl Assets {
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
let font_paths = self.list("fonts")?;
let mut embedded_fonts = Vec::new();
for font_path in font_paths {
if font_path.ends_with(".ttf") {
let font_bytes = cx
.asset_source()
.load(&font_path)?
.expect("Assets should never return None");
embedded_fonts.push(font_bytes);
}
}
cx.text_system().add_fonts(embedded_fonts)
}
}
#[cfg(not(target_family = "wasm"))]
pub use native_assets::Assets;
#[cfg(target_family = "wasm")]
pub use wasm_assets::Assets;
+63
View File
@@ -0,0 +1,63 @@
use std::borrow::Cow;
use anyhow::Context;
use gpui::{App, AssetSource, Result, SharedString};
use rust_embed::RustEmbed;
/// Native implementation using `rust-embed`: assets are embedded into the
/// binary at compile time.
#[derive(RustEmbed)]
#[folder = "../../assets"]
#[include = "fonts/**/*"]
#[include = "brand/**/*"]
#[include = "icons/**/*"]
#[include = "themes/**/*"]
#[exclude = "*.DS_Store"]
pub struct Assets;
impl Assets {
/// Create a new Assets instance. The endpoint parameter is ignored for
/// native builds.
pub fn new(_endpoint: impl Into<SharedString>) -> Self {
Self
}
}
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
Self::get(path)
.map(|f| Some(f.data))
.with_context(|| format!("loading asset at path {path:?}"))
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(Self::iter()
.filter_map(|p| {
if p.starts_with(path) {
Some(p.into())
} else {
None
}
})
.collect())
}
}
impl Assets {
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
let font_paths = self.list("fonts")?;
let mut embedded_fonts = Vec::new();
for font_path in font_paths {
if font_path.ends_with(".ttf") {
let font_bytes = cx
.asset_source()
.load(&font_path)?
.expect("Assets should never return None");
embedded_fonts.push(font_bytes);
}
}
cx.text_system().add_fonts(embedded_fonts)
}
}
+176
View File
@@ -0,0 +1,176 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use gpui::{AssetSource, Result, SharedString};
use wasm_bindgen_futures::spawn_local;
// Compile-time manifest of every asset file served on wasm (see build.rs).
include!(concat!(env!("OUT_DIR"), "/wasm_assets.rs"));
/// Path prefixes that the wasm loader serves. Fonts and themes are not
/// downloaded on web: the web platform bundles its own fonts, and the theme
/// registry falls back to the built-in default theme.
const SERVED_PREFIXES: [&str; 2] = ["icons/", "brand/"];
/// WASM implementation - download assets on demand.
///
/// Assets are fetched from `{endpoint}/assets/{path}` and cached in memory
/// after the first successful download. This keeps the WASM bundle small
/// while still providing the full asset set at runtime.
pub struct Assets {
endpoint: SharedString,
cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
pending: Arc<RwLock<HashMap<String, bool>>>,
}
impl Assets {
/// Create a new Assets instance backed by the given endpoint.
///
/// Assets are resolved as `{endpoint}/assets/{path}`. An empty endpoint
/// resolves against the current page origin (e.g. `/assets/icons/foo.svg`).
pub fn new(endpoint: impl Into<SharedString>) -> Self {
Self {
endpoint: endpoint.into(),
cache: Arc::new(RwLock::new(HashMap::new())),
pending: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Absolute URL of the given asset path.
///
/// `reqwest` requires absolute URLs, so a relative endpoint is resolved
/// against the current page origin.
fn asset_url(&self, path: &str) -> String {
let endpoint = if self.endpoint.is_empty() {
web_sys::window()
.and_then(|window| window.location().origin().ok())
.unwrap_or_default()
} else {
self.endpoint.to_string()
};
format!("{endpoint}/assets/{path}")
}
/// Download every asset in [`WASM_ASSETS`] into the cache, in parallel,
/// before the app starts.
///
/// Preloading is required for two reasons:
/// - Assets loaded through GPUI's [`gpui::Asset`] machinery (e.g. `img()`)
/// cache failed loads and never retry them.
/// - SVG painting only re-attempts an empty load on the next repaint, so
/// an icon would stay invisible until the window happens to redraw.
pub async fn preload(&self) {
let downloads = WASM_ASSETS.iter().map(|path| async move {
let result = reqwest::get(self.asset_url(path)).await;
match result {
Ok(response) if response.status().is_success() => match response.bytes().await {
Ok(bytes) => {
if let Ok(mut cache) = self.cache.write() {
cache.insert(path.to_string(), bytes.to_vec());
}
}
Err(e) => {
log::warn!("Failed to read asset {}: {}", path, e);
}
},
Ok(response) => {
log::warn!(
"Failed to download asset {}: HTTP {}",
path,
response.status()
);
}
Err(e) => {
log::warn!("Failed to fetch asset {}: {}", path, e);
}
}
});
futures::future::join_all(downloads).await;
}
}
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
if path.is_empty() {
return Ok(None);
}
// Only serve paths the web build actually ships.
if !SERVED_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
{
return Ok(None);
}
// Serve from the in-memory cache when available.
if let Ok(cache) = self.cache.read() {
if let Some(data) = cache.get(path) {
return Ok(Some(Cow::Owned(data.clone())));
}
}
// Kick off a single download per path; concurrent requests for the
// same path share it.
let is_pending = self
.pending
.read()
.map(|pending| pending.contains_key(path))
.unwrap_or(false);
if !is_pending {
if let Ok(mut pending) = self.pending.write() {
pending.insert(path.to_string(), true);
}
let url = self.asset_url(path);
let path_clone = path.to_string();
let cache = self.cache.clone();
let pending = self.pending.clone();
spawn_local(async move {
match reqwest::get(&url).await {
Ok(response) if response.status().is_success() => {
match response.bytes().await {
Ok(bytes) => {
if let Ok(mut cache) = cache.write() {
cache.insert(path_clone.clone(), bytes.to_vec());
}
}
Err(e) => {
log::warn!("Failed to read asset {}: {}", path_clone, e);
}
}
}
Ok(response) => {
log::warn!(
"Failed to download asset {}: HTTP {}",
path_clone,
response.status()
);
}
Err(e) => {
log::warn!("Failed to fetch asset {}: {}", path_clone, e);
}
}
// Allow retrying failed downloads on subsequent requests.
if let Ok(mut pending) = pending.write() {
pending.remove(&path_clone);
}
});
}
// The asset is not available yet. GPUI's SVG atlas does not cache
// empty loads, so the next repaint will call `load` again and find
// the asset in the cache once the download completes.
Ok(None)
}
fn list(&self, _path: &str) -> Result<Vec<SharedString>> {
// The asset manifest is not available at runtime on web; embedded
// directories are not listed.
Ok(Vec::new())
}
}
+2 -11
View File
@@ -5,17 +5,8 @@ edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
anyhow.workspace = true
smol.workspace = true
instant.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
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
+116 -497
View File
@@ -1,39 +1,29 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#![cfg(not(target_arch = "wasm32"))]
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 semver::Version;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
use instant::{Duration, Instant};
const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
fn get_github_repo_owner() -> String {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_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(|_| "your-repo".to_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()
}
/// Initialize the auto-update system.
///
/// Skips initialization when running as a Flatpak (updates are handled by the
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
/// entity and schedules a check for updates after a 2-minute delay.
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;
@@ -46,516 +36,145 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { download_url: String },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[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)]
/// The global auto-updater entity.
///
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
/// (GitHub repo, Flatpak detection, delayed auto-check).
///
/// Retrieve the global instance via [`AutoUpdater::global`].
pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
/// The underlying gpui-updater entity that does the heavy lifting.
pub updater: Entity<Updater>,
/// Currently running app version.
pub version: Version,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Keeps the observer subscription alive.
_subscription: Subscription,
/// When the last error was recorded, so we can reset to idle after 5s.
error_time: Option<Instant>,
}
impl AutoUpdater {
/// Retrieve the global auto updater instance
/// Retrieve the global auto updater instance.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
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);
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
});
Self {
status: AutoUpdateStatus::Idle,
version,
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
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();
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let source =
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
"macos" => "macos",
"linux" => "linux",
_ => "",
});
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
let updater: Entity<Updater> =
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
// When an update becomes available, automatically download and install it.
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
let status = this.updater.read(cx).status().clone();
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
if matches!(status, UpdateStatus::Available(_)) {
this.updater.update(cx, |updater, cx| {
updater.download_and_install(cx);
});
}
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
// 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
))?;
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)),
};
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
))?;
Ok(download_url)
if matches!(status, UpdateStatus::Errored(_)) {
this.error_time = Some(Instant::now());
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
this.update(cx, |_this, cx| cx.notify()).ok();
})
.detach();
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
this.error_time = None;
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let download_url = download_url.to_string();
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?;
// Download the release
download(&download_url, &target_path, http_client).await?;
Ok((installer_dir, target_path))
cx.notify();
});
self.tasks.push(
// Install the new release
// Schedule an auto-check after a 2-minute delay (deferred to run at the
// end of the current frame so the window is fully set up).
cx.defer_in(window, |_this, _window, cx| {
let duration = Duration::from_secs(120);
cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
})
.ok();
})
.detach();
});
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.set_status(AutoUpdateStatus::Updated, cx);
})?;
}
}
Err(e) => {
// Update the status to error including the error message
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})?;
}
Self {
updater,
version,
_subscription: subscription,
error_time: None,
}
}
pub fn idle(&self, cx: &App) -> bool {
let status = self.updater.read(cx).status();
if status == &UpdateStatus::Idle {
return true;
}
if matches!(status, UpdateStatus::Errored(_))
&& self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
return true;
}
false
}
pub fn status(&self, cx: &App) -> SharedString {
let status = self.updater.read(cx).status();
match status {
UpdateStatus::Idle => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
match total_mb {
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
None => format!("Downloading {downloaded_mb:.1} MB").into(),
}
Ok(())
}),
);
}
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}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> 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}"),
}
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
}
UpdateStatus::Errored(msg) => {
if self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
"Up to date".into()
} else {
format!("Update failed: {msg}").into()
}
}
}
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
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;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "browser-signer-proxy"
version = "0.1.0"
edition.workspace = true
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
license = "MIT"
repository = "https://github.com/nostrdevkit/nostr"
publish = false
[dependencies]
atomic-destructor = "0.2"
event-listener = "5"
nostr.workspace = true
opaquerr = { version = "0.1", features = ["alloc"] }
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
tracing = { version = "0.1", features = ["std"] }
uuid = { version = "1.23", features = ["serde", "v4"] }
+55
View File
@@ -0,0 +1,55 @@
# browser-signer-proxy
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
## Description
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
the browser extension.
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
## Usage
```rust
use browser_signer_proxy::prelude::*;
async fn example() -> Result<(), Error> {
// Create the proxy with default options (localhost:7400)
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
// Open the proxy URL in a browser
webbrowser::open(&proxy.url())?;
// Start the proxy server
proxy.start().await?;
// Use it as an async Nostr signer
let public_key = proxy.get_public_key_async().await?;
println!("Connected with public key: {public_key}");
Ok(())
}
```
## Differences from the tokio-based version
| Feature | tokio (original) | smol (this crate) |
|---|---|---|
| Async runtime | `tokio` | `smol` |
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
| Task spawning | `tokio::spawn` | `smol::spawn` |
## License
This project is distributed under the MIT software license.
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coop — Web Signer Proxy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
<style>
:root {
--brand: #F8FF37;
--ink: #111111;
--ink-soft: #333333;
--muted: #666666;
--paper: #FFFFFF;
--edge: rgba(17, 17, 17, 0.14);
--radius-sm: 1rem;
--radius-md: 1.5rem;
--radius-lg: 2.5rem;
--green: #2E8B57;
--red: #D32F2F;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background: var(--brand);
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
background: var(--paper);
border-radius: var(--radius-md);
padding: 2.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.logo__mark {
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.2rem;
color: var(--brand);
letter-spacing: -0.02em;
}
.logo__text {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.heading {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--ink-soft);
margin: 0 0 1.75rem;
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.95rem;
transition: background 300ms ease, color 300ms ease;
}
.status--checking {
background: rgba(17, 17, 17, 0.05);
color: var(--muted);
}
.status--connected {
background: rgba(46, 139, 87, 0.1);
color: var(--green);
}
.status--error {
background: rgba(211, 47, 47, 0.08);
color: var(--red);
}
.status__dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.status--checking .status__dot {
background: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
.status--connected .status__dot {
background: var(--green);
}
.status--error .status__dot {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.hint {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--edge);
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.hint strong {
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Web Signer</h1>
<p class="subtitle">
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
</p>
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
</div>
</div>
<script src="proxy.js"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
let isPolling = false;
async function pollForRequests() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch('/api/pending');
const data = await response.json();
console.log('Polled for requests, got:', data);
// Process any new requests
if (data.requests && data.requests.length > 0) {
console.log(`Processing ${data.requests.length} requests`);
for (const request of data.requests) {
await handleNip07Request(request);
}
}
} catch (error) {
console.error('Polling error:', error);
updateStatus('Error: ' + error.message, 'error');
}
isPolling = false;
}
async function handleNip07Request(request) {
console.log('Handling request:', request);
try {
let result;
if (!window.nostr) {
throw new Error('NIP-07 extension not available');
}
switch (request.method) {
case 'get_public_key':
console.log('Calling nostr.getPublicKey()');
result = await window.nostr.getPublicKey();
console.log('Got public key:', result);
break;
case 'sign_event':
console.log('Calling nostr.signEvent() with:', request.params);
result = await window.nostr.signEvent(request.params);
console.log('Got signed event:', result);
break;
case 'nip04_encrypt':
console.log('Calling nostr.nip04.encrypt()');
result = await window.nostr.nip04.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip04_decrypt':
console.log('Calling nostr.nip04.decrypt()');
result = await window.nostr.nip04.decrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_encrypt':
console.log('Calling nostr.nip44.encrypt()');
result = await window.nostr.nip44.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_decrypt':
console.log('Calling nostr.nip44.decrypt()');
result = await window.nostr.nip44.decrypt(
request.params.public_key,
request.params.content
);
break;
default:
throw new Error(`Unknown method: ${request.method}`);
}
// Send response back to server
const responsePayload = {
id: request.id,
result: result,
error: null
};
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(responsePayload)
});
console.log('Response sent successfully');
updateStatus('Request processed successfully', 'connected');
} catch (error) {
console.error('Error handling request:', error);
// Send error response back to server
const errorPayload = {
id: request.id,
result: null,
error: error.message
};
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(errorPayload)
});
updateStatus('Error: ' + error.message, 'error');
}
}
function updateStatus(message, state) {
const container = document.getElementById('nip07-status');
const textEl = document.getElementById('nip07-status-text');
if (container && textEl) {
container.className = 'status status--' + state;
textEl.textContent = message;
}
}
// Start polling when page loads
window.addEventListener('load', () => {
console.log('NIP-07 Proxy loaded');
// Check if NIP-07 extension is available
if (window.nostr) {
console.log('NIP-07 extension detected');
updateStatus('Connected — ready', 'connected');
} else {
console.log('NIP-07 extension not found');
updateStatus('No NIP-07 extension found', 'error');
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Error types for the browser signer proxy.
opaquerr::define_kind! {
/// Nostr browser signer proxy error kind.
pub ErrorKind {
/// Nostr protocol error.
Protocol => "nostr protocol error",
/// I/O error.
IO => "I/O error",
/// JSON error.
Json => "JSON error",
/// The operation timed out.
Timeout => "timeout",
/// The operation cannot be completed in the current state.
State => "invalid state",
/// Anything not covered by the stable categories above.
Other => "other error",
}
}
opaquerr::define_error! {
/// Nostr browser signer proxy error.
pub Error(ErrorKind)
from {
nostr::error::Error => ErrorKind::Protocol,
std::io::Error => ErrorKind::IO,
serde_json::Error => ErrorKind::Json,
}
}
impl Error {
pub(crate) fn generic<S>(message: S) -> Self
where
S: Into<String>,
{
Self::new(ErrorKind::Other, message.into())
}
pub(crate) fn timeout() -> Self {
Self::simple(ErrorKind::Timeout)
}
pub(crate) fn shutdown() -> Self {
Self::with_static_message(ErrorKind::State, "server is shutdown")
}
}
+764
View File
@@ -0,0 +1,764 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use event_listener::Event as ShutdownEvent;
use nostr::prelude::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json};
use smol::channel;
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use smol::lock::Mutex;
use smol::net::{TcpListener, TcpStream};
use uuid::Uuid;
mod error;
pub mod prelude;
pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
struct Message {
id: Uuid,
error: Option<String>,
result: Option<Value>,
}
impl Message {
fn into_result(self) -> Result<Value, String> {
if let Some(error) = self.error {
Err(error)
} else {
Ok(self.result.unwrap_or(Value::Null))
}
}
}
#[derive(Debug, Clone, Copy)]
enum RequestMethod {
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
}
impl RequestMethod {
fn as_str(&self) -> &str {
match self {
Self::GetPublicKey => "get_public_key",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
Self::Nip44Encrypt => "nip44_encrypt",
Self::Nip44Decrypt => "nip44_decrypt",
}
}
}
impl Serialize for RequestMethod {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
struct RequestData {
id: Uuid,
method: RequestMethod,
params: Value,
}
impl RequestData {
#[inline]
fn new(method: RequestMethod, params: Value) -> Self {
Self {
id: Uuid::new_v4(),
method,
params,
}
}
}
#[derive(Serialize)]
struct Requests<'a> {
requests: &'a [RequestData],
}
impl<'a> Requests<'a> {
#[inline]
fn new(requests: &'a [RequestData]) -> Self {
Self { requests }
}
#[inline]
fn len(&self) -> usize {
self.requests.len()
}
}
/// Params for NIP-04 and NIP-44 encryption/decryption
#[derive(Serialize)]
struct CryptoParams<'a> {
public_key: &'a PublicKey,
content: &'a str,
}
impl<'a> CryptoParams<'a> {
#[inline]
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
Self {
public_key,
content,
}
}
}
#[derive(Debug)]
struct ProxyState {
/// Requests waiting to be picked up by browser
pub outgoing_requests: Mutex<Vec<RequestData>>,
/// Map of request ID to response sender
pub pending_responses: Mutex<PendingResponseMap>,
/// Last time the client asked for the pending requests
pub last_pending_request: Arc<AtomicU64>,
}
/// Configuration options for [`BrowserSignerProxy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSignerProxyOptions {
/// Request timeout for the signer extension. Default is 30 seconds.
pub timeout: Duration,
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
pub addr: SocketAddr,
/// Custom HTML page.
// NOTE: not `Option` to move it between threads without reference counter
pub custom_html: &'static str,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Notification trigger for graceful shutdown
shutdown: Arc<ShutdownEvent>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flag indicating if the server is started
is_started: Arc<AtomicBool>,
}
impl AtomicDestroyer for InnerBrowserSignerProxy {
fn on_destroy(&self) {
self.shutdown();
}
}
impl InnerBrowserSignerProxy {
#[inline]
fn is_shutdown(&self) -> bool {
self.is_shutdown.load(Ordering::SeqCst)
}
fn shutdown(&self) {
// Mark the server as shutdown
self.is_shutdown.store(true, Ordering::SeqCst);
// Notify all waiters that the proxy is shutting down
self.shutdown.notify(usize::MAX);
}
}
/// Nostr Browser Signer Proxy
///
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
#[derive(Debug, Clone)]
pub struct BrowserSignerProxy {
inner: AtomicDestructor<InnerBrowserSignerProxy>,
}
impl Default for BrowserSignerProxyOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
custom_html: "",
}
}
}
impl BrowserSignerProxyOptions {
/// Sets the timeout duration.
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Sets the IP address.
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
self.addr = SocketAddr::new(new_ip, self.addr.port());
self
}
/// Sets the port number.
pub const fn port(mut self, new_port: u16) -> Self {
self.addr = SocketAddr::new(self.addr.ip(), new_port);
self
}
/// Sets a custom html page.
///
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
/// which will handle communication with the server and update the element
/// with id `nip07-proxy-status` with the status.
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
self.custom_html = custom_html;
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
shutdown: Arc::new(ShutdownEvent::new()),
is_shutdown: Arc::new(AtomicBool::new(false)),
is_started: Arc::new(AtomicBool::new(false)),
}),
}
}
/// Indicates whether the server is currently running.
#[inline]
pub fn is_started(&self) -> bool {
self.inner.is_started.load(Ordering::SeqCst)
}
/// Checks if there is an open browser tab ready to respond to requests by
/// verifying the time since the last pending request.
#[inline]
pub fn is_session_active(&self) -> bool {
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
}
/// Get the signer proxy webpage URL
#[inline]
pub fn url(&self) -> String {
format!("http://{}", self.inner.options.addr)
}
/// Start the proxy server.
///
/// If this is not called explicitly, the server will be automatically
/// started on the first interaction with the signer.
pub async fn start(&self) -> Result<(), Error> {
// Ensure is not shutdown
if self.inner.is_shutdown() {
return Err(Error::shutdown());
}
// Mark the proxy as started and check if was already started
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
// Immediately return if already started
if is_started {
return Ok(());
}
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
loop {
// Race between accepting a new connection and shutdown signal
let shutdown_listener = shutdown.listen();
enum AcceptEvent {
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
Shutdown,
}
let event = smol::future::or(
async { AcceptEvent::Connection(listener.accept().await) },
async {
shutdown_listener.await;
AcceptEvent::Shutdown
},
)
.await;
match event {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
},
async {
shutdown_listener.await;
tracing::debug!(
"Closing connection, proxy server is shutting down."
);
},
)
.await;
})
.detach();
}
AcceptEvent::Connection(Err(e)) => {
tracing::error!("Failed to accept connection: {e}");
}
AcceptEvent::Shutdown => break,
}
}
tracing::info!("Proxy server shut down.");
})
.detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
let mut pending_responses = self.inner.state.pending_responses.lock().await;
pending_responses.insert(id, tx);
}
#[inline]
async fn store_outgoing_request(&self, request: RequestData) {
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
outgoing_requests.push(request);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
{
// Start the proxy if not already started
self.start().await?;
// Construct the request
let request: RequestData = RequestData::new(method, params);
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
// Store the response sender
self.store_pending_response(request.id, tx).await;
// Add to outgoing requests queue
self.store_outgoing_request(request).await;
// Wait for response with timeout
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
}
}
#[inline]
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
self.request(RequestMethod::GetPublicKey, json!({})).await
}
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
Ok(event)
}
#[inline]
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
.await
}
}
impl AsyncGetPublicKey for BrowserSignerProxy {
type Error = Error;
#[inline]
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
Box::pin(async move { self._get_public_key().await })
}
}
impl AsyncSignEvent for BrowserSignerProxy {
type Error = Error;
#[inline]
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
Box::pin(async move { self._sign_event(unsigned).await })
}
}
impl AsyncNip04 for BrowserSignerProxy {
type Error = Error;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
}
}
impl AsyncNip44 for BrowserSignerProxy {
type Error = Error;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
let mut reader = BufReader::new(stream);
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let method = parts[0].to_uppercase();
let path = parts[1].to_string();
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
DEFAULT_HTML
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
}
// Browser polls this endpoint to get pending requests
("GET", "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests = Requests::new(&outgoing);
let json = match serde_json::to_string(&requests) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize pending requests: {e}");
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
return;
}
};
tracing::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
let mut body_bytes = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse response body: {e}");
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
return;
}
};
tracing::debug!("Received response from browser: {message:?}");
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
}
// 404 - not found
_ => {
send_response(&mut reader, 404, "Not Found", "", "").await;
}
}
}
/// Write an HTTP response to the stream.
async fn send_response(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
) -> Result<T, TimeoutError> {
enum Event<T> {
Value(T),
ChannelClosed,
Timeout,
}
let event = smol::future::or(
async {
match recv.await {
Ok(value) => Event::Value(value),
Err(_) => Event::ChannelClosed,
}
},
async {
smol::Timer::after(duration).await;
Event::Timeout
},
)
.await;
match event {
Event::Value(value) => Ok(value),
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
@@ -0,0 +1,14 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Prelude
#![allow(unknown_lints)]
#![allow(ambiguous_glob_reexports)]
#![doc(hidden)]
pub use nostr::prelude::*;
pub use crate::error::{Error, ErrorKind};
pub use crate::*;
+5 -4
View File
@@ -13,15 +13,16 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true
futures.workspace = true
fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+438 -425
View File
File diff suppressed because it is too large Load Diff
+127 -157
View File
@@ -1,8 +1,110 @@
use std::hash::Hash;
use std::ops::Range;
use common::EventUtils;
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri};
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 {
@@ -23,194 +125,62 @@ impl NewMessage {
}
}
/// Message.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message {
User(RenderedMessage),
Warning(String, Timestamp),
System(Timestamp),
/// Trash message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FailedMessage {
pub raw_event: SharedString,
pub reason: SharedString,
}
impl Message {
pub fn user<I>(user: I) -> Self
impl FailedMessage {
pub fn new<T>(event: &Event, reason: T) -> Self
where
I: Into<RenderedMessage>,
T: Into<SharedString>,
{
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,
Self {
raw_event: SharedString::from(event.as_json()),
reason: reason.into(),
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
Self::User(val.into())
}
}
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>,
pub struct Mention {
pub public_key: PublicKey,
pub range: Range<usize>,
}
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);
impl Mention {
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
Self { public_key, range }
}
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<PublicKey> {
fn extract_mentions(content: &str) -> Vec<Mention> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
.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)),
_ => None,
})
.collect::<Vec<_>>()
.collect()
}
/// 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.filter(TagKind::e()) {
for tag in inner.iter().filter(|tag| tag.kind() == "e") {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
for tag in inner.filter(TagKind::q()) {
for tag in inner.iter().filter(|tag| tag.kind() == "q") {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
+204 -153
View File
@@ -1,16 +1,16 @@
use std::cmp::Ordering;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::Error;
use common::EventUtils;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use instant::Duration;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::NewMessage;
@@ -22,7 +22,7 @@ pub struct SendReport {
pub receiver: PublicKey,
pub gift_wrap_id: Option<EventId>,
pub error: Option<SharedString>,
pub output: Option<Output<EventId>>,
pub output: Option<Output<EventId, EventSendStatus>>,
}
impl SendReport {
@@ -42,7 +42,7 @@ impl SendReport {
}
/// Set the output.
pub fn output(mut self, output: Output<EventId>) -> Self {
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
self.output = Some(output);
self
}
@@ -58,16 +58,44 @@ impl SendReport {
/// Returns true if the send is pending.
pub fn pending(&self) -> bool {
self.output.is_none() && self.error.is_none()
self.error.is_none()
&& self
.output
.as_ref()
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
}
/// Returns true if the send was successful.
pub fn success(&self) -> bool {
if let Some(output) = self.output.as_ref() {
!output.failed.is_empty()
} else {
false
}
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 }
}
}
@@ -144,7 +172,8 @@ impl From<&UnsignedEvent> for Room {
let members = val.extract_public_keys();
let subject = val
.tags
.find(TagKind::Subject)
.iter()
.find(|tag| tag.kind() == "subject")
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
Room {
@@ -178,7 +207,7 @@ impl Room {
// WARNING: never sign this event
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags)
.build(author);
.finalize_unsigned(author);
// Ensure that the ID is set
event.ensure_id();
@@ -205,10 +234,8 @@ impl Room {
/// Sets this room is ongoing conversation
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing;
cx.notify();
}
self.kind = RoomKind::Ongoing;
cx.notify();
}
/// Updates the creation timestamp of the room
@@ -244,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
pub fn members(&self) -> &[PublicKey] {
&self.members
}
/// Checks if the room has more than two members (group)
@@ -329,46 +356,39 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let sender = signer.public_key().unwrap();
// Get room's id
let id = self.id;
// Get all members, excluding the sender
let members: Vec<PublicKey> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.copied()
.collect();
let members = self.members().to_vec();
cx.background_spawn(async move {
let id = SubscriptionId::new(format!("room-{id}"));
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct filters for each member
let filters: Vec<Filter> = members
let tasks: Vec<_> = members
.into_iter()
.map(|public_key| {
Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)
let client = client.clone();
async move {
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
let announcement = Filter::new()
.author(public_key)
.kind(Kind::Custom(10044))
.limit(1);
client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.await
}
})
.collect();
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, filters.clone()))
.collect();
// Subscribe to the target
client.subscribe(target).close_on(opts).with_id(id).await?;
for result in futures::future::join_all(tasks).await {
result?;
}
Ok(())
})
@@ -378,12 +398,12 @@ impl Room {
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let conversation_id = self.id.to_string();
let room_id = self.id.to_string();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
let messages = client
.database()
@@ -399,12 +419,23 @@ impl Room {
}
// Construct a rumor event for direct message
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
pub fn rumor<S, I>(
&self,
content: S,
replies: I,
reaction: bool,
cx: &App,
) -> Option<UnsignedEvent>
where
S: Into<String>,
I: IntoIterator<Item = EventId>,
{
let kind = Kind::PrivateDirectMessage;
let kind = if reaction {
Kind::Reaction
} else {
Kind::PrivateDirectMessage
};
let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect();
@@ -412,24 +443,14 @@ impl Room {
let nostr = NostrRegistry::global(cx);
// Get current user's public key
let sender = nostr.read(cx).signer().public_key()?;
// Get all members, excluding the sender
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx))
.collect();
let sender = nostr.read(cx).current_user()?;
// Construct event's tags
let mut tags = vec![];
// Add subject tag if present
if let Some(value) = self.subject.as_ref() {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
tags.push(Tag::custom("subject", vec![value.to_string()]));
}
// Add all reply tags
@@ -437,21 +458,20 @@ impl Room {
tags.push(Tag::event(id))
}
// Add all receiver tags
for member in members.into_iter() {
tags.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
public_key: member.public_key(),
relay_url: member.messaging_relay_hint(),
alias: None,
uppercase: false,
},
));
// Add all receiver tags (no intermediate allocation)
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
let member = persons.read(cx).get(public_key, cx);
tags.push(Tag::from(Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}));
}
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
@@ -459,17 +479,45 @@ impl Room {
Some(event)
}
/// Select the appropriate signer based on signer kind and available keys.
fn select_signer(
signer_kind: &SignerKind,
has_announcement: bool,
encryption_signer: &Option<UniversalSigner>,
user_signer: &UniversalSigner,
) -> UniversalSigner {
match signer_kind {
SignerKind::Auto => {
if has_announcement {
encryption_signer
.clone()
.unwrap_or_else(|| user_signer.clone())
} else {
user_signer.clone()
}
}
SignerKind::Encryption => encryption_signer
.clone()
.expect("encryption signer must be set"),
SignerKind::User => user_signer.clone(),
}
}
/// 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 persons = PersonRegistry::global(cx);
let device = DeviceRegistry::global(cx);
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let user_signer = nostr.read(cx).signer();
let current_user = nostr.read(cx).current_user()?;
// Get current user's public key
let public_key = nostr.read(cx).signer().public_key()?;
let sender = persons.read(cx).get(&public_key, cx);
// Get sender's profile
let persons = PersonRegistry::global(cx);
let sender = persons.read(cx).get(&current_user, cx);
// Get all members (excluding sender)
let members: Vec<Person> = self
@@ -483,65 +531,45 @@ impl Room {
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();
if relays.is_empty() {
reports.push(SendReport::new(public_key).error("No messaging relays"));
continue;
}
// 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;
}
}
// Determine receiver and signer
let (receiver, signer) = match signer_kind {
SignerKind::Auto => {
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 {
(member.public_key(), user_signer.clone())
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
(
announcement.unwrap().public_key(),
encryption_signer.as_ref().unwrap().clone(),
)
}
SignerKind::User => (member.public_key(), user_signer.clone()),
};
// Determine the signer to use
let signer = Self::select_signer(
signer_kind,
announcement.is_some(),
&encryption_signer,
&user_signer,
);
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await
{
Ok((report, _)) => {
// 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(report) => {
Err(error) => {
let report = SendReport::new(public_key).error(error.to_string());
reports.push(report);
}
}
@@ -549,14 +577,22 @@ impl Room {
// Send backup to current user if needed
if backup && sents >= 1 {
let relays = sender.messaging_relays();
let public_key = sender.public_key();
let signer = encryption_signer.as_ref().unwrap_or(&user_signer);
match send_gift_wrap(&client, signer, &public_key, &rumor, relays, public_key).await
{
Ok((report, _)) => reports.push(report),
Err(report) => reports.push(report),
// Determine the signer to use
let signer = Self::select_signer(
signer_kind,
sender.announcement().is_some(),
&encryption_signer,
&user_signer,
);
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);
}
}
}
@@ -566,39 +602,54 @@ impl Room {
}
// Helper function to send a gift-wrapped event
async fn send_gift_wrap<T>(
async fn send_gift_wrap(
client: &Client,
signer: &T,
receiver: &PublicKey,
signer: &UniversalSigner,
receiver: &Person,
rumor: &UnsignedEvent,
relays: &[RelayUrl],
public_key: PublicKey,
) -> Result<(SendReport, bool), SendReport>
where
T: NostrSigner + 'static,
{
// Ensure relay connections
for url in relays {
client.add_relay(url).and_connect().await.ok();
}
config: &SignerKind,
) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", vec!["14"]);
let mut extra_tags = vec![k_tag];
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
Ok(event) => {
match client
.send_event(&event)
.to(relays)
.ack_policy(AckPolicy::none())
.await
{
Ok(output) => Ok((
SendReport::new(public_key)
.gift_wrap_id(event.id)
.output(output),
true,
)),
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
// 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()
}
}
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
}
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"));
}
}
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?;
// 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)
})?;
Ok(report)
}
+4 -5
View File
@@ -14,17 +14,16 @@ chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
once_cell = "1.19.0"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
+5 -15
View File
@@ -7,21 +7,11 @@ use settings::SignerKind;
#[action(namespace = chat, no_json)]
pub enum Command {
Insert(&'static str),
ChangeSubject(&'static str),
ChangeSubject(String),
ChangeSigner(SignerKind),
ToggleBackup,
Copy(PublicKey),
Relays(PublicKey),
Njump(PublicKey),
Trace(EventId),
}
#[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);
+762 -376
View File
File diff suppressed because it is too large Load Diff
-60
View File
@@ -1,60 +0,0 @@
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.",
)),
)
}
}
+281 -205
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, HighlightStyle, InteractiveText, IntoElement, SharedString,
StyledText, UnderlineStyle, Window,
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme;
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());
#[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Link,
Nostr,
Code,
InlineCode(bool),
Highlight(HighlightStyle),
Mention,
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
}
}
#[derive(Default)]
@@ -35,7 +35,12 @@ pub struct RenderedText {
}
impl RenderedText {
pub fn new(content: &str, cx: &App) -> Self {
pub fn new(
content: &str,
mentions: &[Mention],
persons: &Entity<PersonRegistry>,
cx: &App,
) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
@@ -43,10 +48,12 @@ impl RenderedText {
render_plain_text_mut(
content,
mentions,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
persons,
cx,
);
@@ -61,7 +68,8 @@ impl RenderedText {
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent;
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
InteractiveText::new(
id,
@@ -71,15 +79,36 @@ impl RenderedText {
(
range.clone(),
match highlight {
Highlight::Link => HighlightStyle {
color: Some(link_color),
underline: Some(UnderlineStyle::default()),
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
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()
}),
..Default::default()
},
Highlight::Highlight(highlight) => *highlight,
},
)
}),
@@ -87,22 +116,10 @@ impl RenderedText {
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
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}")
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
cx.open_url(url);
}
}
})
@@ -110,214 +127,273 @@ impl RenderedText {
}
}
#[allow(clippy::too_many_arguments)]
fn render_plain_text_mut(
content: &str,
block: &str,
mut mentions: &[Mention],
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,
) {
// Copy the content directly
text.push_str(content);
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
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();
for link in URL_REGEX.find_iter(content) {
let range = link.start()..link.end();
let url = link.as_str().to_string();
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
url_matches.push((range, url));
}
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
let prev_len = text.len();
// Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
match event {
Event::Text(t) => {
// Process text with mention replacements
let t_str = t.as_ref();
let mut last_processed = 0;
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();
while let Some(mention) = mentions.first() {
if !source_range.contains_inclusive(&mention.range) {
break;
}
// 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));
}
}
// 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;
// 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);
// 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,
);
}
// 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));
// Process the mention replacement
let profile = persons.read(cx).get(&mention.public_key, cx);
let replacement_text = format!("@{}", profile.name());
// 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;
};
let replacement_start = text.len();
text.push_str(&replacement_text);
let replacement_end = text.len();
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,
);
highlights.push((replacement_start..replacement_end, Highlight::Mention));
last_processed = mention_end_in_text;
mentions = &mentions[1..];
}
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
// 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(),
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'),
_ => {}
}
}
}
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
}
#[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>,
text: &mut String,
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()
});
}
/// 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);
// Add the text
text.push_str(segment);
let text_end = text.len();
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
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()
});
format!("{prefix}...{suffix}")
// Add highlight for the entire linked segment
if style != HighlightStyle::default() {
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
}
} else {
entity.to_string()
}
}
// Handle link detection within the segment
let mut finder = linkify::LinkFinder::new();
finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0;
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());
for link in finder.links(segment) {
let start = link.start();
let end = link.end();
text.replace_range(range.clone(), &display_name);
// 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;
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);
if style != HighlightStyle::default() {
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
}
}
highlights.push((new_range.clone(), Highlight::Nostr));
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Add the link
let range = (segment_start + start)..(segment_start + end);
link_ranges.push(range.clone());
link_urls.push(link.as_str().to_string());
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
// Apply link styling (underline + existing style)
let mut link_style = style;
link_style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
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>,
) {
let njump_url = format!("https://njump.me/{bech32}");
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
highlights.push((range, Highlight::Highlight(link_style)));
text.replace_range(range.clone(), &display_text);
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);
highlights.push((new_range.clone(), Highlight::Link));
link_ranges.push(new_range);
link_urls.push(njump_url);
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
// 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;
last_link_pos = end;
}
}
// 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;
// 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)));
}
}
}
}
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;
}
}
if !text.is_empty() {
if !text.ends_with('\n') {
text.push('\n');
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
}
+2 -3
View File
@@ -7,15 +7,14 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
nostr.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
chrono.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
log.workspace = true
dirs = "5.0"
qrcode = "0.14.1"
regex = "1.10"
+135
View File
@@ -0,0 +1,135 @@
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
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
use std::marker::PhantomData;
use std::time::Duration;
use instant::Duration;
use futures::channel::oneshot;
use futures::FutureExt;
+5 -18
View File
@@ -1,11 +1,10 @@
use std::sync::Arc;
use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone};
use gpui::{Image, ImageFormat, SharedString};
use nostr_sdk::prelude::*;
use qrcode::render::svg;
use qrcode::QrCode;
use qrcode::render::svg;
const NOW: &str = "now";
const SECONDS_IN_MINUTE: i64 = 60;
@@ -13,12 +12,12 @@ const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
pub trait RenderedTimestamp {
pub trait TimestampExt {
fn to_human_time(&self) -> SharedString;
fn to_ago(&self) -> SharedString;
}
impl RenderedTimestamp for Timestamp {
impl TimestampExt 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,
@@ -61,23 +60,11 @@ impl RenderedTimestamp for Timestamp {
}
}
pub trait TextUtils {
fn to_public_key(&self) -> Result<PublicKey, Error>;
pub trait StringExt {
fn to_qr(&self) -> Option<Arc<Image>>;
}
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"))
}
}
impl<T: AsRef<str>> StringExt for T {
fn to_qr(&self) -> Option<Arc<Image>> {
let s = self.as_ref();
let code = QrCode::new(s).unwrap();
+5 -5
View File
@@ -3,12 +3,12 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools;
use nostr_sdk::prelude::*;
pub trait EventUtils {
pub trait EventExt {
fn uniq_id(&self) -> u64;
fn extract_public_keys(&self) -> Vec<PublicKey>;
}
impl EventUtils for Event {
impl EventExt 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 EventUtils for Event {
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().collect()
}
}
impl EventUtils for UnsignedEvent {
impl EventExt for UnsignedEvent {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = vec![];
@@ -46,7 +46,7 @@ impl EventUtils for UnsignedEvent {
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect()
}
+8
View File
@@ -1,9 +1,17 @@
pub use caching::*;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use media_extractor::*;
pub use parser::*;
pub use paths::*;
pub use range::*;
mod caching;
mod debounced_delay;
mod display;
mod event;
mod media_extractor;
mod parser;
mod paths;
mod range;
+117
View File
@@ -0,0 +1,117 @@
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)
}
+210
View File
@@ -0,0 +1,210 @@
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,
}
}
}
+7 -6
View File
@@ -7,6 +7,13 @@ 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();
@@ -56,9 +63,3 @@ 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"))
}
+45
View File
@@ -0,0 +1,45 @@
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()
}
}
-67
View File
@@ -1,67 +0,0 @@
[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 = "1.0.0-beta"
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" }
title_bar = { path = "../title_bar" }
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_platform.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.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
webbrowser.workspace = true
indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
-127
View File
@@ -1,127 +0,0 @@
use std::sync::Arc;
use common::TextUtils;
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::dock_area::panel::{Panel, PanelEvent};
use ui::dock_area::ClosePanel;
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),
)
})
}
}
-292
View File
@@ -1,292 +0,0 @@
use anyhow::Error;
use device::DeviceRegistry;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use person::{shorten_pubkey, PersonRegistry};
use state::Announcement;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::notification::Notification;
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
const MSG: &str =
"Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
const NOTICE: &str = "By resetting your encryption key, you will lose access to \
all your encrypted messages before. This action cannot be undone.";
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
cx.new(|cx| EncryptionPanel::new(public_key, window, cx))
}
#[derive(Debug)]
pub struct EncryptionPanel {
name: SharedString,
focus_handle: FocusHandle,
/// User's public key
public_key: PublicKey,
/// Whether the panel is loading
loading: bool,
/// Tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl EncryptionPanel {
fn new(public_key: PublicKey, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Encryption".into(),
focus_handle: cx.focus_handle(),
public_key,
loading: false,
tasks: vec![],
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
let device = DeviceRegistry::global(cx);
let task = device.read(cx).approve(event, cx);
let id = event.id;
// Update loading status
self.set_loading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
// Reset loading status
this.set_loading(false, cx);
// Remove request
device.update(cx, |this, cx| {
this.remove_request(&id, cx);
});
window.push_notification("Approved", cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_loading(false, cx);
window.push_notification(Notification::error(e.to_string()), cx);
})?;
}
}
Ok(())
}));
}
fn render_requests(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
const TITLE: &str = "You've requested for the Encryption Key from:";
let device = DeviceRegistry::global(cx);
let requests = device.read(cx).requests.clone();
let mut items = Vec::new();
for event in requests.into_iter() {
let request = Announcement::from(&event);
let client_name = request.client_name();
let target = request.public_key();
items.push(
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(TITLE))
.child(
v_flex()
.h_12()
.items_center()
.justify_center()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(client_name.clone()),
)
.child(
h_flex()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(SharedString::from(target.to_hex())),
)
.child(
h_flex().justify_end().gap_2().child(
Button::new("approve")
.label("Approve")
.ghost()
.small()
.disabled(self.loading)
.loading(self.loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.approve(&event, window, cx);
})),
),
),
)
}
items
}
}
impl Panel for EncryptionPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for EncryptionPanel {}
impl Focusable for EncryptionPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for EncryptionPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let device = DeviceRegistry::global(cx);
let state = device.read(cx).state();
let has_requests = device.read(cx).has_requests();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&self.public_key, cx);
let Some(announcement) = profile.announcement() else {
return div();
};
let pubkey = SharedString::from(shorten_pubkey(announcement.public_key(), 16));
let client_name = announcement.client_name();
v_flex()
.p_3()
.gap_3()
.w_full()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
.child(divider(cx))
.child(
v_flex()
.gap_3()
.text_sm()
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Device Name:")),
)
.child(
h_flex()
.h_12()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(client_name.clone()),
),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Encryption Public Key:")),
)
.child(
h_flex()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(pubkey),
),
),
)
.when(has_requests, |this| {
this.child(divider(cx)).child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Requests:")),
)
.child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.children(self.render_requests(cx)),
),
)
})
.child(divider(cx))
.when(state.requesting(), |this| {
this.child(
h_flex()
.h_8()
.justify_center()
.text_xs()
.text_center()
.text_color(cx.theme().text_accent)
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.child(SharedString::from(
"Please open other device and approve the request",
)),
)
})
.child(
v_flex()
.gap_1()
.child(
Button::new("reset")
.icon(IconName::Reset)
.label("Reset")
.warning()
.small()
.font_semibold(),
)
.child(
div()
.italic()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.child(SharedString::from(NOTICE)),
),
)
}
}
-289
View File
@@ -1,289 +0,0 @@
use chat::{ChatRegistry, InboxState};
use gpui::prelude::FluentBuilder;
use gpui::{
div, 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::dock_area::dock::DockPlacement;
use ui::dock_area::panel::{Panel, PanelEvent};
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::Right,
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 chat = ChatRegistry::global(cx);
let nip17 = chat.read(cx).state(cx);
let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).relay_list_state();
let signer = nostr.read(cx).signer();
let owned = signer.owned();
let required_actions =
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
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()
.text_color(cx.theme().text)
.child(SharedString::from(TITLE)),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(DESCRIPTION)),
),
),
)
.when(required_actions, |this| {
this.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_2()
.w_full()
.text_xs()
.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.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.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_2()
.w_full()
.text_xs()
.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_2()
.w_full()
.text_xs()
.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("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(),
),
),
),
)
}
}
-371
View File
@@ -1,371 +0,0 @@
use std::time::Duration;
use anyhow::anyhow;
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::dock_area::panel::{Panel, PanelEvent};
use ui::dock_area::ClosePanel;
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)),
),
)
}
}
-648
View File
@@ -1,648 +0,0 @@
use std::sync::Arc;
use ::settings::AppSettings;
use chat::{ChatEvent, ChatRegistry, InboxState};
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use person::PersonRegistry;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, RelayState};
use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH};
use title_bar::TitleBar;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView;
use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
use crate::dialogs::settings;
use crate::panels::{
backup, contact_list, encryption_key, greeter, messaging_relays, profile, relay_list,
};
use crate::sidebar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
RefreshRelayList,
RefreshMessagingRelays,
ShowRelayList,
ShowMessaging,
ShowEncryption,
ShowProfile,
ShowSettings,
ShowBackup,
ShowContactList,
}
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));
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe system appearance and update theme
cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}),
);
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 on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::ShowSettings => {
let view = settings::init(window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.show_close(true)
.pb_4()
.title("Preferences")
.child(view.clone())
});
}
Command::ShowProfile => {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
}
Command::ShowContactList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(contact_list::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowBackup => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(backup::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowEncryption => {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(encryption_key::init(public_key, window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
}
Command::ShowMessaging => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(messaging_relays::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowRelayList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(relay_list::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::RefreshRelayList => {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.ensure_relay_list(cx);
});
}
Command::RefreshMessagingRelays => {
let chat = ChatRegistry::global(cx);
chat.update(cx, |this, cx| {
this.ensure_messaging_relays(cx);
});
}
Command::ToggleTheme => {
self.theme_selector(window, cx);
}
}
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx);
let themes = registry.read(cx).themes();
this.width(px(520.))
.show_close(true)
.title("Select theme")
.pb_4()
.child(v_flex().gap_2().w_full().children({
let mut items = vec![];
for (ix, (path, theme)) in themes.iter().enumerate() {
items.push(
h_flex()
.group("")
.px_2()
.h_8()
.w_full()
.justify_between()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(
h_flex()
.gap_1p5()
.flex_1()
.text_sm()
.child(theme.name.clone())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_muted)
.child(theme.author.clone()),
),
)
.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("url-{ix}"))
.icon(IconName::Link)
.ghost()
.small()
.on_click({
let theme = theme.clone();
move |_ev, _window, cx| {
cx.open_url(&theme.url);
}
}),
)
.child(
Button::new(format!("set-{ix}"))
.icon(IconName::Check)
.primary()
.small()
.on_click({
let path = path.clone();
move |_ev, window, cx| {
let settings = AppSettings::global(cx);
let path = path.clone();
settings.update(cx, |this, cx| {
this.set_theme(path, window, cx);
})
}
}),
),
),
);
}
items
}))
});
}
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let current_user = signer.public_key();
h_flex()
.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);
let avatar = profile.avatar();
let name = profile.name();
this.child(
Button::new("current-user")
.child(Avatar::new(avatar.clone()).xsmall())
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
let avatar = avatar.clone();
let name = name.clone();
this.min_w(px(256.))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(avatar.clone()).xsmall())
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.separator()
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
})
.when(nostr.read(cx).creating(), |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...")),
)
})
}
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let relay_list = nostr.read(cx).relay_list_state();
let chat = ChatRegistry::global(cx);
let inbox_state = chat.read(cx).state(cx);
let Some(pkey) = signer.public_key() else {
return div();
};
h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_3()
.child(
Button::new("key")
.icon(IconName::UserKey)
.tooltip("Decoupled encryption key")
.small()
.ghost()
.on_click(|_ev, window, cx| {
window.dispatch_action(Box::new(Command::ShowEncryption), cx);
}),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.map(|this| match inbox_state {
InboxState::Checking => this.child(div().child(
SharedString::from("Fetching user's messaging relay list..."),
)),
InboxState::RelayNotAvailable => {
this.child(div().text_color(cx.theme().warning_active).child(
SharedString::from(
"User hasn't configured a messaging relay list",
),
))
}
_ => this,
}),
)
.child(
Button::new("inbox")
.icon(IconName::Inbox)
.tooltip("Inbox")
.small()
.ghost()
.when(inbox_state.subscribing(), |this| this.indicator())
.dropdown_menu(move |this, _window, cx| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&pkey, cx);
let urls: Vec<SharedString> = profile
.messaging_relays()
.iter()
.map(|url| SharedString::from(url.to_string()))
.collect();
// Header
let menu = this.min_w(px(260.)).label("Messaging Relays");
// Content
let menu = urls.into_iter().fold(menu, |this, url| {
this.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div().size_1p5().rounded_full().bg(gpui::green()),
)
.child(url.clone())
}))
});
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshMessagingRelays),
)
.menu_with_icon(
"Update relays",
IconName::Settings,
Box::new(Command::ShowMessaging),
)
}),
),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.map(|this| match relay_list {
RelayState::Checking => this
.child(div().child(SharedString::from(
"Fetching user's relay list...",
))),
RelayState::NotConfigured => {
this.child(div().text_color(cx.theme().warning_active).child(
SharedString::from("User hasn't configured a relay list"),
))
}
_ => this,
}),
)
.child(
Button::new("relay-list")
.icon(IconName::Relay)
.tooltip("User's relay list")
.small()
.ghost()
.when(relay_list.configured(), |this| this.indicator())
.dropdown_menu(move |this, _window, cx| {
let nostr = NostrRegistry::global(cx);
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
// Header
let menu = this.min_w(px(260.)).label("Relays");
// Content
let menu = urls.into_iter().fold(menu, |this, url| {
this.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div().size_1p5().rounded_full().bg(gpui::green()),
)
.child(url.clone())
}))
});
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshRelayList),
)
.menu_with_icon(
"Update relay list",
IconName::Settings,
Box::new(Command::ShowRelayList),
)
}),
),
)
}
}
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"))
.on_action(cx.listener(Self::on_command))
.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)
}
}
+7 -5
View File
@@ -8,15 +8,17 @@ publish.workspace = true
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
instant.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+487 -383
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -10,8 +10,9 @@ state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
futures.workspace = true
+148 -126
View File
@@ -1,21 +1,21 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use std::sync::RwLock;
use anyhow::{anyhow, Error};
use common::EventUtils;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use anyhow::{Error, anyhow};
use common::EventExt;
use futures::FutureExt;
use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod person;
pub use person::*;
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
pub fn init(window: &mut Window, cx: &mut App) {
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -24,9 +24,9 @@ impl Global for GlobalPersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Box<Person>),
Announcement(Box<Event>),
Relays(Box<Event>),
Person(Person),
Announcement(Event),
Relays(Event),
}
/// Person Registry
@@ -36,13 +36,13 @@ pub struct PersonRegistry {
persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen
seen: Rc<RefCell<HashSet<PublicKey>>>,
seen: RwLock<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,46 +57,47 @@ impl PersonRegistry {
}
/// Create a new person registry instance
fn new(cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, 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::bounded::<PublicKey>(100);
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
let mut tasks = smallvec![];
tasks.push(
// Handle nostr notifications
cx.background_spawn({
let client = client.clone();
let client2 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_notifications(&client2, &tx).await;
}));
async move {
Self::handle_notifications(&client, &tx).await;
}
}),
);
let client3 = client.clone();
let executor = cx.background_executor().clone();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx, &executor).await;
}));
tasks.push(
// Handle metadata requests
cx.background_spawn({
let client = client.clone();
tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
// `update_in` (rather than `update`) routes through a
// try-borrow: on wasm a task poll that lands while the app
// context is borrowed can't panic and kill this consumer
// (which would stall the whole metadata pipeline).
this.update_in(cx, |this, _window, cx| {
// Drain the whole queue in a single update so a burst of
// events collapses into one repaint instead of one per
// event (important on wasm, where everything runs on the
// main thread).
let mut dispatch = vec![event];
while let Ok(extra) = rx.try_recv() {
dispatch.push(extra);
}
async move {
Self::handle_requests(&client, &mta_rx).await;
}
}),
);
tasks.push(
// Update GPUI state
cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
for event in dispatch {
match event {
Dispatch::Person(person) => {
this.insert(*person, cx);
this.insert(person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
@@ -105,39 +106,22 @@ impl PersonRegistry {
this.set_messaging_relays(&event, cx);
}
};
})
.ok();
}
}),
);
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 { 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}");
}
};
}),
);
})
.ok();
}
}));
// Load all user profiles from the database
cx.defer_in(window, |this, _window, cx| {
this.load(cx);
});
Self {
persons: HashMap::new(),
seen: Rc::new(RefCell::new(HashSet::new())),
sender: mta_tx,
_tasks: tasks,
seen: RwLock::new(HashSet::new()),
sender: metadata_tx,
tasks,
}
}
@@ -152,7 +136,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;
@@ -162,28 +146,25 @@ impl PersonRegistry {
Kind::Metadata => {
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();
if tx.send_async(Dispatch::Person(person)).await.is_err() {
log::warn!("PersonRegistry channel closed, dropping metadata event");
}
}
Kind::ContactList => {
let public_keys = event.extract_public_keys();
// Get metadata for all public keys
get_metadata(client, public_keys).await.ok();
if let Err(e) = get_metadata(client, public_keys).await {
log::warn!("Failed to get metadata for contact list: {e}");
}
}
Kind::InboxRelays => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Relays(val)).await.ok();
tx.send_async(Dispatch::Relays(event.into_owned()))
.await
.ok();
}
Kind::Custom(10044) => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Announcement(val)).await.ok();
tx.send_async(Dispatch::Announcement(event.into_owned()))
.await
.ok();
}
_ => {}
}
@@ -192,56 +173,114 @@ impl PersonRegistry {
}
/// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
///
/// Requests are collected into batches and flushed when the batch is
/// full or the timeout expires.
///
/// Note: `flume::Selector::wait_timeout` is intentionally not used here:
/// it relies on `std::time::Instant` and `thread::park_timeout`, which are
/// unavailable on `wasm32-unknown-unknown` (the former panics, the latter
/// is a no-op that would turn the wait into a busy loop on the main
/// thread).
async fn handle_requests(
client: &Client,
rx: &flume::Receiver<PublicKey>,
executor: &BackgroundExecutor,
) {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.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();
// Wait for the next request, or the batch timeout.
futures::select! {
result = rx.recv_async() => match result {
Ok(public_key) => {
batch.insert(public_key);
// Keep collecting until the batch is full
if batch.len() < 20 {
continue;
}
}
}
_ => {
get_metadata(client, std::mem::take(&mut batch)).await.ok();
}
Err(_) => return,
},
_ = executor.timer(Duration::from_secs(TIMEOUT)).fuse() => {}
}
// Flush the batch
if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
}
/// 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();
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();
Ok(persons)
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(persons) = task.await {
this.update_in(cx, |this, _window, cx| {
this.bulk_insert(persons, cx);
})
.ok();
}
}));
}
/// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
if let Some(person) = self.persons.get(&event.pubkey) {
let announcement = Announcement::from(event);
let announcement = Announcement::from(event);
if let Some(person) = self.persons.get(&event.pubkey) {
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) {
if let Some(person) = self.persons.get(&event.pubkey) {
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
if let Some(person) = self.persons.get(&event.pubkey) {
person.update(cx, |person, cx| {
person.set_messaging_relays(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>) {
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() {
self.persons.insert(person.public_key(), cx.new(|_| person));
let public_key = person.public_key();
self.persons
.entry(public_key)
.or_insert_with(|| cx.new(|_| person));
}
cx.notify();
}
@@ -270,15 +309,14 @@ impl PersonRegistry {
}
let public_key = *public_key;
let mut seen = self.seen.borrow_mut();
if seen.insert(public_key) {
if self.seen.write().unwrap().insert(public_key) {
let sender = self.sender.clone();
// Spawn background task to request metadata
cx.background_spawn(async move {
if let Err(e) = sender.send_async(public_key).await {
log::warn!("Failed to send public key for metadata request: {}", e);
log::warn!("Failed to send public key for metadata request: {e}");
}
})
.detach();
@@ -322,19 +360,3 @@ where
Ok(())
}
/// Load all user profiles from the database
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 mut persons = vec![];
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);
}
Ok(persons)
}
+53 -45
View File
@@ -5,8 +5,6 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -65,6 +63,21 @@ 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
@@ -75,21 +88,11 @@ impl Person {
self.metadata.clone()
}
/// Set profile metadata
pub fn set_metadata(&mut self, metadata: Metadata) {
self.metadata = metadata;
}
/// Get profile encryption keys announcement
pub fn announcement(&self) -> Option<Announcement> {
self.announcement.clone()
}
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
}
/// Get profile messaging relays
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
&self.messaging_relays
@@ -100,6 +103,43 @@ impl Person {
self.messaging_relays.first().cloned()
}
/// Get profile avatar
pub fn avatar(&self) -> SharedString {
self.metadata
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}
/// 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.trim());
}
if let Some(name) = self.metadata.name.as_ref()
&& !name.is_empty()
{
return SharedString::from(name.trim());
}
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
@@ -107,38 +147,6 @@ impl Person {
{
self.messaging_relays = relays.into_iter().collect();
}
/// Get profile avatar
pub fn avatar(&self) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
let url = format!(
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
/// Get profile name
pub fn name(&self) -> SharedString {
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() {
if !name.is_empty() {
return SharedString::from(name);
}
}
SharedString::from(shorten_pubkey(self.public_key(), 4))
}
}
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
@@ -148,7 +156,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..]
)
-378
View File
@@ -1,378 +0,0 @@
use std::borrow::Cow;
use std::cell::Cell;
use std::collections::HashSet;
use std::hash::Hash;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Task, Window,
};
use nostr_sdk::prelude::*;
use settings::{AppSettings, AuthMode};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
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.";
pub fn init(window: &mut Window, cx: &mut App) {
RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx);
}
/// Authentication request
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
struct AuthRequest {
url: RelayUrl,
challenge: String,
}
impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
url,
}
}
pub fn url(&self) -> &RelayUrl {
&self.url
}
pub fn challenge(&self) -> &str {
&self.challenge
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Signal {
Auth(Arc<AuthRequest>),
Pending((EventId, RelayUrl)),
}
struct GlobalRelayAuth(Entity<RelayAuth>);
impl Global for GlobalRelayAuth {}
// Relay authentication
#[derive(Debug)]
pub struct RelayAuth {
/// Pending events waiting for resend after authentication
pending_events: HashSet<(EventId, RelayUrl)>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 2]>,
}
impl RelayAuth {
/// Retrieve the global relay auth state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRelayAuth>().0.clone()
}
/// Set the global relay auth instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalRelayAuth(state));
}
/// Create a new relay auth instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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 {
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 {
RelayMessage::Auth { challenge } => {
if challenges.insert(challenge.clone()) {
let request = Arc::new(AuthRequest::new(challenge, relay_url));
let signal = Signal::Auth(request);
tx.send_async(signal).await.ok();
}
}
RelayMessage::Closed {
subscription_id,
message,
} => {
let msg = MachineReadablePrefix::parse(&message);
if let Some(MachineReadablePrefix::AuthRequired) = msg {
if let Ok(Some(relay)) = client.relay(&relay_url).await {
// Send close message to relay
relay
.send_msg(ClientMessage::Close(subscription_id))
.await
.ok();
}
}
}
RelayMessage::Ok {
event_id, message, ..
} => {
let msg = MachineReadablePrefix::parse(&message);
// Handle authentication messages
if let Some(MachineReadablePrefix::AuthRequired) = msg {
let signal = Signal::Pending((event_id, relay_url));
tx.send_async(signal).await.ok();
}
}
_ => {}
}
}
}
}));
tasks.push(cx.spawn_in(window, async move |this, cx| {
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Auth(req) => {
this.update_in(cx, |this, window, cx| {
this.handle_auth(&req, window, cx);
})
.ok();
}
Signal::Pending((event_id, relay_url)) => {
this.update_in(cx, |this, _window, cx| {
this.insert_pending_event(event_id, relay_url, cx);
})
.ok();
}
}
}
}));
Self {
pending_events: HashSet::default(),
_tasks: tasks,
}
}
/// Insert a pending event waiting for resend after authentication
fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context<Self>) {
self.pending_events.insert((id, relay));
cx.notify();
}
/// Get all pending events for a specific relay,
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
self.pending_events
.iter()
.filter(|(_, pending_relay)| pending_relay == relay)
.map(|(id, _relay)| id)
.cloned()
.collect()
}
/// Clear all pending events for a specific relay,
fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context<Self>) {
self.pending_events
.retain(|(_, pending_relay)| pending_relay != relay);
cx.notify();
}
/// Handle authentication request
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
let settings = AppSettings::global(cx);
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
let mode = AppSettings::get_auth_mode(cx);
if trusted_relay && mode == AuthMode::Auto {
// Automatically authenticate if the relay is authenticated before
self.response(req, window, cx);
} else {
// Otherwise open the auth request popup
self.ask_for_approval(req, window, cx);
}
}
/// Send auth response and wait for confirmation
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let req = req.clone();
// Get all pending events for the relay
let pending_events = self.get_pending_events(req.url(), cx);
cx.background_spawn(async move {
// Construct event
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;
// Get the relay
let relay = client.relay(req.url()).await?.context("Relay not found")?;
// Subscribe to notifications
let mut notifications = relay.notifications();
// Send the AUTH message
relay
.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: 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,
_ => {}
}
}
Err(anyhow!("Authentication failed"))
})
}
/// Respond to an authentication request.
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let settings = AppSettings::global(cx);
let req = req.clone();
let challenge = req.challenge().to_string();
// Create a task for authentication
let task = self.auth(&req, cx);
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
let url = req.url();
this.update_in(cx, |this, window, cx| {
window.clear_notification(challenge, 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();
})
.detach();
}
/// Push a popup to approve the authentication request.
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let notification = self.notification(req, cx);
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
window.push_notification(notification, cx);
})
.ok();
})
.detach();
}
/// Build a notification for the authentication request.
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
let req = req.clone();
let url = SharedString::from(req.url().to_string());
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
Notification::new()
.custom_id(SharedString::from(&req.challenge))
.autohide(false)
.icon(IconName::Info)
.title(SharedString::from("Authentication Required"))
.content(move |_window, cx| {
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(AUTH_MESSAGE))
.child(
v_flex()
.py_1()
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(url.clone()),
)
.into_any_element()
})
.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);
})
.ok();
}
})
})
}
}
+3 -1
View File
@@ -10,7 +10,6 @@ common = { path = "../common" }
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
@@ -18,3 +17,6 @@ serde.workspace = true
serde_json.workspace = true
paste = "1.0.15"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+80 -86
View File
@@ -1,13 +1,11 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::rc::Rc;
use anyhow::{anyhow, Error};
use anyhow::{Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{smallvec, SmallVec};
use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) {
@@ -20,13 +18,15 @@ macro_rules! setting_accessors {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).values.$field.clone()
Self::global(cx).read(cx).inner.read(cx).$field.clone()
}
pub fn [<update_ $field>](value: $type, cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.values.$field = value;
cx.notify();
this.inner.update(cx, |inner, cx| {
inner.$field = value;
cx.notify();
});
});
}
}
@@ -40,36 +40,18 @@ setting_accessors! {
pub theme_mode: ThemeMode,
pub hide_avatar: bool,
pub screening: bool,
pub auth_mode: AuthMode,
pub trusted_relays: HashSet<RelayUrl>,
pub room_configs: HashMap<u64, RoomConfig>,
pub nip4e: bool,
pub trusted_relays: Vec<String>,
pub file_server: Url,
}
/// Authentication mode
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AuthMode {
#[default]
Auto,
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 {
#[default]
Auto,
User,
Encryption,
#[default]
User,
}
impl SignerKind {
@@ -97,7 +79,7 @@ impl RoomConfig {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::Auto,
signer_kind: SignerKind::default(),
}
}
@@ -137,14 +119,11 @@ pub struct Settings {
/// Enable screening for unknown chat requests
pub screening: bool,
/// Authentication mode
pub auth_mode: AuthMode,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: HashSet<RelayUrl>,
/// Configuration for each chat room
pub room_configs: HashMap<u64, RoomConfig>,
pub trusted_relays: Vec<String>,
/// Server for blossom media attachments
pub file_server: Url,
@@ -157,9 +136,8 @@ impl Default for Settings {
theme_mode: ThemeMode::default(),
hide_avatar: false,
screening: true,
auth_mode: AuthMode::default(),
trusted_relays: HashSet::default(),
room_configs: HashMap::default(),
nip4e: false,
trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(),
}
}
@@ -178,7 +156,7 @@ impl Global for GlobalAppSettings {}
/// Application settings
pub struct AppSettings {
/// Settings
values: Settings,
inner: Entity<Settings>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
@@ -196,11 +174,12 @@ impl AppSettings {
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let inner = cx.new(|_| Settings::default());
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe and automatically save settings on changes
cx.observe_self(|this, cx| {
cx.observe(&inner, |this, _inner, cx| {
this.save(cx);
}),
);
@@ -211,27 +190,30 @@ impl AppSettings {
});
Self {
values: Settings::default(),
inner,
_subscriptions: subscriptions,
}
}
/// Update settings
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
self.values = settings;
cx.notify();
self.inner.update(cx, |this, cx| {
*this = settings;
cx.notify();
});
}
/// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
Ok(serde_json::from_str(&content)?)
} else {
Err(anyhow!("Not found"))
#[cfg(not(target_arch = "wasm32"))]
{
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?);
}
}
Err(anyhow!("Not found"))
});
cx.spawn_in(window, async move |this, cx| {
@@ -249,19 +231,15 @@ impl AppSettings {
/// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) {
let settings = self.values.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings");
let content = serde_json::to_string(&settings)?;
// Write settings to file
smol::fs::write(&path, content).await?;
Ok(())
});
task.detach();
let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) {
#[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move {
let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok();
})
.detach();
}
}
/// Set theme
@@ -270,50 +248,66 @@ impl AppSettings {
T: Into<String>,
{
// Update settings
self.values.theme = Some(theme.into());
cx.notify();
self.inner.update(cx, |this, cx| {
this.theme = Some(theme.into());
cx.notify();
});
// 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.values.theme.as_ref() {
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);
}
}
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None;
self.apply_theme(window, cx);
/// Check if decoupling encryption key is enabled
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
self.inner.read(cx).nip4e
}
/// Check if the given relay is already authenticated
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()
})
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())
}
/// Add a relay to the trusted list
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
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();
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();
}
});
}
}
+14 -8
View File
@@ -9,21 +9,27 @@ common = { path = "../common" }
nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-connect.workspace = true
nostr-gossip-memory.workspace = true
nostr-blossom.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
smol.workspace = true
instant.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
serde.workspace = true
serde_json.workspace = true
rustls = "0.23"
petname = "2.0.2"
whoami = "1.6.1"
mime_guess = "2.0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
rustls = "0.23"
+8 -1
View File
@@ -1,12 +1,14 @@
use std::path::PathBuf;
use anyhow::{anyhow, Error};
use anyhow::{Error, anyhow};
use gpui::AsyncApp;
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
use mime_guess::from_path;
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
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?;
@@ -25,3 +27,8 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Er
.await
.map_err(|e| anyhow!("Upload error: {e}"))?
}
#[cfg(target_arch = "wasm32")]
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
Err(anyhow!("File upload not supported on web"))
}
+17 -24
View File
@@ -1,5 +1,3 @@
use std::sync::OnceLock;
/// Client name (Application name)
pub const CLIENT_NAME: &str = "Coop";
@@ -10,51 +8,46 @@ pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5w
pub const APP_ID: &str = "su.reya.coop";
/// Keyring name
pub const KEYRING: &str = "Coop Safe Storage";
pub const MASTER_KEYRING: &str = "Coop Master Key";
pub const USER_KEYRING: &str = "Coop User Credential";
/// 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; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://nos.lol",
"wss://relay.damus.io",
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
];
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})")
})
}
-83
View File
@@ -1,83 +0,0 @@
use std::collections::{HashMap, HashSet};
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// Gossip
#[derive(Debug, Clone, Default)]
pub struct Gossip {
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
}
impl Gossip {
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.map(|(url, _)| url.to_string().into())
.collect()
})
.unwrap_or_default()
}
/// Get read relays for a given public key
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Get write relays for a given public key
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Insert gossip relays for a public key
pub fn insert_relays(&mut self, event: &Event) {
self.relays.entry(event.pubkey).or_default().extend(
event
.tags
.iter()
.filter_map(|tag| {
if let Some(TagStandard::RelayMetadata {
relay_url,
metadata,
}) = tag.clone().to_standardized()
{
Some((relay_url, metadata))
} else {
None
}
})
.take(3),
);
}
}
+303 -693
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,9 +1,10 @@
use std::sync::Arc;
use anyhow::Error;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, HttpClient};
use nostr_sdk::prelude::*;
use smol::io::AsyncReadExt;
use serde_json::Value;
#[allow(async_fn_in_trait)]
pub trait NostrAddress {
@@ -1,28 +1,6 @@
use gpui::SharedString;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum DeviceState {
#[default]
Idle,
Requesting,
Set,
}
impl DeviceState {
pub fn idle(&self) -> bool {
matches!(self, DeviceState::Idle)
}
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 {
@@ -38,14 +16,15 @@ impl From<&Event> for Announcement {
let public_key = val
.tags
.iter()
.find(|tag| tag.kind().as_str() == "n")
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.unwrap_or(val.pubkey);
let client_name = val
.tags
.find(TagKind::Client)
.iter()
.find(|tag| tag.kind() == "client")
.and_then(|tag| tag.content())
.map(|c| c.to_string());
+178 -118
View File
@@ -1,146 +1,206 @@
use std::borrow::Cow;
use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::error::Error;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use nostr_connect::client::AuthUrlHandler;
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
#[derive(Debug)]
pub struct CoopSigner {
/// User's signer
signer: RwLock<Arc<dyn NostrSigner>>,
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
/// User's signer public key
signer_pkey: RwLock<Option<PublicKey>>,
/// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
/// 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 fmt::Display for UniversalSignerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl CoopSigner {
impl Error for UniversalSignerError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&*self.0)
}
}
impl UniversalSignerError {
pub fn new<E>(err: E) -> Self
where
E: Error + Send + Sync + 'static,
{
UniversalSignerError(Box::new(err))
}
}
#[derive(Clone, Debug)]
pub struct UniversalSigner {
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
}
impl UniversalSigner {
pub fn new<T>(signer: T) -> Self
where
T: IntoNostrSigner,
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
Self {
signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None),
owned: AtomicBool::new(false),
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
}
}
/// 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 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)
/// Swap the inner signer in-place. All clones see the new signer.
pub fn swap_inner<T>(&self, new_signer: T)
where
T: IntoNostrSigner,
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
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());
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_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,
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'a, Result<Event, SignerError>> {
Box::pin(async move { self.get().await.sign_event(unsigned).await })
}
fn nip04_encrypt<'a>(
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
fn nip44_encrypt_async<'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>(
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
fn nip44_decrypt_async<'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 })
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
}
#[derive(Debug)]
struct InnerSignerImpl<T>(T);
impl<T> InnerSigner for InnerSignerImpl<T>
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
Box::pin(async move {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
.map_err(UniversalSignerError::new)
})
}
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
Box::pin(async move {
AsyncSignEvent::sign_event_async(&self.0, unsigned)
.await
.map_err(UniversalSignerError::new)
})
}
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
.map_err(UniversalSignerError::new)
})
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
.await
.map_err(UniversalSignerError::new)
})
}
}
impl UniversalSigner {
#[allow(dead_code)]
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
let guard = self.inner.read().expect("RwLock poisoned");
f(&**guard)
}
}
impl AsyncGetPublicKey for UniversalSigner {
type Error = UniversalSignerError;
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.get_public_key_async().await })
}
}
impl AsyncSignEvent for UniversalSigner {
type Error = UniversalSignerError;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.sign_event_async(unsigned).await })
}
}
impl AsyncNip44 for UniversalSigner {
type Error = UniversalSignerError;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
}
}
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
fn on_auth_url(
&self,
auth_url: Url,
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str()).unwrap();
Ok(())
})
}
}
+24 -18
View File
@@ -1,4 +1,4 @@
use gpui::{hsla, Hsla, Rgba};
use gpui::{Hsla, Rgba, hsla};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -30,6 +30,8 @@ pub struct ThemeColors {
pub text_muted: Hsla,
pub text_placeholder: Hsla,
pub text_accent: Hsla,
pub text_danger: Hsla,
pub text_warning: Hsla,
// Icon colors
pub icon: Hsla,
@@ -77,11 +79,11 @@ pub struct ThemeColors {
pub ghost_element_disabled: Hsla,
// Tab colors
pub tab_inactive_background: Hsla,
pub tab_inactive_foreground: Hsla,
pub tab_background: Hsla,
pub tab_foreground: Hsla,
pub tab_hover_background: Hsla,
pub tab_active_background: Hsla,
pub tab_active_foreground: Hsla,
pub tab_hover_foreground: Hsla,
// Scrollbar colors
pub scrollbar_thumb_background: Hsla,
@@ -111,7 +113,7 @@ impl ThemeColors {
panel_background: neutral().light().step_1(),
overlay: neutral().light_alpha().step_3(),
title_bar: neutral().light().step_2(),
title_bar_inactive: neutral().light().step_3(),
title_bar_inactive: neutral().light().step_1(),
window_border: hsl(240.0, 5.9, 78.0),
border: neutral().light().step_6(),
@@ -125,7 +127,9 @@ impl ThemeColors {
text: neutral().light().step_12(),
text_muted: neutral().light().step_11(),
text_placeholder: neutral().light().step_10(),
text_accent: brand().light().step_11(),
text_accent: brand().light().step_9(),
text_danger: danger().light().step_9(),
text_warning: warning().light().step_9(),
icon: neutral().light().step_11(),
icon_muted: neutral().light().step_10(),
@@ -166,17 +170,17 @@ impl ThemeColors {
ghost_element_selected: neutral().light().step_5(),
ghost_element_disabled: neutral().light_alpha().step_2(),
tab_inactive_background: neutral().light().step_2(),
tab_inactive_foreground: neutral().light().step_11(),
tab_background: neutral().light().step_3(),
tab_foreground: neutral().light().step_11(),
tab_hover_background: neutral().light_alpha().step_4(),
tab_active_background: neutral().light().step_1(),
tab_active_foreground: neutral().light().step_12(),
tab_hover_foreground: brand().light().step_9(),
scrollbar_thumb_background: neutral().light_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: gpui::transparent_black(),
scrollbar_track_border: neutral().light().step_5(),
scrollbar_track_border: gpui::transparent_black(),
drop_target_background: brand().light_alpha().step_2(),
cursor: hsl(200., 100., 50.),
@@ -192,9 +196,9 @@ impl ThemeColors {
background: neutral().dark().step_1(),
surface_background: neutral().dark().step_2(),
elevated_surface_background: neutral().dark().step_3(),
panel_background: gpui::black(),
panel_background: neutral().dark().step_1(),
overlay: neutral().dark_alpha().step_3(),
title_bar: gpui::transparent_black(),
title_bar: neutral().dark().step_2(),
title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0),
@@ -209,7 +213,9 @@ impl ThemeColors {
text: neutral().dark().step_12(),
text_muted: neutral().dark().step_11(),
text_placeholder: neutral().dark().step_10(),
text_accent: brand().dark().step_11(),
text_accent: brand().dark().step_9(),
text_danger: danger().dark().step_9(),
text_warning: warning().dark().step_9(),
icon: neutral().dark().step_11(),
icon_muted: neutral().dark().step_10(),
@@ -250,17 +256,17 @@ impl ThemeColors {
ghost_element_selected: neutral().dark().step_5(),
ghost_element_disabled: neutral().dark_alpha().step_2(),
tab_inactive_background: neutral().dark().step_2(),
tab_inactive_foreground: neutral().dark().step_11(),
tab_active_background: neutral().dark().step_3(),
tab_background: neutral().dark().step_3(),
tab_foreground: neutral().dark().step_11(),
tab_hover_background: neutral().dark_alpha().step_4(),
tab_active_background: neutral().dark().step_1(),
tab_active_foreground: neutral().dark().step_12(),
tab_hover_foreground: brand().dark().step_9(),
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: gpui::transparent_black(),
scrollbar_track_border: neutral().dark().step_5(),
scrollbar_track_border: gpui::transparent_black(),
drop_target_background: brand().dark_alpha().step_2(),
cursor: hsl(200., 100., 50.),
+159
View File
@@ -0,0 +1,159 @@
use std::fmt::{self, Debug, Display, Formatter};
use gpui::{AbsoluteLength, Axis, Length, Pixels};
use serde::{Deserialize, Serialize};
/// A enum for defining the placement of the element.
///
/// See also: [`Side`] if you need to define the left, right side.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Placement {
#[serde(rename = "top")]
Top,
#[serde(rename = "bottom")]
Bottom,
#[serde(rename = "left")]
Left,
#[serde(rename = "right")]
Right,
}
impl Display for Placement {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Placement::Top => write!(f, "Top"),
Placement::Bottom => write!(f, "Bottom"),
Placement::Left => write!(f, "Left"),
Placement::Right => write!(f, "Right"),
}
}
}
impl Placement {
#[inline]
pub fn is_horizontal(&self) -> bool {
matches!(self, Placement::Left | Placement::Right)
}
#[inline]
pub fn is_vertical(&self) -> bool {
matches!(self, Placement::Top | Placement::Bottom)
}
#[inline]
pub fn axis(&self) -> Axis {
match self {
Placement::Top | Placement::Bottom => Axis::Vertical,
Placement::Left | Placement::Right => Axis::Horizontal,
}
}
}
/// A enum for defining the side of the element.
///
/// See also: [`Placement`] if you need to define the 4 edges.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Side {
#[serde(rename = "left")]
Left,
#[serde(rename = "right")]
Right,
}
impl Side {
/// Returns true if the side is left.
#[inline]
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
/// Returns true if the side is right.
#[inline]
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// A trait to extend the [`Axis`] enum with utility methods.
pub trait AxisExt {
#[allow(clippy::wrong_self_convention)]
fn is_horizontal(self) -> bool;
#[allow(clippy::wrong_self_convention)]
fn is_vertical(self) -> bool;
}
impl AxisExt for Axis {
#[inline]
fn is_horizontal(self) -> bool {
self == Axis::Horizontal
}
#[inline]
fn is_vertical(self) -> bool {
self == Axis::Vertical
}
}
/// A trait for converting [`Pixels`] to `f32` and `f64`.
pub trait PixelsExt {
fn as_f32(&self) -> f32;
#[allow(clippy::wrong_self_convention)]
fn as_f64(self) -> f64;
}
impl PixelsExt for Pixels {
fn as_f32(&self) -> f32 {
f32::from(self)
}
fn as_f64(self) -> f64 {
f64::from(self)
}
}
/// A trait to extend the [`Length`] enum with utility methods.
pub trait LengthExt {
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
///
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
}
impl LengthExt for Length {
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
match self {
Length::Auto => None,
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
}
}
}
/// A struct for defining the edges of an element.
///
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
#[repr(C)]
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
/// The size of the top edge.
pub top: T,
/// The size of the right edge.
pub right: T,
/// The size of the bottom edge.
pub bottom: T,
/// The size of the left edge.
pub left: T,
}
impl<T> Edges<T>
where
T: Clone + Debug + Default + PartialEq,
{
/// Creates a new `Edges` instance with all edges set to the same value.
pub fn all(value: T) -> Self {
Self {
top: value.clone(),
right: value.clone(),
bottom: value.clone(),
left: value,
}
}
}
+11 -4
View File
@@ -1,9 +1,11 @@
use std::ops::{Deref, DerefMut};
use std::rc::Rc;
use gpui::{px, App, Global, Pixels, SharedString, Window};
use gpui::{App, Global, Pixels, SharedString, Window, px};
mod colors;
mod geometry;
mod notification;
mod platform_kind;
mod registry;
mod scale;
@@ -11,6 +13,8 @@ mod scrollbar_mode;
mod theme;
pub use colors::*;
pub use geometry::*;
pub use notification::*;
pub use platform_kind::PlatformKind;
pub use registry::*;
pub use scale::*;
@@ -30,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(28.0);
pub const TABBAR_HEIGHT: Pixels = px(44.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
@@ -82,6 +86,9 @@ pub struct Theme {
/// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode,
/// Notification settings
pub notification: NotificationSettings,
/// Platform kind
pub platform: PlatformKind,
}
@@ -185,7 +192,6 @@ impl From<ThemeFamily> for Theme {
let mode = ThemeMode::default();
// Define the font family based on the platform.
// TODO: Use native fonts on Linux too.
let font_family = match platform {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",
@@ -200,10 +206,11 @@ impl From<ThemeFamily> for Theme {
Theme {
font_size: px(15.),
font_family: font_family.into(),
radius: px(5.),
radius: px(6.),
radius_lg: px(10.),
shadow: true,
scrollbar_mode: ScrollbarMode::default(),
notification: NotificationSettings::default(),
mode,
colors: *colors,
theme: Rc::new(family),
+30
View File
@@ -0,0 +1,30 @@
use gpui::{Anchor, Pixels, px};
use crate::{Edges, TITLEBAR_HEIGHT};
/// The settings for notifications.
#[derive(Debug, Clone)]
pub struct NotificationSettings {
/// The placement of the notification, default: [`Anchor::TopRight`]
pub placement: Anchor,
/// The margins of the notification with respect to the window edges.
pub margins: Edges<Pixels>,
/// The maximum number of notifications to show at once, default: 10
pub max_items: usize,
}
impl Default for NotificationSettings {
fn default() -> Self {
let offset = px(16.);
Self {
placement: Anchor::TopRight,
margins: Edges {
top: TITLEBAR_HEIGHT + offset, // avoid overlap with title bar
right: offset,
bottom: offset,
left: offset,
},
max_items: 10,
}
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ pub enum PlatformKind {
Mac,
Linux,
Windows,
Web,
}
impl PlatformKind {
@@ -11,22 +12,21 @@ impl PlatformKind {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
} else if cfg!(target_os = "macos") {
Self::Mac
} else {
Self::Web
}
}
#[allow(dead_code)]
pub fn is_linux(&self) -> bool {
matches!(self, Self::Linux)
}
#[allow(dead_code)]
pub fn is_windows(&self) -> bool {
matches!(self, Self::Windows)
}
#[allow(dead_code)]
pub fn is_mac(&self) -> bool {
matches!(self, Self::Mac)
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "title_bar"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
smallvec.workspace = true
anyhow.workspace = true
log.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = ["Wdk_System_SystemServices"] }
[target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"
-174
View File
@@ -1,174 +0,0 @@
use gpui::prelude::FluentBuilder;
#[cfg(target_os = "linux")]
use gpui::MouseButton;
#[cfg(not(target_os = "windows"))]
use gpui::Pixels;
use gpui::{
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
};
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
use ui::h_flex;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
use crate::platforms::windows::WindowsWindowControls;
mod platforms;
/// Titlebar
pub struct TitleBar {
/// Children elements of the title bar.
children: SmallVec<[AnyElement; 2]>,
/// Whether the title bar is currently being moved.
should_move: bool,
}
impl TitleBar {
pub fn new() -> Self {
Self {
children: smallvec![],
should_move: false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn height(&self, window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(&self, _window: &mut Window) -> Pixels {
px(32.)
}
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().title_bar
} else {
cx.theme().title_bar_inactive
}
} else {
cx.theme().title_bar
}
}
pub fn set_children<T>(&mut self, children: T)
where
T: IntoIterator<Item = AnyElement>,
{
self.children = children.into_iter().collect();
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
impl Render for TitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let height = self.height(window);
let color = self.titlebar_color(window, cx);
let children = std::mem::take(&mut self.children);
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
h_flex()
.window_control_area(WindowControlArea::Drag)
.h(height)
.w_full()
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
})
.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.bg(color)
.border_b_1()
.border_color(cx.theme().border)
.content_stretch()
.child(
h_flex()
.id("title-bar")
.justify_between()
.w_full()
.when(cx.theme().platform.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.zoom_window();
}
})
})
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.children(children),
)
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
PlatformKind::Linux => {
#[cfg(target_os = "linux")]
if matches!(decorations, Decorations::Client { .. }) {
this.child(LinuxWindowControls::new(None))
.when(supported_controls.window_menu, |this| {
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
window.show_window_menu(ev.position)
})
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.should_move {
this.should_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = true;
}),
)
} else {
this
}
#[cfg(not(target_os = "linux"))]
this
}
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
PlatformKind::Mac => this,
})
}
}
-227
View File
@@ -1,227 +0,0 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use linicon::{lookup_icon, IconType};
use theme::ActiveTheme;
use ui::{h_flex, Icon, IconName, Sizable};
#[derive(IntoElement)]
pub struct LinuxWindowControls {
close_window_action: Option<Box<dyn Action>>,
}
impl LinuxWindowControls {
pub fn new(close_window_action: Option<Box<dyn Action>>) -> Self {
Self {
close_window_action,
}
}
}
impl RenderOnce for LinuxWindowControls {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let supported_controls = window.window_controls();
h_flex()
.id("linux-window-controls")
.gap_2()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.when(supported_controls.minimize, |this| {
this.child(WindowControl::new(
LinuxControl::Minimize,
IconName::WindowMinimize,
))
})
.when(supported_controls.maximize, |this| {
this.child({
if window.is_maximized() {
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
} else {
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
}
})
})
.child(
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
.when_some(self.close_window_action, |this, close_action| {
this.close_action(close_action)
}),
)
}
}
#[derive(IntoElement)]
pub struct WindowControl {
kind: LinuxControl,
fallback: IconName,
close_action: Option<Box<dyn Action>>,
}
impl WindowControl {
pub fn new(kind: LinuxControl, fallback: IconName) -> Self {
Self {
kind,
fallback,
close_action: None,
}
}
pub fn close_action(mut self, action: Box<dyn Action>) -> Self {
self.close_action = Some(action);
self
}
pub fn is_gnome(&self) -> bool {
matches!(detect_desktop_environment(), DesktopEnvironment::Gnome)
}
}
impl RenderOnce for WindowControl {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_gnome = self.is_gnome();
h_flex()
.id(self.kind.as_icon_name())
.group("")
.justify_center()
.items_center()
.rounded_full()
.size_6()
.when(is_gnome, |this| {
this.bg(cx.theme().ghost_element_background_alt)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.active(|this| this.bg(cx.theme().ghost_element_active))
})
.map(|this| {
if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() {
this.child(
svg()
.external_path(SharedString::from(path))
.size_4()
.text_color(cx.theme().text),
)
} else {
this.child(Icon::new(self.fallback).small().text_color(cx.theme().text))
}
})
.on_mouse_move(|_, _window, cx| cx.stop_propagation())
.on_click(move |_, window, cx| {
cx.stop_propagation();
match self.kind {
LinuxControl::Minimize => window.minimize_window(),
LinuxControl::Restore => window.zoom_window(),
LinuxControl::Maximize => window.zoom_window(),
LinuxControl::Close => cx.quit(),
}
})
}
}
static DE: OnceLock<DesktopEnvironment> = OnceLock::new();
static LINUX_CONTROLS: OnceLock<HashMap<LinuxControl, Option<String>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DesktopEnvironment {
Gnome,
Kde,
Unknown,
}
/// Detect the current desktop environment
pub fn detect_desktop_environment() -> &'static DesktopEnvironment {
DE.get_or_init(|| {
// Try to use environment variables first
if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = output.to_lowercase();
if desktop.contains("gnome") {
return DesktopEnvironment::Gnome;
} else if desktop.contains("kde") {
return DesktopEnvironment::Kde;
}
}
// Fallback detection methods
if let Ok(output) = std::env::var("DESKTOP_SESSION") {
let session = output.to_lowercase();
if session.contains("gnome") {
return DesktopEnvironment::Gnome;
} else if session.contains("kde") || session.contains("plasma") {
return DesktopEnvironment::Kde;
}
}
DesktopEnvironment::Unknown
})
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum LinuxControl {
Minimize,
Restore,
Maximize,
Close,
}
impl LinuxControl {
pub fn as_icon_name(&self) -> &'static str {
match self {
LinuxControl::Close => "window-close",
LinuxControl::Minimize => "window-minimize",
LinuxControl::Maximize => "window-maximize",
LinuxControl::Restore => "window-restore",
}
}
}
fn linux_controls() -> &'static HashMap<LinuxControl, Option<String>> {
LINUX_CONTROLS.get_or_init(|| {
let mut icons = HashMap::new();
icons.insert(LinuxControl::Close, None);
icons.insert(LinuxControl::Minimize, None);
icons.insert(LinuxControl::Maximize, None);
icons.insert(LinuxControl::Restore, None);
let icon_names = [
(LinuxControl::Close, vec!["window-close", "dialog-close"]),
(
LinuxControl::Minimize,
vec!["window-minimize", "window-lower"],
),
(
LinuxControl::Maximize,
vec!["window-maximize", "window-expand"],
),
(
LinuxControl::Restore,
vec!["window-restore", "window-return"],
),
];
for (control, icon_names) in icon_names {
for icon_name in icon_names {
// Try GNOME-style naming first
let mut control_icon = lookup_icon(format!("{icon_name}-symbolic"))
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
// If not found, try KDE-style naming
if control_icon.is_none() {
control_icon = lookup_icon(icon_name)
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
}
if let Some(Ok(icon)) = control_icon {
icons
.entry(control)
.and_modify(|v| *v = Some(icon.path.to_string_lossy().to_string()));
}
}
}
icons
})
}
-6
View File
@@ -1,6 +0,0 @@
/// Use pixels here instead of a rem-based size because the macOS traffic
/// lights are a static size, and don't scale with the rest of the UI.
///
/// Magic number: There is one extra pixel of padding on the left side due to
/// the 1px border around the window on macOS apps.
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
-4
View File
@@ -1,4 +0,0 @@
#[cfg(target_os = "linux")]
pub mod linux;
pub mod mac;
pub mod windows;
-147
View File
@@ -1,147 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
};
use theme::ActiveTheme;
use ui::h_flex;
#[derive(IntoElement)]
pub struct WindowsWindowControls {
button_height: Pixels,
}
impl WindowsWindowControls {
pub fn new(button_height: Pixels) -> Self {
Self { button_height }
}
#[cfg(not(target_os = "windows"))]
fn get_font() -> &'static str {
"Segoe Fluent Icons"
}
#[cfg(target_os = "windows")]
fn get_font() -> &'static str {
use windows::Wdk::System::SystemServices::RtlGetVersion;
let mut version = unsafe { std::mem::zeroed() };
let status = unsafe { RtlGetVersion(&mut version) };
if status.is_ok() && version.dwBuildNumber >= 22000 {
"Segoe Fluent Icons"
} else {
"Segoe MDL2 Assets"
}
}
}
impl RenderOnce for WindowsWindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let close_button_hover_color = Rgba {
r: 232.0 / 255.0,
g: 17.0 / 255.0,
b: 32.0 / 255.0,
a: 1.0,
};
let button_hover_color = cx.theme().ghost_element_hover;
let button_active_color = cx.theme().ghost_element_active;
div()
.id("windows-window-controls")
.font_family(Self::get_font())
.flex()
.flex_row()
.justify_center()
.content_stretch()
.max_h(self.button_height)
.min_h(self.button_height)
.child(WindowsCaptionButton::new(
"minimize",
WindowsCaptionButtonIcon::Minimize,
button_hover_color,
button_active_color,
))
.child(WindowsCaptionButton::new(
"maximize-or-restore",
if window.is_maximized() {
WindowsCaptionButtonIcon::Restore
} else {
WindowsCaptionButtonIcon::Maximize
},
button_hover_color,
button_active_color,
))
.child(WindowsCaptionButton::new(
"close",
WindowsCaptionButtonIcon::Close,
close_button_hover_color,
button_active_color,
))
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum WindowsCaptionButtonIcon {
Minimize,
Restore,
Maximize,
Close,
}
#[derive(IntoElement)]
struct WindowsCaptionButton {
id: ElementId,
icon: WindowsCaptionButtonIcon,
hover_background_color: Hsla,
active_background_color: Hsla,
}
impl WindowsCaptionButton {
pub fn new(
id: impl Into<ElementId>,
icon: WindowsCaptionButtonIcon,
hover_background_color: impl Into<Hsla>,
active_background_color: impl Into<Hsla>,
) -> Self {
Self {
id: id.into(),
icon,
hover_background_color: hover_background_color.into(),
active_background_color: active_background_color.into(),
}
}
}
impl RenderOnce for WindowsCaptionButton {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.justify_center()
.content_center()
.occlude()
.w(px(36.))
.h_full()
.text_size(px(10.0))
.hover(|style| style.bg(self.hover_background_color))
.active(|style| style.bg(self.active_background_color))
.map(|this| match self.icon {
WindowsCaptionButtonIcon::Close => {
this.window_control_area(WindowControlArea::Close)
}
WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
this.window_control_area(WindowControlArea::Max)
}
WindowsCaptionButtonIcon::Minimize => {
this.window_control_area(WindowControlArea::Min)
}
})
.child(match self.icon {
WindowsCaptionButtonIcon::Minimize => "\u{e921}",
WindowsCaptionButtonIcon::Restore => "\u{e923}",
WindowsCaptionButtonIcon::Maximize => "\u{e922}",
WindowsCaptionButtonIcon::Close => "\u{e8bb}",
})
}
}
+6 -4
View File
@@ -9,9 +9,8 @@ common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
smol.workspace = true
instant.workspace = true
serde.workspace = true
serde_json.workspace = true
smallvec.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -20,7 +19,10 @@ log.workspace = true
unicode-segmentation = "1.12.0"
uuid = "1.10"
regex = "1"
image = "0.25.1"
lsp-types = "0.97.0"
rope = { git = "https://github.com/zed-industries/zed" }
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
sum_tree = { git = "https://github.com/zed-industries/zed" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
tree-sitter = "0.26"
-191
View File
@@ -1,191 +0,0 @@
Copyright 2024 Longbridge <https://longbridge.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
-333
View File
@@ -1,333 +0,0 @@
//! This is a fork of gpui's anchored element that adds support for offsetting
//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
use gpui::{
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
Window,
};
use smallvec::SmallVec;
use crate::Anchor;
/// The state that the anchored element element uses to track its children.
pub struct AnchoredState {
child_layout_ids: SmallVec<[LayoutId; 4]>,
}
/// An anchored element that can be used to display UI that
/// will avoid overflowing the window bounds.
pub(crate) struct Anchored {
children: SmallVec<[AnyElement; 2]>,
anchor_corner: Anchor,
fit_mode: AnchoredFitMode,
anchor_position: Option<Point<Pixels>>,
position_mode: AnchoredPositionMode,
offset: Option<Point<Pixels>>,
}
/// anchored gives you an element that will avoid overflowing the window bounds.
/// Its children should have no margin to avoid measurement issues.
pub(crate) fn anchored() -> Anchored {
Anchored {
children: SmallVec::new(),
anchor_corner: Anchor::TopLeft,
fit_mode: AnchoredFitMode::SwitchAnchor,
anchor_position: None,
position_mode: AnchoredPositionMode::Window,
offset: None,
}
}
#[allow(dead_code)]
impl Anchored {
/// Sets which corner of the anchored element should be anchored to the current position.
pub fn anchor(mut self, anchor: Anchor) -> Self {
self.anchor_corner = anchor;
self
}
/// Sets the position in window coordinates
/// (otherwise the location the anchored element is rendered is used)
pub fn position(mut self, anchor: Point<Pixels>) -> Self {
self.anchor_position = Some(anchor);
self
}
/// Offset the final position by this amount.
/// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu.
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
self.offset = Some(offset);
self
}
/// Sets the position mode for this anchored element. Local will have this
/// interpret its [`Anchored::position`] as relative to the parent element.
/// While Window will have it interpret the position as relative to the window.
pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self {
self.position_mode = mode;
self
}
/// Snap to window edge instead of switching anchor corner when an overflow would occur.
pub fn snap_to_window(mut self) -> Self {
self.fit_mode = AnchoredFitMode::SnapToWindow;
self
}
/// Snap to window edge and leave some margins.
pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
self
}
}
impl ParentElement for Anchored {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Element for Anchored {
type PrepaintState = ();
type RequestLayoutState = AnchoredState;
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let child_layout_ids = self
.children
.iter_mut()
.map(|child| child.request_layout(window, cx))
.collect::<SmallVec<_>>();
let anchored_style = Style {
position: Position::Absolute,
display: Display::Flex,
..Style::default()
};
let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
(layout_id, AnchoredState { child_layout_ids })
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
if request_layout.child_layout_ids.is_empty() {
return;
}
let mut child_min = point(Pixels::MAX, Pixels::MAX);
let mut child_max = Point::default();
for child_layout_id in &request_layout.child_layout_ids {
let child_bounds = window.layout_bounds(*child_layout_id);
child_min = child_min.min(&child_bounds.origin);
child_max = child_max.max(&child_bounds.bottom_right());
}
let size: Size<Pixels> = (child_max - child_min).into();
let (origin, mut desired) = self.position_mode.get_position_and_bounds(
self.anchor_position,
self.anchor_corner,
size,
bounds,
self.offset,
);
let limits = Bounds {
origin: Point::default(),
size: window.viewport_size(),
};
if self.fit_mode == AnchoredFitMode::SwitchAnchor {
let mut anchor_corner = self.anchor_corner;
if desired.left() < limits.left() || desired.right() > limits.right() {
let switched = Bounds::from_corner_and_size(
anchor_corner
.other_side_corner_along(Axis::Horizontal)
.into(),
origin,
size,
);
if !(switched.left() < limits.left() || switched.right() > limits.right()) {
anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal);
desired = switched
}
}
if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
let switched = Bounds::from_corner_and_size(
anchor_corner.other_side_corner_along(Axis::Vertical).into(),
origin,
size,
);
if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
desired = switched;
}
}
}
let client_inset = window.client_inset().unwrap_or(px(0.));
let edges = match self.fit_mode {
AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
_ => Edges::default(),
}
.map(|edge| *edge + client_inset);
// Snap the horizontal edges of the anchored element to the horizontal edges of the window if
// its horizontal bounds overflow, aligning to the left if it is wider than the limits.
if desired.right() > limits.right() {
desired.origin.x -= desired.right() - limits.right() + edges.right;
}
if desired.left() < limits.left() {
desired.origin.x = limits.origin.x + edges.left;
}
// Snap the vertical edges of the anchored element to the vertical edges of the window if
// its vertical bounds overflow, aligning to the top if it is taller than the limits.
if desired.bottom() > limits.bottom() {
desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
}
if desired.top() < limits.top() {
desired.origin.y = limits.origin.y + edges.top;
}
let offset = desired.origin - bounds.origin;
let offset = point(offset.x.round(), offset.y.round());
window.with_element_offset(offset, |window| {
for child in &mut self.children {
child.prepaint(window, cx);
}
})
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
for child in &mut self.children {
child.paint(window, cx);
}
}
}
impl IntoElement for Anchored {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
/// Which algorithm to use when fitting the anchored element to be inside the window.
#[allow(dead_code)]
#[derive(Copy, Clone, PartialEq)]
pub enum AnchoredFitMode {
/// Snap the anchored element to the window edge.
SnapToWindow,
/// Snap to window edge and leave some margins.
SnapToWindowWithMargin(Edges<Pixels>),
/// Switch which corner anchor this anchored element is attached to.
SwitchAnchor,
}
/// Which algorithm to use when positioning the anchored element.
#[allow(dead_code)]
#[derive(Copy, Clone, PartialEq)]
pub enum AnchoredPositionMode {
/// Position the anchored element relative to the window.
Window,
/// Position the anchored element relative to its parent.
Local,
}
impl AnchoredPositionMode {
fn get_position_and_bounds(
&self,
anchor_position: Option<Point<Pixels>>,
anchor_corner: Anchor,
size: Size<Pixels>,
bounds: Bounds<Pixels>,
offset: Option<Point<Pixels>>,
) -> (Point<Pixels>, Bounds<Pixels>) {
let offset = offset.unwrap_or_default();
match self {
AnchoredPositionMode::Window => {
let anchor_position = anchor_position.unwrap_or(bounds.origin);
let bounds =
Self::from_corner_and_size(anchor_corner, anchor_position + offset, size);
(anchor_position, bounds)
}
AnchoredPositionMode::Local => {
let anchor_position = anchor_position.unwrap_or_default();
let bounds = Self::from_corner_and_size(
anchor_corner,
bounds.origin + anchor_position + offset,
size,
);
(anchor_position, bounds)
}
}
}
// Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863
fn from_corner_and_size(
anchor: Anchor,
origin: Point<Pixels>,
size: Size<Pixels>,
) -> Bounds<Pixels> {
let origin = match anchor {
Anchor::TopLeft => origin,
Anchor::TopCenter => Point {
x: origin.x - size.width.half(),
y: origin.y,
},
Anchor::TopRight => Point {
x: origin.x - size.width,
y: origin.y,
},
Anchor::BottomLeft => Point {
x: origin.x,
y: origin.y - size.height,
},
Anchor::BottomCenter => Point {
x: origin.x - size.width.half(),
y: origin.y - size.height,
},
Anchor::BottomRight => Point {
x: origin.x - size.width,
y: origin.y - size.height,
},
};
Bounds { origin, size }
}
}
+19 -8
View File
@@ -1,12 +1,12 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement,
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window,
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
};
use theme::ActiveTheme;
use crate::{Sizable, Size};
use crate::{Selectable, Sizable, Size};
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
@@ -26,9 +26,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
@@ -37,6 +35,7 @@ pub struct Avatar {
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
selected: bool,
}
impl Avatar {
@@ -48,6 +47,7 @@ impl Avatar {
style: StyleRefinement::default(),
size: Size::Medium,
border_color: None,
selected: false,
}
}
@@ -89,6 +89,17 @@ impl Styled for Avatar {
}
}
impl Selectable for Avatar {
fn is_selected(&self) -> bool {
self.selected
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl InteractiveElement for Avatar {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
@@ -117,7 +128,7 @@ impl RenderOnce for Avatar {
self.image
.size(image_size)
.rounded_full()
.object_fit(gpui::ObjectFit::Fill)
.object_fit(ObjectFit::Cover)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
+5 -5
View File
@@ -2,15 +2,15 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, relative,
};
use theme::ActiveTheme;
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
@@ -617,7 +617,7 @@ impl ButtonVariant {
};
let fg = match self {
ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color?
ButtonVariant::Primary => cx.theme().text_muted,
_ => cx.theme().text_muted,
};
+13 -7
View File
@@ -1,16 +1,16 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, StyleRefinement, Styled, Window,
Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Window, div, px, relative, rems, svg,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::icon::IconNamed;
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
use crate::{Disableable, IconName, Selectable, Sizable, Size, StyledExt as _, v_flex};
/// A Checkbox element.
#[allow(clippy::type_complexity)]
@@ -172,10 +172,16 @@ pub(crate) fn checkbox_check_icon(
if !disabled && checked != *toggle_state.read(cx) {
let duration = Duration::from_secs_f64(0.25);
cx.spawn({
let toggle_state = toggle_state.clone();
let toggle_state = toggle_state.downgrade();
async move |cx| {
cx.background_executor().timer(duration).await;
toggle_state.update(cx, |this, _| *this = checked);
// `update_in` (rather than `update`) routes through a
// try-borrow: on wasm a task poll that lands while
// the app context is borrowed can't panic and kill
// this task.
toggle_state
.update_in(cx, |this, _window, _| *this = checked)
.ok();
}
})
.detach();
@@ -3,20 +3,26 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
Window,
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
};
use super::{DockArea, DockItem};
use crate::dock_area::panel::PanelView;
use crate::dock_area::tab_panel::TabPanel;
use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
use crate::StyledExt;
use crate::dock::panel::PanelView;
use crate::dock::tab_panel::TabPanel;
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
#[derive(Clone, Render)]
#[derive(Clone)]
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,
@@ -208,6 +214,8 @@ impl Dock {
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
// Use defer_in (not window.defer) so the callback is cancelled
// if this Dock entity is dropped before the deferred frame runs.
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
@@ -265,22 +273,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 {
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;
}
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;
}
}
// 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 {
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;
}
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;
}
}
@@ -321,6 +329,8 @@ impl Render for Dock {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
@@ -336,7 +346,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()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {
@@ -2,21 +2,23 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
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,
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,
};
use crate::dock_area::dock::{Dock, DockPlacement};
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
use crate::dock_area::stack_panel::StackPanel;
use crate::dock_area::tab_panel::TabPanel;
use crate::ElementExt;
pub mod dock;
pub mod panel;
pub mod stack_panel;
pub mod tab_panel;
#[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::*;
actions!(dock, [ToggleZoom, ClosePanel]);
@@ -107,15 +109,8 @@ impl DockItem {
window: &mut Window,
cx: &mut App,
) -> Self {
let mut items = items;
let stack_panel = cx.new(|cx| {
let mut stack_panel = StackPanel::new(axis, window, cx);
for (i, item) in items.iter_mut().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
}
for (i, item) in items.iter().enumerate() {
let view = item.view();
@@ -202,19 +197,16 @@ impl DockItem {
/// Returns all panel ids
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
match self {
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
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![],
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(),
}
}
@@ -750,7 +742,6 @@ impl Render for DockArea {
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
@@ -761,7 +752,7 @@ impl Render for DockArea {
div()
.flex()
.flex_row()
.h_full()
.size_full()
// Left dock
.when_some(self.left_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
@@ -772,14 +763,8 @@ impl Render for DockArea {
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
// Top center
.child(
div()
.flex_1()
.overflow_hidden()
.child(self.render_items(window, cx)),
)
.child(div().flex_1().child(self.render_items(window, cx)))
// Bottom Dock
.when_some(self.bottom_dock.clone(), |this, dock| {
this.child(dock)
@@ -1,5 +1,5 @@
use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render,
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window,
};
@@ -21,12 +21,6 @@ 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.
///
@@ -1,22 +1,21 @@
use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
Window,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
use theme::{AxisExt as _, Placement};
use super::{DockArea, PanelEvent};
use crate::dock_area::panel::{Panel, PanelView};
use crate::dock_area::tab_panel::TabPanel;
use crate::dock::panel::{Panel, PanelView};
use crate::dock::tab_panel::TabPanel;
use crate::h_flex;
use crate::resizable::{
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
PANEL_MIN_SIZE,
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
resizable_panel,
};
use crate::{h_flex, AxisExt as _, Placement};
pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>,
@@ -70,10 +69,10 @@ impl StackPanel {
return false;
}
if let Some(parent) = &self.parent {
if let Some(parent) = parent.upgrade() {
return parent.read(cx).is_last_panel(cx);
}
if let Some(parent) = &self.parent
&& let Some(parent) = parent.upgrade()
{
return parent.read(cx).is_last_panel(cx);
}
true
@@ -297,12 +296,11 @@ 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 {
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);
}
}
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);
}
let first_panel = self.panels.first();
@@ -321,12 +319,11 @@ 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 {
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);
}
}
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);
}
let panel = if self.axis.is_vertical() {
@@ -371,26 +368,20 @@ impl Focusable for StackPanel {
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.overflow_hidden()
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
h_flex().size_full().overflow_hidden().child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}
@@ -2,22 +2,22 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
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,
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,
};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
use theme::{ActiveTheme, AxisExt, Placement, TABBAR_HEIGHT};
use crate::button::{Button, ButtonVariants as _};
use crate::dock_area::dock::DockPlacement;
use crate::dock_area::panel::{Panel, PanelView};
use crate::dock_area::stack_panel::StackPanel;
use crate::dock_area::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
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_bar::TabBar;
use crate::tab::Tab;
use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
use crate::tab::tab_bar::TabBar;
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
#[derive(Clone)]
struct TabState {
@@ -42,22 +42,20 @@ impl DragPanel {
impl Render for DragPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
h_flex()
.id("drag-panel")
.cursor_grab()
.py_1()
.px_2()
.w_24()
.flex()
.items_center()
.p_2()
.min_w_24()
.justify_center()
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius_lg)
.text_xs()
.when(cx.theme().shadow, |this| this.shadow_lg())
.rounded(cx.theme().radius)
.text_sm()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
.bg(cx.theme().background)
.text_color(cx.theme().text_accent)
.child(self.panel.title(cx))
}
}
@@ -234,14 +232,13 @@ impl TabPanel {
.any(|p| p.panel_id(cx) == panel.panel_id(cx))
{
// Set the active panel to the matched panel
if active {
if let Some(ix) = self
if active
&& 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;
@@ -315,6 +312,7 @@ impl TabPanel {
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn detach_panel(
@@ -324,10 +322,22 @@ impl TabPanel {
cx: &mut Context<Self>,
) {
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
} else if let Some(removed_ix) = removed_ix {
if removed_ix < self.active_ix {
self.active_ix = self.active_ix.saturating_sub(1);
} else if removed_ix == self.active_ix {
// The active panel was removed and another panel shifted into
// its position. Activate the new panel at the same index.
if let Some(new_active) = self.panels.get(self.active_ix) {
new_active.set_active(true, cx);
}
self.focus_active_panel(window, cx);
}
}
}
@@ -374,12 +384,11 @@ 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 {
if let Some(stack_panel) = parent.upgrade() {
if !stack_panel.read(cx).is_last_panel(cx) {
return false;
}
}
if let Some(parent) = &self.stack_panel
&& let Some(stack_panel) = parent.upgrade()
&& !stack_panel.read(cx).is_last_panel(cx)
{
return false;
}
self.panels.len() <= 1
@@ -425,14 +434,13 @@ 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_1()
.gap_1p5()
.occlude()
.rounded_full()
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
.children(toolbar.into_iter().map(|btn| btn.small().ghost()))
.when(self.zoomed, |this| {
this.child(
Button::new("zoom")
@@ -445,15 +453,11 @@ 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;
@@ -469,7 +473,7 @@ impl TabPanel {
})
}
})
.anchor(Corner::TopRight),
.anchor(Anchor::TopRight),
)
}
@@ -576,8 +580,10 @@ impl TabPanel {
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
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();
@@ -594,6 +600,7 @@ impl TabPanel {
.py_2()
.pl_3()
.pr_2()
.rounded_t(cx.theme().radius_lg)
.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())
@@ -619,7 +626,7 @@ impl TabPanel {
div()
.w_full()
.text_ellipsis()
.text_xs()
.text_sm()
.child(panel.title(cx)),
)
.when(state.draggable, |this| {
@@ -646,21 +653,19 @@ impl TabPanel {
.into_any_element();
}
TabBar::new()
TabBar::new("tab-bar")
.track_scroll(&self.tab_bar_scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.rounded_t(cx.theme().radius_lg)
.when(has_extend_dock_button, |this| {
this.prefix(
h_flex()
.items_center()
.top_0()
.right(-px(1.))
.border_r_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().surface_background)
.px_2()
.pl_0p5()
.pr_1()
.children(left_dock_button)
.children(bottom_dock_button),
)
@@ -682,16 +687,44 @@ impl TabPanel {
Some(
Tab::new()
.ix(ix)
.label(panel.title(cx))
.py_2()
.tab_bar_prefix(has_extend_dock_button)
.child(panel.title(cx))
.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| {
cx.stop_propagation();
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, _, window, cx| {
move |view, _ev, window, cx| {
view.remove_panel(&panel, window, cx);
}
}),
@@ -730,7 +763,7 @@ impl TabPanel {
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow()
.flex_grow_1()
.min_w_16()
.when(state.droppable, |this| {
let view = cx.entity();
@@ -757,14 +790,11 @@ 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()
.child(self.render_toolbar(state, window, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
)
@@ -794,10 +824,8 @@ impl TabPanel {
.child(
div()
.size_full()
.rounded_b(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_b(CLIENT_SIDE_DECORATION_ROUNDING)
})
.overflow_hidden()
.child(
active_panel
@@ -1080,7 +1108,9 @@ impl TabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(panel) = self.active_panel(cx) {
if self.panels.len() > 1
&& let Some(panel) = self.active_panel(cx)
{
self.remove_panel(&panel, window, cx);
}
}
@@ -1097,6 +1127,7 @@ impl Focusable for TabPanel {
}
impl EventEmitter<DismissEvent> for TabPanel {}
impl EventEmitter<PanelEvent> for TabPanel {}
impl Render for TabPanel {
@@ -1116,17 +1147,24 @@ impl Render for TabPanel {
state.closable = false;
}
v_flex()
div()
.when(!self.collapsed, |this| {
this.on_action(cx.listener(Self::on_action_toggle_zoom))
.on_action(cx.listener(Self::on_action_close_panel))
})
.id("tab-panel")
.tab_group()
.track_focus(&focus_handle)
.size_full()
.p_1()
.overflow_hidden()
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx))
.child(
v_flex()
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xs())
.size_full()
.tab_group()
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx)),
)
}
}
-294
View File
@@ -1,294 +0,0 @@
use std::fmt::{self, Debug, Display, Formatter};
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels};
use serde::{Deserialize, Serialize};
/// A enum for defining the placement of the element.
///
/// See also: [`Side`] if you need to define the left, right side.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Placement {
#[serde(rename = "top")]
Top,
#[serde(rename = "bottom")]
Bottom,
#[serde(rename = "left")]
Left,
#[serde(rename = "right")]
Right,
}
impl Display for Placement {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Placement::Top => write!(f, "Top"),
Placement::Bottom => write!(f, "Bottom"),
Placement::Left => write!(f, "Left"),
Placement::Right => write!(f, "Right"),
}
}
}
impl Placement {
#[inline]
pub fn is_horizontal(&self) -> bool {
matches!(self, Placement::Left | Placement::Right)
}
#[inline]
pub fn is_vertical(&self) -> bool {
matches!(self, Placement::Top | Placement::Bottom)
}
#[inline]
pub fn axis(&self) -> Axis {
match self {
Placement::Top | Placement::Bottom => Axis::Vertical,
Placement::Left | Placement::Right => Axis::Horizontal,
}
}
}
/// The anchor position of an element.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Anchor {
#[default]
#[serde(rename = "top-left")]
TopLeft,
#[serde(rename = "top-center")]
TopCenter,
#[serde(rename = "top-right")]
TopRight,
#[serde(rename = "bottom-left")]
BottomLeft,
#[serde(rename = "bottom-center")]
BottomCenter,
#[serde(rename = "bottom-right")]
BottomRight,
}
impl Display for Anchor {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Anchor::TopLeft => write!(f, "TopLeft"),
Anchor::TopCenter => write!(f, "TopCenter"),
Anchor::TopRight => write!(f, "TopRight"),
Anchor::BottomLeft => write!(f, "BottomLeft"),
Anchor::BottomCenter => write!(f, "BottomCenter"),
Anchor::BottomRight => write!(f, "BottomRight"),
}
}
}
impl Anchor {
/// Returns true if the anchor is at the top.
#[inline]
pub fn is_top(&self) -> bool {
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
}
/// Returns true if the anchor is at the bottom.
#[inline]
pub fn is_bottom(&self) -> bool {
matches!(
self,
Self::BottomLeft | Self::BottomCenter | Self::BottomRight
)
}
/// Returns true if the anchor is at the left.
#[inline]
pub fn is_left(&self) -> bool {
matches!(self, Self::TopLeft | Self::BottomLeft)
}
/// Returns true if the anchor is at the right.
#[inline]
pub fn is_right(&self) -> bool {
matches!(self, Self::TopRight | Self::BottomRight)
}
/// Returns true if the anchor is at the center.
#[inline]
pub fn is_center(&self) -> bool {
matches!(self, Self::TopCenter | Self::BottomCenter)
}
/// Swaps the vertical position of the anchor.
pub fn swap_vertical(&self) -> Self {
match self {
Anchor::TopLeft => Anchor::BottomLeft,
Anchor::TopCenter => Anchor::BottomCenter,
Anchor::TopRight => Anchor::BottomRight,
Anchor::BottomLeft => Anchor::TopLeft,
Anchor::BottomCenter => Anchor::TopCenter,
Anchor::BottomRight => Anchor::TopRight,
}
}
/// Swaps the horizontal position of the anchor.
pub fn swap_horizontal(&self) -> Self {
match self {
Anchor::TopLeft => Anchor::TopRight,
Anchor::TopCenter => Anchor::TopCenter,
Anchor::TopRight => Anchor::TopLeft,
Anchor::BottomLeft => Anchor::BottomRight,
Anchor::BottomCenter => Anchor::BottomCenter,
Anchor::BottomRight => Anchor::BottomLeft,
}
}
pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor {
match axis {
Axis::Vertical => match self {
Self::TopLeft => Self::BottomLeft,
Self::TopCenter => Self::BottomCenter,
Self::TopRight => Self::BottomRight,
Self::BottomLeft => Self::TopLeft,
Self::BottomCenter => Self::TopCenter,
Self::BottomRight => Self::TopRight,
},
Axis::Horizontal => match self {
Self::TopLeft => Self::TopRight,
Self::TopCenter => Self::TopCenter,
Self::TopRight => Self::TopLeft,
Self::BottomLeft => Self::BottomRight,
Self::BottomCenter => Self::BottomCenter,
Self::BottomRight => Self::BottomLeft,
},
}
}
}
impl From<Corner> for Anchor {
fn from(corner: Corner) -> Self {
match corner {
Corner::TopLeft => Anchor::TopLeft,
Corner::TopRight => Anchor::TopRight,
Corner::BottomLeft => Anchor::BottomLeft,
Corner::BottomRight => Anchor::BottomRight,
}
}
}
impl From<Anchor> for Corner {
fn from(anchor: Anchor) -> Self {
match anchor {
Anchor::TopLeft => Corner::TopLeft,
Anchor::TopRight => Corner::TopRight,
Anchor::BottomLeft => Corner::BottomLeft,
Anchor::BottomRight => Corner::BottomRight,
Anchor::TopCenter => Corner::TopLeft,
Anchor::BottomCenter => Corner::BottomLeft,
}
}
}
/// A enum for defining the side of the element.
///
/// See also: [`Placement`] if you need to define the 4 edges.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Side {
#[serde(rename = "left")]
Left,
#[serde(rename = "right")]
Right,
}
impl Side {
/// Returns true if the side is left.
#[inline]
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
/// Returns true if the side is right.
#[inline]
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// A trait to extend the [`Axis`] enum with utility methods.
pub trait AxisExt {
#[allow(clippy::wrong_self_convention)]
fn is_horizontal(self) -> bool;
#[allow(clippy::wrong_self_convention)]
fn is_vertical(self) -> bool;
}
impl AxisExt for Axis {
#[inline]
fn is_horizontal(self) -> bool {
self == Axis::Horizontal
}
#[inline]
fn is_vertical(self) -> bool {
self == Axis::Vertical
}
}
/// A trait for converting [`Pixels`] to `f32` and `f64`.
pub trait PixelsExt {
fn as_f32(&self) -> f32;
#[allow(clippy::wrong_self_convention)]
fn as_f64(self) -> f64;
}
impl PixelsExt for Pixels {
fn as_f32(&self) -> f32 {
f32::from(self)
}
fn as_f64(self) -> f64 {
f64::from(self)
}
}
/// A trait to extend the [`Length`] enum with utility methods.
pub trait LengthExt {
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
///
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
}
impl LengthExt for Length {
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
match self {
Length::Auto => None,
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
}
}
}
/// A struct for defining the edges of an element.
///
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
#[repr(C)]
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
/// The size of the top edge.
pub top: T,
/// The size of the right edge.
pub right: T,
/// The size of the bottom edge.
pub bottom: T,
/// The size of the left edge.
pub left: T,
}
impl<T> Edges<T>
where
T: Clone + Debug + Default + PartialEq,
{
/// Creates a new `Edges` instance with all edges set to the same value.
pub fn all(value: T) -> Self {
Self {
top: value.clone(),
right: value.clone(),
bottom: value.clone(),
left: value,
}
}
}
+25 -8
View File
@@ -1,6 +1,8 @@
use std::fmt::Debug;
use std::time::{Duration, Instant};
use instant::{Duration, Instant};
/// A HistoryItem represents a single change in the history.
/// It must implement Clone and PartialEq to be used in the History.
pub trait HistoryItem: Clone + PartialEq {
fn version(&self) -> usize;
fn set_version(&mut self, version: usize);
@@ -22,10 +24,11 @@ pub struct History<I: HistoryItem> {
redos: Vec<I>,
last_changed_at: Instant,
version: usize,
max_undo: usize,
pub(crate) ignore: bool,
max_undos: usize,
group_interval: Option<Duration>,
grouping: bool,
unique: bool,
pub ignore: bool,
}
impl<I> History<I>
@@ -39,15 +42,16 @@ where
ignore: false,
last_changed_at: Instant::now(),
version: 0,
max_undo: 1000,
max_undos: 1000,
group_interval: None,
grouping: false,
unique: false,
}
}
/// Set the maximum number of undo steps to keep, defaults to 1000.
pub fn max_undo(mut self, max_undo: usize) -> Self {
self.max_undo = max_undo;
pub fn max_undos(mut self, max_undos: usize) -> Self {
self.max_undos = max_undos;
self
}
@@ -64,10 +68,20 @@ where
self
}
/// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called.
pub fn start_grouping(&mut self) {
self.grouping = true;
}
/// End grouping changes, this will allow the version to be incremented again.
pub fn end_grouping(&mut self) {
self.grouping = false;
}
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
fn inc_version(&mut self) -> usize {
let t = Instant::now();
if Some(self.last_changed_at.elapsed()) > self.group_interval {
if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval {
self.version += 1;
}
@@ -80,10 +94,11 @@ where
self.version
}
/// Push a new change to the history.
pub fn push(&mut self, item: I) {
let version = self.inc_version();
if self.undos.len() >= self.max_undo {
if self.undos.len() >= self.max_undos {
self.undos.remove(0);
}
@@ -113,6 +128,7 @@ where
self.redos.clear();
}
/// Undo the last change and return the changes that were undone.
pub fn undo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.undos.pop() {
let mut changes = vec![first_change.clone()];
@@ -135,6 +151,7 @@ where
}
}
/// Redo the last undone change and return the changes that were redone.
pub fn redo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.redos.pop() {
let mut changes = vec![first_change.clone()];
+10 -2
View File
@@ -1,7 +1,7 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
svg, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render,
RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
SharedString, StyleRefinement, Styled, Svg, Transformation, Window, svg,
};
use theme::ActiveTheme;
@@ -34,10 +34,12 @@ pub enum IconName {
CloseCircle,
CloseCircleFill,
Copy,
Device,
Door,
Ellipsis,
Emoji,
Eye,
Input,
Info,
Invite,
Inbox,
@@ -52,12 +54,14 @@ pub enum IconName {
Relay,
Reply,
Refresh,
Scan,
Search,
Settings,
Settings2,
Sun,
Ship,
Shield,
Group,
UserKey,
Upload,
Usb,
@@ -102,10 +106,12 @@ impl IconNamed for IconName {
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
@@ -120,6 +126,7 @@ impl IconNamed for IconName {
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
@@ -129,6 +136,7 @@ impl IconNamed for IconName {
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",

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