47 Commits

Author SHA1 Message Date
2e451aae12 chore: fix ci and clean up 2026-05-30 07:51:04 +07:00
cdfcfdd782 chore: release version 1.0.0-beta4 2026-05-28 09:02:17 +07:00
9817dd29a6 nip4e: remove app name in the client tag 2026-05-22 07:52:49 +07:00
f066cb8223 chore: force gossip ignore neg sync 2026-05-20 18:03:46 +07:00
6d60726f27 chore: bump gpui 2026-04-25 07:01:14 +07:00
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: #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: #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: #28
2026-04-04 02:22:08 +00:00
Ren Amamiya
d9b16aea9a feat: add tracking for which signer encrypted the message (#27)
Reviewed-on: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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: #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
Some checks failed
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)
Some checks failed
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: #16
2026-03-04 07:49:42 +00:00
reya
7a6b6feacc feat: refactor the text parser (#15)
Some checks failed
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: #15
2026-03-03 08:55:36 +00:00
reya
55c5ebbf17 feat: multi-account switcher (#14)
Some checks failed
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: #14
2026-03-02 08:08:04 +00:00
reya
3fecda175b feat: refactor encryption panel (#13)
Some checks failed
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: #13
2026-02-28 11:25:02 +00:00
138 changed files with 9266 additions and 6749 deletions

View File

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

3
.gitignore vendored
View File

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

1646
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,28 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/*"] members = ["crates/*", "desktop", "web"]
default-members = ["crates/coop"] default-members = ["desktop"]
[workspace.package] [workspace.package]
version = "1.0.0-beta" version = "1.0.0-beta4"
edition = "2021" edition = "2024"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
# GPUI # GPUI
gpui = { git = "https://github.com/zed-industries/zed" } 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_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" } gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" } gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" } gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# TODO: remove after fixed, issue: https://github.com/zed-industries/zed/issues/47168
core-text = "=21.0.0"
# Nostr # Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { 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 = [ "nip96", "nip59", "nip49", "nip44" ] }
@@ -44,6 +42,7 @@ smallvec = "1.14.0"
smol = "2" smol = "2"
tracing = "0.1.40" tracing = "0.1.40"
webbrowser = "1.0.4" webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
[profile.release] [profile.release]
strip = true strip = true

View File

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

3
assets/icons/device.svg Normal file
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
assets/icons/group.svg Normal file
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
assets/icons/input.svg Normal file
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
assets/icons/scan.svg Normal file
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
assets/themes/aurora.json Normal file
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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,140 +1,144 @@
{ {
"id": "flexoki", "id": "flexoki",
"name": "Flexoki", "name": "Flexoki",
"author": "Stephan Ango", "author": "Steph Ango (ported by Coop)",
"url": "https://stephango.com/flexoki", "url": "https://stephango.com/flexoki",
"light": { "light": {
"background": "#FFFCF0", "background": "#FFFCF0",
"surface_background": "#F2F0E5", "surface_background": "#F2F0E5",
"elevated_surface_background": "#E6E4D9", "elevated_surface_background": "#E6E4D9",
"panel_background": "#FFFCF0", "panel_background": "#FFFCF0",
"overlay": "#100F0F1a", "overlay": "#100F0F1A",
"title_bar": "#F2F0E5", "title_bar": "#E6E4D9",
"title_bar_inactive": "#E6E4D9", "title_bar_inactive": "#FFFCF0",
"window_border": "#B7B5AC", "window_border": "#CECDC3",
"border": "#CECDC3", "border": "#CECDC3",
"border_variant": "#DAD8CE", "border_variant": "#DAD8CE",
"border_focused": "#205EA6", "border_focused": "#24837B",
"border_selected": "#205EA6", "border_selected": "#24837B",
"border_transparent": "#00000000", "border_transparent": "#100F0F00",
"border_disabled": "#E6E4D9", "border_disabled": "#E6E4D9",
"ring": "#205EA6", "ring": "#3AA99F",
"text": "#100F0F", "text": "#100F0F",
"text_muted": "#6F6E69", "text_muted": "#6F6E69",
"text_placeholder": "#9F9D96", "text_placeholder": "#B7B5AC",
"text_accent": "#205EA6", "text_accent": "#24837B",
"text_danger": "#AF3029",
"text_warning": "#BC5215",
"icon": "#6F6E69", "icon": "#6F6E69",
"icon_muted": "#9F9D96", "icon_muted": "#B7B5AC",
"icon_accent": "#205EA6", "icon_accent": "#3AA99F",
"element_foreground": "#FFFCF0", "element_foreground": "#FFFCF0",
"element_background": "#205EA6", "element_background": "#24837B",
"element_hover": "#1A4F8C", "element_hover": "#3AA99F",
"element_active": "#163B66", "element_active": "#1C1B1A",
"element_selected": "#133051", "element_selected": "#100F0F",
"element_disabled": "#205EA64d", "element_disabled": "#24837B4D",
"secondary_foreground": "#163B66", "secondary_foreground": "#100F0F",
"secondary_background": "#F2F0E5", "secondary_background": "#E6E4D9",
"secondary_hover": "#205EA61a", "secondary_hover": "#DAD8CE",
"secondary_active": "#E6E4D9", "secondary_active": "#CECDC3",
"secondary_selected": "#E6E4D9", "secondary_selected": "#CECDC3",
"secondary_disabled": "#205EA64d", "secondary_disabled": "#24837B4D",
"danger_foreground": "#FFFCF0", "danger_foreground": "#FFFCF0",
"danger_background": "#D14D41", "danger_background": "#AF3029",
"danger_hover": "#C03E35", "danger_hover": "#D14D41",
"danger_active": "#AF3029", "danger_active": "#1C1B1A",
"danger_selected": "#942822", "danger_selected": "#100F0F",
"danger_disabled": "#D14D414d", "danger_disabled": "#AF30294D",
"warning_foreground": "#100F0F", "warning_foreground": "#FFFCF0",
"warning_background": "#D0A215", "warning_background": "#BC5215",
"warning_hover": "#BE9207", "warning_hover": "#DA702C",
"warning_active": "#AD8301", "warning_active": "#1C1B1A",
"warning_selected": "#8E6B01", "warning_selected": "#100F0F",
"warning_disabled": "#D0A2154d", "warning_disabled": "#BC52154D",
"ghost_element_background": "#00000000", "ghost_element_background": "#100F0F00",
"ghost_element_background_alt": "#E6E4D9", "ghost_element_background_alt": "#F2F0E5",
"ghost_element_hover": "#100F0F1a", "ghost_element_hover": "#100F0F0D",
"ghost_element_active": "#DAD8CE", "ghost_element_active": "#100F0F1A",
"ghost_element_selected": "#DAD8CE", "ghost_element_selected": "#100F0F1A",
"ghost_element_disabled": "#100F0F0d", "ghost_element_disabled": "#100F0F05",
"tab_inactive_background": "#F2F0E5", "tab_background": "#E6E4D9",
"tab_inactive_foreground": "#6F6E69", "tab_foreground": "#6F6E69",
"tab_hover_background": "#100F0F0D",
"tab_active_background": "#FFFCF0", "tab_active_background": "#FFFCF0",
"tab_active_foreground": "#100F0F", "tab_active_foreground": "#100F0F",
"tab_hover_foreground": "#205EA6", "scrollbar_thumb_background": "#100F0F1A",
"scrollbar_thumb_background": "#100F0F33", "scrollbar_thumb_hover_background": "#100F0F26",
"scrollbar_thumb_hover_background": "#100F0F4d", "scrollbar_thumb_border": "#100F0F00",
"scrollbar_thumb_border": "#00000000", "scrollbar_track_background": "#100F0F00",
"scrollbar_track_background": "#00000000", "scrollbar_track_border": "#100F0F00",
"scrollbar_track_border": "#DAD8CE", "drop_target_background": "#24837B1A",
"drop_target_background": "#205EA61a", "cursor": "#24837B",
"cursor": "#205EA6", "selection": "#24837B40"
"selection": "#205EA640"
}, },
"dark": { "dark": {
"background": "#100F0F", "background": "#100F0F",
"surface_background": "#1C1B1A", "surface_background": "#1C1B1A",
"elevated_surface_background": "#282726", "elevated_surface_background": "#282726",
"panel_background": "#100F0F", "panel_background": "#100F0F",
"overlay": "#FFFCF01a", "overlay": "#FFFCF01A",
"title_bar": "#1C1B1A", "title_bar": "#282726",
"title_bar_inactive": "#282726", "title_bar_inactive": "#100F0F",
"window_border": "#575653", "window_border": "#403E3C",
"border": "#403E3C", "border": "#403E3C",
"border_variant": "#343331", "border_variant": "#343331",
"border_focused": "#4385BE", "border_focused": "#3AA99F",
"border_selected": "#4385BE", "border_selected": "#3AA99F",
"border_transparent": "#00000000", "border_transparent": "#100F0F00",
"border_disabled": "#282726", "border_disabled": "#282726",
"ring": "#4385BE", "ring": "#24837B",
"text": "#FFFCF0", "text": "#CECDC3",
"text_muted": "#878580", "text_muted": "#878580",
"text_placeholder": "#6F6E69", "text_placeholder": "#575653",
"text_accent": "#4385BE", "text_accent": "#3AA99F",
"text_danger": "#D14D41",
"text_warning": "#DA702C",
"icon": "#878580", "icon": "#878580",
"icon_muted": "#6F6E69", "icon_muted": "#575653",
"icon_accent": "#4385BE", "icon_accent": "#24837B",
"element_foreground": "#100F0F", "element_foreground": "#100F0F",
"element_background": "#4385BE", "element_background": "#3AA99F",
"element_hover": "#3171B2", "element_hover": "#24837B",
"element_active": "#205EA6", "element_active": "#CECDC3",
"element_selected": "#1A4F8C", "element_selected": "#F2F0E5",
"element_disabled": "#4385BE4d", "element_disabled": "#3AA99F4D",
"secondary_foreground": "#205EA6", "secondary_foreground": "#CECDC3",
"secondary_background": "#1C1B1A", "secondary_background": "#1C1B1A",
"secondary_hover": "#4385BE1a", "secondary_hover": "#282726",
"secondary_active": "#282726", "secondary_active": "#343331",
"secondary_selected": "#282726", "secondary_selected": "#343331",
"secondary_disabled": "#4385BE4d", "secondary_disabled": "#3AA99F4D",
"danger_foreground": "#100F0F", "danger_foreground": "#100F0F",
"danger_background": "#E8705F", "danger_background": "#D14D41",
"danger_hover": "#D14D41", "danger_hover": "#AF3029",
"danger_active": "#C03E35", "danger_active": "#CECDC3",
"danger_selected": "#AF3029", "danger_selected": "#F2F0E5",
"danger_disabled": "#E8705F4d", "danger_disabled": "#D14D414D",
"warning_foreground": "#100F0F", "warning_foreground": "#100F0F",
"warning_background": "#DFB431", "warning_background": "#DA702C",
"warning_hover": "#D0A215", "warning_hover": "#BC5215",
"warning_active": "#BE9207", "warning_active": "#CECDC3",
"warning_selected": "#AD8301", "warning_selected": "#F2F0E5",
"warning_disabled": "#DFB4314d", "warning_disabled": "#DA702C4D",
"ghost_element_background": "#00000000", "ghost_element_background": "#100F0F00",
"ghost_element_background_alt": "#282726", "ghost_element_background_alt": "#1C1B1A",
"ghost_element_hover": "#FFFCF01a", "ghost_element_hover": "#FFFCF00D",
"ghost_element_active": "#343331", "ghost_element_active": "#FFFCF01A",
"ghost_element_selected": "#343331", "ghost_element_selected": "#FFFCF01A",
"ghost_element_disabled": "#FFFCF00d", "ghost_element_disabled": "#FFFCF005",
"tab_inactive_background": "#1C1B1A", "tab_background": "#282726",
"tab_inactive_foreground": "#878580", "tab_foreground": "#878580",
"tab_hover_background": "#FFFCF00D",
"tab_active_background": "#100F0F", "tab_active_background": "#100F0F",
"tab_active_foreground": "#FFFCF0", "tab_active_foreground": "#CECDC3",
"tab_hover_foreground": "#4385BE", "scrollbar_thumb_background": "#FFFCF01A",
"scrollbar_thumb_background": "#FFFCF033", "scrollbar_thumb_hover_background": "#FFFCF026",
"scrollbar_thumb_hover_background": "#FFFCF04d", "scrollbar_thumb_border": "#100F0F00",
"scrollbar_thumb_border": "#00000000", "scrollbar_track_background": "#100F0F00",
"scrollbar_track_background": "#00000000", "scrollbar_track_border": "#100F0F00",
"scrollbar_track_border": "#343331", "drop_target_background": "#3AA99F1A",
"drop_target_background": "#4385BE1a", "cursor": "#3AA99F",
"cursor": "#4385BE", "selection": "#3AA99F40"
"selection": "#4385BE40"
} }
} }

144
assets/themes/forest.json Normal file
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
assets/themes/ocean.json Normal file
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"
}
}

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"
}
}

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"
}
}

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"
}
}

View File

@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient}; use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{ use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task, App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
@@ -11,7 +11,7 @@ use gpui::{
}; };
use semver::Version; use semver::Version;
use serde::Deserialize; use serde::Deserialize;
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use smol::fs::File; use smol::fs::File;
use smol::io::AsyncReadExt; use smol::io::AsyncReadExt;
use smol::process::Command; use smol::process::Command;
@@ -20,11 +20,11 @@ const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
fn get_github_repo_owner() -> String { 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 { 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 { fn is_flatpak_installation() -> bool {

View File

@@ -1,20 +1,23 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::EventUtils; use common::EventExt;
use fuzzy_matcher::skim::SkimMatcherV2; use device::{DeviceEvent, DeviceRegistry};
use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP}; use smol::lock::RwLock;
use state::{CoopSigner, DEVICE_GIFTWRAP, NostrRegistry, StateEvent, TIMEOUT, USER_GIFTWRAP};
mod message; mod message;
mod room; mod room;
@@ -39,6 +42,8 @@ pub enum ChatEvent {
CloseRoom(u64), CloseRoom(u64),
/// An event to notify UI about a new chat request /// An event to notify UI about a new chat request
Ping, Ping,
/// An error occurred
Error(SharedString),
} }
/// Channel signal. /// Channel signal.
@@ -48,46 +53,62 @@ enum Signal {
Message(NewMessage), Message(NewMessage),
/// Eose received from relay pool /// Eose received from relay pool
Eose, Eose,
/// An error occurred
Error(FailedMessage),
} }
/// Inbox state. impl Signal {
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] pub fn message(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
pub enum InboxState { Self::Message(NewMessage::new(gift_wrap, rumor))
#[default]
Idle,
Checking,
RelayNotAvailable,
RelayConfigured(Box<Event>),
Subscribing,
} }
impl InboxState { pub fn eose() -> Self {
pub fn not_configured(&self) -> bool { Self::Eose
matches!(self, InboxState::RelayNotAvailable)
} }
pub fn subscribing(&self) -> bool { pub fn error<T>(event: &Event, reason: T) -> Self
matches!(self, InboxState::Subscribing) where
T: Into<SharedString>,
{
Self::Error(FailedMessage::new(event, reason))
} }
} }
type Dekey = bool;
type GiftWrapId = EventId;
/// Chat Registry /// Chat Registry
#[derive(Debug)] #[derive(Debug)]
pub struct ChatRegistry { pub struct ChatRegistry {
/// Relay state for messaging relay list /// Whether the chat registry is currently initializing.
state: Entity<InboxState>, pub initializing: bool,
/// Collection of all chat rooms /// Chat rooms
rooms: Vec<Entity<Room>>, rooms: Vec<Entity<Room>>,
/// Events that failed to unwrap for any reason
trashes: Entity<BTreeSet<FailedMessage>>,
/// Tracking events seen on which relays in the current session
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
/// Mapping of unwrapped event ids to their gift wrap event ids
event_map: Arc<RwLock<HashMap<EventId, (GiftWrapId, Dekey)>>>,
/// Tracking the status of unwrapping gift wrap events. /// Tracking the status of unwrapping gift wrap events.
tracking_flag: Arc<AtomicBool>, tracking_flag: Arc<AtomicBool>,
/// Channel for sending signals to the UI.
signal_tx: flume::Sender<Signal>,
/// Channel for receiving signals from the UI.
signal_rx: flume::Receiver<Signal>,
/// Async tasks /// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 2]>, tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
/// Subscriptions /// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
impl EventEmitter<ChatEvent> for ChatRegistry {} impl EventEmitter<ChatEvent> for ChatRegistry {}
@@ -105,37 +126,50 @@ impl ChatRegistry {
/// Create a new chat registry instance /// Create a new chat registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| InboxState::default());
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let device = DeviceRegistry::global(cx);
let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
// Observe the nip65 state and load chat rooms on every state change // Subscribe to the signer event
cx.observe(&nostr, |this, state, cx| { cx.subscribe_in(&nostr, window, |this, state, event, window, cx| {
match state.read(cx).relay_list_state() { if event == &StateEvent::SignerSet {
RelayState::Idle => {
this.reset(cx); this.reset(cx);
}
RelayState::Configured => {
this.get_contact_list(cx); this.get_contact_list(cx);
this.ensure_messaging_relays(cx);
}
_ => {}
}
// Load rooms on every state change
this.get_rooms(cx); this.get_rooms(cx);
let signer = state.read(cx).signer();
cx.spawn_in(window, async move |this, cx| {
let user_signer = signer.get().await;
this.update(cx, |this, cx| {
this.get_messages(user_signer, cx);
})
.ok();
})
.detach();
};
}), }),
); );
subscriptions.push( subscriptions.push(
// Observe the nip17 state and load chat rooms on every state change // Subscribe to the device event
cx.observe(&state, |this, state, cx| { cx.subscribe_in(&device, window, |_this, _s, event, window, cx| {
if let InboxState::RelayConfigured(event) = state.read(cx) { if event == &DeviceEvent::Set {
let relay_urls: Vec<_> = nip17::extract_relay_list(event).cloned().collect(); let nostr = NostrRegistry::global(cx);
this.get_messages(relay_urls, cx); let signer = nostr.read(cx).signer();
cx.spawn_in(window, async move |this, cx| {
if let Some(device_signer) = signer.get_encryption_signer().await {
this.update(cx, |this, cx| {
this.get_messages(device_signer, cx);
})
.ok();
} }
})
.detach();
};
}), }),
); );
@@ -147,9 +181,14 @@ impl ChatRegistry {
}); });
Self { Self {
state, initializing: true,
rooms: vec![], rooms: vec![],
trashes: cx.new(|_| BTreeSet::default()),
seens: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
tracking_flag: Arc::new(AtomicBool::new(false)), tracking_flag: Arc::new(AtomicBool::new(false)),
signal_rx: rx,
signal_tx: tx,
tasks: smallvec![], tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -161,58 +200,92 @@ impl ChatRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let status = self.tracking_flag.clone(); let status = self.tracking_flag.clone();
let seens = self.seens.clone();
let event_map = self.event_map.clone();
let trashes = self.trashes.downgrade();
let initialized_at = Timestamp::now(); let initialized_at = Timestamp::now();
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP); let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP); let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
// Channel for communication between nostr and gpui // Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Signal>(1024); let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
let device_signer = signer.get_encryption_signer().await;
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
while let Some(notification) = notifications.next().await { while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, .. } = notification else { let ClientNotification::Message { message, relay_url } = notification else {
// Skip non-message notifications // Skip non-message notifications
continue; continue;
}; };
match message { match *message {
RelayMessage::Event { event, .. } => { RelayMessage::Event {
event,
subscription_id,
} => {
// Keep track of which relays have seen this event
{
let mut seens = seens.write().await;
seens.entry(event.id).or_default().insert(relay_url);
}
// De-duplicate events by their ID
if !processed_events.insert(event.id) { if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue; continue;
} }
if event.kind != Kind::GiftWrap {
// Skip non-gift wrap events // Skip non-gift wrap events
if event.kind != Kind::GiftWrap {
continue; continue;
} }
// Extract the rumor from the gift wrap event // Extract the rumor from the gift wrap event
match extract_rumor(&client, &device_signer, event.as_ref()).await { match extract_rumor(&client, &signer, event.as_ref()).await {
Ok(rumor) => match rumor.created_at >= initialized_at { Ok(rumor) => {
true => { // Map the rumor id to the gift wrap event id for later lookup
let new_message = NewMessage::new(event.id, rumor); {
let signal = Signal::Message(new_message); let mut event_map = event_map.write().await;
let dekey = subscription_id.as_ref() == &sub_id1;
event_map.insert(rumor.id.unwrap(), (event.id, dekey));
}
if rumor.kind != Kind::PrivateDirectMessage
|| rumor.kind != Kind::Custom(15)
{
log::info!("Rumor is not releated to NIP17");
continue;
}
// Check if the rumor has a recipient
if rumor.tags.is_empty() {
let signal =
Signal::error(event.as_ref(), "Recipient is missing");
tx.send_async(signal).await?; tx.send_async(signal).await?;
} }
false => {
// Check if the rumor was created after the chat was initialized (for detecting new messages)
if rumor.created_at >= initialized_at {
let signal = Signal::message(event.id, rumor);
tx.send_async(signal).await?;
} else {
// Mark the chat still processing new messages
status.store(true, Ordering::Release); status.store(true, Ordering::Release);
} }
}, }
Err(e) => { Err(e) => {
log::warn!("Failed to unwrap the gift wrap event: {e}"); let reason = format!("Failed to extract rumor: {e}");
let signal = Signal::error(event.as_ref(), reason);
tx.send_async(signal).await?;
} }
} }
} }
RelayMessage::EndOfStoredEvents(id) => { RelayMessage::EndOfStoredEvents(id) => {
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 { if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
tx.send_async(Signal::Eose).await?; tx.send_async(Signal::eose()).await?;
} }
} }
_ => {} _ => {}
@@ -235,6 +308,12 @@ impl ChatRegistry {
this.get_rooms(cx); this.get_rooms(cx);
})?; })?;
} }
Signal::Error(trash) => {
trashes.update(cx, |this, cx| {
this.insert(trash);
cx.notify();
})?;
}
}; };
} }
@@ -245,6 +324,7 @@ impl ChatRegistry {
/// Tracking the status of unwrapping gift wrap events. /// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) { fn tracking(&mut self, cx: &mut Context<Self>) {
let status = self.tracking_flag.clone(); let status = self.tracking_flag.clone();
let tx = self.signal_tx.clone();
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(15); let loop_duration = Duration::from_secs(15);
@@ -252,6 +332,9 @@ impl ChatRegistry {
loop { loop {
if status.load(Ordering::Acquire) { if status.load(Ordering::Acquire) {
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed); _ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
_ = tx.send_async(Signal::Eose).await;
} else {
_ = tx.send_async(Signal::Eose).await;
} }
smol::Timer::after(loop_duration).await; smol::Timer::after(loop_duration).await;
} }
@@ -259,13 +342,14 @@ impl ChatRegistry {
} }
/// Get contact list from relays /// Get contact list from relays
pub fn get_contact_list(&mut self, cx: &mut Context<Self>) { fn get_contact_list(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let write_relays = nostr.read(cx).write_relays(&public_key, cx); let Some(public_key) = signer.public_key() else {
return;
};
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let id = SubscriptionId::new("contact-list"); let id = SubscriptionId::new("contact-list");
@@ -273,21 +357,14 @@ impl ChatRegistry {
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT))); .timeout(Some(Duration::from_secs(TIMEOUT)));
// Get user's write relays
let urls = write_relays.await;
// Construct filter for inbox relays // Construct filter for inbox relays
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ContactList) .kind(Kind::ContactList)
.author(public_key) .author(public_key)
.limit(1); .limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe // Subscribe
client.subscribe(target).close_on(opts).with_id(id).await?; client.subscribe(filter).close_on(opts).with_id(id).await?;
Ok(()) Ok(())
}); });
@@ -295,36 +372,39 @@ impl ChatRegistry {
self.tasks.push(task); self.tasks.push(task);
} }
/// Ensure messaging relays are set up for the current user. /// Get all messages for the provided signer
pub fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) { fn get_messages<T>(&mut self, signer: T, cx: &mut Context<Self>)
let task = self.verify_relays(cx); where
T: NostrSigner + 'static,
// Set state to checking {
self.set_state(InboxState::Checking, cx); let task = self.subscribe_gift_wrap_events(signer, cx);
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await?; match task.await {
Ok(_) => {
// Update state
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_state(result, cx); this.set_initializing(false, cx);
})?; })?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
})?;
}
}
Ok(()) Ok(())
})); }));
} }
// Verify messaging relay list for current user // Get messaging relay list for current user
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> { fn get_messaging_relays(&self, cx: &App) -> Task<Result<Vec<RelayUrl>, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
cx.background_spawn(async move { cx.background_spawn(async move {
let urls = write_relays.await; let public_key = signer.get_public_key().await?;
let id = SubscriptionId::new("inbox-relay");
// Construct filter for inbox relays // Construct filter for inbox relays
let filter = Filter::new() let filter = Filter::new()
@@ -332,64 +412,38 @@ impl ChatRegistry {
.author(public_key) .author(public_key)
.limit(1); .limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Stream events from user's write relays // Stream events from user's write relays
let mut stream = client let mut stream = client
.stream_events(target) .stream_events(filter)
.with_id(id)
.timeout(Duration::from_secs(TIMEOUT)) .timeout(Duration::from_secs(TIMEOUT))
.await?; .await?;
while let Some((_url, res)) = stream.next().await { while let Some((_url, res)) = stream.next().await {
match res { if let Ok(event) = res {
Ok(event) => { let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
return Ok(InboxState::RelayConfigured(Box::new(event))); return Ok(urls);
}
Err(e) => {
log::error!("Failed to receive relay list event: {e}");
}
} }
} }
Ok(InboxState::RelayNotAvailable) Err(anyhow!("Messaging Relays not found"))
}) })
} }
/// Get all messages for current user /// Continuously get gift wrap events for the signer
fn get_messages<I>(&mut self, relay_urls: I, cx: &mut Context<Self>) fn subscribe_gift_wrap_events<T>(&self, signer: T, cx: &App) -> Task<Result<(), Error>>
where where
I: IntoIterator<Item = RelayUrl>, T: NostrSigner + 'static,
{
let task = self.subscribe(relay_urls, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
task.await?;
// Update state
this.update(cx, |this, cx| {
this.set_state(InboxState::Subscribing, cx);
})?;
Ok(())
}));
}
/// Continuously get gift wrap events for the current user in their messaging relays
fn subscribe<I>(&mut self, urls: I, cx: &mut Context<Self>) -> Task<Result<(), Error>>
where
I: IntoIterator<Item = RelayUrl>,
{ {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let urls = self.get_messaging_relays(cx);
let urls = urls.into_iter().collect::<Vec<_>>();
cx.background_spawn(async move { cx.background_spawn(async move {
let urls = urls.await?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(USER_GIFTWRAP); let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
// Ensure relay connections // Ensure relay connections
for url in urls.iter() { for url in urls.iter() {
@@ -413,17 +467,35 @@ impl ChatRegistry {
}) })
} }
/// Set the state of the inbox /// Refresh the chat registry, fetching messages and contact list from relays.
fn set_state(&mut self, state: InboxState, cx: &mut Context<Self>) { pub fn refresh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.state.update(cx, |this, cx| { self.reset(cx);
*this = state; self.get_contact_list(cx);
cx.notify(); self.get_rooms(cx);
});
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
cx.spawn_in(window, async move |this, cx| {
let user_signer = signer.get().await;
let device_signer = signer.get_encryption_signer().await;
this.update(cx, |this, cx| {
this.get_messages(user_signer, cx);
if let Some(device_signer) = device_signer {
this.get_messages(device_signer, cx);
}
})
.ok();
})
.detach();
} }
/// Get the relay state /// Set the initializing status of the chat registry
pub fn state(&self, cx: &App) -> InboxState { fn set_initializing(&mut self, initializing: bool, cx: &mut Context<Self>) {
self.state.read(cx).clone() self.initializing = initializing;
cx.notify();
} }
/// Get the loading status of the chat registry /// Get the loading status of the chat registry
@@ -456,6 +528,51 @@ impl ChatRegistry {
.count() .count()
} }
/// Count the number of messages seen by a given relay.
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
self.seens
.read_blocking()
.values()
.filter(|seen| seen.contains(relay_url))
.count()
}
/// Count the number of trash messages.
pub fn count_trash_messages(&self, cx: &App) -> usize {
self.trashes.read(cx).len()
}
/// Get the trash messages entity.
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
self.trashes.clone()
}
/// Get the relays that have seen a given rumor id.
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
self.event_map
.read_blocking()
.get(id)
.map(|(id, _dekey)| self.seen_on(id))
}
/// Get the relays that have seen a given gift wrap id.
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
self.seens
.read_blocking()
.get(id)
.cloned()
.unwrap_or_default()
}
/// Check if a given rumor was encrypted by the dekey.
pub fn encrypted_by_dekey(&self, id: &EventId) -> bool {
self.event_map
.read_blocking()
.get(id)
.map(|(_, dekey)| *dekey)
.unwrap_or(false)
}
/// Add a new room to the start of list. /// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>) pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where where
@@ -533,7 +650,12 @@ impl ChatRegistry {
/// Reset the registry. /// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) { pub fn reset(&mut self, cx: &mut Context<Self>) {
self.initializing = true;
self.rooms.clear(); self.rooms.clear();
self.trashes.update(cx, |this, cx| {
this.clear();
cx.notify();
});
cx.notify(); cx.notify();
} }
@@ -627,12 +749,12 @@ impl ChatRegistry {
// Process each event and group by room hash // Process each event and group by room hash
for raw in events.into_iter() { for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) { if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
if rumor.tags.public_keys().peekable().peek().is_some() { && rumor.tags.public_keys().peekable().peek().is_some()
{
grouped.entry(rumor.uniq_id()).or_default().push(rumor); grouped.entry(rumor.uniq_id()).or_default().push(rumor);
} }
} }
}
for (_id, mut messages) in grouped.into_iter() { for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at)); messages.sort_by_key(|m| Reverse(m.created_at));
@@ -670,9 +792,18 @@ impl ChatRegistry {
/// If the room doesn't exist, it will be created. /// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages. /// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) { pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
match self.rooms.iter().find(|e| e.read(cx).id == message.room) { match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
Some(room) => { Some(room) => {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
if this.kind == RoomKind::Request
&& let Some(public_key) = signer.public_key()
&& message.rumor.pubkey == public_key
{
this.set_ongoing(cx);
}
this.push_message(message, cx); this.push_message(message, cx);
}); });
self.sort(cx); self.sort(cx);
@@ -685,8 +816,7 @@ impl ChatRegistry {
} }
/// Trigger a refresh of the opened chat rooms by their IDs /// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) { pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
if let Some(ids) = ids {
for room in self.rooms.iter() { for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) { if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
@@ -696,21 +826,20 @@ impl ChatRegistry {
} }
} }
} }
}
/// Unwraps a gift-wrapped event and processes its contents. /// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor( async fn extract_rumor(
client: &Client, client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>, signer: &Arc<CoopSigner>,
gift_wrap: &Event, gift_wrap: &Event,
) -> Result<UnsignedEvent, Error> { ) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first // Try to get cached rumor first
if let Ok(event) = get_rumor(client, gift_wrap.id).await { if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
return Ok(event); return Ok(rumor);
} }
// Try to unwrap with the available signer // Try to unwrap with the available signer
let unwrapped = try_unwrap(client, device_signer, gift_wrap).await?; let unwrapped = try_unwrap(signer, gift_wrap).await?;
let mut rumor = unwrapped.rumor; let mut rumor = unwrapped.rumor;
// Generate event id for the rumor if it doesn't have one // Generate event id for the rumor if it doesn't have one
@@ -725,30 +854,27 @@ async fn extract_rumor(
} }
/// Helper method to try unwrapping with different signers /// Helper method to try unwrapping with different signers
async fn try_unwrap( async fn try_unwrap(signer: &Arc<CoopSigner>, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
// Try with the device signer first // Try with the device signer first
if let Some(signer) = device_signer { if let Some(signer) = signer.get_encryption_signer().await {
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, signer).await { log::info!("trying with encryption key");
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
return Ok(unwrapped); return Ok(unwrapped);
}; }
}; }
// Try with the user's signer // Fallback to the user's signer
let user_signer = client.signer().context("Signer not found")?; let user_signer = signer.get().await;
let unwrapped = try_unwrap_with(gift_wrap, user_signer).await?; let unwrapped = try_unwrap_with(gift_wrap, &user_signer).await?;
Ok(unwrapped) Ok(unwrapped)
} }
/// Attempts to unwrap a gift wrap event with a given signer. /// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with( async fn try_unwrap_with<T>(gift_wrap: &Event, signer: &T) -> Result<UnwrappedGift, Error>
gift_wrap: &Event, where
signer: &Arc<dyn NostrSigner>, T: NostrSigner + 'static,
) -> Result<UnwrappedGift, Error> { {
// Get the sealed event // Get the sealed event
let seal = signer let seal = signer
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content) .nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)

View File

@@ -1,6 +1,8 @@
use std::hash::Hash; 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::*; use nostr_sdk::prelude::*;
/// New message. /// New message.
@@ -23,6 +25,25 @@ impl NewMessage {
} }
} }
/// Trash message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FailedMessage {
pub raw_event: SharedString,
pub reason: SharedString,
}
impl FailedMessage {
pub fn new<T>(event: &Event, reason: T) -> Self
where
T: Into<SharedString>,
{
Self {
raw_event: SharedString::from(event.as_json()),
reason: reason.into(),
}
}
}
/// Message. /// Message.
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message { pub enum Message {
@@ -91,6 +112,18 @@ impl PartialOrd for Message {
} }
} }
#[derive(Debug, Clone)]
pub struct Mention {
pub public_key: PublicKey,
pub range: Range<usize>,
}
impl Mention {
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
Self { public_key, range }
}
}
/// Rendered message. /// Rendered message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RenderedMessage { pub struct RenderedMessage {
@@ -99,10 +132,12 @@ pub struct RenderedMessage {
pub author: PublicKey, pub author: PublicKey,
/// The content/text of the message /// The content/text of the message
pub content: String, pub content: String,
/// List of media URLs in the message
pub media: Vec<SharedUri>,
/// Message created time as unix timestamp /// Message created time as unix timestamp
pub created_at: Timestamp, pub created_at: Timestamp,
/// List of mentioned public keys in the message /// List of mentioned public keys in the message
pub mentions: Vec<PublicKey>, pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to /// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>, pub replies_to: Vec<EventId>,
} }
@@ -111,11 +146,13 @@ impl From<&Event> for RenderedMessage {
fn from(val: &Event) -> Self { fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content); let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags); let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self { Self {
id: val.id, id: val.id,
author: val.pubkey, author: val.pubkey,
content: val.content.clone(), content: string,
media,
created_at: val.created_at, created_at: val.created_at,
mentions, mentions,
replies_to, replies_to,
@@ -127,12 +164,14 @@ impl From<&UnsignedEvent> for RenderedMessage {
fn from(val: &UnsignedEvent) -> Self { fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content); let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags); let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self { Self {
// Event ID must be known // Event ID must be known
id: val.id.unwrap(), id: val.id.unwrap(),
author: val.pubkey, author: val.pubkey,
content: val.content.clone(), content: string,
media,
created_at: val.created_at, created_at: val.created_at,
mentions, mentions,
replies_to, replies_to,
@@ -144,12 +183,14 @@ impl From<&NewMessage> for RenderedMessage {
fn from(val: &NewMessage) -> Self { fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content); let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags); let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self { Self {
// Event ID must be known // Event ID must be known
id: val.rumor.id.unwrap(), id: val.rumor.id.unwrap(),
author: val.rumor.pubkey, author: val.rumor.pubkey,
content: val.rumor.content.clone(), content: string,
media,
created_at: val.rumor.created_at, created_at: val.rumor.created_at,
mentions, mentions,
replies_to, replies_to,
@@ -184,20 +225,17 @@ impl Hash for RenderedMessage {
} }
/// Extracts all mentions (public keys) from a content string. /// 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 parser = NostrParser::new();
let tokens = parser.parse(content); let tokens = parser.parse(content);
tokens tokens
.filter_map(|token| match token { .filter_map(|token| match token.value {
Token::Nostr(nip21) => match nip21 { Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
Nip21::Pubkey(pubkey) => Some(pubkey), Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None, _ => None,
}) })
.collect::<Vec<_>>() .collect()
} }
/// Extracts all reply (ids) from the event tags. /// Extracts all reply (ids) from the event tags.

View File

@@ -1,16 +1,15 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::HashMap;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::{Error, anyhow};
use common::EventUtils; use common::EventExt;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry}; use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind}; use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT}; use state::{NostrRegistry, TIMEOUT};
use crate::NewMessage; use crate::NewMessage;
@@ -58,16 +57,44 @@ impl SendReport {
/// Returns true if the send is pending. /// Returns true if the send is pending.
pub fn pending(&self) -> bool { 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. /// Returns true if the send was successful.
pub fn success(&self) -> bool { pub fn success(&self) -> bool {
if let Some(output) = self.output.as_ref() { self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
!output.failed.is_empty()
} else {
false
} }
/// 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 }
} }
} }
@@ -205,11 +232,9 @@ impl Room {
/// Sets this room is ongoing conversation /// Sets this room is ongoing conversation
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) { pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing; self.kind = RoomKind::Ongoing;
cx.notify(); cx.notify();
} }
}
/// Updates the creation timestamp of the room /// Updates the creation timestamp of the room
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) { pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
@@ -329,46 +354,30 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> { pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let members = self.members();
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();
cx.background_spawn(async move { cx.background_spawn(async move {
let id = SubscriptionId::new(format!("room-{id}"));
let opts = SubscribeAutoCloseOptions::default() let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT))); .timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct filters for each member for public_key in members.into_iter() {
let filters: Vec<Filter> = members let inbox = Filter::new()
.into_iter()
.map(|public_key| {
Filter::new()
.author(public_key) .author(public_key)
.kind(Kind::RelayList) .kind(Kind::InboxRelays)
.limit(1) .limit(1);
})
.collect();
// Construct target for subscription let announcement = Filter::new()
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS .author(public_key)
.into_iter() .kind(Kind::Custom(10044))
.map(|relay| (relay, filters.clone())) .limit(1);
.collect();
// Subscribe to the target // Subscribe to the target
client.subscribe(target).close_on(opts).with_id(id).await?; client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.await?;
}
Ok(()) Ok(())
}) })
@@ -391,6 +400,10 @@ impl Room {
.await? .await?
.into_iter() .into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok()) .filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.filter(|event| {
// Only process private direct messages and file messages
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
})
.sorted_by_key(|message| message.created_at) .sorted_by_key(|message| message.created_at)
.collect(); .collect();
@@ -491,57 +504,51 @@ impl Room {
// Process each member // Process each member
for member in members { for member in members {
let relays = member.messaging_relays();
let announcement = member.announcement(); let announcement = member.announcement();
let public_key = member.public_key(); 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 // Handle encryption signer requirements
if signer_kind.encryption() { if signer_kind.encryption() {
// Receiver didn't set up a decoupled encryption key
if announcement.is_none() { if announcement.is_none() {
reports.push(SendReport::new(public_key).error(NO_DEKEY)); reports.push(SendReport::new(public_key).error(NO_DEKEY));
continue; continue;
} }
// Sender didn't set up a decoupled encryption key
if encryption_signer.is_none() { if encryption_signer.is_none() {
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY)); reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
continue; continue;
} }
} }
// Determine receiver and signer // Determine the signer to use
let (receiver, signer) = match signer_kind { let signer = match signer_kind {
SignerKind::Auto => { SignerKind::Auto => {
if let Some(announcement) = announcement { if announcement.is_some()
if let Some(enc_signer) = encryption_signer.as_ref() { && let Some(encryption_signer) = encryption_signer.clone()
(announcement.public_key(), enc_signer.clone()) {
// Safe to unwrap due to earlier checks
encryption_signer
} else { } else {
(member.public_key(), user_signer.clone()) user_signer.clone()
}
} else {
(member.public_key(), user_signer.clone())
} }
} }
SignerKind::Encryption => { SignerKind::Encryption => {
// Safe to unwrap due to earlier checks // Safe to unwrap due to earlier checks
( encryption_signer.as_ref().unwrap().clone()
announcement.unwrap().public_key(),
encryption_signer.as_ref().unwrap().clone(),
)
} }
SignerKind::User => (member.public_key(), user_signer.clone()), SignerKind::User => user_signer.clone(),
}; };
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await // Send the gift wrap event and collect the report
{ match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
Ok((report, _)) => { Ok(report) => {
reports.push(report); reports.push(report);
sents += 1; sents += 1;
} }
Err(report) => { Err(error) => {
let report = SendReport::new(public_key).error(error.to_string());
reports.push(report); reports.push(report);
} }
} }
@@ -549,14 +556,33 @@ impl Room {
// Send backup to current user if needed // Send backup to current user if needed
if backup && sents >= 1 { if backup && sents >= 1 {
let relays = sender.messaging_relays();
let public_key = sender.public_key(); 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 // Determine the signer to use
let signer = match signer_kind {
SignerKind::Auto => {
if sender.announcement().is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
{ {
Ok((report, _)) => reports.push(report), // Safe to unwrap due to earlier checks
Err(report) => reports.push(report), encryption_signer
} else {
user_signer.clone()
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
}
SignerKind::User => user_signer.clone(),
};
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
Ok(report) => reports.push(report),
Err(error) => {
let report = SendReport::new(public_key).error(error.to_string());
reports.push(report);
}
} }
} }
@@ -569,36 +595,51 @@ impl Room {
async fn send_gift_wrap<T>( async fn send_gift_wrap<T>(
client: &Client, client: &Client,
signer: &T, signer: &T,
receiver: &PublicKey, receiver: &Person,
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
relays: &[RelayUrl], config: &SignerKind,
public_key: PublicKey, ) -> Result<SendReport, Error>
) -> Result<(SendReport, bool), SendReport>
where where
T: NostrSigner + 'static, T: NostrSigner + 'static,
{ {
// Ensure relay connections let k_tag = Tag::custom(TagKind::k(), vec!["14"]);
for url in relays { let mut extra_tags = vec![k_tag];
client.add_relay(url).and_connect().await.ok();
}
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await { // Determine the receiver public key based on the config
Ok(event) => { let receiver = match config {
match client 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()
}
}
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 = EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), extra_tags).await?;
// Send the gift wrap event and collect the report
let report = client
.send_event(&event) .send_event(&event)
.to(relays) .to_nip17()
.ack_policy(AckPolicy::none()) .ack_policy(AckPolicy::none())
.await .await
{ .map(|output| {
Ok(output) => Ok(( SendReport::new(receiver)
SendReport::new(public_key)
.gift_wrap_id(event.id) .gift_wrap_id(event.id)
.output(output), .output(output)
true, })?;
)),
Err(e) => Err(SendReport::new(public_key).error(e.to_string())), Ok(report)
}
}
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
}
} }

View File

@@ -28,3 +28,5 @@ serde_json.workspace = true
once_cell = "1.19.0" once_cell = "1.19.0"
regex = "1" regex = "1"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"

View File

@@ -7,21 +7,11 @@ use settings::SignerKind;
#[action(namespace = chat, no_json)] #[action(namespace = chat, no_json)]
pub enum Command { pub enum Command {
Insert(&'static str), Insert(&'static str),
ChangeSubject(&'static str), ChangeSubject(String),
ChangeSigner(SignerKind), ChangeSigner(SignerKind),
ToggleBackup, 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);

View File

@@ -1,38 +1,36 @@
use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
pub use actions::*; pub use actions::*;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error};
use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport}; use chat::{ChatRegistry, Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus};
use common::RenderedTimestamp; use common::{TimestampExt, coop_cache};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
deferred, div, img, list, px, red, relative, svg, white, AnyElement, App, AppContext, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage, StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window,
Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red, relative, svg, white,
}; };
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry}; use person::{Person, PersonRegistry};
use settings::{AppSettings, SignerKind}; use settings::{AppSettings, SignerKind};
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use smol::lock::RwLock; use smol::lock::RwLock;
use state::{upload, NostrRegistry}; use state::{NostrRegistry, upload};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::menu::{ContextMenuExt, DropdownMenu}; use ui::menu::DropdownMenu;
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::scroll::Scrollbar;
use ui::{ use ui::{
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
WindowExtension, h_flex, v_flex,
}; };
use crate::text::RenderedText; use crate::text::RenderedText;
@@ -42,11 +40,6 @@ mod text;
const ANNOUNCEMENT: &str = const ANNOUNCEMENT: &str =
"This conversation is private. Only members can see each other's messages."; "This conversation is private. Only members can see each other's messages.";
const NO_INBOX: &str = "has not set up messaging relays. \
They will not receive messages you send.";
const NO_ANNOUNCEMENT: &str = "has not set up an encryption key. \
You cannot send messages encrypted with an encryption key to them yet. \
Coop automatically uses your identity to encrypt messages.";
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> { pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx)) cx.new(|cx| ChatPanel::new(room, window, cx))
@@ -72,9 +65,15 @@ pub struct ChatPanel {
/// Mapping message (rumor event) ids to their reports /// Mapping message (rumor event) ids to their reports
reports_by_id: Entity<BTreeMap<EventId, Vec<SendReport>>>, reports_by_id: Entity<BTreeMap<EventId, Vec<SendReport>>>,
/// Input state /// Chat input state
input: Entity<InputState>, input: Entity<InputState>,
/// Subject input state
subject_input: Entity<InputState>,
/// Subject bar visibility
subject_bar: Entity<bool>,
/// Sent message ids /// Sent message ids
sent_ids: Arc<RwLock<Vec<EventId>>>, sent_ids: Arc<RwLock<Vec<EventId>>>,
@@ -91,7 +90,7 @@ pub struct ChatPanel {
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions /// Event subscriptions
subscriptions: SmallVec<[Subscription; 2]>, subscriptions: SmallVec<[Subscription; 3]>,
} }
impl ChatPanel { impl ChatPanel {
@@ -124,19 +123,38 @@ impl ChatPanel {
.clean_on_escape() .clean_on_escape()
}); });
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
let subject_bar = cx.new(|_cx| false);
// Define subscriptions // Define subscriptions
let subscriptions = let mut subscriptions = smallvec![];
smallvec![
subscriptions.push(
// Subscribe the chat input event
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event { if let InputEvent::PressEnter { .. } = event {
this.send_text_message(window, cx); this.send_text_message(window, cx);
}; };
}) }),
]; );
subscriptions.push(
// Subscribe the subject input event
cx.subscribe_in(
&subject_input,
window,
move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.change_subject(window, cx);
};
},
),
);
// Define all functions that will run after the current cycle // Define all functions that will run after the current cycle
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.connect(window, cx); this.connect(cx);
this.handle_notifications(cx); this.handle_notifications(cx);
this.subscribe_room_events(window, cx); this.subscribe_room_events(window, cx);
this.get_messages(window, cx); this.get_messages(window, cx);
@@ -149,6 +167,8 @@ impl ChatPanel {
room, room,
list_state, list_state,
input, input,
subject_input,
subject_bar,
replies_to, replies_to,
attachments, attachments,
rendered_texts_by_id: BTreeMap::new(), rendered_texts_by_id: BTreeMap::new(),
@@ -160,47 +180,12 @@ impl ChatPanel {
} }
} }
/// Get all necessary data for each member /// Get messaging relays and announcement for each member
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn connect(&mut self, cx: &mut Context<Self>) {
let Ok((members, connect)) = self if let Some(room) = self.room.upgrade() {
.room let task = room.read(cx).connect(cx);
.read_with(cx, |this, cx| (this.members(), this.connect(cx))) self.tasks.push(task);
else {
return;
};
// Run the connect task in background
self.tasks.push(connect);
// Spawn another task to verify after 3 seconds
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
// Verify the connection
this.update_in(cx, |this, _window, cx| {
let persons = PersonRegistry::global(cx);
for member in members.into_iter() {
let profile = persons.read(cx).get(&member, cx);
if profile.announcement().is_none() {
let content = format!("{} {}", profile.name(), NO_ANNOUNCEMENT);
let message = Message::warning(content);
this.insert_message(message, true, cx);
} }
if profile.messaging_relays().is_empty() {
let content = format!("{} {}", profile.name(), NO_INBOX);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
}
})?;
Ok(())
}));
} }
/// Handle nostr notifications /// Handle nostr notifications
@@ -208,22 +193,30 @@ impl ChatPanel {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let sent_ids = self.sent_ids.clone(); let sent_ids = self.sent_ids.clone();
let reports = self.reports_by_id.downgrade();
let (tx, rx) = flume::bounded::<(EventId, RelayUrl)>(256); let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256);
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
while let Some(notification) = notifications.next().await { while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { if let ClientNotification::Message { message, relay_url } = notification
message: RelayMessage::Ok { event_id, .. }, && let RelayMessage::Ok {
relay_url, event_id,
} = notification status,
message,
} = *message
{ {
let sent_ids = sent_ids.read().await; let sent_ids = sent_ids.read().await;
if sent_ids.contains(&event_id) { if sent_ids.contains(&event_id) {
tx.send_async((event_id, relay_url)).await.ok(); let status = if status {
SendStatus::ok(event_id, relay_url)
} else {
SendStatus::failed(event_id, relay_url, message.into())
};
tx.send_async(Arc::new(status)).await.ok();
} }
} }
} }
@@ -231,36 +224,42 @@ impl ChatPanel {
Ok(()) Ok(())
})); }));
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |_this, cx| {
while let Ok((event_id, relay_url)) = rx.recv_async().await { while let Ok(status) = rx.recv_async().await {
this.update(cx, |this, cx| { reports.update(cx, |this, cx| {
this.reports_by_id.update(cx, |this, cx| {
for reports in this.values_mut() { for reports in this.values_mut() {
for report in reports.iter_mut() { for report in reports.iter_mut() {
if let Some(output) = report.output.as_mut() { let Some(output) = report.output.as_mut() else {
if output.id() == &event_id { continue;
output.success.insert(relay_url.clone()); };
match &*status {
SendStatus::Ok { id, relay } => {
if output.id() == id {
output.success.insert(relay.clone());
}
}
SendStatus::Failed { id, relay, message } => {
if output.id() == id {
output.failed.insert(relay.clone(), message.clone());
}
}
}
cx.notify(); cx.notify();
} }
} }
}
}
});
})?; })?;
} }
Ok(()) Ok(())
})); }));
} }
/// Subscribe to room events
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(room) = self.room.upgrade() else { if let Some(room) = self.room.upgrade() {
return; self.subscriptions.push(cx.subscribe_in(
}; &room,
window,
self.subscriptions.push( move |this, _room, event, window, cx| {
// Subscribe to room events
cx.subscribe_in(&room, window, move |this, _room, event, window, cx| {
match event { match event {
RoomEvent::Incoming(message) => { RoomEvent::Incoming(message) => {
this.insert_message(message, false, cx); this.insert_message(message, false, cx);
@@ -269,8 +268,9 @@ impl ChatPanel {
this.get_messages(window, cx); this.get_messages(window, cx);
} }
}; };
}), },
); ));
}
} }
/// Load all messages belonging to this room /// Load all messages belonging to this room
@@ -316,6 +316,16 @@ impl ChatPanel {
content content
} }
fn change_subject(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let subject = self.subject_input.read(cx).value();
self.room
.update(cx, |this, cx| {
this.set_subject(subject, cx);
})
.ok();
}
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the message which includes all attachments // Get the message which includes all attachments
let content = self.get_input_value(cx); let content = self.get_input_value(cx);
@@ -366,9 +376,13 @@ impl ChatPanel {
/// Send message in the background and wait for the response /// Send message in the background and wait for the response
fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context<Self>) { fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context<Self>) {
let sent_ids = self.sent_ids.clone(); let sent_ids = self.sent_ids.clone();
// This can't fail, because we already ensured that the ID is set // This can't fail, because we already ensured that the ID is set
let id = rumor.id.unwrap(); let id = rumor.id.unwrap();
// Add empty reports
self.insert_reports(id, vec![], cx);
// Upgrade room reference // Upgrade room reference
let Some(room) = self.room.upgrade() else { let Some(room) = self.room.upgrade() else {
return; return;
@@ -417,7 +431,7 @@ impl ChatPanel {
/// Insert reports /// Insert reports
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) { fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
self.reports_by_id.update(cx, |this, cx| { self.reports_by_id.update(cx, |this, cx| {
this.insert(id, reports); this.entry(id).or_default().extend(reports);
cx.notify(); cx.notify();
}); });
} }
@@ -452,28 +466,15 @@ impl ChatPanel {
} }
} }
/// Check if a message is pending /// Check if a message has any reports
fn sent_pending(&self, id: &EventId, cx: &App) -> bool { fn has_reports(&self, id: &EventId, cx: &App) -> bool {
self.reports_by_id self.reports_by_id.read(cx).get(id).is_some()
.read(cx)
.get(id)
.is_some_and(|reports| reports.iter().any(|r| r.pending()))
} }
/// Check if a message was sent successfully by its ID /// Check if a message was encrypted by the dekey
fn sent_success(&self, id: &EventId, cx: &App) -> bool { fn encrypted_by_dekey(&self, id: &EventId, cx: &App) -> bool {
self.reports_by_id let chat = ChatRegistry::global(cx);
.read(cx) chat.read(cx).encrypted_by_dekey(id)
.get(id)
.is_some_and(|reports| reports.iter().any(|r| r.success()))
}
/// Check if a message failed to send by its ID
fn sent_failed(&self, id: &EventId, cx: &App) -> Option<bool> {
self.reports_by_id
.read(cx)
.get(id)
.map(|reports| reports.iter().all(|r| !r.success()))
} }
/// Get all sent reports for a message by its ID /// Get all sent reports for a message by its ID
@@ -484,11 +485,11 @@ impl ChatPanel {
/// Get a message by its ID /// Get a message by its ID
fn message(&self, id: &EventId) -> Option<&RenderedMessage> { fn message(&self, id: &EventId) -> Option<&RenderedMessage> {
self.messages.iter().find_map(|msg| { self.messages.iter().find_map(|msg| {
if let Message::User(rendered) = msg { if let Message::User(rendered) = msg
if &rendered.id == id { && &rendered.id == id
{
return Some(rendered); return Some(rendered);
} }
}
None None
}) })
} }
@@ -505,10 +506,21 @@ impl ChatPanel {
} }
} }
fn copy_message(&self, id: &EventId, cx: &Context<Self>) { fn copy_author(&self, public_key: &PublicKey, cx: &App) {
if let Some(message) = self.message(id) { let content = public_key.to_bech32().unwrap();
cx.write_to_clipboard(ClipboardItem::new_string(message.content.to_string())); let item = ClipboardItem::new_string(content);
cx.write_to_clipboard(item);
} }
fn copy_message(&self, id: &EventId, cx: &App) {
let Some(message) = self.message(id) else {
return;
};
let content = message.content.to_string();
let item = ClipboardItem::new_string(content);
cx.write_to_clipboard(item);
} }
fn reply_to(&mut self, id: &EventId, cx: &mut Context<Self>) { fn reply_to(&mut self, id: &EventId, cx: &mut Context<Self>) {
@@ -558,7 +570,10 @@ impl ChatPanel {
Err(e) => { Err(e) => {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx); this.set_uploading(false, cx);
window.push_notification(Notification::error(e.to_string()), cx); window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?; })?;
} }
} }
@@ -588,7 +603,7 @@ impl ChatPanel {
}); });
} }
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Person { fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx) persons.read(cx).get(public_key, cx)
} }
@@ -602,11 +617,14 @@ impl ChatPanel {
if self if self
.room .room
.update(cx, |this, cx| { .update(cx, |this, cx| {
this.set_subject(*subject, cx); this.set_subject(subject, cx);
}) })
.is_err() .is_err()
{ {
window.push_notification(Notification::error("Failed to change subject"), cx); window.push_notification(
Notification::error("Failed to change subject").autohide(false),
cx,
);
} }
} }
Command::ChangeSigner(kind) => { Command::ChangeSigner(kind) => {
@@ -617,7 +635,10 @@ impl ChatPanel {
}) })
.is_err() .is_err()
{ {
window.push_notification(Notification::error("Failed to change signer"), cx); window.push_notification(
Notification::error("Failed to change signer").autohide(false),
cx,
);
} }
} }
Command::ToggleBackup => { Command::ToggleBackup => {
@@ -628,10 +649,104 @@ impl ChatPanel {
}) })
.is_err() .is_err()
{ {
window.push_notification(Notification::error("Failed to toggle backup"), cx); window.push_notification(
Notification::error("Failed to toggle backup").autohide(false),
cx,
);
}
}
Command::Copy(public_key) => {
self.copy_author(public_key, cx);
}
Command::Relays(public_key) => {
self.open_relays(public_key, window, cx);
}
Command::Njump(public_key) => {
self.open_njump(public_key, cx);
}
Command::Trace(id) => {
self.open_trace(id, window, cx);
} }
} }
} }
fn open_trace(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let seen_on = chat.read(cx).rumor_seen_on(id);
window.open_modal(cx, move |this, _window, cx| {
this.title("Seen on").show_close(true).child(
v_flex()
.gap_1()
.when_none(&seen_on, |this| {
this.child(
h_flex()
.h_10()
.justify_center()
.text_sm()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.child("Message isn't traced yet"),
)
})
.when_some(seen_on.as_ref(), |this, relays| {
this.children({
let mut items = vec![];
for url in relays.iter() {
items.push(
h_flex()
.h_7()
.px_2()
.gap_2()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(div().size_1p5().rounded_full().bg(gpui::green()))
.child(SharedString::from(url.to_string())),
);
}
items
})
}),
)
});
}
fn open_relays(&mut self, public_key: &PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let profile = self.profile(public_key, cx);
window.open_modal(cx, move |this, _window, cx| {
let relays = profile.messaging_relays();
this.title("Messaging Relays")
.show_close(true)
.child(v_flex().gap_1().children({
let mut items = vec![];
for url in relays.iter() {
items.push(
h_flex()
.h_7()
.px_2()
.gap_2()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(div().size_1p5().rounded_full().bg(gpui::green()))
.child(SharedString::from(url.to_string())),
);
}
items
}))
});
}
fn open_njump(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
let content = format!("https://njump.me/{}", public_key.to_bech32().unwrap());
cx.open_url(&content);
} }
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement { fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
@@ -699,10 +814,13 @@ impl ChatPanel {
if let Some(message) = self.messages.iter().nth(ix) { if let Some(message) = self.messages.iter().nth(ix) {
match message { match message {
Message::User(rendered) => { Message::User(rendered) => {
let persons = PersonRegistry::global(cx);
let text = self let text = self
.rendered_texts_by_id .rendered_texts_by_id
.entry(rendered.id) .entry(rendered.id)
.or_insert_with(|| RenderedText::new(&rendered.content, cx)) .or_insert_with(|| {
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
})
.element(ix.into(), window, cx); .element(ix.into(), window, cx);
self.render_text_message(ix, rendered, text, cx) self.render_text_message(ix, rendered, text, cx)
@@ -726,19 +844,12 @@ impl ChatPanel {
) -> AnyElement { ) -> AnyElement {
let id = message.id; let id = message.id;
let author = self.profile(&message.author, cx); let author = self.profile(&message.author, cx);
let public_key = author.public_key(); let pk = author.public_key();
let replies = message.replies_to.as_slice(); let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty(); let has_replies = !replies.is_empty();
let has_reports = self.has_reports(&id, cx);
// Check if message is sent failed let encrypted_by_dekey = self.encrypted_by_dekey(&id, cx);
let sent_pending = self.sent_pending(&id, cx);
// Check if message is sent successfully
let sent_success = self.sent_success(&id, cx);
// Check if message is sent failed
let sent_failed = self.sent_failed(&id, cx);
// Hide avatar setting // Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -756,15 +867,14 @@ impl ChatPanel {
.gap_3() .gap_3()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.child( this.child(
div() Avatar::new(author.avatar())
.id(SharedString::from(format!("{ix}-avatar"))) .flex_shrink_0()
.child(Avatar::new(author.avatar())) .relative()
.context_menu(move |this, _window, _cx| { .dropdown_menu(move |this, _window, _cx| {
let view = Box::new(OpenPublicKey(public_key)); this.menu("Public Key", Box::new(Command::Copy(pk)))
let copy = Box::new(CopyPublicKey(public_key)); .menu("View Relays", Box::new(Command::Relays(pk)))
.separator()
this.menu("View Profile", view) .menu("View on njump.me", Box::new(Command::Njump(pk)))
.menu("Copy Public Key", copy)
}), }),
) )
}) })
@@ -785,27 +895,40 @@ impl ChatPanel {
.text_color(cx.theme().text) .text_color(cx.theme().text)
.child(author.name()), .child(author.name()),
) )
.child(message.created_at.to_human_time()) .when(encrypted_by_dekey, |this| {
.when(sent_pending, |this| { this.child(
this.child(deferred(Indicator::new().small())) Button::new(format!("dekey-{ix}"))
.icon(IconName::Shield)
.ghost()
.xsmall()
.px_4()
.tooltip("Encrypted by Dekey")
.disabled(true),
)
}) })
.when(sent_success, |this| { .child(message.created_at.to_human_time())
this.child(deferred(self.render_sent_indicator(&id, cx))) .when(has_reports, |this| {
this.child(deferred(self.render_sent_reports(&id, cx)))
}), }),
) )
.when(has_replies, |this| { .when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx)) this.children(self.render_message_replies(replies, cx))
}) })
.child(rendered_text) .child(rendered_text)
.when_some(sent_failed, |this, failed| { .child(self.render_media(&message.media, cx)),
this.when(failed, |this| {
this.child(deferred(self.render_message_reports(&id, cx)))
})
}),
), ),
) )
.child(self.render_border(cx)) .child(
.child(self.render_actions(&id, cx)) div()
.group_hover("", |this| this.bg(cx.theme().element_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent),
)
.child(self.render_actions(&id, &pk, cx))
.on_mouse_down( .on_mouse_down(
MouseButton::Middle, MouseButton::Middle,
cx.listener(move |this, _, _window, cx| { cx.listener(move |this, _, _window, cx| {
@@ -819,6 +942,55 @@ impl ChatPanel {
.into_any_element() .into_any_element()
} }
fn render_media(&self, media: &[SharedUri], cx: &Context<Self>) -> impl IntoElement {
// No media: return empty div
if media.is_empty() {
return div();
};
// Single media item: render full-width image
if media.len() == 1 {
return div().child(
img(media[0].clone())
.border_1()
.border_color(cx.theme().border_variant)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
);
}
// Multiple media items: render in a row
div()
.w_full()
.flex_1()
.flex()
.flex_row()
.flex_wrap()
.gap_2()
.children({
let mut items = vec![];
for (ix, item) in media.iter().enumerate() {
items.push(
div()
.id(format!("media-{ix}"))
.flex_grow_0()
.flex_shrink_0()
.child(
img(item.clone())
.h_32()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius),
),
);
}
items
})
}
fn render_message_replies( fn render_message_replies(
&self, &self,
replies: &[EventId], replies: &[EventId],
@@ -865,18 +1037,44 @@ impl ChatPanel {
items items
} }
fn render_sent_indicator(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement { fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
let reports = self.sent_reports(id, cx);
let pending = reports
.as_ref()
.is_some_and(|reports| reports.is_empty() || reports.iter().any(|r| r.pending()));
let success = reports
.as_ref()
.is_some_and(|reports| !reports.is_empty() && reports.iter().any(|r| r.success()));
let failed = reports
.as_ref()
.is_some_and(|reports| !reports.is_empty() && reports.iter().all(|r| r.failed()));
let label = if success {
SharedString::from("• Sent")
} else if failed {
SharedString::from("• Error")
} else if pending {
SharedString::from("• Sending...")
} else {
SharedString::from("• Unknown")
};
div() div()
.id(SharedString::from(id.to_hex())) .id(SharedString::from(id.to_hex()))
.child(SharedString::from("• Sent")) .child(label)
.when_some(self.sent_reports(id, cx), |this, reports| { .when(failed, |this| this.text_color(cx.theme().text_danger))
.when_some(reports, |this, reports| {
this.when(!pending, |this| {
this.on_click(move |_e, window, cx| { this.on_click(move |_e, window, cx| {
let reports = reports.clone(); let reports = reports.clone();
window.open_modal(cx, move |this, _window, cx| { window.open_modal(cx, move |this, _window, cx| {
this.show_close(true) this.title(SharedString::from("Sent Reports"))
.title(SharedString::from("Sent Reports")) .show_close(true)
.child(v_flex().pb_4().gap_4().children({ .child(v_flex().gap_4().children({
let mut items = Vec::with_capacity(reports.len()); let mut items = Vec::with_capacity(reports.len());
for report in reports.iter() { for report in reports.iter() {
@@ -888,37 +1086,6 @@ impl ChatPanel {
}); });
}) })
}) })
}
fn render_message_reports(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
h_flex()
.id(SharedString::from(id.to_hex()))
.gap_0p5()
.text_color(cx.theme().danger_active)
.text_xs()
.italic()
.child(Icon::new(IconName::Info).xsmall())
.child(SharedString::from(
"Failed to send message. Click to see details.",
))
.when_some(self.sent_reports(id, cx), |this, reports| {
this.on_click(move |_e, window, cx| {
let reports = reports.clone();
window.open_modal(cx, move |this, _window, cx| {
this.show_close(true)
.title(SharedString::from("Sent Reports"))
.child(v_flex().gap_4().pb_4().w_full().children({
let mut items = Vec::with_capacity(reports.len());
for report in reports.iter() {
items.push(Self::render_report(report, cx))
}
items
}))
});
})
}) })
} }
@@ -1027,18 +1194,12 @@ impl ChatPanel {
}) })
} }
fn render_border(&self, cx: &Context<Self>) -> impl IntoElement { fn render_actions(
div() &self,
.group_hover("", |this| this.bg(cx.theme().element_active)) id: &EventId,
.absolute() public_key: &PublicKey,
.left_0() cx: &Context<Self>,
.top_0() ) -> impl IntoElement {
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent)
}
fn render_actions(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
h_flex() h_flex()
.p_0p5() .p_0p5()
.gap_1() .gap_1()
@@ -1079,13 +1240,17 @@ impl ChatPanel {
) )
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) .child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child( .child(
Button::new("seen-on") Button::new("advance")
.icon(IconName::Ellipsis) .icon(IconName::Ellipsis)
.small() .small()
.ghost() .ghost()
.dropdown_menu({ .dropdown_menu({
let id = id.to_owned(); let public_key = *public_key;
move |this, _window, _cx| this.menu("Seen on", Box::new(SeenOn(id))) let id = *id;
move |this, _window, _cx| {
this.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(id)))
}
}), }),
) )
.group_hover("", |this| this.visible()) .group_hover("", |this| this.visible())
@@ -1256,7 +1421,7 @@ impl ChatPanel {
.icon(IconName::Emoji) .icon(IconName::Emoji)
.ghost() .ghost()
.large() .large()
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| { .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
this.horizontal() this.horizontal()
.menu("👍", Box::new(Command::Insert("👍"))) .menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎"))) .menu("👎", Box::new(Command::Insert("👎")))
@@ -1283,12 +1448,30 @@ impl Panel for ChatPanel {
h_flex() h_flex()
.gap_1p5() .gap_1p5()
.child(Avatar::new(url).small()) .child(Avatar::new(url).xsmall())
.child(label) .child(label)
.into_any_element() .into_any_element()
}) })
.unwrap_or(div().child("Unknown").into_any_element()) .unwrap_or(div().child("Unknown").into_any_element())
} }
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
let subject_bar = self.subject_bar.clone();
vec![
Button::new("subject")
.icon(IconName::Input)
.tooltip("Change subject")
.small()
.ghost()
.on_click(move |_ev, _window, cx| {
subject_bar.update(cx, |this, cx| {
*this = !*this;
cx.notify();
});
}),
]
}
} }
impl EventEmitter<PanelEvent> for ChatPanel {} impl EventEmitter<PanelEvent> for ChatPanel {}
@@ -1302,12 +1485,40 @@ impl Focusable for ChatPanel {
impl Render for ChatPanel { impl Render for ChatPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex() v_flex()
.image_cache(coop_cache(self.id.clone(), 100))
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.size_full() .size_full()
.when(*self.subject_bar.read(cx), |this| {
this.child(
h_flex()
.h_12()
.w_full()
.px_2()
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.child( .child(
div() TextInput::new(&self.subject_input)
.text_sm()
.small()
.bordered(false),
)
.child(
Button::new("change")
.icon(IconName::CheckCircle)
.label("Change")
.secondary()
.disabled(self.uploading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.change_subject(window, cx);
})),
),
)
})
.child(
v_flex()
.flex_1() .flex_1()
.size_full() .relative()
.child( .child(
list( list(
self.list_state.clone(), self.list_state.clone(),

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.",
)),
)
}
}

View File

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

View File

@@ -19,3 +19,5 @@ log.workspace = true
dirs = "5.0" dirs = "5.0"
qrcode = "0.14.1" qrcode = "0.14.1"
bech32 = "0.11.1"
regex = "1.10"

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

View File

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

View File

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

View File

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

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
crates/common/src/parser.rs Normal file
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,
}
}
}

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")) 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. /// Returns the path to the configuration directory used by Coop.
pub fn config_dir() -> &'static PathBuf { pub fn config_dir() -> &'static PathBuf {
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new(); static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -56,9 +63,3 @@ pub fn support_dir() -> &'static PathBuf {
config_dir().clone() config_dir().clone()
}) })
} }
/// Returns the path to the `nostr` file.
pub fn nostr_file() -> &'static PathBuf {
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
}

View File

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

View File

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

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),
)
})
}
}

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)),
),
)
}
}

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

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)),
),
)
}
}

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

View File

@@ -8,6 +8,8 @@ publish.workspace = true
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } state = { path = "../state" }
person = { path = "../person" } person = { path = "../person" }
ui = { path = "../ui" }
theme = { path = "../theme" }
gpui.workspace = true gpui.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true

View File

@@ -1,16 +1,26 @@
use std::collections::{HashMap, HashSet}; use std::cell::Cell;
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use smallvec::{smallvec, SmallVec}; use state::{Announcement, CLIENT_NAME, NostrRegistry, StateEvent, TIMEOUT};
use state::{ use theme::ActiveTheme;
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, use ui::avatar::Avatar;
}; use ui::button::Button;
use ui::notification::{Notification, NotificationKind};
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const IDENTIFIER: &str = "coop:device"; const IDENTIFIER: &str = "coop:device";
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it.";
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(window: &mut Window, cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
@@ -20,24 +30,48 @@ struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
impl Global for GlobalDeviceRegistry {} impl Global for GlobalDeviceRegistry {}
/// Device event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DeviceEvent {
/// A new encryption signer has been set
Set,
/// The device is requesting an encryption key
Requesting,
/// The device is creating a new encryption key
Creating,
/// An error occurred
Error(SharedString),
}
impl DeviceEvent {
pub fn error<T>(error: T) -> Self
where
T: Into<SharedString>,
{
Self::Error(error.into())
}
}
/// Device Registry /// Device Registry
/// ///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
#[derive(Debug)] #[derive(Debug)]
pub struct DeviceRegistry { pub struct DeviceRegistry {
/// Request for encryption key from other devices /// Whether the registry is currently initializing
pub requests: Vec<Event>, pub initializing: bool,
/// Device state /// Whether there is a pending request for encryption key approval
state: DeviceState, pub pending_request: bool,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions /// Event subscription
_subscriptions: SmallVec<[Subscription; 1]>, _subscription: Option<Subscription>,
} }
impl EventEmitter<DeviceEvent> for DeviceRegistry {}
impl DeviceRegistry { impl DeviceRegistry {
/// Retrieve the global device registry state /// Retrieve the global device registry state
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
@@ -52,31 +86,28 @@ impl DeviceRegistry {
/// Create a new device registry instance /// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push( // Subscribe to nostr state events
// Observe the NIP-65 state let subscription = cx.subscribe_in(&nostr, window, |this, _e, event, _window, cx| {
cx.observe(&nostr, |this, state, cx| { if event == &StateEvent::SignerSet {
if state.read(cx).relay_list_state() == RelayState::Configured { this.set_initializing(true, cx);
this.get_announcement(cx); this.get_announcement(cx);
}; };
}), });
);
// Run at the end of current cycle cx.defer_in(window, |this, window, cx| {
cx.defer_in(window, |this, _window, cx| { this.handle_notifications(window, cx);
this.handle_notifications(cx);
}); });
Self { Self {
requests: vec![], initializing: true,
state: DeviceState::default(), pending_request: false,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscription: Some(subscription),
} }
} }
fn handle_notifications(&mut self, cx: &mut Context<Self>) { fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let (tx, rx) = flume::bounded::<Event>(100); let (tx, rx) = flume::bounded::<Event>(100);
@@ -86,10 +117,8 @@ impl DeviceRegistry {
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
while let Some(notification) = notifications.next().await { while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { if let ClientNotification::Message { message, .. } = notification
message: RelayMessage::Event { event, .. }, && let RelayMessage::Event { event, .. } = *message
..
} = notification
{ {
if !processed_events.insert(event.id) { if !processed_events.insert(event.id) {
// Skip if the event has already been processed // Skip if the event has already been processed
@@ -115,38 +144,37 @@ impl DeviceRegistry {
Ok(()) Ok(())
})); }));
self.tasks.push( self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Update GPUI states
cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
match event.kind { match event.kind {
// New request event from other device
Kind::Custom(4454) => { Kind::Custom(4454) => {
this.update(cx, |this, cx| { this.update_in(cx, |this, window, cx| {
this.add_request(event, cx); this.ask_for_approval(event, window, cx);
})?; })?;
} }
// New response event from the master device
Kind::Custom(4455) => { Kind::Custom(4455) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.parse_response(event, cx); this.extract_encryption(event, cx);
})?; })?;
} }
_ => {} _ => {}
} }
} }
Ok(()) Ok(())
}), }));
);
} }
/// Get the device state /// Set whether the registry is currently initializing
pub fn state(&self) -> DeviceState { fn set_initializing(&mut self, initializing: bool, cx: &mut Context<Self>) {
self.state.clone() self.initializing = initializing;
cx.notify();
} }
/// Set the device state /// Set whether there is a pending request for encryption key approval
fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) { fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
self.state = state; self.pending_request = pending;
cx.notify(); cx.notify();
} }
@@ -163,100 +191,37 @@ impl DeviceRegistry {
// Update state // Update state
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_state(DeviceState::Set, cx); this.set_initializing(false, cx);
this.get_messages(cx); cx.emit(DeviceEvent::Set);
})?; })?;
Ok(()) Ok(())
})); }));
} }
/// Reset the device state /// Backup the encryption's secret key to a file
fn reset(&mut self, cx: &mut Context<Self>) { pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
self.state = DeviceState::Idle;
self.requests.clear();
cx.notify();
}
/// Add a request for device keys
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
self.requests.push(request);
cx.notify();
}
/// Remove a request for device keys
pub fn remove_request(&mut self, id: &EventId, cx: &mut Context<Self>) {
self.requests.retain(|r| r.id != *id);
cx.notify();
}
/// Check if there are any pending requests
pub fn has_requests(&self) -> bool {
!self.requests.is_empty()
}
/// Get all messages for encryption keys
fn get_messages(&mut self, cx: &mut Context<Self>) {
let task = self.subscribe_to_giftwrap_events(cx);
self.tasks.push(cx.spawn(async move |_this, _cx| {
task.await?;
// Update state
Ok(())
}));
}
/// Continuously get gift wrap events for the current user in their messaging relays
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let relay_urls = profile.messaging_relays().clone();
cx.background_spawn(async move { cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); let keys = get_keys(&client).await?;
let id = SubscriptionId::new(DEVICE_GIFTWRAP); let content = keys.secret_key().to_bech32()?;
// Construct target for subscription smol::fs::write(path, &content).await?;
let target: HashMap<RelayUrl, Filter> = relay_urls
.into_iter()
.map(|relay| (relay, filter.clone()))
.collect();
let output = client.subscribe(target).with_id(id).await?;
log::info!(
"Successfully subscribed to encryption gift-wrap messages on: {:?}",
output.success
);
Ok(()) Ok(())
}) })
} }
/// Get device announcement for current user /// Get device announcement for current user
fn get_announcement(&mut self, cx: &mut Context<Self>) { pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
// Reset state before fetching announcement
self.reset(cx);
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<Event, Error>> = cx.background_spawn(async move { let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
let urls = write_relays.await; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Construct the filter for the device announcement event // Construct the filter for the device announcement event
let filter = Filter::new() let filter = Filter::new()
@@ -264,41 +229,33 @@ impl DeviceRegistry {
.author(public_key) .author(public_key)
.limit(1); .limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Stream events from user's write relays // Stream events from user's write relays
let mut stream = client let mut stream = client
.stream_events(target) .stream_events(filter)
.timeout(Duration::from_secs(TIMEOUT)) .timeout(Duration::from_secs(TIMEOUT))
.await?; .await?;
while let Some((_url, res)) = stream.next().await { while let Some((_url, res)) = stream.next().await {
match res { if let Ok(event) = res {
Ok(event) => {
log::info!("Received device announcement event: {event:?}");
return Ok(event); return Ok(event);
} }
Err(e) => {
log::error!("Failed to receive device announcement event: {e}");
}
}
} }
Err(anyhow!("Device announcement not found")) Err(anyhow!("Announcement not found"))
}); });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match task.await {
Ok(event) => { Ok(event) => {
// Set encryption key from the announcement event
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.new_signer(&event, cx); this.set_encryption(&event, cx);
})?; })?;
} }
Err(_) => { Err(_) => {
// User has no announcement, create a new one
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.announce(cx); this.set_announcement(Keys::generate(), cx);
})?; })?;
} }
} }
@@ -307,175 +264,134 @@ impl DeviceRegistry {
})); }));
} }
/// Create a new device signer and announce it /// Create a new device signer and announce it to user's relay list
fn announce(&mut self, cx: &mut Context<Self>) { pub fn set_announcement(&mut self, keys: Keys, cx: &mut Context<Self>) {
let task = self.create_encryption(keys, cx);
// Notify that we're creating a new encryption key
cx.emit(DeviceEvent::Creating);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.wait_for_request(cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
}
}
Ok(())
}));
}
/// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
// Get current user
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_hex(); let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key(); let n = keys.public_key();
let task: Task<Result<(), Error>> = cx.background_spawn(async move { cx.background_spawn(async move {
let urls = write_relays.await;
// Construct an announcement event // Construct an announcement event
let event = client let builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![
.sign_event_builder(EventBuilder::new(Kind::Custom(10044), "").tags(vec![
Tag::custom(TagKind::custom("n"), vec![n]), Tag::custom(TagKind::custom("n"), vec![n]),
Tag::client(app_name()), Tag::client(CLIENT_NAME),
])) ]);
.await?;
// Sign the event with user's signer
let event = client.sign_event_builder(builder).await?;
// Publish announcement // Publish announcement
client.send_event(&event).to(urls).await?; client
.send_event(&event)
.to_nip65()
.ack_policy(AckPolicy::none())
.await?;
// Save device keys to the database // Save device keys to the database
set_keys(&client, &secret).await?; set_keys(&client, &secret).await?;
Ok(()) Ok(keys)
}); })
self.tasks.push(cx.spawn(async move |this, cx| {
if task.await.is_ok() {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
})?;
} }
Ok(()) /// Set encryption key from the announcement event
})); fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
}
/// Initialize device signer (decoupled encryption key) for the current user
fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let announcement = Announcement::from(event); let announcement = Announcement::from(event);
let device_pubkey = announcement.public_key(); let device_pubkey = announcement.public_key();
// Get encryption key from the database and compare with the announcement
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move { let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
if let Ok(keys) = get_keys(&client).await { let keys = get_keys(&client).await?;
// Compare the public key from the announcement with the one from the database
if keys.public_key() != device_pubkey { if keys.public_key() != device_pubkey {
return Err(anyhow!("Key mismatch")); return Err(anyhow!("Encryption Key doesn't match the announcement"));
}; };
Ok(keys) Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}); });
cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { if let Ok(keys) = task.await {
Ok(keys) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.set_signer(keys, cx);
this.listen_request(cx); this.wait_for_request(cx);
}) })?;
.ok(); } else {
}
Err(e) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.request(cx); this.request(cx);
this.listen_approval(cx); })?;
})
.ok();
log::warn!("Failed to initialize device signer: {e}");
} }
}; Ok(())
}) }));
.detach();
} }
/// Listen for device key requests on user's write relays /// Wait for encryption key requests from now on
fn listen_request(&mut self, cx: &mut Context<Self>) { fn wait_for_request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let write_relays = nostr.read(cx).write_relays(&public_key, cx); self.tasks.push(cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
let id = SubscriptionId::new("dekey-requests");
let task: Task<Result<(), Error>> = cx.background_spawn(async move { // Construct a filter for encryption key requests
let urls = write_relays.await;
// Construct a filter for device key requests
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::Custom(4454)) .kind(Kind::Custom(4454))
.author(public_key) .author(public_key)
.since(Timestamp::now()); .since(Timestamp::now());
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe to the device key requests on user's write relays // Subscribe to the device key requests on user's write relays
client.subscribe(target).await?; client.subscribe(vec![filter]).with_id(id).await?;
Ok(())
});
task.detach();
}
/// Listen for device key approvals on user's write relays
fn listen_approval(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
self.tasks.push(cx.background_spawn(async move {
let urls = write_relays.await;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.since(Timestamp::now());
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe to the device key requests on user's write relays
client.subscribe(target).await?;
Ok(()) Ok(())
})); }));
} }
/// Request encryption keys from other device /// Request encryption keys from other device
fn request(&mut self, cx: &mut Context<Self>) { pub fn request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let write_relays = nostr.read(cx).write_relays(&public_key, cx); let app_keys = nostr.read(cx).keys();
let app_keys = nostr.read(cx).app_keys().clone();
let app_pubkey = app_keys.public_key(); let app_pubkey = app_keys.public_key();
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move { let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
// Construct a filter to get the latest approval event
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::Custom(4455)) .kind(Kind::Custom(4455))
.author(public_key) .author(public_key)
@@ -483,70 +399,81 @@ impl DeviceRegistry {
.limit(1); .limit(1);
match client.database().query(filter).await?.first_owned() { match client.database().query(filter).await?.first_owned() {
Some(event) => { // Found an approval event
let root_device = event Some(event) => Ok(Some(event)),
.tags // No approval event found, construct a request event
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = app_keys.nip44_decrypt(&root_device, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
Ok(Some(keys))
}
None => { None => {
let urls = write_relays.await;
// Construct an event for device key request // Construct an event for device key request
let event = client let builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![
.sign_event_builder(EventBuilder::new(Kind::Custom(4454), "").tags(vec![
Tag::client(app_name()),
Tag::custom(TagKind::custom("P"), vec![app_pubkey]), Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
])) Tag::client(CLIENT_NAME),
.await?; ]);
// Sign the event with user's signer
let event = client.sign_event_builder(builder).await?;
// Send the event to write relays // Send the event to write relays
client.send_event(&event).to(urls).await?; client.send_event(&event).to_nip65().await?;
Ok(None) Ok(None)
} }
} }
}); });
cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match task.await {
Ok(Some(keys)) => { Ok(Some(event)) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.extract_encryption(event, cx);
}) })?;
.ok();
} }
Ok(None) => { Ok(None) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_state(DeviceState::Requesting, cx); this.set_initializing(false, cx);
}) this.wait_for_approval(cx);
.ok();
cx.emit(DeviceEvent::Requesting);
})?;
} }
Err(e) => { Err(e) => {
log::error!("Failed to request the encryption key: {e}"); this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
} }
}; };
}) Ok(())
.detach(); }));
} }
/// Parse the response event for device keys from other devices /// Wait for encryption key approvals
fn parse_response(&mut self, event: Event, cx: &mut Context<Self>) { fn wait_for_approval(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys().clone(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.since(Timestamp::now());
// Subscribe to the device key requests on user's write relays
client.subscribe(filter).await?;
Ok(())
}));
}
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).keys();
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move { let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let root_device = event let master = event
.tags .tags
.find(TagKind::custom("P")) .find(TagKind::custom("P"))
.and_then(|tag| tag.content()) .and_then(|tag| tag.content())
@@ -554,7 +481,7 @@ impl DeviceRegistry {
.context("Invalid event's tags")?; .context("Invalid event's tags")?;
let payload = event.content.as_str(); let payload = event.content.as_str();
let decrypted = app_keys.nip44_decrypt(&root_device, payload).await?; let decrypted = app_keys.nip44_decrypt(&master, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?; let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret); let keys = Keys::new(secret);
@@ -563,33 +490,32 @@ impl DeviceRegistry {
}); });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let keys = task.await?; match task.await {
Ok(keys) => {
// Update signer
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.set_signer(keys, cx);
})?; })?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
}
}
Ok(()) Ok(())
})); }));
} }
/// Approve requests for device keys from other devices /// Approve requests for device keys from other devices
pub fn approve(&self, event: &Event, cx: &App) -> Task<Result<(), Error>> { fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
// Get current user
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
// Get user's write relays // Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let event = event.clone(); let event = event.clone();
let id: SharedString = event.id.to_hex().into();
cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Get device keys // Get device keys
let keys = get_keys(&client).await?; let keys = get_keys(&client).await?;
let secret = keys.secret_key().to_secret_hex(); let secret = keys.secret_key().to_secret_hex();
@@ -603,34 +529,172 @@ impl DeviceRegistry {
.context("Target is not a valid public key")?; .context("Target is not a valid public key")?;
// Encrypt the device keys with the user's signer // Encrypt the device keys with the user's signer
let payload = signer.nip44_encrypt(&target, &secret).await?; let payload = keys.nip44_encrypt(&target, &secret).await?;
// Construct the response event // Construct the response event
// //
// P tag: the current device's public key // P tag: the current device's public key
// p tag: the requester's public key // p tag: the requester's public key
let event = client let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
.sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![ Tag::custom(TagKind::custom("P"), vec![keys.public_key().to_hex()]),
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
Tag::public_key(target), Tag::public_key(target),
])) ]);
.await?;
// Sign the builder
let event = client.sign_event_builder(builder).await?;
// Send the response event to the user's relay list // Send the response event to the user's relay list
client.send_event(&event).to(urls).await?; client.send_event(&event).to_nip65().await?;
Ok(()) Ok(())
});
cx.spawn_in(window, async move |_this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.clear_notification_by_id::<DeviceNotification>(id, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})
.ok();
}
};
})
.detach();
}
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
// Ignore if there is already a pending request
if self.pending_request {
return;
}
self.set_pending_request(true, cx);
// Show notification
let notification = self.notification(event, cx);
window.push_notification(notification, cx);
}
/// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
let request = Announcement::from(&event);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&request.public_key(), cx);
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
let key = SharedString::from(event.id.to_hex());
Notification::new()
.type_id::<DeviceNotification>(key)
.autohide(false)
.with_kind(NotificationKind::Info)
.title("Encryption Key Request")
.content(move |_this, _window, cx| {
v_flex()
.gap_2()
.text_sm()
.child(
div()
.text_sm()
.line_height(relative(1.25))
.child(SharedString::from(MSG)),
)
.child(
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.font_semibold()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("From:")),
)
.child(
div()
.h_8()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).xsmall())
.child(profile.name()),
),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.font_semibold()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Client:")),
)
.child(
div()
.h_8()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(request.client_name()),
),
),
)
.into_any_element()
})
.action(move |_this, _window, _cx| {
let view = entity.clone();
let event = event.clone();
Button::new("approve")
.label("Approve")
.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.approve(&event, window, cx);
})
.ok();
}
})
}) })
} }
} }
struct DeviceNotification;
/// Verify the author of an event /// Verify the author of an event
async fn verify_author(client: &Client, event: &Event) -> bool { async fn verify_author(client: &Client, event: &Event) -> bool {
if let Some(signer) = client.signer() { if let Some(signer) = client.signer()
if let Ok(public_key) = signer.get_public_key().await { && let Ok(public_key) = signer.get_public_key().await
{
return public_key == event.pubkey; return public_key == event.pubkey;
} }
}
false false
} }

View File

@@ -15,3 +15,4 @@ smallvec.workspace = true
smol.workspace = true smol.workspace = true
flume.workspace = true flume.workspace = true
log.workspace = true log.workspace = true
urlencoding = "2.1.3"

View File

@@ -3,19 +3,19 @@ use std::collections::{HashMap, HashSet};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{Error, anyhow};
use common::EventUtils; use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task}; use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT}; use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod person; mod person;
pub use person::*; pub use person::*;
pub fn init(cx: &mut App) { pub fn init(window: &mut Window, cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
} }
struct GlobalPersonRegistry(Entity<PersonRegistry>); struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -36,13 +36,13 @@ pub struct PersonRegistry {
persons: HashMap<PublicKey, Entity<Person>>, persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen /// Set of public keys that have been seen
seen: Rc<RefCell<HashSet<PublicKey>>>, seens: Rc<RefCell<HashSet<PublicKey>>>,
/// Sender for requesting metadata /// Sender for requesting metadata
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
/// Tasks for asynchronous operations /// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 4]>, tasks: SmallVec<[Task<()>; 4]>,
} }
impl PersonRegistry { impl PersonRegistry {
@@ -57,13 +57,13 @@ impl PersonRegistry {
} }
/// Create a new person registry instance /// 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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
// Channel for communication between nostr and gpui // Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100); let (tx, rx) = flume::bounded::<Dispatch>(100);
let (mta_tx, mta_rx) = flume::bounded::<PublicKey>(100); let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
let mut tasks = smallvec![]; let mut tasks = smallvec![];
@@ -111,33 +111,16 @@ impl PersonRegistry {
}), }),
); );
tasks.push(
// Load all user profiles from the database // Load all user profiles from the database
cx.spawn(async move |this, cx| { cx.defer_in(window, |this, _window, cx| {
let result = cx this.load(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}");
}
};
}),
);
Self { Self {
persons: HashMap::new(), persons: HashMap::new(),
seen: Rc::new(RefCell::new(HashSet::new())), seens: Rc::new(RefCell::new(HashSet::new())),
sender: mta_tx, sender: mta_tx,
_tasks: tasks, tasks,
} }
} }
@@ -152,7 +135,7 @@ impl PersonRegistry {
continue; continue;
}; };
if let RelayMessage::Event { event, .. } = message { if let RelayMessage::Event { event, .. } = *message {
// Skip if the event has already been processed // Skip if the event has already been processed
if !processed.insert(event.id) { if !processed.insert(event.id) {
continue; continue;
@@ -163,25 +146,21 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata); let person = Person::new(event.pubkey, metadata);
let val = Box::new(person); let val = Box::new(person);
// Send // Send
tx.send_async(Dispatch::Person(val)).await.ok(); tx.send_async(Dispatch::Person(val)).await.ok();
} }
Kind::ContactList => { Kind::ContactList => {
let public_keys = event.extract_public_keys(); let public_keys = event.extract_public_keys();
// Get metadata for all public keys // Get metadata for all public keys
get_metadata(client, public_keys).await.ok(); get_metadata(client, public_keys).await.ok();
} }
Kind::InboxRelays => { Kind::InboxRelays => {
let val = Box::new(event.into_owned()); let val = Box::new(event.into_owned());
// Send // Send
tx.send_async(Dispatch::Relays(val)).await.ok(); tx.send_async(Dispatch::Relays(val)).await.ok();
} }
Kind::Custom(10044) => { Kind::Custom(10044) => {
let val = Box::new(event.into_owned()); let val = Box::new(event.into_owned());
// Send // Send
tx.send_async(Dispatch::Announcement(val)).await.ok(); tx.send_async(Dispatch::Announcement(val)).await.ok();
} }
@@ -198,7 +177,7 @@ impl PersonRegistry {
loop { loop {
match flume::Selector::new() match flume::Selector::new()
.recv(rx, |result| result.ok()) .recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(2)) .wait_timeout(Duration::from_secs(TIMEOUT))
{ {
Ok(Some(public_key)) => { Ok(Some(public_key)) => {
batch.insert(public_key); batch.insert(public_key);
@@ -208,40 +187,81 @@ impl PersonRegistry {
} }
} }
_ => { _ => {
if !batch.is_empty() {
get_metadata(client, std::mem::take(&mut batch)).await.ok(); get_metadata(client, std::mem::take(&mut batch)).await.ok();
} }
} }
} }
} }
}
/// Load all user profiles from the database
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
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(cx, |this, cx| {
this.bulk_inserts(persons, cx);
})
.ok();
}
}));
}
/// Set profile encryption keys announcement /// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) { 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.update(cx, |person, cx| {
person.set_announcement(announcement); person.set_announcement(announcement);
cx.notify(); cx.notify();
}); });
} else {
let person =
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
self.insert(person, cx);
} }
} }
/// Set messaging relays for a person /// Set messaging relays for a person
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) { 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).cloned().collect();
if let Some(person) = self.persons.get(&event.pubkey) {
person.update(cx, |person, cx| { person.update(cx, |person, cx| {
person.set_messaging_relays(urls); person.set_messaging_relays(urls);
cx.notify(); cx.notify();
}); });
} else {
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
self.insert(person, cx);
} }
} }
/// Insert batch of persons /// Insert batch of persons
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) { fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() { 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(); cx.notify();
} }
@@ -270,7 +290,7 @@ impl PersonRegistry {
} }
let public_key = *public_key; let public_key = *public_key;
let mut seen = self.seen.borrow_mut(); let mut seen = self.seens.borrow_mut();
if seen.insert(public_key) { if seen.insert(public_key) {
let sender = self.sender.clone(); let sender = self.sender.clone();
@@ -322,19 +342,3 @@ where
Ok(()) 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)
}

View File

@@ -65,6 +65,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 /// Get profile public key
pub fn public_key(&self) -> PublicKey { pub fn public_key(&self) -> PublicKey {
self.public_key self.public_key
@@ -75,21 +90,11 @@ impl Person {
self.metadata.clone() self.metadata.clone()
} }
/// Set profile metadata
pub fn set_metadata(&mut self, metadata: Metadata) {
self.metadata = metadata;
}
/// Get profile encryption keys announcement /// Get profile encryption keys announcement
pub fn announcement(&self) -> Option<Announcement> { pub fn announcement(&self) -> Option<Announcement> {
self.announcement.clone() self.announcement.clone()
} }
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
}
/// Get profile messaging relays /// Get profile messaging relays
pub fn messaging_relays(&self) -> &Vec<RelayUrl> { pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
&self.messaging_relays &self.messaging_relays
@@ -100,14 +105,6 @@ impl Person {
self.messaging_relays.first().cloned() self.messaging_relays.first().cloned()
} }
/// Set profile messaging relays
pub fn set_messaging_relays<I>(&mut self, relays: I)
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
}
/// Get profile avatar /// Get profile avatar
pub fn avatar(&self) -> SharedString { pub fn avatar(&self) -> SharedString {
self.metadata() self.metadata()
@@ -115,8 +112,9 @@ impl Person {
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .filter(|picture| !picture.is_empty())
.map(|picture| { .map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!( let url = format!(
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1" "{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
); );
url.into() url.into()
}) })
@@ -125,20 +123,38 @@ impl Person {
/// Get profile name /// Get profile name
pub fn name(&self) -> SharedString { pub fn name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref() { if let Some(display_name) = self.metadata().display_name.as_ref()
if !display_name.is_empty() { && !display_name.is_empty()
{
return SharedString::from(display_name); return SharedString::from(display_name);
} }
}
if let Some(name) = self.metadata().name.as_ref() { if let Some(name) = self.metadata().name.as_ref()
if !name.is_empty() { && !name.is_empty()
{
return SharedString::from(name); return SharedString::from(name);
} }
}
SharedString::from(shorten_pubkey(self.public_key(), 4)) SharedString::from(shorten_pubkey(self.public_key(), 4))
} }
/// Set profile metadata
pub fn set_metadata(&mut self, metadata: Metadata) {
self.metadata = metadata;
}
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
}
/// Set profile messaging relays
pub fn set_messaging_relays<I>(&mut self, relays: I)
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
}
} }
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters /// Shorten a [`PublicKey`] to a string with the first and last `len` characters
@@ -148,7 +164,7 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32(); let Ok(pubkey) = public_key.to_bech32();
format!( format!(
"{}:{}", "{}...{}",
&pubkey[0..(len + 1)], &pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..] &pubkey[pubkey.len() - len..]
) )

View File

@@ -5,19 +5,19 @@ use std::hash::Hash;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{ use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Task, Window, Task, Window, div, relative,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::{AppSettings, AuthMode}; use settings::{AppSettings, AuthMode};
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::Button;
use ui::notification::Notification; use ui::notification::{Notification, NotificationKind};
use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension}; use ui::{Disableable, WindowExtension, v_flex};
const AUTH_MESSAGE: &str = const AUTH_MESSAGE: &str =
"Approve the authentication request to allow Coop to continue sending or receiving events."; "Approve the authentication request to allow Coop to continue sending or receiving events.";
@@ -34,7 +34,10 @@ struct AuthRequest {
} }
impl AuthRequest { impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self { pub fn new<S>(challenge: S, url: RelayUrl) -> Self
where
S: Into<String>,
{
Self { Self {
challenge: challenge.into(), challenge: challenge.into(),
url, url,
@@ -97,7 +100,7 @@ impl RelayAuth {
while let Some(notification) = notifications.next().await { while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { relay_url, message } = notification { if let ClientNotification::Message { relay_url, message } = notification {
match message { match *message {
RelayMessage::Auth { challenge } => { RelayMessage::Auth { challenge } => {
if challenges.insert(challenge.clone()) { if challenges.insert(challenge.clone()) {
let request = Arc::new(AuthRequest::new(challenge, relay_url)); let request = Arc::new(AuthRequest::new(challenge, relay_url));
@@ -106,22 +109,6 @@ impl RelayAuth {
tx.send_async(signal).await.ok(); 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 { RelayMessage::Ok {
event_id, message, .. event_id, message, ..
} => { } => {
@@ -234,9 +221,8 @@ impl RelayAuth {
while let Some(notification) = notifications.next().await { while let Some(notification) = notifications.next().await {
match notification { match notification {
RelayNotification::Message { RelayNotification::Message { message } => {
message: RelayMessage::Ok { event_id, .. }, if let RelayMessage::Ok { event_id, .. } = *message {
} => {
if id != event_id { if id != event_id {
continue; continue;
} }
@@ -260,6 +246,7 @@ impl RelayAuth {
return Ok(()); return Ok(());
} }
}
RelayNotification::AuthenticationFailed => break, RelayNotification::AuthenticationFailed => break,
_ => {} _ => {}
} }
@@ -273,7 +260,7 @@ impl RelayAuth {
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) { fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let settings = AppSettings::global(cx); let settings = AppSettings::global(cx);
let req = req.clone(); let req = req.clone();
let challenge = req.challenge().to_string(); let challenge = SharedString::from(req.challenge().to_string());
// Create a task for authentication // Create a task for authentication
let task = self.auth(&req, cx); let task = self.auth(&req, cx);
@@ -283,7 +270,7 @@ impl RelayAuth {
let url = req.url(); let url = req.url();
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
window.clear_notification(challenge, cx); window.clear_notification_by_id::<AuthNotification>(challenge, cx);
match result { match result {
Ok(_) => { Ok(_) => {
@@ -295,10 +282,19 @@ impl RelayAuth {
this.add_trusted_relay(url, cx); this.add_trusted_relay(url, cx);
}); });
window.push_notification(format!("{} has been authenticated", url), cx); window.push_notification(
Notification::success(format!(
"Relay {} has been authenticated",
url.domain().unwrap_or_default()
)),
cx,
);
} }
Err(e) => { Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx); window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
} }
} }
}) })
@@ -323,49 +319,50 @@ impl RelayAuth {
/// Build a notification for the authentication request. /// Build a notification for the authentication request.
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification { fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
let req = req.clone(); let req = req.clone();
let challenge = SharedString::from(req.challenge.clone());
let url = SharedString::from(req.url().to_string()); let url = SharedString::from(req.url().to_string());
let entity = cx.entity().downgrade(); let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false)); let loading = Rc::new(Cell::new(false));
Notification::new() Notification::new()
.custom_id(SharedString::from(&req.challenge)) .type_id::<AuthNotification>(challenge)
.autohide(false) .autohide(false)
.icon(IconName::Info) .with_kind(NotificationKind::Info)
.title(SharedString::from("Authentication Required")) .title("Authentication Required")
.content(move |_window, cx| { .content(move |_this, _window, cx| {
v_flex() v_flex()
.gap_2() .gap_2()
.child(
div()
.text_sm() .text_sm()
.child(SharedString::from(AUTH_MESSAGE)) .line_height(relative(1.25))
.child(SharedString::from(AUTH_MESSAGE)),
)
.child( .child(
v_flex() v_flex()
.py_1() .py_1()
.px_1p5() .px_1p5()
.rounded_sm() .rounded_sm()
.text_xs() .text_xs()
.bg(cx.theme().warning_background) .bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().warning_foreground) .text_color(cx.theme().text)
.child(url.clone()), .child(url.clone()),
) )
.into_any_element() .into_any_element()
}) })
.action(move |_window, _cx| { .action(move |_this, _window, _cx| {
let view = entity.clone(); let view = entity.clone();
let req = req.clone(); let req = req.clone();
Button::new("approve") Button::new("approve")
.label("Approve") .label("Approve")
.small()
.primary()
.loading(loading.get()) .loading(loading.get())
.disabled(loading.get()) .disabled(loading.get())
.on_click({ .on_click({
let loading = Rc::clone(&loading); let loading = Rc::clone(&loading);
move |_ev, window, cx| { move |_ev, window, cx| {
// Set loading state to true // Set loading state to true
loading.set(true); loading.set(true);
// Process to approve the request // Process to approve the request
view.update(cx, |this, cx| { view.update(cx, |this, cx| {
this.response(&req, window, cx); this.response(&req, window, cx);
@@ -376,3 +373,5 @@ impl RelayAuth {
}) })
} }
} }
struct AuthNotification;

View File

@@ -2,12 +2,12 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Display; use std::fmt::Display;
use std::rc::Rc; use std::rc::Rc;
use anyhow::{anyhow, Error}; use anyhow::{Error, anyhow};
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode}; use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(window: &mut Window, cx: &mut App) {
@@ -277,23 +277,30 @@ impl AppSettings {
self.apply_theme(window, cx); self.apply_theme(window, cx);
} }
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None;
cx.notify();
self.apply_theme(window, cx);
}
/// Apply theme /// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) { 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.values.theme.as_ref() {
let mode = self.values.theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) { if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx); 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 { } else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx); 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 the given relay is already authenticated /// Check if the given relay is already authenticated
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool { pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.iter().any(|relay| { self.values.trusted_relays.iter().any(|relay| {

View File

@@ -10,6 +10,7 @@ common = { path = "../common" }
nostr.workspace = true nostr.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
nostr-lmdb.workspace = true nostr-lmdb.workspace = true
nostr-gossip-memory.workspace = true
nostr-connect.workspace = true nostr-connect.workspace = true
nostr-blossom.workspace = true nostr-blossom.workspace = true

View File

@@ -1,5 +1,3 @@
use std::sync::OnceLock;
/// Client name (Application name) /// Client name (Application name)
pub const CLIENT_NAME: &str = "Coop"; pub const CLIENT_NAME: &str = "Coop";
@@ -15,46 +13,39 @@ pub const KEYRING: &str = "Coop Safe Storage";
/// Default timeout for subscription /// Default timeout for subscription
pub const TIMEOUT: u64 = 2; pub const TIMEOUT: u64 = 2;
/// Default image cache size
pub const IMAGE_CACHE_SIZE: usize = 20;
/// Default delay for searching /// Default delay for searching
pub const FIND_DELAY: u64 = 600; pub const FIND_DELAY: u64 = 600;
/// Default limit for searching /// Default limit for searching
pub const FIND_LIMIT: usize = 20; 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 /// Default subscription id for device gift wrap events
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events /// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps"; 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 /// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
/// Default search relays /// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"]; pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays /// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 4] = [ pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://nos.lol",
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://user.kindpag.es", "wss://user.kindpag.es",
]; ];
static APP_NAME: OnceLock<String> = OnceLock::new();
/// Get the app name
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = whoami::devicename();
let platform = whoami::platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}

View File

@@ -1,28 +1,6 @@
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; 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 /// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement { pub struct Announcement {

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::result::Result; use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -16,11 +15,6 @@ pub struct CoopSigner {
/// Specific signer for encryption purposes /// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>, 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 CoopSigner { impl CoopSigner {
@@ -32,7 +26,6 @@ impl CoopSigner {
signer: RwLock::new(signer.into_nostr_signer()), signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None), signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None), encryption_signer: RwLock::new(None),
owned: AtomicBool::new(false),
} }
} }
@@ -47,17 +40,15 @@ impl CoopSigner {
} }
/// Get public key /// Get public key
///
/// Ensure to call this method after the signer has been initialized.
/// Otherwise, it will panic.
pub fn public_key(&self) -> Option<PublicKey> { pub fn public_key(&self) -> Option<PublicKey> {
self.signer_pkey.read_blocking().to_owned() *self.signer_pkey.read_blocking()
}
/// 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. /// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T, owned: bool) pub async fn switch<T>(&self, new: T)
where where
T: IntoNostrSigner, T: IntoNostrSigner,
{ {
@@ -75,9 +66,6 @@ impl CoopSigner {
// Reset the encryption signer // Reset the encryption signer
*encryption_signer = None; *encryption_signer = None;
// Update the owned flag
self.owned.store(owned, Ordering::SeqCst);
} }
/// Set the encryption signer. /// Set the encryption signer.

View File

@@ -1,4 +1,4 @@
use gpui::{hsla, Hsla, Rgba}; use gpui::{Hsla, Rgba, hsla};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -30,6 +30,8 @@ pub struct ThemeColors {
pub text_muted: Hsla, pub text_muted: Hsla,
pub text_placeholder: Hsla, pub text_placeholder: Hsla,
pub text_accent: Hsla, pub text_accent: Hsla,
pub text_danger: Hsla,
pub text_warning: Hsla,
// Icon colors // Icon colors
pub icon: Hsla, pub icon: Hsla,
@@ -77,11 +79,11 @@ pub struct ThemeColors {
pub ghost_element_disabled: Hsla, pub ghost_element_disabled: Hsla,
// Tab colors // Tab colors
pub tab_inactive_background: Hsla, pub tab_background: Hsla,
pub tab_inactive_foreground: Hsla, pub tab_foreground: Hsla,
pub tab_hover_background: Hsla,
pub tab_active_background: Hsla, pub tab_active_background: Hsla,
pub tab_active_foreground: Hsla, pub tab_active_foreground: Hsla,
pub tab_hover_foreground: Hsla,
// Scrollbar colors // Scrollbar colors
pub scrollbar_thumb_background: Hsla, pub scrollbar_thumb_background: Hsla,
@@ -110,8 +112,8 @@ impl ThemeColors {
elevated_surface_background: neutral().light().step_3(), elevated_surface_background: neutral().light().step_3(),
panel_background: neutral().light().step_1(), panel_background: neutral().light().step_1(),
overlay: neutral().light_alpha().step_3(), overlay: neutral().light_alpha().step_3(),
title_bar: neutral().light().step_2(), title_bar: neutral().light().step_3(),
title_bar_inactive: neutral().light().step_3(), title_bar_inactive: neutral().light().step_1(),
window_border: hsl(240.0, 5.9, 78.0), window_border: hsl(240.0, 5.9, 78.0),
border: neutral().light().step_6(), border: neutral().light().step_6(),
@@ -125,7 +127,9 @@ impl ThemeColors {
text: neutral().light().step_12(), text: neutral().light().step_12(),
text_muted: neutral().light().step_11(), text_muted: neutral().light().step_11(),
text_placeholder: neutral().light().step_10(), 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: neutral().light().step_11(),
icon_muted: neutral().light().step_10(), icon_muted: neutral().light().step_10(),
@@ -166,17 +170,17 @@ impl ThemeColors {
ghost_element_selected: neutral().light().step_5(), ghost_element_selected: neutral().light().step_5(),
ghost_element_disabled: neutral().light_alpha().step_2(), ghost_element_disabled: neutral().light_alpha().step_2(),
tab_inactive_background: neutral().light().step_2(), tab_background: neutral().light().step_3(),
tab_inactive_foreground: neutral().light().step_11(), tab_foreground: neutral().light().step_11(),
tab_hover_background: neutral().light_alpha().step_4(),
tab_active_background: neutral().light().step_1(), tab_active_background: neutral().light().step_1(),
tab_active_foreground: neutral().light().step_12(), tab_active_foreground: neutral().light().step_12(),
tab_hover_foreground: brand().light().step_9(),
scrollbar_thumb_background: neutral().light_alpha().step_3(), scrollbar_thumb_background: neutral().light_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(), scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(), scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: 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(), drop_target_background: brand().light_alpha().step_2(),
cursor: hsl(200., 100., 50.), cursor: hsl(200., 100., 50.),
@@ -192,9 +196,9 @@ impl ThemeColors {
background: neutral().dark().step_1(), background: neutral().dark().step_1(),
surface_background: neutral().dark().step_2(), surface_background: neutral().dark().step_2(),
elevated_surface_background: neutral().dark().step_3(), elevated_surface_background: neutral().dark().step_3(),
panel_background: gpui::black(), panel_background: neutral().dark().step_1(),
overlay: neutral().dark_alpha().step_3(), overlay: neutral().dark_alpha().step_3(),
title_bar: gpui::transparent_black(), title_bar: neutral().dark().step_3(),
title_bar_inactive: neutral().dark().step_1(), title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0), window_border: hsl(240.0, 3.7, 28.0),
@@ -209,7 +213,9 @@ impl ThemeColors {
text: neutral().dark().step_12(), text: neutral().dark().step_12(),
text_muted: neutral().dark().step_11(), text_muted: neutral().dark().step_11(),
text_placeholder: neutral().dark().step_10(), 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: neutral().dark().step_11(),
icon_muted: neutral().dark().step_10(), icon_muted: neutral().dark().step_10(),
@@ -250,17 +256,17 @@ impl ThemeColors {
ghost_element_selected: neutral().dark().step_5(), ghost_element_selected: neutral().dark().step_5(),
ghost_element_disabled: neutral().dark_alpha().step_2(), ghost_element_disabled: neutral().dark_alpha().step_2(),
tab_inactive_background: neutral().dark().step_2(), tab_background: neutral().dark().step_3(),
tab_inactive_foreground: neutral().dark().step_11(), tab_foreground: neutral().dark().step_11(),
tab_active_background: neutral().dark().step_3(), tab_hover_background: neutral().dark_alpha().step_4(),
tab_active_background: neutral().dark().step_1(),
tab_active_foreground: neutral().dark().step_12(), tab_active_foreground: neutral().dark().step_12(),
tab_hover_foreground: brand().dark().step_9(),
scrollbar_thumb_background: neutral().dark_alpha().step_3(), scrollbar_thumb_background: neutral().dark_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(), scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(), scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: 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(), drop_target_background: brand().dark_alpha().step_2(),
cursor: hsl(200., 100., 50.), cursor: hsl(200., 100., 50.),

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,
}
}
}

View File

@@ -1,9 +1,11 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::rc::Rc; use std::rc::Rc;
use gpui::{px, App, Global, Pixels, SharedString, Window}; use gpui::{App, Global, Pixels, SharedString, Window, px};
mod colors; mod colors;
mod geometry;
mod notification;
mod platform_kind; mod platform_kind;
mod registry; mod registry;
mod scale; mod scale;
@@ -11,6 +13,8 @@ mod scrollbar_mode;
mod theme; mod theme;
pub use colors::*; pub use colors::*;
pub use geometry::*;
pub use notification::*;
pub use platform_kind::PlatformKind; pub use platform_kind::PlatformKind;
pub use registry::*; pub use registry::*;
pub use scale::*; pub use scale::*;
@@ -82,6 +86,9 @@ pub struct Theme {
/// Show the scrollbar mode, default: scrolling /// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode, pub scrollbar_mode: ScrollbarMode,
/// Notification settings
pub notification: NotificationSettings,
/// Platform kind /// Platform kind
pub platform: PlatformKind, pub platform: PlatformKind,
} }
@@ -200,10 +207,11 @@ impl From<ThemeFamily> for Theme {
Theme { Theme {
font_size: px(15.), font_size: px(15.),
font_family: font_family.into(), font_family: font_family.into(),
radius: px(5.), radius: px(6.),
radius_lg: px(10.), radius_lg: px(10.),
shadow: true, shadow: true,
scrollbar_mode: ScrollbarMode::default(), scrollbar_mode: ScrollbarMode::default(),
notification: NotificationSettings::default(),
mode, mode,
colors: *colors, colors: *colors,
theme: Rc::new(family), theme: Rc::new(family),

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,
}
}
}

View File

@@ -1,14 +1,12 @@
use gpui::prelude::FluentBuilder;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use gpui::MouseButton; use gpui::MouseButton;
#[cfg(not(target_os = "windows"))] use gpui::prelude::FluentBuilder;
use gpui::Pixels;
use gpui::{ use gpui::{
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
}; };
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING}; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
use ui::h_flex; use ui::h_flex;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]

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

View File

@@ -1,12 +1,12 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
Window, px,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{Sizable, Size}; use crate::{Selectable, Sizable, Size};
/// Returns the size of the avatar based on the given [`Size`]. /// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength { pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
@@ -37,6 +37,7 @@ pub struct Avatar {
style: StyleRefinement, style: StyleRefinement,
size: Size, size: Size,
border_color: Option<Hsla>, border_color: Option<Hsla>,
selected: bool,
} }
impl Avatar { impl Avatar {
@@ -48,6 +49,7 @@ impl Avatar {
style: StyleRefinement::default(), style: StyleRefinement::default(),
size: Size::Medium, size: Size::Medium,
border_color: None, border_color: None,
selected: false,
} }
} }
@@ -89,6 +91,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 { impl InteractiveElement for Avatar {
fn interactivity(&mut self) -> &mut Interactivity { fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity() self.base.interactivity()

View File

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

View File

@@ -2,21 +2,24 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
}; };
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
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; use crate::ElementExt;
pub mod dock; #[allow(clippy::module_inception)]
pub mod panel; mod dock;
pub mod stack_panel; mod panel;
pub mod tab_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]); actions!(dock, [ToggleZoom, ClosePanel]);
@@ -202,19 +205,16 @@ impl DockItem {
/// Returns all panel ids /// Returns all panel ids
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> { pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
match self { 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::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(),
} }
} }
@@ -745,6 +745,7 @@ impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea { impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone(); let view = cx.entity().clone();
let decorations = window.window_decorations();
div() div()
.id("dock-area") .id("dock-area")
@@ -754,7 +755,17 @@ impl Render for DockArea {
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)) .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| { .map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() { if let Some(zoom_view) = self.zoom_view.clone() {
this.child(zoom_view) this.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
} else { } else {
// render dock // render dock
this.child( this.child(

View File

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

View File

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

View File

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

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,
}
}
}

View File

@@ -1,7 +1,7 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
svg, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window, SharedString, StyleRefinement, Styled, Svg, Transformation, Window, svg,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -34,10 +34,12 @@ pub enum IconName {
CloseCircle, CloseCircle,
CloseCircleFill, CloseCircleFill,
Copy, Copy,
Device,
Door, Door,
Ellipsis, Ellipsis,
Emoji, Emoji,
Eye, Eye,
Input,
Info, Info,
Invite, Invite,
Inbox, Inbox,
@@ -52,12 +54,14 @@ pub enum IconName {
Relay, Relay,
Reply, Reply,
Refresh, Refresh,
Scan,
Search, Search,
Settings, Settings,
Settings2, Settings2,
Sun, Sun,
Ship, Ship,
Shield, Shield,
Group,
UserKey, UserKey,
Upload, Upload,
Usb, Usb,
@@ -102,10 +106,12 @@ impl IconNamed for IconName {
Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg", Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg", Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg", Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg", Self::Eye => "icons/eye.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg", Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg", Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg", Self::Inbox => "icons/inbox.svg",
@@ -120,6 +126,7 @@ impl IconNamed for IconName {
Self::Relay => "icons/relay.svg", Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg", Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg", Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg", Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg", Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg", Self::Settings2 => "icons/settings2.svg",
@@ -129,6 +136,7 @@ impl IconNamed for IconName {
Self::UserKey => "icons/user-key.svg", Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg", Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg", Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::PanelLeft => "icons/panel-left.svg", Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg", Self::PanelRight => "icons/panel-right.svg",

View File

@@ -2,10 +2,10 @@ use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, App, Bounds, Corners, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, Hitbox,
Entity, GlobalElementId, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, Point, ShapedLine,
Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, fill, point, px,
Window, relative, size,
}; };
use rope::Rope; use rope::Rope;
use smallvec::SmallVec; use smallvec::SmallVec;
@@ -348,12 +348,12 @@ impl TextElement {
let mut rev_line_corners = line_corners.iter().rev().peekable(); let mut rev_line_corners = line_corners.iter().rev().peekable();
while let Some(corners) = rev_line_corners.next() { while let Some(corners) = rev_line_corners.next() {
points.push(corners.top_left); points.push(corners.top_left);
if let Some(next) = rev_line_corners.peek() { if let Some(next) = rev_line_corners.peek()
if next.top_left.x > corners.top_left.x { && next.top_left.x > corners.top_left.x
{
points.push(point(next.top_left.x, corners.top_left.y)); points.push(point(next.top_left.x, corners.top_left.y));
} }
} }
}
// print_points_as_svg_path(&line_corners, &points); // print_points_as_svg_path(&line_corners, &points);
@@ -376,11 +376,11 @@ impl TextElement {
) -> Option<Path<Pixels>> { ) -> Option<Path<Pixels>> {
let state = self.state.read(cx); let state = self.state.read(cx);
let mut selected_range = state.selected_range; let mut selected_range = state.selected_range;
if let Some(ime_marked_range) = &state.ime_marked_range { if let Some(ime_marked_range) = &state.ime_marked_range
if !ime_marked_range.is_empty() { && !ime_marked_range.is_empty()
{
selected_range = (ime_marked_range.end..ime_marked_range.end).into(); selected_range = (ime_marked_range.end..ime_marked_range.end).into();
} }
}
if selected_range.is_empty() { if selected_range.is_empty() {
return None; return None;
} }
@@ -830,12 +830,13 @@ impl Element for TextElement {
} }
// Paint blinking cursor // Paint blinking cursor
if focused && show_cursor { if focused
if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { && show_cursor
&& let Some(mut cursor_bounds) = prepaint.cursor_bounds.take()
{
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y; cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
window.paint_quad(fill(cursor_bounds, cx.theme().cursor)); window.paint_quad(fill(cursor_bounds, cx.theme().cursor));
} }
}
// Paint line numbers // Paint line numbers
let mut offset_y = px(0.); let mut offset_y = px(0.);

View File

@@ -225,14 +225,13 @@ impl MaskPattern {
} }
// check if the fraction part is valid // check if the fraction part is valid
if let Some(frac) = frac_part { if let Some(frac) = frac_part
if !frac && !frac
.chars() .chars()
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
{ {
return false; return false;
} }
}
true true
} }
@@ -255,13 +254,13 @@ impl MaskPattern {
if token.is_sep() { if token.is_sep() {
// If next token is match, it's valid // If next token is match, it's valid
if let Some(next_token) = tokens.get(pos + 1) { if let Some(next_token) = tokens.get(pos + 1)
if next_token.is_match(ch) { && next_token.is_match(ch)
{
return true; return true;
} }
} }
} }
}
false false
} }

View File

@@ -4,11 +4,11 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
actions, div, point, px, Action, App, AppContext, Bounds, ClipboardItem, Context, Entity, Action, App, AppContext, Bounds, ClipboardItem, Context, Entity, EntityInputHandler,
EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding,
KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
ParentElement as _, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription,
Styled as _, Subscription, UTF16Selection, Window, WrappedLine, UTF16Selection, Window, WrappedLine, actions, div, point, px,
}; };
use lsp_types::Position; use lsp_types::Position;
use rope::{OffsetUtf16, Rope}; use rope::{OffsetUtf16, Rope};
@@ -25,9 +25,9 @@ use super::mask_pattern::MaskPattern;
use super::mode::{InputMode, TabSize}; use super::mode::{InputMode, TabSize};
use super::rope_ext::RopeExt; use super::rope_ext::RopeExt;
use super::text_wrapper::{LineItem, TextWrapper}; use super::text_wrapper::{LineItem, TextWrapper};
use crate::Root;
use crate::history::History; use crate::history::History;
use crate::input::element::RIGHT_MARGIN; use crate::input::element::RIGHT_MARGIN;
use crate::Root;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = input, no_json)] #[action(namespace = input, no_json)]
@@ -521,19 +521,19 @@ impl InputState {
let new_row = new_row as usize; let new_row = new_row as usize;
if new_row >= last_layout.visible_range.start { if new_row >= last_layout.visible_range.start {
let visible_row = new_row.saturating_sub(last_layout.visible_range.start); let visible_row = new_row.saturating_sub(last_layout.visible_range.start);
if let Some(line) = last_layout.lines.get(visible_row) { if let Some(line) = last_layout.lines.get(visible_row)
if let Ok(x) = line.closest_index_for_position( && let Ok(x) = line.closest_index_for_position(
Point { Point {
x: preferred_x, x: preferred_x,
y: px(0.), y: px(0.),
}, },
last_layout.line_height, last_layout.line_height,
) { )
{
new_offset = line_start_offset + x; new_offset = line_start_offset + x;
} }
} }
} }
}
self.pause_blink_cursor(cx); self.pause_blink_cursor(cx);
self.move_to(new_offset, cx); self.move_to(new_offset, cx);
// Set back the preferred_column // Set back the preferred_column
@@ -1355,11 +1355,11 @@ impl InputState {
) { ) {
// If there have IME marked range and is empty (Means pressed Esc to abort IME typing) // If there have IME marked range and is empty (Means pressed Esc to abort IME typing)
// Clear the marked range. // Clear the marked range.
if let Some(ime_marked_range) = &self.ime_marked_range { if let Some(ime_marked_range) = &self.ime_marked_range
if ime_marked_range.is_empty() { && ime_marked_range.is_empty()
{
self.ime_marked_range = None; self.ime_marked_range = None;
} }
}
self.selecting = true; self.selecting = true;
let offset = self.index_for_mouse_position(event.position, window, cx); let offset = self.index_for_mouse_position(event.position, window, cx);
@@ -1842,23 +1842,21 @@ impl InputState {
fn previous_boundary(&self, offset: usize) -> usize { fn previous_boundary(&self, offset: usize) -> usize {
let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left); let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left);
if let Some(ch) = self.text.char_at(offset) { if let Some(ch) = self.text.char_at(offset)
if ch == '\r' { && ch == '\r'
{
offset -= 1; offset -= 1;
} }
}
offset offset
} }
fn next_boundary(&self, offset: usize) -> usize { fn next_boundary(&self, offset: usize) -> usize {
let mut offset = self.text.clip_offset(offset + 1, Bias::Right); let mut offset = self.text.clip_offset(offset + 1, Bias::Right);
if let Some(ch) = self.text.char_at(offset) { if let Some(ch) = self.text.char_at(offset)
if ch == '\r' { && ch == '\r'
{
offset += 1; offset += 1;
} }
}
offset offset
} }
@@ -1927,11 +1925,11 @@ impl InputState {
return true; return true;
} }
if let Some(validate) = &self.validate { if let Some(validate) = &self.validate
if !validate(new_text, cx) { && !validate(new_text, cx)
{
return false; return false;
} }
}
if !self.mask_pattern.is_valid(new_text) { if !self.mask_pattern.is_valid(new_text) {
return false; return false;
@@ -1979,8 +1977,9 @@ impl InputState {
self.input_bounds = new_bounds; self.input_bounds = new_bounds;
// Update text_wrapper wrap_width if changed. // Update text_wrapper wrap_width if changed.
if let Some(last_layout) = self.last_layout.as_ref() { if let Some(last_layout) = self.last_layout.as_ref()
if wrap_width_changed { && wrap_width_changed
{
let wrap_width = if !self.soft_wrap { let wrap_width = if !self.soft_wrap {
// None to disable wrapping (will use Pixels::MAX) // None to disable wrapping (will use Pixels::MAX)
None None
@@ -1993,7 +1992,6 @@ impl InputState {
cx.notify(); cx.notify();
} }
} }
}
/// Replace text by [`lsp_types::Range`]. /// Replace text by [`lsp_types::Range`].
/// ///
@@ -2209,21 +2207,19 @@ impl EntityInputHandler for InputState {
break; break;
} }
if start_origin.is_none() { if start_origin.is_none()
if let Some(p) = && let Some(p) =
line.position_for_index(range.start.saturating_sub(index_offset), line_height) line.position_for_index(range.start.saturating_sub(index_offset), line_height)
{ {
start_origin = Some(p + point(px(0.), y_offset)); start_origin = Some(p + point(px(0.), y_offset));
} }
}
if end_origin.is_none() { if end_origin.is_none()
if let Some(p) = && let Some(p) =
line.position_for_index(range.end.saturating_sub(index_offset), line_height) line.position_for_index(range.end.saturating_sub(index_offset), line_height)
{ {
end_origin = Some(p + point(px(0.), y_offset)); end_origin = Some(p + point(px(0.), y_offset));
} }
}
index_offset += line.len() + 1; index_offset += line.len() + 1;
y_offset += line.size(line_height).height; y_offset += line.size(line_height).height;

View File

@@ -1,12 +1,10 @@
pub use anchored::*;
pub use element_ext::ElementExt; pub use element_ext::ElementExt;
pub use event::InteractiveElementExt; pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle; pub use focusable::FocusableCycle;
pub use geometry::*;
pub use icon::*; pub use icon::*;
pub use index_path::IndexPath; pub use index_path::IndexPath;
pub use kbd::*; pub use kbd::*;
pub use root::{window_paddings, Root}; pub use root::{Root, window_paddings};
pub use styled::*; pub use styled::*;
pub use window_ext::*; pub use window_ext::*;
@@ -18,7 +16,7 @@ pub mod avatar;
pub mod button; pub mod button;
pub mod checkbox; pub mod checkbox;
pub mod divider; pub mod divider;
pub mod dock_area; pub mod dock;
pub mod group_box; pub mod group_box;
pub mod history; pub mod history;
pub mod indicator; pub mod indicator;
@@ -35,11 +33,9 @@ pub mod switch;
pub mod tab; pub mod tab;
pub mod tooltip; pub mod tooltip;
mod anchored;
mod element_ext; mod element_ext;
mod event; mod event;
mod focusable; mod focusable;
mod geometry;
mod icon; mod icon;
mod index_path; mod index_path;
mod kbd; mod kbd;

View File

@@ -3,21 +3,21 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context, App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton, ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window, UniformListScrollHandle, Window, div, px, size, uniform_list,
}; };
use smol::Timer; use smol::Timer;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::{InputEvent, InputState, TextInput}; use crate::input::{InputEvent, InputState, TextInput};
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::list::ListDelegate; use crate::list::ListDelegate;
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::scroll::{Scrollbar, ScrollbarHandle}; use crate::scroll::{Scrollbar, ScrollbarHandle};
use crate::{v_flex, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt}; use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex};
pub(crate) fn init(cx: &mut App) { pub(crate) fn init(cx: &mut App) {
let context: Option<&str> = Some("List"); let context: Option<&str> = Some("List");
@@ -578,11 +578,11 @@ where
self.prepare_items_if_needed(window, cx); self.prepare_items_if_needed(window, cx);
// Scroll to the selected item if it is set. // Scroll to the selected item if it is set.
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() { if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
if let Some(item_ix) = self.rows_cache.position_of(&ix) { && let Some(item_ix) = self.rows_cache.position_of(&ix)
{
self.scroll_handle.scroll_to_item(item_ix, strategy); self.scroll_handle.scroll_to_item(item_ix, strategy);
} }
}
let loading = self.delegate().loading(cx); let loading = self.delegate().loading(cx);
let query_input = if self.searchable { let query_input = if self.searchable {

View File

@@ -1,14 +1,15 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
deferred, div, px,
}; };
use crate::actions::{Cancel, SelectLeft, SelectRight}; use crate::actions::{Cancel, SelectLeft, SelectRight};
use crate::button::{Button, ButtonVariants}; use crate::button::{Button, ButtonVariants};
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
use crate::{h_flex, Selectable, Sizable}; use crate::{Selectable, Sizable, h_flex};
const CONTEXT: &str = "AppMenuBar"; const CONTEXT: &str = "AppMenuBar";
@@ -241,7 +242,7 @@ impl Render for AppMenu {
.when(is_selected, |this| { .when(is_selected, |this| {
this.child(deferred( this.child(deferred(
anchored() anchored()
.anchor(gpui::Corner::TopLeft) .anchor(gpui::Anchor::TopLeft)
.snap_to_window_with_margin(px(8.)) .snap_to_window_with_margin(px(8.))
.child( .child(
div() div()

View File

@@ -3,10 +3,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element, Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
StyleRefinement, Styled, Subscription, Window, Subscription, Window, anchored, deferred, div, px,
}; };
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
@@ -41,7 +41,7 @@ pub struct ContextMenu<E: ParentElement + Styled + Sized> {
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>, menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
// This is not in use, just for style refinement forwarding. // This is not in use, just for style refinement forwarding.
_ignore_style: StyleRefinement, _ignore_style: StyleRefinement,
anchor: Corner, anchor: Anchor,
} }
impl<E: ParentElement + Styled> ContextMenu<E> { impl<E: ParentElement + Styled> ContextMenu<E> {
@@ -51,7 +51,7 @@ impl<E: ParentElement + Styled> ContextMenu<E> {
id: id.into(), id: id.into(),
element: Some(element), element: Some(element),
menu: None, menu: None,
anchor: Corner::TopLeft, anchor: Anchor::TopLeft,
_ignore_style: StyleRefinement::default(), _ignore_style: StyleRefinement::default(),
} }
} }

View File

@@ -1,14 +1,15 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window, RenderOnce, SharedString, StyleRefinement, Styled, Window,
}; };
use crate::Selectable;
use crate::avatar::Avatar;
use crate::button::Button; use crate::button::Button;
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
use crate::popover::Popover; use crate::popover::Popover;
use crate::Selectable;
/// A dropdown menu trait for buttons and other interactive elements /// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
@@ -17,13 +18,13 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
self, self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> { ) -> DropdownMenuPopover<Self> {
self.dropdown_menu_with_anchor(Corner::TopLeft, f) self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
} }
/// Create a dropdown menu with the given items, anchored to the given corner /// Create a dropdown menu with the given items, anchored to the given corner
fn dropdown_menu_with_anchor( fn dropdown_menu_with_anchor(
mut self, mut self,
anchor: impl Into<Corner>, anchor: impl Into<Anchor>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> { ) -> DropdownMenuPopover<Self> {
let style = self.style().clone(); let style = self.style().clone();
@@ -35,11 +36,13 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
impl DropdownMenu for Button {} impl DropdownMenu for Button {}
impl DropdownMenu for Avatar {}
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> { pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
id: ElementId, id: ElementId,
style: StyleRefinement, style: StyleRefinement,
anchor: Corner, anchor: Anchor,
trigger: T, trigger: T,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>, builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
@@ -51,7 +54,7 @@ where
{ {
fn new( fn new(
id: ElementId, id: ElementId,
anchor: impl Into<Corner>, anchor: impl Into<Anchor>,
trigger: T, trigger: T,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self { ) -> Self {
@@ -65,7 +68,7 @@ where
} }
/// Set the anchor corner for the dropdown menu popover. /// Set the anchor corner for the dropdown menu popover.
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self { pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into(); self.anchor = anchor.into();
self self
} }

View File

@@ -2,19 +2,19 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle,
Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored,
Subscription, WeakEntity, Window, div, px, rems,
}; };
use theme::ActiveTheme; use theme::{ActiveTheme, Side};
use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
use crate::kbd::Kbd; use crate::kbd::Kbd;
use crate::menu::menu_item::MenuItemElement; use crate::menu::menu_item::MenuItemElement;
use crate::scroll::ScrollableElement; use crate::scroll::ScrollableElement;
use crate::{h_flex, v_flex, ElementExt, Icon, IconName, Side, Sizable as _, Size, StyledExt}; use crate::{ElementExt, Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
const CONTEXT: &str = "PopupMenu"; const CONTEXT: &str = "PopupMenu";
@@ -299,7 +299,7 @@ pub struct PopupMenu {
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
/// This will update on render /// This will update on render
submenu_anchor: (Corner, Pixels), submenu_anchor: (Anchor, Pixels),
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -322,7 +322,7 @@ impl PopupMenu {
scroll_handle: ScrollHandle::default(), scroll_handle: ScrollHandle::default(),
external_link_icon: true, external_link_icon: true,
size: Size::default(), size: Size::default(),
submenu_anchor: (Corner::TopLeft, Pixels::ZERO), submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
_subscriptions: vec![], _subscriptions: vec![],
} }
} }
@@ -719,14 +719,14 @@ impl PopupMenu {
} }
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> { pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
if let Some(ix) = self.selected_index { if let Some(ix) = self.selected_index
if let Some(item) = self.menu_items.get(ix) { && let Some(item) = self.menu_items.get(ix)
{
return match item { return match item {
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()), PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
_ => None, _ => None,
}; };
} }
}
None None
} }
@@ -840,7 +840,7 @@ impl PopupMenu {
} }
fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) { fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) { let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
self._unselect_submenu(window, cx) self._unselect_submenu(window, cx)
} else { } else {
self._select_submenu(window, cx) self._select_submenu(window, cx)
@@ -861,7 +861,7 @@ impl PopupMenu {
} }
fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) { fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) { let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
self._select_submenu(window, cx) self._select_submenu(window, cx)
} else { } else {
self._unselect_submenu(window, cx) self._unselect_submenu(window, cx)
@@ -930,8 +930,9 @@ impl PopupMenu {
}; };
match parent.read(cx).submenu_anchor.0 { match parent.read(cx).submenu_anchor.0 {
Corner::TopLeft | Corner::BottomLeft => Side::Left, Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
Corner::TopRight | Corner::BottomRight => Side::Right, Anchor::TopRight | Anchor::BottomRight => Side::Right,
_ => Side::Left,
} }
} }
@@ -965,13 +966,12 @@ impl PopupMenu {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
// Do not dismiss, if click inside the parent menu // Do not dismiss, if click inside the parent menu
if let Some(parent) = self.parent_menu.as_ref() { if let Some(parent) = self.parent_menu.as_ref()
if let Some(parent) = parent.upgrade() { && let Some(parent) = parent.upgrade()
if parent.read(cx).bounds.contains(position) { && parent.read(cx).bounds.contains(position)
{
return; return;
} }
}
}
self.dismiss(&Cancel, window, cx); self.dismiss(&Cancel, window, cx);
} }
@@ -1042,14 +1042,14 @@ impl PopupMenu {
let bounds = self.bounds; let bounds = self.bounds;
let max_width = self.max_width(); let max_width = self.max_width();
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width { let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
(Corner::TopRight, -px(16.)) (Anchor::TopRight, -px(16.))
} else { } else {
(Corner::TopLeft, bounds.size.width - px(8.)) (Anchor::TopLeft, bounds.size.width - px(8.))
}; };
let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height; let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
self.submenu_anchor = if is_bottom_pos { self.submenu_anchor = if is_bottom_pos {
(anchor.other_side_corner_along(gpui::Axis::Vertical), left) (anchor.other_side_along(gpui::Axis::Vertical), left)
} else { } else {
(anchor, left) (anchor, left)
}; };
@@ -1231,7 +1231,7 @@ impl PopupMenu {
this.child({ this.child({
let (anchor, left) = self.submenu_anchor; let (anchor, left) = self.submenu_anchor;
let is_bottom_pos = let is_bottom_pos =
matches!(anchor, Corner::BottomLeft | Corner::BottomRight); matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
anchored() anchored()
.anchor(anchor) .anchor(anchor)
.child( .child(

View File

@@ -3,10 +3,9 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Bounds, Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle,
BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
Window,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -14,7 +13,7 @@ use crate::actions::{Cancel, Confirm};
use crate::animation::cubic_bezier; use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}; use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::scroll::ScrollableElement; use crate::scroll::ScrollableElement;
use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension}; use crate::{IconName, Root, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const CONTEXT: &str = "Modal"; const CONTEXT: &str = "Modal";
@@ -297,11 +296,11 @@ impl RenderOnce for Modal {
let on_close = on_close.clone(); let on_close = on_close.clone();
move |_, window, cx| { move |_, window, cx| {
if let Some(on_ok) = &on_ok { if let Some(on_ok) = &on_ok
if !on_ok(&ClickEvent::default(), window, cx) { && !on_ok(&ClickEvent::default(), window, cx)
{
return; return;
} }
}
on_close(&ClickEvent::default(), window, cx); on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx); window.close_modal(cx);
@@ -343,7 +342,7 @@ impl RenderOnce for Modal {
}); });
let window_paddings = crate::root::window_paddings(window, cx); let window_paddings = crate::root::window_paddings(window, cx);
let radius = (cx.theme().radius_lg * 2.).min(px(20.)); let radius = cx.theme().radius_lg;
let view_size = window.viewport_size() let view_size = window.viewport_size()
- gpui::size( - gpui::size(
@@ -360,8 +359,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.; let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(16.); let mut padding_right = px(8.);
let mut padding_left = px(16.); let mut padding_left = px(8.);
if let Some(pl) = self.style.padding.left { if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size()); padding_left = pl.to_pixels(self.width.into(), window.rem_size());
@@ -500,6 +499,7 @@ impl RenderOnce for Modal {
.child(self.content), .child(self.content),
), ),
) )
.when_none(&self.footer, |this| this.child(div().pt(padding_left)))
.when_some(self.footer, |this, footer| { .when_some(self.footer, |this, footer| {
this.child( this.child(
h_flex() h_flex()

View File

@@ -1,25 +1,23 @@
use std::any::TypeId; use std::any::TypeId;
use std::borrow::Cow;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Subscription, Window, Subscription, Window, div, px, relative,
}; };
use smol::Timer;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::animation::cubic_bezier; use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt}; use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub enum NotificationType { pub enum NotificationKind {
#[default] #[default]
Info, Info,
Success, Success,
@@ -27,13 +25,21 @@ pub enum NotificationType {
Error, Error,
} }
impl NotificationType { impl NotificationKind {
fn icon(&self, cx: &App) -> Icon { fn icon(&self, cx: &App) -> Icon {
match self { match self {
Self::Info => Icon::new(IconName::Info).text_color(cx.theme().element_foreground), Self::Info => Icon::new(IconName::Info)
Self::Success => Icon::new(IconName::Info).text_color(cx.theme().secondary_foreground), .with_size(Size::Medium)
Self::Warning => Icon::new(IconName::Warning).text_color(cx.theme().warning_foreground), .text_color(cx.theme().icon),
Self::Error => Icon::new(IconName::Warning).text_color(cx.theme().danger_foreground), Self::Success => Icon::new(IconName::CheckCircle)
.with_size(Size::Medium)
.text_color(cx.theme().icon_accent),
Self::Warning => Icon::new(IconName::Warning)
.with_size(Size::Medium)
.text_color(cx.theme().text_warning),
Self::Error => Icon::new(IconName::CloseCircle)
.with_size(Size::Medium)
.text_color(cx.theme().danger_foreground),
} }
} }
} }
@@ -56,6 +62,7 @@ impl From<(TypeId, ElementId)> for NotificationId {
} }
} }
#[allow(clippy::type_complexity)]
/// A notification element. /// A notification element.
pub struct Notification { pub struct Notification {
/// The id is used make the notification unique. /// The id is used make the notification unique.
@@ -64,16 +71,13 @@ pub struct Notification {
/// None means the notification will be added to the end of the list. /// None means the notification will be added to the end of the list.
id: NotificationId, id: NotificationId,
style: StyleRefinement, style: StyleRefinement,
type_: Option<NotificationType>, kind: Option<NotificationKind>,
title: Option<SharedString>, title: Option<SharedString>,
message: Option<SharedString>, message: Option<SharedString>,
icon: Option<Icon>, icon: Option<Icon>,
autohide: bool, autohide: bool,
#[allow(clippy::type_complexity)] action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
action_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> Button>>, content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
#[allow(clippy::type_complexity)]
content_builder: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>>,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
closing: bool, closing: bool,
} }
@@ -84,12 +88,6 @@ impl From<String> for Notification {
} }
} }
impl From<Cow<'static, str>> for Notification {
fn from(s: Cow<'static, str>) -> Self {
Self::new().message(s)
}
}
impl From<SharedString> for Notification { impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self { fn from(s: SharedString) -> Self {
Self::new().message(s) Self::new().message(s)
@@ -102,24 +100,24 @@ impl From<&'static str> for Notification {
} }
} }
impl From<(NotificationType, &'static str)> for Notification { impl From<(NotificationKind, &'static str)> for Notification {
fn from((type_, content): (NotificationType, &'static str)) -> Self { fn from((kind, content): (NotificationKind, &'static str)) -> Self {
Self::new().message(content).with_type(type_) Self::new().message(content).with_kind(kind)
} }
} }
impl From<(NotificationType, SharedString)> for Notification { impl From<(NotificationKind, SharedString)> for Notification {
fn from((type_, content): (NotificationType, SharedString)) -> Self { fn from((kind, content): (NotificationKind, SharedString)) -> Self {
Self::new().message(content).with_type(type_) Self::new().message(content).with_kind(kind)
} }
} }
struct DefaultIdType; struct DefaultIdType;
impl Notification { impl Notification {
/// Create a new notification with the given content. /// Create a new notification.
/// ///
/// default width is 320px. /// The default id is a random UUID.
pub fn new() -> Self { pub fn new() -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into(); let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into()); let id = (TypeId::of::<DefaultIdType>(), id.into());
@@ -129,7 +127,7 @@ impl Notification {
style: StyleRefinement::default(), style: StyleRefinement::default(),
title: None, title: None,
message: None, message: None,
type_: None, kind: None,
icon: None, icon: None,
autohide: true, autohide: true,
action_builder: None, action_builder: None,
@@ -139,33 +137,38 @@ impl Notification {
} }
} }
/// Set the message of the notification, default is None.
pub fn message(mut self, message: impl Into<SharedString>) -> Self { pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into()); self.message = Some(message.into());
self self
} }
/// Create an info notification with the given message.
pub fn info(message: impl Into<SharedString>) -> Self { pub fn info(message: impl Into<SharedString>) -> Self {
Self::new() Self::new()
.message(message) .message(message)
.with_type(NotificationType::Info) .with_kind(NotificationKind::Info)
} }
/// Create a success notification with the given message.
pub fn success(message: impl Into<SharedString>) -> Self { pub fn success(message: impl Into<SharedString>) -> Self {
Self::new() Self::new()
.message(message) .message(message)
.with_type(NotificationType::Success) .with_kind(NotificationKind::Success)
} }
/// Create a warning notification with the given message.
pub fn warning(message: impl Into<SharedString>) -> Self { pub fn warning(message: impl Into<SharedString>) -> Self {
Self::new() Self::new()
.message(message) .message(message)
.with_type(NotificationType::Warning) .with_kind(NotificationKind::Warning)
} }
/// Create an error notification with the given message.
pub fn error(message: impl Into<SharedString>) -> Self { pub fn error(message: impl Into<SharedString>) -> Self {
Self::new() Self::new()
.message(message) .message(message)
.with_type(NotificationType::Error) .with_kind(NotificationKind::Error)
} }
/// Set the type for unique identification of the notification. /// Set the type for unique identification of the notification.
@@ -180,8 +183,8 @@ impl Notification {
} }
/// Set the type and id of the notification, used to uniquely identify the notification. /// Set the type and id of the notification, used to uniquely identify the notification.
pub fn custom_id(mut self, key: impl Into<ElementId>) -> Self { pub fn type_id<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
self.id = (TypeId::of::<DefaultIdType>(), key.into()).into(); self.id = (TypeId::of::<T>(), key.into()).into();
self self
} }
@@ -202,8 +205,8 @@ impl Notification {
} }
/// Set the type of the notification, default is NotificationType::Info. /// Set the type of the notification, default is NotificationType::Info.
pub fn with_type(mut self, type_: NotificationType) -> Self { pub fn with_kind(mut self, kind: NotificationKind) -> Self {
self.type_ = Some(type_); self.kind = Some(kind);
self self
} }
@@ -223,22 +226,31 @@ impl Notification {
} }
/// Set the action button of the notification. /// Set the action button of the notification.
///
/// When an action is set, the notification will not autohide.
pub fn action<F>(mut self, action: F) -> Self pub fn action<F>(mut self, action: F) -> Self
where where
F: Fn(&mut Window, &mut Context<Self>) -> Button + 'static, F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
{ {
self.action_builder = Some(Rc::new(action)); self.action_builder = Some(Rc::new(action));
self.autohide = false;
self self
} }
/// Dismiss the notification. /// Dismiss the notification.
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) { pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if self.closing {
return;
}
self.closing = true; self.closing = true;
cx.notify(); cx.notify();
// Dismiss the notification after 0.15s to show the animation. // Dismiss the notification after 0.15s to show the animation.
cx.spawn(async move |view, cx| { cx.spawn(async move |view, cx| {
Timer::after(Duration::from_secs_f32(0.15)).await; cx.background_executor()
.timer(Duration::from_secs_f32(0.15))
.await;
cx.update(|cx| { cx.update(|cx| {
if let Some(view) = view.upgrade() { if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| { view.update(cx, |view, cx| {
@@ -248,13 +260,13 @@ impl Notification {
} }
}) })
}) })
.detach() .detach();
} }
/// Set the content of the notification. /// Set the content of the notification.
pub fn content( pub fn content(
mut self, mut self,
content: impl Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static, content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
) -> Self { ) -> Self {
self.content_builder = Some(Rc::new(content)); self.content_builder = Some(Rc::new(content));
self self
@@ -279,30 +291,59 @@ impl Styled for Notification {
impl Render for Notification { impl Render for Notification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let closing = self.closing; let content = self
let icon = match self.type_ { .content_builder
.clone()
.map(|builder| builder(self, window, cx));
let action = self.action_builder.clone().map(|builder| {
builder(self, window, cx)
.xsmall()
.primary()
.px_3()
.font_semibold()
});
let icon = match self.kind {
None => self.icon.clone(), None => self.icon.clone(),
Some(type_) => Some(type_.icon(cx)), Some(kind) => Some(kind.icon(cx)),
}; };
let background = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_background,
_ => cx.theme().surface_background,
};
let text_color = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_foreground,
_ => cx.theme().text,
};
let closing = self.closing;
let has_title = self.title.is_some();
let only_message = !has_title && content.is_none() && action.is_none();
let placement = cx.theme().notification.placement;
h_flex() h_flex()
.id("notification") .id("notification")
.refine_style(&self.style)
.group("") .group("")
.occlude() .occlude()
.relative() .relative()
.w_96() .w_112()
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.bg(cx.theme().surface_background) .bg(background)
.text_color(text_color)
.rounded(cx.theme().radius_lg) .rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_md()) .when(cx.theme().shadow, |this| this.shadow_md())
.p_2() .p_2()
.gap_3() .gap_2()
.justify_start() .justify_start()
.items_start() .items_start()
.when(only_message, |this| this.items_center())
.refine_style(&self.style)
.when_some(icon, |this, icon| { .when_some(icon, |this, icon| {
this.child(div().flex_shrink_0().pt_1().child(icon)) this.child(div().flex_shrink_0().size_5().child(icon))
}) })
.child( .child(
v_flex() v_flex()
@@ -310,23 +351,28 @@ impl Render for Notification {
.gap_1() .gap_1()
.overflow_hidden() .overflow_hidden()
.when_some(self.title.clone(), |this, title| { .when_some(self.title.clone(), |this, title| {
this.child(div().text_sm().font_semibold().child(title)) this.child(h_flex().h_5().text_sm().font_semibold().child(title))
}) })
.when_some(self.message.clone(), |this, message| { .when_some(self.message.clone(), |this, message| {
this.child(div().text_sm().child(message)) this.child(
div()
.text_sm()
.when(has_title, |this| this.text_color(cx.theme().text_muted))
.line_height(relative(1.3))
.child(message),
)
}) })
.when_some(self.content_builder.clone(), |this, child_builder| { .when_some(content, |this, content| this.child(content))
this.child(child_builder(window, cx)) .when_some(action, |this, action| {
}) this.gap_2()
.when_some(self.action_builder.clone(), |this, action_builder| { .child(h_flex().w_full().flex_1().justify_end().child(action))
this.child(action_builder(window, cx).small().w_full().my_2())
}), }),
) )
.child( .child(
div() div()
.absolute() .absolute()
.top_2p5() .top(px(6.5))
.right_2p5() .right(px(6.5))
.invisible() .invisible()
.group_hover("", |this| this.visible()) .group_hover("", |this| this.visible())
.child( .child(
@@ -334,7 +380,7 @@ impl Render for Notification {
.icon(IconName::Close) .icon(IconName::Close)
.ghost() .ghost()
.xsmall() .xsmall()
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.dismiss(window, cx); this.dismiss(window, cx);
})), })),
), ),
@@ -345,21 +391,52 @@ impl Render for Notification {
on_click(event, window, cx); on_click(event, window, cx);
})) }))
}) })
.on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
if event.is_middle_click() {
view.dismiss(window, cx);
}
}))
.with_animation( .with_animation(
ElementId::NamedInteger("slide-down".into(), closing as u64), ElementId::NamedInteger("slide-down".into(), closing as u64),
Animation::new(Duration::from_secs_f64(0.25)) Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)), .with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
move |this, delta| { move |this, delta| {
if closing { if closing {
let x_offset = px(0.) + delta * px(45.);
let opacity = 1. - delta; let opacity = 1. - delta;
this.left(px(0.) + x_offset) let that = this
.shadow_none() .shadow_none()
.opacity(opacity) .opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none()) .when(opacity < 0.85, |this| this.shadow_none());
match placement {
Anchor::TopRight | Anchor::BottomRight => {
let x_offset = px(0.) + delta * px(45.);
that.left(px(0.) + x_offset)
}
Anchor::TopLeft | Anchor::BottomLeft => {
let x_offset = px(0.) - delta * px(45.);
that.left(px(0.) + x_offset)
}
Anchor::TopCenter => {
let y_offset = px(0.) - delta * px(45.);
that.top(px(0.) + y_offset)
}
Anchor::BottomCenter => {
let y_offset = px(0.) + delta * px(45.);
that.top(px(0.) + y_offset)
}
_ => that,
}
} else { } else {
let y_offset = px(-45.) + delta * px(45.);
let opacity = delta; let opacity = delta;
let y_offset = match placement {
Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
px(-45.) + delta * px(45.)
}
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
px(45.) - delta * px(45.)
}
_ => px(0.),
};
this.top(px(0.) + y_offset) this.top(px(0.) + y_offset)
.opacity(opacity) .opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none()) .when(opacity < 0.85, |this| this.shadow_none())
@@ -373,7 +450,11 @@ impl Render for Notification {
pub struct NotificationList { pub struct NotificationList {
/// Notifications that will be auto hidden. /// Notifications that will be auto hidden.
pub(crate) notifications: VecDeque<Entity<Notification>>, pub(crate) notifications: VecDeque<Entity<Notification>>,
/// Whether the notification list is expanded.
expanded: bool, expanded: bool,
/// Subscriptions
_subscriptions: HashMap<NotificationId, Subscription>, _subscriptions: HashMap<NotificationId, Subscription>,
} }
@@ -386,10 +467,12 @@ impl NotificationList {
} }
} }
pub fn push<T>(&mut self, notification: T, window: &mut Window, cx: &mut Context<Self>) pub fn push(
where &mut self,
T: Into<Notification>, notification: impl Into<Notification>,
{ window: &mut Window,
cx: &mut Context<Self>,
) {
let notification = notification.into(); let notification = notification.into();
let id = notification.id.clone(); let id = notification.id.clone();
let autohide = notification.autohide; let autohide = notification.autohide;
@@ -411,36 +494,35 @@ impl NotificationList {
if autohide { if autohide {
// Sleep for 5 seconds to autohide the notification // Sleep for 5 seconds to autohide the notification
cx.spawn_in(window, async move |_, cx| { cx.spawn_in(window, async move |_this, cx| {
Timer::after(Duration::from_secs(5)).await; cx.background_executor().timer(Duration::from_secs(5)).await;
if let Err(error) = if let Err(err) =
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx)) notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
{ {
log::error!("Failed to auto hide notification: {error}"); log::error!("failed to auto hide notification: {:?}", err);
} }
}) })
.detach(); .detach();
} }
cx.notify(); cx.notify();
} }
pub fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>) pub(crate) fn close(
where &mut self,
T: Into<ElementId>, id: impl Into<NotificationId>,
{ window: &mut Window,
let id = (TypeId::of::<DefaultIdType>(), key.into()).into(); cx: &mut Context<Self>,
) {
let id: NotificationId = id.into();
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) { if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
n.update(cx, |note, cx| { n.update(cx, |note, cx| note.dismiss(window, cx))
note.dismiss(window, cx);
});
} }
cx.notify(); cx.notify();
} }
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) { pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.notifications.clear(); self.notifications.clear();
cx.notify(); cx.notify();
} }
@@ -451,25 +533,46 @@ impl NotificationList {
} }
impl Render for NotificationList { impl Render for NotificationList {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
let size = window.viewport_size(); let size = window.viewport_size();
let items = self.notifications.iter().rev().take(10).rev().cloned(); let items = self.notifications.iter().rev().take(10).rev().cloned();
div() let placement = cx.theme().notification.placement;
.id("notification-wrapper") let margins = &cx.theme().notification.margins;
.absolute()
.top_4()
.right_4()
.child(
v_flex() v_flex()
.id("notification-list") .id("notification-list")
.h(size.height - px(8.)) .max_h(size.height)
.pt(margins.top)
.pb(margins.bottom)
.gap_3() .gap_3()
.children(items) .when(
matches!(placement, Anchor::TopRight),
|this| this.pr(margins.right), // ignore left
)
.when(
matches!(placement, Anchor::TopLeft),
|this| this.pl(margins.left), // ignore right
)
.when(
matches!(placement, Anchor::BottomLeft),
|this| this.flex_col_reverse().pl(margins.left), // ignore right
)
.when(
matches!(placement, Anchor::BottomRight),
|this| this.flex_col_reverse().pr(margins.right), // ignore left
)
.when(matches!(placement, Anchor::BottomCenter), |this| {
this.flex_col_reverse()
})
.on_hover(cx.listener(|view, hovered, _, cx| { .on_hover(cx.listener(|view, hovered, _, cx| {
view.expanded = *hovered; view.expanded = *hovered;
cx.notify() cx.notify()
})), }))
) .children(items)
} }
} }

View File

@@ -2,14 +2,14 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
Styled, Subscription, Window, Subscription, Window, anchored, deferred, div, px,
}; };
use crate::actions::Cancel; use crate::actions::Cancel;
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _}; use crate::{ElementExt, Selectable, StyledExt as _, v_flex};
const CONTEXT: &str = "Popover"; const CONTEXT: &str = "Popover";
@@ -174,18 +174,25 @@ impl Popover {
self self
} }
fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> { pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
let offset = if anchor.is_center() { match anchor {
gpui::point(trigger_bounds.size.width.half(), px(0.)) Anchor::TopLeft => trigger_bounds.origin,
} else { Anchor::TopCenter => trigger_bounds.top_center(),
Point::default() Anchor::TopRight => trigger_bounds.top_right(),
}; Anchor::BottomLeft => Point {
x: trigger_bounds.origin.x,
trigger_bounds.corner(anchor.swap_vertical().into()) y: trigger_bounds.origin.y - trigger_bounds.size.height,
+ offset },
+ Point { Anchor::BottomCenter => Point {
x: px(0.), x: trigger_bounds.top_center().x,
y: -trigger_bounds.size.height, y: trigger_bounds.origin.y - trigger_bounds.size.height,
},
Anchor::BottomRight => Point {
x: trigger_bounds.top_right().x,
y: trigger_bounds.origin.y - trigger_bounds.size.height,
},
// Fallback for LeftCenter/RightCenter adjust as needed.
_ => trigger_bounds.origin,
} }
} }
} }
@@ -329,6 +336,7 @@ impl Popover {
.map(|this| match anchor { .map(|this| match anchor {
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
}) })
} }
} }

View File

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

View File

@@ -3,14 +3,15 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, div,
}; };
use theme::AxisExt;
use super::{resizable_panel, resize_handle, ResizableState}; use super::{ResizableState, resizable_panel, resize_handle};
use crate::resizable::PANEL_MIN_SIZE; use crate::resizable::PANEL_MIN_SIZE;
use crate::{h_flex, v_flex, AxisExt, ElementExt}; use crate::{ElementExt, h_flex, v_flex};
pub enum ResizablePanelEvent { pub enum ResizablePanelEvent {
Resized, Resized,

View File

@@ -3,14 +3,13 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
Point, Render, StatefulInteractiveElement, Styled as _, Window, StatefulInteractiveElement, Styled as _, Window, div, px,
}; };
use theme::ActiveTheme; use theme::{ActiveTheme, AxisExt};
use crate::dock_area::dock::DockPlacement; use crate::dock::DockPlacement;
use crate::AxisExt;
pub(crate) const HANDLE_PADDING: Pixels = px(4.); pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.); pub(crate) const HANDLE_SIZE: Pixels = px(1.);

View File

@@ -1,11 +1,12 @@
use std::any::TypeId;
use std::rc::Rc; use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle, AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, ElementId, Entity,
Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
Tiling, WeakFocusHandle, Window, Window, canvas, div, point, px, size,
}; };
use theme::{ use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING, ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
@@ -91,7 +92,7 @@ impl Root {
pub fn render_notification_layer( pub fn render_notification_layer(
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Option<impl IntoElement> { ) -> Option<impl IntoElement + use<>> {
let root = window.root::<Root>()??; let root = window.root::<Root>()??;
Some( Some(
@@ -104,7 +105,10 @@ impl Root {
} }
/// Render the modal layer. /// Render the modal layer.
pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> { pub fn render_modal_layer(
window: &mut Window,
cx: &mut App,
) -> Option<impl IntoElement + use<>> {
let root = window.root::<Root>()??; let root = window.root::<Root>()??;
let active_modals = root.read(cx).active_modals.clone(); let active_modals = root.read(cx).active_modals.clone();
@@ -138,11 +142,11 @@ impl Root {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if let Some(ix) = show_overlay_ix { if let Some(ix) = show_overlay_ix
if let Some(modal) = modals.get_mut(ix) { && let Some(modal) = modals.get_mut(ix)
{
modal.overlay_visible = true; modal.overlay_visible = true;
} }
}
Some(div().children(modals)) Some(div().children(modals))
} }
@@ -213,13 +217,30 @@ impl Root {
cx.notify(); cx.notify();
} }
/// Clear a notification by its ID. /// Clear a notification by its type.
pub fn clear_notification<T>(&mut self, id: T, window: &mut Window, cx: &mut Context<Self>) pub fn clear_notification<T: Sized + 'static>(
where &mut self,
T: Into<SharedString>, window: &mut Window,
{ cx: &mut Context<'_, Root>,
self.notification ) {
.update(cx, |view, cx| view.close(id.into(), window, cx)); self.notification.update(cx, |view, cx| {
let id = TypeId::of::<T>();
view.close(id, window, cx);
});
cx.notify();
}
/// Clear a notification by its type.
pub fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
window: &mut Window,
cx: &mut Context<'_, Root>,
) {
self.notification.update(cx, |view, cx| {
let id = (TypeId::of::<T>(), key.into());
view.close(id, window, cx);
});
cx.notify(); cx.notify();
} }
@@ -249,7 +270,6 @@ impl Render for Root {
div() div()
.id("window") .id("window")
.size_full() .size_full()
.bg(gpui::transparent_black())
.map(|div| match decorations { .map(|div| match decorations {
Decorations::Server => div, Decorations::Server => div,
Decorations::Client { tiling } => div Decorations::Client { tiling } => div

View File

@@ -3,13 +3,13 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
}; };
use super::{Scrollbar, ScrollbarAxis}; use super::{Scrollbar, ScrollbarAxis};
use crate::scroll::ScrollbarHandle;
use crate::StyledExt; use crate::StyledExt;
use crate::scroll::ScrollbarHandle;
/// A trait for elements that can be made scrollable with scrollbars. /// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element { pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
@@ -160,6 +160,7 @@ where
} }
impl ScrollableElement for Div {} impl ScrollableElement for Div {}
impl<E> ScrollableElement for Stateful<E> impl<E> ScrollableElement for Stateful<E>
where where
E: ParentElement + Styled + Element, E: ParentElement + Styled + Element,
@@ -195,6 +196,7 @@ fn render_scrollbar<H: ScrollbarHandle + Clone>(
// Do not render scrollbar when inspector is picking elements, // Do not render scrollbar when inspector is picking elements,
// to allow us to pick the background elements. // to allow us to pick the background elements.
let is_inspector_picking = window.is_inspector_picking(cx); let is_inspector_picking = window.is_inspector_picking(cx);
if is_inspector_picking { if is_inspector_picking {
return div(); return div();
} }

View File

@@ -1,10 +1,9 @@
use gpui::{ use gpui::{
px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId,
EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, px, relative,
}; };
use theme::AxisExt;
use crate::AxisExt;
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening. /// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
/// ///

View File

@@ -5,15 +5,13 @@ use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use gpui::{ use gpui::{
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
UniformListScrollHandle, Window, point, px, relative, size,
}; };
use theme::{ActiveTheme, ScrollbarMode}; use theme::{ActiveTheme, AxisExt, ScrollbarMode};
use crate::AxisExt;
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) /// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
const WIDTH: Pixels = px(1. * 2. + 8.); const WIDTH: Pixels = px(1. * 2. + 8.);
@@ -54,7 +52,7 @@ impl ScrollbarHandle for ScrollHandle {
} }
fn content_size(&self) -> Size<Pixels> { fn content_size(&self) -> Size<Pixels> {
self.max_offset() + self.bounds().size Size::from(self.max_offset()) + self.bounds().size
} }
} }
@@ -69,7 +67,7 @@ impl ScrollbarHandle for UniformListScrollHandle {
fn content_size(&self) -> Size<Pixels> { fn content_size(&self) -> Size<Pixels> {
let base_handle = &self.0.borrow().base_handle; let base_handle = &self.0.borrow().base_handle;
base_handle.max_offset() + base_handle.bounds().size Size::from(base_handle.max_offset()) + base_handle.bounds().size
} }
} }
@@ -83,7 +81,7 @@ impl ScrollbarHandle for ListState {
} }
fn content_size(&self) -> Size<Pixels> { fn content_size(&self) -> Size<Pixels> {
self.viewport_bounds().size + self.max_offset_for_scrollbar() Size::from(self.max_offset_for_scrollbar()) + self.viewport_bounds().size
} }
fn start_drag(&self) { fn start_drag(&self) {
@@ -407,7 +405,6 @@ impl Scrollbar {
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
}; };
( (
cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_track_background, cx.theme().scrollbar_track_background,
@@ -522,6 +519,7 @@ impl Element for Scrollbar {
let mut states = vec![]; let mut states = vec![];
let mut has_both = self.axis.is_both(); let mut has_both = self.axis.is_both();
let scroll_size = self let scroll_size = self
.scroll_size .scroll_size
.unwrap_or(self.scroll_handle.content_size()); .unwrap_or(self.scroll_handle.content_size());
@@ -650,14 +648,14 @@ impl Element for Scrollbar {
// The clickable area of the thumb // The clickable area of the thumb
let thumb_length = thumb_end - thumb_start - inset * 2; let thumb_length = thumb_end - thumb_start - inset * 2;
let thumb_bounds = if is_vertical { let thumb_bounds = if is_vertical {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::TopRight, Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start), bounds.top_right() + point(-inset, inset + thumb_start),
size(WIDTH, thumb_length), size(WIDTH, thumb_length),
) )
} else { } else {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::BottomLeft, Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset), bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, WIDTH), size(thumb_length, WIDTH),
) )
@@ -665,14 +663,14 @@ impl Element for Scrollbar {
// The actual render area of the thumb // The actual render area of the thumb
let thumb_fill_bounds = if is_vertical { let thumb_fill_bounds = if is_vertical {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::TopRight, Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start), bounds.top_right() + point(-inset, inset + thumb_start),
size(thumb_width, thumb_length), size(thumb_width, thumb_length),
) )
} else { } else {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::BottomLeft, Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset), bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, thumb_width), size(thumb_length, thumb_width),
) )

View File

@@ -1,4 +1,4 @@
use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled}; use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -46,6 +46,30 @@ pub trait StyledExt: Styled + Sized {
self.flex().flex_col() self.flex().flex_col()
} }
/// Apply paddings to the element.
fn paddings<L>(self, paddings: impl Into<Edges<L>>) -> Self
where
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
{
let paddings = paddings.into();
self.pt(paddings.top.into())
.pb(paddings.bottom.into())
.pl(paddings.left.into())
.pr(paddings.right.into())
}
/// Apply margins to the element.
fn margins<L>(self, margins: impl Into<Edges<L>>) -> Self
where
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
{
let margins = margins.into();
self.mt(margins.top.into())
.mb(margins.bottom.into())
.ml(margins.left.into())
.mr(margins.right.into())
}
font_weight!(font_thin, THIN); font_weight!(font_thin, THIN);
font_weight!(font_extralight, EXTRA_LIGHT); font_weight!(font_extralight, EXTRA_LIGHT);
font_weight!(font_light, LIGHT); font_weight!(font_light, LIGHT);

View File

@@ -4,13 +4,13 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, white, Animation, AnimationExt as _, AnyElement, App, Element, ElementId, Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId,
GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _,
Styled as _, Window, Window, div, px, white,
}; };
use theme::ActiveTheme; use theme::{ActiveTheme, Side};
use crate::{Disableable, Side, Sizable, Size}; use crate::{Disableable, Sizable, Size};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>; type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;

View File

@@ -1,74 +1,557 @@
use gpui::prelude::FluentBuilder; use std::rc::Rc;
use gpui::{
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, StatefulInteractiveElement, Styled, Window,
};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use crate::{Selectable, Sizable, Size}; use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, px, relative,
};
use theme::ActiveTheme;
use crate::{Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
pub mod tab_bar; pub mod tab_bar;
/// Tab variants.
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
pub enum TabVariant {
#[default]
Tab,
Outline,
Pill,
Segmented,
Underline,
}
impl TabVariant {
fn height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Underline => px(26.),
_ => px(20.),
},
Size::Small => match self {
TabVariant::Underline => px(30.),
_ => px(24.),
},
Size::Large => match self {
TabVariant::Underline => px(44.),
_ => px(36.),
},
_ => match self {
TabVariant::Underline => px(36.),
_ => px(32.),
},
}
}
fn inner_height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(18.),
TabVariant::Segmented => px(16.),
TabVariant::Underline => px(20.),
},
Size::Small => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(22.),
TabVariant::Segmented => px(18.),
TabVariant::Underline => px(22.),
},
Size::Large => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(36.),
TabVariant::Segmented => px(28.),
TabVariant::Underline => px(32.),
},
_ => match self {
TabVariant::Tab => px(30.),
TabVariant::Outline | TabVariant::Pill => px(26.),
TabVariant::Segmented => px(24.),
TabVariant::Underline => px(26.),
},
}
}
/// Default px(12) to match panel px_3, See [`crate::dock::TabPanel`]
fn inner_paddings(&self, size: Size) -> Edges<Pixels> {
let mut padding_x = match size {
Size::XSmall => px(8.),
Size::Small => px(10.),
Size::Large => px(16.),
_ => px(12.),
};
if matches!(self, TabVariant::Underline) {
padding_x = px(0.);
}
Edges {
left: padding_x,
right: padding_x,
..Default::default()
}
}
fn inner_margins(&self, size: Size) -> Edges<Pixels> {
match size {
Size::XSmall => match self {
TabVariant::Underline => Edges {
top: px(1.),
bottom: px(2.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Small => match self {
TabVariant::Underline => Edges {
top: px(2.),
bottom: px(3.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Large => match self {
TabVariant::Underline => Edges {
top: px(5.),
bottom: px(6.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
_ => match self {
TabVariant::Underline => Edges {
top: px(3.),
bottom: px(4.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
}
}
fn normal(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().text,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn hovered(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_hover,
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn selected(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: cx.theme().tab_active_background,
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_accent,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().element_active,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().element_foreground,
bg: cx.theme().element_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
inner_bg: cx.theme().background,
shadow: true,
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: cx.theme().element_active,
..Default::default()
},
}
}
fn disabled(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: if selected {
cx.theme().element_active
} else {
cx.theme().border
},
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: if selected {
cx.theme().element_foreground.opacity(0.5)
} else {
cx.theme().text_muted
},
bg: if selected {
cx.theme().element_background.opacity(0.5)
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().text_muted,
bg: cx.theme().tab_background,
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
bottom: px(2.),
..Default::default()
},
..Default::default()
},
}
}
pub(super) fn tab_bar_radius(&self, size: Size, cx: &App) -> Pixels {
if *self != TabVariant::Segmented {
return px(0.);
}
match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
}
}
fn radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Outline | TabVariant::Pill => px(99.),
TabVariant::Segmented => match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
},
_ => px(0.),
}
}
fn inner_radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Segmented => match size {
Size::Large => self.tab_bar_radius(size, cx) - px(3.),
_ => self.tab_bar_radius(size, cx) - px(2.),
},
_ => px(0.),
}
}
}
#[allow(dead_code)]
struct TabStyle {
borders: Edges<Pixels>,
border_color: Hsla,
bg: Hsla,
fg: Hsla,
shadow: bool,
inner_bg: Hsla,
}
impl Default for TabStyle {
fn default() -> Self {
TabStyle {
borders: Edges::all(px(0.)),
border_color: gpui::transparent_white(),
bg: gpui::transparent_white(),
fg: gpui::transparent_white(),
shadow: false,
inner_bg: gpui::transparent_white(),
}
}
}
#[allow(clippy::type_complexity)]
/// A Tab element for the [`super::TabBar`].
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Tab { pub struct Tab {
ix: usize, ix: usize,
base: Div, base: Div,
label: Option<AnyElement>, pub(super) label: Option<SharedString>,
icon: Option<Icon>,
prefix: Option<AnyElement>, prefix: Option<AnyElement>,
pub(super) tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>, suffix: Option<AnyElement>,
disabled: bool, children: Vec<AnyElement>,
selected: bool, variant: TabVariant,
size: Size, size: Size,
pub(super) disabled: bool,
pub(super) selected: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
} }
impl Tab { impl From<&'static str> for Tab {
pub fn new() -> Self { fn from(label: &'static str) -> Self {
Self { Self::new().label(label)
ix: 0,
base: div(),
label: None,
disabled: false,
selected: false,
prefix: None,
suffix: None,
size: Size::default(),
} }
} }
/// Set label for the tab. impl From<String> for Tab {
pub fn label(mut self, label: impl Into<AnyElement>) -> Self { fn from(label: String) -> Self {
self.label = Some(label.into()); Self::new().label(label)
self }
} }
/// Set the left side of the tab impl From<SharedString> for Tab {
pub fn prefix(mut self, prefix: impl Into<AnyElement>) -> Self { fn from(label: SharedString) -> Self {
self.prefix = Some(prefix.into()); Self::new().label(label)
self }
} }
/// Set the right side of the tab impl From<Icon> for Tab {
pub fn suffix(mut self, suffix: impl Into<AnyElement>) -> Self { fn from(icon: Icon) -> Self {
self.suffix = Some(suffix.into()); Self::default().icon(icon)
self }
} }
/// Set disabled state to the tab impl From<IconName> for Tab {
pub fn disabled(mut self, disabled: bool) -> Self { fn from(icon_name: IconName) -> Self {
self.disabled = disabled; Self::default().icon(Icon::new(icon_name))
self
}
/// Set index to the tab.
pub fn ix(mut self, ix: usize) -> Self {
self.ix = ix;
self
} }
} }
impl Default for Tab { impl Default for Tab {
fn default() -> Self { fn default() -> Self {
Self::new() Self {
ix: 0,
base: div(),
label: None,
icon: None,
tab_bar_prefix: None,
children: Vec::new(),
disabled: false,
selected: false,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
on_click: None,
}
}
}
impl Tab {
/// Create a new tab with a label.
pub fn new() -> Self {
Self::default()
}
/// Set label for the tab.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set icon for the tab.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set Tab Variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Use Pill variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Use outline variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Use Segmented variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Use Underline variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the right side of the tab
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set disabled state to the tab, default false.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the click handler for the tab.
pub fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
/// Set index to the tab.
pub(crate) fn ix(mut self, ix: usize) -> Self {
self.ix = ix;
self
}
/// Set if the tab bar has a prefix.
pub(crate) fn tab_bar_prefix(mut self, tab_bar_prefix: bool) -> Self {
self.tab_bar_prefix = Some(tab_bar_prefix);
self
}
}
impl ParentElement for Tab {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
} }
} }
@@ -105,62 +588,115 @@ impl Sizable for Tab {
} }
impl RenderOnce for Tab { impl RenderOnce for Tab {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (text_color, hover_text_color, bg_color, border_color) = let mut tab_style = if self.selected {
match (self.selected, self.disabled) { self.variant.selected(cx)
(true, false) => ( } else {
cx.theme().tab_active_foreground, self.variant.normal(cx)
cx.theme().tab_hover_foreground,
cx.theme().tab_active_background,
cx.theme().border,
),
(false, false) => (
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().border_transparent,
),
(true, true) => (
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().border_disabled,
),
(false, true) => (
cx.theme().tab_inactive_foreground,
cx.theme().tab_hover_foreground,
cx.theme().ghost_element_background,
cx.theme().border_disabled,
),
}; };
let mut hover_style = self.variant.hovered(self.selected, cx);
if self.disabled {
tab_style = self.variant.disabled(self.selected, cx);
hover_style = self.variant.disabled(self.selected, cx);
}
let tab_bar_prefix = self.tab_bar_prefix.unwrap_or_default();
if !tab_bar_prefix && self.ix == 0 && self.variant == TabVariant::Tab {
tab_style.borders.left = px(0.);
hover_style.borders.left = px(0.);
}
let radius = self.variant.radius(self.size, cx);
let inner_radius = self.variant.inner_radius(self.size, cx);
let inner_paddings = self.variant.inner_paddings(self.size);
let inner_margins = self.variant.inner_margins(self.size);
let inner_height = self.variant.inner_height(self.size);
let height = self.variant.height(self.size);
self.base self.base
.id(self.ix) .id(self.ix)
.h(TABBAR_HEIGHT)
.px_4()
.relative()
.flex() .flex()
.flex_wrap()
.gap_1()
.items_center() .items_center()
.flex_shrink_0() .flex_shrink_0()
.cursor_pointer() .h(height)
.overflow_hidden() .overflow_hidden()
.text_xs() .text_color(tab_style.fg)
.text_ellipsis() .map(|this| match self.size {
.text_color(text_color) Size::XSmall => this.text_xs(),
.bg(bg_color) Size::Large => this.text_base(),
.border_l(px(1.)) _ => this.text_sm(),
.border_r(px(1.)) })
.border_color(border_color) .bg(tab_style.bg)
.border_l(tab_style.borders.left)
.border_r(tab_style.borders.right)
.border_t(tab_style.borders.top)
.border_b(tab_style.borders.bottom)
.border_color(tab_style.border_color)
.rounded(radius)
.when(!self.selected && !self.disabled, |this| { .when(!self.selected && !self.disabled, |this| {
this.hover(|this| this.text_color(hover_text_color)) this.hover(|this| {
this.text_color(hover_style.fg)
.bg(hover_style.bg)
.border_l(hover_style.borders.left)
.border_r(hover_style.borders.right)
.border_t(hover_style.borders.top)
.border_b(hover_style.borders.bottom)
.border_color(hover_style.border_color)
.rounded(radius)
}) })
.when_some(self.prefix, |this, prefix| {
this.child(prefix).text_color(text_color)
}) })
.when_some(self.label, |this, label| this.child(label)) .when_some(self.prefix, |this, prefix| this.child(prefix))
.when_some(self.suffix, |this, suffix| this.child(suffix)) .child(
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| { h_flex()
.flex_1()
.h(inner_height)
.line_height(relative(1.))
.whitespace_nowrap()
.items_center()
.justify_center()
.overflow_hidden()
.margins(inner_margins)
.flex_shrink_0()
.map(|this| match self.icon {
Some(icon) => {
this.w(inner_height * 1.25)
.child(icon.map(|this| match self.size {
Size::XSmall => this.size_2p5(),
Size::Small => this.size_3p5(),
Size::Large => this.size_4(),
_ => this.size_4(),
}))
}
None => this
.paddings(inner_paddings)
.map(|this| match self.label {
Some(label) => this.child(label),
None => this,
})
.children(self.children),
})
.bg(tab_style.inner_bg)
.rounded(inner_radius)
.when(tab_style.shadow, |this| this.shadow_xs())
.hover(|this| this.bg(hover_style.inner_bg).rounded(inner_radius)),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().pr_2().child(suffix))
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
// Stop propagation behavior, for works on TitleBar.
// https://github.com/longbridge/gpui-component/issues/1836
cx.stop_propagation(); cx.stop_propagation();
}) })
.when(!self.disabled, |this| {
this.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
} }
} }

View File

@@ -1,41 +1,92 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
#[cfg(not(target_os = "windows"))]
use gpui::Pixels;
use gpui::{ use gpui::{
div, px, AnyElement, App, Div, InteractiveElement, IntoElement, ParentElement, RenderOnce, Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
ScrollHandle, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, div, px,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{h_flex, Sizable, Size, StyledExt}; use super::{Tab, TabVariant};
use crate::button::{Button, ButtonVariants as _};
use crate::menu::{DropdownMenu as _, PopupMenuItem};
use crate::{IconName, Selectable, Sizable, Size, StyledExt, h_flex};
#[allow(clippy::type_complexity)]
/// A TabBar element that contains multiple [`Tab`] items.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct TabBar { pub struct TabBar {
base: Div, base: Stateful<Div>,
style: StyleRefinement, style: StyleRefinement,
scroll_handle: Option<ScrollHandle>, scroll_handle: Option<ScrollHandle>,
prefix: Option<AnyElement>, prefix: Option<AnyElement>,
suffix: Option<AnyElement>, suffix: Option<AnyElement>,
children: SmallVec<[Tab; 2]>,
last_empty_space: AnyElement, last_empty_space: AnyElement,
children: SmallVec<[AnyElement; 2]>, selected_index: Option<usize>,
variant: TabVariant,
size: Size, size: Size,
menu: bool,
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
} }
impl TabBar { impl TabBar {
pub fn new() -> Self { /// Create a new TabBar.
pub fn new(id: impl Into<ElementId>) -> Self {
Self { Self {
base: h_flex().px(px(-1.)), base: div().id(id).px(px(-1.)),
style: StyleRefinement::default(), style: StyleRefinement::default(),
scroll_handle: None,
children: SmallVec::new(), children: SmallVec::new(),
scroll_handle: None,
prefix: None, prefix: None,
suffix: None, suffix: None,
variant: TabVariant::default(),
size: Size::default(), size: Size::default(),
last_empty_space: div().w_3().into_any_element(), last_empty_space: div().w_3().into_any_element(),
selected_index: None,
on_click: None,
menu: false,
} }
} }
/// Set the Tab variant, all children will inherit the variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Set the Tab variant to Pill, all children will inherit the variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Set the Tab variant to Outline, all children will inherit the variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Set the Tab variant to Segmented, all children will inherit the variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Set the Tab variant to Underline, all children will inherit the variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set whether to show the menu button when tabs overflow, default is false.
pub fn menu(mut self, menu: bool) -> Self {
self.menu = menu;
self
}
/// Track the scroll of the TabBar. /// Track the scroll of the TabBar.
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self { pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle.clone()); self.scroll_handle = Some(scroll_handle.clone());
@@ -54,27 +105,39 @@ impl TabBar {
self self
} }
/// Add children of the TabBar, all children will inherit the variant.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
/// Add child of the TabBar, tab will inherit the variant.
pub fn child(mut self, child: impl Into<Tab>) -> Self {
self.children.push(child.into());
self
}
/// Set the selected index of the TabBar.
pub fn selected_index(mut self, index: usize) -> Self {
self.selected_index = Some(index);
self
}
/// Set the last empty space element of the TabBar. /// Set the last empty space element of the TabBar.
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self { pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
self.last_empty_space = last_empty_space.into_any_element(); self.last_empty_space = last_empty_space.into_any_element();
self self
} }
#[cfg(not(target_os = "windows"))] /// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
pub fn height(window: &mut Window) -> Pixels { ///
(1.75 * window.rem_size()).max(px(36.)) /// When this is set, the children's on_click will be ignored.
} pub fn on_click<F>(mut self, on_click: F) -> Self
} where
F: Fn(&usize, &mut Window, &mut App) + 'static,
impl Default for TabBar { {
fn default() -> Self { self.on_click = Some(Rc::new(on_click));
Self::new() self
}
}
impl ParentElement for TabBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
} }
} }
@@ -92,15 +155,69 @@ impl Sizable for TabBar {
} }
impl RenderOnce for TabBar { impl RenderOnce for TabBar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let default_gap = match self.size {
Size::Small | Size::XSmall => px(8.),
Size::Large => px(16.),
_ => px(12.),
};
let (bg, paddings, gap) = match self.variant {
TabVariant::Tab => {
let padding = Edges::all(px(0.));
(cx.theme().tab_background, padding, px(0.))
}
TabVariant::Outline => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, default_gap)
}
TabVariant::Pill => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, px(4.))
}
TabVariant::Segmented => {
let padding_x = match self.size {
Size::XSmall => px(2.),
Size::Small => px(3.),
_ => px(4.),
};
let padding = Edges {
left: padding_x,
right: padding_x,
..Default::default()
};
(cx.theme().tab_background, padding, px(2.))
}
TabVariant::Underline => {
// This gap is same as the tab inner_paddings
let gap = match self.size {
Size::XSmall => px(10.),
Size::Small => px(12.),
Size::Large => px(20.),
_ => px(16.),
};
(gpui::transparent_black(), Edges::all(px(0.)), gap)
}
};
let mut item_labels = Vec::new();
let selected_index = self.selected_index;
let on_click = self.on_click.clone();
self.base self.base
.group("tab-bar") .group("tab-bar")
.relative() .relative()
.refine_style(&self.style) .flex()
.bg(cx.theme().surface_background) .items_center()
.child( .bg(bg)
.text_color(cx.theme().tab_foreground)
.when(
self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
|this| {
this.child(
div() div()
.id("border-bottom") .id("border-b")
.absolute() .absolute()
.left_0() .left_0()
.bottom_0() .bottom_0()
@@ -108,21 +225,66 @@ impl RenderOnce for TabBar {
.border_b_1() .border_b_1()
.border_color(cx.theme().border), .border_color(cx.theme().border),
) )
.text_color(cx.theme().text) },
)
.rounded(self.variant.tab_bar_radius(self.size, cx))
.paddings(paddings)
.refine_style(&self.style)
.when_some(self.prefix, |this, prefix| this.child(prefix)) .when_some(self.prefix, |this, prefix| this.child(prefix))
.child( .child(
h_flex() h_flex()
.id("tabs") .id("tabs")
.flex_grow() .flex_1()
.overflow_x_scroll() .overflow_x_scroll()
.when_some(self.scroll_handle, |this, scroll_handle| { .when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle) this.track_scroll(&scroll_handle)
}) })
.children(self.children) .gap(gap)
.when(self.suffix.is_some(), |this| { .children(self.children.into_iter().enumerate().map(|(ix, child)| {
item_labels.push((child.label.clone(), child.disabled));
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child
.ix(ix)
.tab_bar_prefix(tab_bar_prefix)
.with_variant(self.variant)
.with_size(self.size)
.when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix)
})
.when_some(self.on_click.clone(), move |this, on_click| {
this.on_click(move |_, window, cx| on_click(&ix, window, cx))
})
}))
.when(self.suffix.is_some() || self.menu, |this| {
this.child(self.last_empty_space) this.child(self.last_empty_space)
}), }),
) )
.when(self.menu, |this| {
this.child(
Button::new("more")
.xsmall()
.ghost()
.icon(IconName::ChevronDown)
.dropdown_menu(move |mut this, _, _| {
this = this.scrollable(true);
for (ix, (label, disabled)) in item_labels.iter().enumerate() {
this = this.item(
PopupMenuItem::new(label.clone().unwrap_or_default())
.checked(selected_index == Some(ix))
.disabled(*disabled)
.when_some(on_click.clone(), |this, on_click| {
this.on_click(move |_, window, cx| {
on_click(&ix, window, cx)
})
}),
)
}
this
})
.anchor(Anchor::TopRight),
)
})
.when_some(self.suffix, |this, suffix| this.child(suffix)) .when_some(self.suffix, |this, suffix| this.child(suffix))
} }
} }

View File

@@ -1,11 +1,11 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{App, Entity, SharedString, Window}; use gpui::{App, ElementId, Entity, Window};
use crate::Root;
use crate::input::InputState; use crate::input::InputState;
use crate::modal::Modal; use crate::modal::Modal;
use crate::notification::Notification; use crate::notification::Notification;
use crate::Root;
/// Extension trait for [`Window`] to add modal, notification .. functionality. /// Extension trait for [`Window`] to add modal, notification .. functionality.
pub trait WindowExtension: Sized { pub trait WindowExtension: Sized {
@@ -31,10 +31,15 @@ pub trait WindowExtension: Sized {
where where
T: Into<Notification>; T: Into<Notification>;
/// Clears a notification by its ID. /// Clear the unique notification.
fn clear_notification<T>(&mut self, id: T, cx: &mut App) fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App);
where
T: Into<SharedString>; /// Clear the unique notification with the given id.
fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
cx: &mut App,
);
/// Clear all notifications /// Clear all notifications
fn clear_notifications(&mut self, cx: &mut App); fn clear_notifications(&mut self, cx: &mut App);
@@ -88,13 +93,21 @@ impl WindowExtension for Window {
} }
#[inline] #[inline]
fn clear_notification<T>(&mut self, id: T, cx: &mut App) fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App) {
where Root::update(self, cx, |root, window, cx| {
T: Into<SharedString>, root.clear_notification::<T>(window, cx);
{ })
let id = id.into(); }
Root::update(self, cx, move |root, window, cx| {
root.clear_notification(id, window, cx); #[inline]
fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
cx: &mut App,
) {
let key: ElementId = key.into();
Root::update(self, cx, |root, window, cx| {
root.clear_notification_by_id::<T>(key, window, cx);
}) })
} }

View File

@@ -14,8 +14,8 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr" description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop" identifier = "su.reya.coop"
category = "SocialNetworking" category = "SocialNetworking"
version = "1.0.0-beta" version = "1.0.0-beta4"
out-dir = "../../dist" out-dir = "../dist"
before-packaging-command = "cargo build --release" before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"] resources = ["Cargo.toml", "src"]
icons = [ icons = [
@@ -27,19 +27,19 @@ icons = [
] ]
[dependencies] [dependencies]
assets = { path = "../assets" } assets = { path = "../crates/assets" }
ui = { path = "../ui" } ui = { path = "../crates/ui" }
title_bar = { path = "../title_bar" } title_bar = { path = "../crates/title_bar" }
theme = { path = "../theme" } theme = { path = "../crates/theme" }
common = { path = "../common" } common = { path = "../crates/common" }
state = { path = "../state" } state = { path = "../crates/state" }
device = { path = "../device" } device = { path = "../crates/device" }
chat = { path = "../chat" } chat = { path = "../crates/chat" }
chat_ui = { path = "../chat_ui" } chat_ui = { path = "../crates/chat_ui" }
settings = { path = "../settings" } settings = { path = "../crates/settings" }
auto_update = { path = "../auto_update" } auto_update = { path = "../crates/auto_update" }
person = { path = "../person" } person = { path = "../crates/person" }
relay_auth = { path = "../relay_auth" } relay_auth = { path = "../crates/relay_auth" }
gpui.workspace = true gpui.workspace = true
gpui_platform.workspace = true gpui_platform.workspace = true
@@ -62,6 +62,10 @@ smol.workspace = true
futures.workspace = true futures.workspace = true
oneshot.workspace = true oneshot.workspace = true
webbrowser.workspace = true webbrowser.workspace = true
tracing-subscriber.workspace = true
indexset = "0.12.3" indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
core-text = "=21.0.0"

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

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