29 Commits

Author SHA1 Message Date
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
119 changed files with 7133 additions and 4271 deletions

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/

1740
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/*"] members = ["crates/*", "desktop", "web", "mobile"]
default-members = ["crates/coop"] default-members = ["desktop"]
[workspace.package] [workspace.package]
version = "1.0.0-beta" version = "1.0.0-beta3"
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 = ["font-kit", "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" }
@@ -22,6 +22,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
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" ] }
@@ -41,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

54
GEMINI.md Normal file
View File

@@ -0,0 +1,54 @@
# Coop - Gemini Context
## Project Overview
Coop is a simple, fast, and reliable Nostr client for secure messaging across all platforms. It is written in Rust and structured as a Cargo workspace. The project utilizes:
- **GPUI**: The GPU-accelerated UI framework developed by Zed Industries for cross-platform, high-performance user interfaces.
- **Rust Nostr SDK**: For handling the Nostr protocol (including NIPs like 44, 49, 59, 96).
The workspace is divided into several sub-crates under `crates/`, including the main application (`coop`), UI components (`ui`, `chat_ui`, `title_bar`, `theme`), and domain logic (`chat`, `person`, `relay_auth`, `state`, `device`).
## Building and Running
### Prerequisites
- **Rust Toolchain**: Use `rustup` to install the Rust toolchain.
- **System Dependencies**: Depending on your OS, you must run the provided setup scripts to install necessary libraries before compiling.
### Commands
- **Install Linux Dependencies**:
```bash
./script/linux
```
- **Install FreeBSD Dependencies**:
```bash
./script/freebsd
```
- **Install macOS Dependencies**:
```bash
./script/macos
```
- **Build the project** (debug mode):
```bash
cargo build
```
- **Run the application**:
```bash
cargo run
```
- **Build for Production** (optimized release binary):
```bash
cargo build --release
```
### Packaging
- Packaging scripts and manifests are available in the `script/` directory (e.g., `bundle-linux`, `bundle-snap`, `prepare-flathub`) and the `flathub/` directory.
## Development Conventions
- **Code Formatting**: The project enforces a strict Rust code formatting style via `rustfmt.toml`.
- **Edition**: 2024
- **Indentation**: Block style with 4 tab spaces.
- **Imports**: Grouped by `StdExternalCrate`, with module granularity and automatic reordering of imports, modules, and impl items.
- **Modularity**: Code is split into focused crates (e.g., separating UI code in `chat_ui` from core messaging logic in `chat`). When contributing, ensure your code respects these boundaries.
- **Contributing**: Contributions are made via Pull Requests on the `lumehq/coop` GitHub repository. Ensure all tests pass before submitting.
- **UI Architecture**: Because Coop uses GPUI, UI components are built using GPUI's element tree structure, event handling, and view contexts.

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

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

@@ -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::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::EventUtils; use common::EventExt;
use device::{DeviceEvent, DeviceRegistry};
use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2; 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::{DEVICE_GIFTWRAP, NostrRegistry, RelayState, 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,7 +342,7 @@ 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();
@@ -268,29 +351,20 @@ impl ChatRegistry {
return; return;
}; };
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
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");
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)));
// 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(())
}); });
@@ -298,39 +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 Some(public_key) = signer.public_key() else {
return Task::ready(Err(anyhow!("User not found")));
};
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()
@@ -338,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() {
@@ -419,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
@@ -462,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
@@ -539,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();
} }
@@ -633,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));
@@ -676,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);
@@ -705,16 +830,16 @@ 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
@@ -729,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,7 +1,8 @@
use std::hash::Hash; use std::hash::Hash;
use std::ops::Range; use std::ops::Range;
use common::{EventUtils, NostrParser}; 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.
@@ -24,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 {
@@ -112,6 +132,8 @@ 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
@@ -124,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,
@@ -140,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,
@@ -157,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,

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();
// Get room's id
let id = self.id;
// Get all members, excluding the sender
let members: Vec<PublicKey> = self
.members
.iter()
.filter(|public_key| Some(**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

@@ -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,18 +1,17 @@
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::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red, StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window,
relative, svg, white, deferred, div, img, list, px, red, relative, svg, white,
}; };
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -24,10 +23,9 @@ 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::{
@@ -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 {
@@ -729,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);
@@ -759,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)
}), }),
) )
}) })
@@ -788,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| {
@@ -822,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],
@@ -868,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_2().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() {
@@ -891,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
}))
});
})
}) })
} }
@@ -1030,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()
@@ -1082,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())
@@ -1286,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 {}
@@ -1305,8 +1485,36 @@ 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(
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( .child(
v_flex() v_flex()
.flex_1() .flex_1()

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

@@ -69,6 +69,7 @@ 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 code_background = cx.theme().elevated_surface_background; let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
InteractiveText::new( InteractiveText::new(
id, id,
@@ -100,6 +101,7 @@ impl RenderedText {
} }
} }
Highlight::Mention => HighlightStyle { Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle { underline: Some(UnderlineStyle {
thickness: 1.0.into(), thickness: 1.0.into(),
..Default::default() ..Default::default()

View File

@@ -20,3 +20,4 @@ log.workspace = true
dirs = "5.0" dirs = "5.0"
qrcode = "0.14.1" qrcode = "0.14.1"
bech32 = "0.11.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,13 +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 parser::*;
pub use paths::*; pub use paths::*;
pub use range::*; 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 parser;
mod paths; mod paths;
mod range; 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)
}

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

@@ -1,24 +1,22 @@
use std::cell::Cell; use std::cell::Cell;
use std::collections::{HashMap, HashSet}; use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc; 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::{ use gpui::{
div, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
Styled, Subscription, Task, Window, 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, NostrRegistry, StateEvent, TIMEOUT, app_name};
use state::{
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::Button;
use ui::notification::Notification; use ui::notification::{Notification, NotificationKind};
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, WindowExtension}; 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. \ const MSG: &str = "You've requested an encryption key from another device. \
@@ -32,21 +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 {
/// Device state /// Whether the registry is currently initializing
state: DeviceState, pub initializing: bool,
/// Whether there is a pending request for encryption key approval
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> {
@@ -61,26 +86,24 @@ 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(window, cx);
}); });
Self { Self {
state: DeviceState::default(), initializing: true,
pending_request: false,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscription: Some(subscription),
} }
} }
@@ -94,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
@@ -123,18 +144,16 @@ impl DeviceRegistry {
Ok(()) Ok(())
})); }));
self.tasks.push( self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Update GPUI states
cx.spawn_in(window, 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 // New request event from other device
Kind::Custom(4454) => { Kind::Custom(4454) => {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.ask_for_approval(event, window, cx); this.ask_for_approval(event, window, cx);
})?; })?;
} }
// New response event // New response event from the master device
Kind::Custom(4455) => { Kind::Custom(4455) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.extract_encryption(event, cx); this.extract_encryption(event, cx);
@@ -143,25 +162,24 @@ impl DeviceRegistry {
_ => {} _ => {}
} }
} }
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();
} }
/// Set the decoupled encryption key for the current user /// Set the decoupled encryption key for the current user
pub fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>) fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
where where
S: NostrSigner + 'static, S: NostrSigner + 'static,
{ {
@@ -173,63 +191,24 @@ 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;
cx.notify();
}
/// 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 Some(public_key) = signer.public_key() else {
return Task::ready(Err(anyhow!("User not found")));
};
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(())
}) })
@@ -239,20 +218,10 @@ impl DeviceRegistry {
pub 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 Some(public_key) = signer.public_key() else {
return;
};
// 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()
@@ -260,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);
})?; })?;
} }
} }
@@ -303,36 +264,55 @@ impl DeviceRegistry {
})); }));
} }
/// Create new encryption keys /// Create a new device signer and announce it to user's relay list
pub fn create_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> { 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();
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
return Task::ready(Err(anyhow!("User not found")));
};
// 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();
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(app_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?;
@@ -341,148 +321,77 @@ impl DeviceRegistry {
}) })
} }
/// Create a new device signer and announce it /// Set encryption key from the announcement event
fn announce(&mut self, cx: &mut Context<Self>) { fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
let task = self.create_encryption(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let keys = task.await?;
// Update signer
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
})?;
Ok(())
}));
}
/// Initialize device signer (decoupled encryption key) for the current user
pub 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"))
}
}); });
self.tasks.push(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);
})?; })?;
} } else {
Err(e) => {
log::warn!("Failed to initialize device signer: {e}");
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.request(cx); this.request(cx);
this.listen_approval(cx);
})?; })?;
} }
};
Ok(()) Ok(())
})); }));
} }
/// Listen for device key requests on user's write relays /// Wait for encryption key requests from now on
pub 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 Some(public_key) = signer.public_key() else { self.tasks.push(cx.background_spawn(async move {
return; let public_key = signer.get_public_key().await?;
}; let id = SubscriptionId::new("dekey-requests");
let write_relays = nostr.read(cx).write_relays(&public_key, cx); // Construct a filter for encryption key requests
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
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 Some(public_key) = signer.public_key() else {
return;
};
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 Some(public_key) = signer.public_key() else { let app_keys = nostr.read(cx).keys();
return;
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
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)
@@ -490,35 +399,21 @@ 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(app_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)
} }
@@ -527,32 +422,58 @@ impl DeviceRegistry {
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(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(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);
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(())
}));
}
/// Wait for encryption key approvals
fn wait_for_approval(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
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(()) Ok(())
})); }));
} }
/// Parse the response event for device keys from other devices /// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) { fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone(); 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())
@@ -560,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);
@@ -569,13 +490,18 @@ 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(())
})); }));
} }
@@ -584,20 +510,12 @@ impl DeviceRegistry {
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) { 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();
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
return;
};
// 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(); let id: SharedString = event.id.to_hex().into();
let task: Task<Result<(), Error>> = 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();
@@ -611,14 +529,14 @@ 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 builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![ let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]), Tag::custom(TagKind::custom("P"), vec![keys.public_key().to_hex()]),
Tag::public_key(target), Tag::public_key(target),
]); ]);
@@ -626,7 +544,7 @@ impl DeviceRegistry {
let event = client.sign_event_builder(builder).await?; 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(())
}); });
@@ -635,13 +553,16 @@ impl DeviceRegistry {
match task.await { match task.await {
Ok(_) => { Ok(_) => {
cx.update(|window, cx| { cx.update(|window, cx| {
window.clear_notification(id, cx); window.clear_notification_by_id::<DeviceNotification>(id, cx);
}) })
.ok(); .ok();
} }
Err(e) => { Err(e) => {
cx.update(|window, cx| { cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx); window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
}) })
.ok(); .ok();
} }
@@ -652,15 +573,15 @@ impl DeviceRegistry {
/// Handle encryption request /// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) { fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let notification = self.notification(event, cx); // Ignore if there is already a pending request
if self.pending_request {
return;
}
self.set_pending_request(true, cx);
cx.spawn_in(window, async move |_this, cx| { // Show notification
cx.update(|window, cx| { let notification = self.notification(event, cx);
window.push_notification(notification, cx); window.push_notification(notification, cx);
})
.ok();
})
.detach();
} }
/// Build a notification for the encryption request. /// Build a notification for the encryption request.
@@ -671,17 +592,23 @@ impl DeviceRegistry {
let entity = cx.entity().downgrade(); let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false)); let loading = Rc::new(Cell::new(false));
let key = SharedString::from(event.id.to_hex());
Notification::new() Notification::new()
.custom_id(SharedString::from(event.id.to_hex())) .type_id::<DeviceNotification>(key)
.autohide(false) .autohide(false)
.icon(IconName::UserKey) .with_kind(NotificationKind::Info)
.title(SharedString::from("New request")) .title("Encryption Key Request")
.content(move |_window, cx| { .content(move |_this, _window, cx| {
v_flex() v_flex()
.gap_2() .gap_2()
.text_sm() .text_sm()
.child(SharedString::from(MSG)) .child(
div()
.text_sm()
.line_height(relative(1.25))
.child(SharedString::from(MSG)),
)
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()
@@ -691,13 +618,14 @@ impl DeviceRegistry {
.text_sm() .text_sm()
.child( .child(
div() div()
.font_semibold()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(SharedString::from("Requester:")), .child(SharedString::from("From:")),
) )
.child( .child(
div() div()
.h_7() .h_8()
.w_full() .w_full()
.px_2() .px_2()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
@@ -716,13 +644,14 @@ impl DeviceRegistry {
.text_sm() .text_sm()
.child( .child(
div() div()
.font_semibold()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(SharedString::from("Client:")), .child(SharedString::from("Client:")),
) )
.child( .child(
div() div()
.h_7() .h_8()
.w_full() .w_full()
.px_2() .px_2()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
@@ -733,14 +662,12 @@ impl DeviceRegistry {
) )
.into_any_element() .into_any_element()
}) })
.action(move |_window, _cx| { .action(move |_this, _window, _cx| {
let view = entity.clone(); let view = entity.clone();
let event = event.clone(); let event = event.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({
@@ -759,13 +686,15 @@ impl DeviceRegistry {
} }
} }
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,20 +319,25 @@ 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()
@@ -344,19 +345,17 @@ impl RelayAuth {
.rounded_sm() .rounded_sm()
.text_xs() .text_xs()
.bg(cx.theme().elevated_surface_background) .bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text_accent) .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({
@@ -374,3 +373,5 @@ impl RelayAuth {
}) })
} }
} }
struct AuthNotification;

View File

@@ -277,17 +277,6 @@ impl AppSettings {
self.apply_theme(window, cx); self.apply_theme(window, cx);
} }
/// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(name) = self.values.theme.as_ref() {
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
}
} else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
}
}
/// Reset theme /// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None; self.values.theme = None;
@@ -296,6 +285,22 @@ impl AppSettings {
self.apply_theme(window, cx); self.apply_theme(window, cx);
} }
/// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(name) = self.values.theme.as_ref() {
let mode = self.values.theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
Theme::change(mode, Some(window), cx);
} else {
log::info!("Failed to load theme: {name}");
}
} else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
}
}
/// Check if 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

@@ -15,6 +15,9 @@ 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;
@@ -36,13 +39,16 @@ 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; 3] = [ pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://indexer.coracle.social",
"wss://user.kindpag.es", "wss://user.kindpag.es",
]; ];

View File

@@ -1,40 +1,6 @@
use std::fmt::Display;
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 Display for DeviceState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceState::Idle => write!(f, "Idle"),
DeviceState::Requesting => write!(f, "Wait for approval"),
DeviceState::Set => write!(f, "Encryption Key is ready"),
}
}
}
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

@@ -42,7 +42,7 @@ impl CoopSigner {
/// Get public key /// Get public key
/// ///
/// Ensure to call this method after the signer has been initialized. /// Ensure to call this method after the signer has been initialized.
/// Otherwise, this method will panic. /// Otherwise, it will panic.
pub fn public_key(&self) -> Option<PublicKey> { pub fn public_key(&self) -> Option<PublicKey> {
*self.signer_pkey.read_blocking() *self.signer_pkey.read_blocking()
} }

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

@@ -138,7 +138,7 @@ impl Anchor {
} }
} }
pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor { pub fn other_side_corner_along(&self, axis: Axis) -> Anchor {
match axis { match axis {
Axis::Vertical => match self { Axis::Vertical => match self {
Self::TopLeft => Self::BottomLeft, Self::TopLeft => Self::BottomLeft,

View File

@@ -4,6 +4,8 @@ use std::rc::Rc;
use gpui::{App, Global, Pixels, SharedString, Window, px}; 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,31 @@
use gpui::{Pixels, px};
use serde::{Deserialize, Serialize};
use crate::{Anchor, Edges, TITLEBAR_HEIGHT};
/// The settings for notifications.
#[derive(Debug, Clone, Serialize, Deserialize)]
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,13 +1,12 @@
//! This is a fork of gpui's anchored element that adds support for offsetting //! 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 //! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
use gpui::{ use gpui::{
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
Window, Window, point, px,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::Anchor;
use crate::Anchor;
/// The state that the anchored element element uses to track its children. /// The state that the anchored element element uses to track its children.
pub struct AnchoredState { pub struct AnchoredState {

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,22 +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, Decorations, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
Edges, Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
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 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]);

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, App, AppContext, Context, Corner, 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;
@@ -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,13 +1101,13 @@ impl TabPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if self.panels.len() > 1 { if self.panels.len() > 1
if let Some(panel) = self.active_panel(cx) { && let Some(panel) = self.active_panel(cx)
{
self.remove_panel(&panel, window, cx); self.remove_panel(&panel, window, cx);
} }
} }
} }
}
impl Focusable for TabPanel { impl Focusable for TabPanel {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
@@ -1099,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,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;
@@ -39,6 +39,7 @@ pub enum IconName {
Ellipsis, Ellipsis,
Emoji, Emoji,
Eye, Eye,
Input,
Info, Info,
Invite, Invite,
Inbox, Inbox,
@@ -110,6 +111,7 @@ impl IconNamed for IconName {
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",

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

@@ -2,11 +2,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 +17,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;
@@ -39,7 +38,6 @@ 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

@@ -5,10 +5,11 @@ use gpui::{
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 {
@@ -35,6 +36,8 @@ 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,

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, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, Corner, 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";
@@ -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
} }
@@ -965,13 +965,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);
} }

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);
@@ -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, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
Subscription, Window, Window, div, px, relative,
}; };
use smol::Timer; use theme::{ActiveTheme, Anchor};
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
@@ -276,33 +288,61 @@ impl Styled for Notification {
&mut self.style &mut self.style
} }
} }
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 +350,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 +379,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 +390,47 @@ 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)
}
}
} else { } else {
let y_offset = px(-45.) + delta * px(45.);
let opacity = delta; let opacity = delta;
let y_offset = match placement {
placement if placement.is_top() => px(-45.) + delta * px(45.),
placement if placement.is_bottom() => 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 +444,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 +461,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 +488,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 +527,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,15 @@ 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, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, FocusHandle, Focusable, Half, 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, deferred, div, px,
}; };
use theme::Anchor;
use crate::actions::Cancel; use crate::actions::Cancel;
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _}; use crate::{ElementExt, Selectable, StyledExt as _, anchored, v_flex};
const CONTEXT: &str = "Popover"; const CONTEXT: &str = "Popover";

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::{
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, Entity, AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, ElementId, Entity,
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, Tiling, ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
WeakFocusHandle, Window, canvas, div, point, px, size, 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();
} }

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

@@ -11,9 +11,7 @@ use gpui::{
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size, 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) {

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, AnyElement, App, Corner, 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(Corner::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-beta3"
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,9 +62,9 @@ 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", "env-filter"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
# Temporary workaround https://github.com/zed-industries/zed/issues/47168 # Temporary workaround https://github.com/zed-industries/zed/issues/47168

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,17 +1,17 @@
use anyhow::Error; use anyhow::Error;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use state::{NostrRegistry, SignerEvent}; use state::{NostrRegistry, StateEvent};
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::indicator::Indicator; use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension}; use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::connect::ConnectSigner; use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey; use crate::dialogs::import::ImportKey;
@@ -44,13 +44,14 @@ impl AccountSelector {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| { let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event { match event {
SignerEvent::Set => { StateEvent::SignerSet => {
window.close_all_modals(cx); window.close_all_modals(cx);
window.refresh(); window.refresh();
} }
SignerEvent::Error(e) => { StateEvent::Error(e) => {
this.set_error(e.to_string(), cx); this.set_error(e.to_string(), cx);
} }
_ => {}
}; };
}); });
@@ -90,7 +91,7 @@ impl AccountSelector {
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) { fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_signer(&public_key, cx); let task = nostr.read(cx).get_secret(public_key, cx);
// Mark the public key as being logged in // Mark the public key as being logged in
self.set_logging_in(public_key, cx); self.set_logging_in(public_key, cx);
@@ -116,7 +117,7 @@ impl AccountSelector {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| { nostr.update(cx, |this, cx| {
this.remove_signer(&public_key, cx); this.remove_secret(&public_key, cx);
}); });
} }
@@ -161,7 +162,7 @@ impl Render for AccountSelector {
.italic() .italic()
.text_xs() .text_xs()
.text_center() .text_center()
.text_color(cx.theme().danger_active) .text_color(cx.theme().text_danger)
.child(error.clone()), .child(error.clone()),
) )
}) })

View File

@@ -1,16 +1,16 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use common::TextUtils; use common::StringExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, SharedString, Styled,
SharedString, Styled, Subscription, Window, Subscription, Window, div, img, px,
}; };
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::{ use state::{
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY, CLIENT_NAME, CoopAuthUrlHandler, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT, NostrRegistry,
NOSTR_CONNECT_TIMEOUT, StateEvent,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::v_flex; use ui::v_flex;
@@ -31,7 +31,7 @@ impl ConnectSigner {
let error = cx.new(|_| None); let error = cx.new(|_| None);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone(); let app_keys = nostr.read(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(); let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
@@ -55,7 +55,7 @@ impl ConnectSigner {
// Subscribe to the signer event // Subscribe to the signer event
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| { let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event { if let StateEvent::Error(e) = event {
this.set_error(e, cx); this.set_error(e, cx);
} }
}); });
@@ -101,7 +101,7 @@ impl Render for ConnectSigner {
div() div()
.text_xs() .text_xs()
.text_center() .text_center()
.text_color(cx.theme().danger_active) .text_color(cx.theme().text_danger)
.child(error.clone()), .child(error.clone()),
) )
}) })

View File

@@ -1,18 +1,18 @@
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, Subscription, Task, Window, div,
}; };
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent}; use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::{v_flex, Disableable}; use ui::{Disableable, v_flex};
#[derive(Debug)] #[derive(Debug)]
pub struct ImportKey { pub struct ImportKey {
@@ -60,7 +60,7 @@ impl ImportKey {
subscriptions.push( subscriptions.push(
// Subscribe to the nostr signer event // Subscribe to the nostr signer event
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| { cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event { if let StateEvent::Error(e) = event {
this.set_error(e, cx); this.set_error(e, cx);
} }
}), }),
@@ -117,7 +117,7 @@ impl ImportKey {
}; };
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone(); let app_keys = nostr.read(cx).keys();
let timeout = Duration::from_secs(30); let timeout = Duration::from_secs(30);
// Construct the nostr connect signer // Construct the nostr connect signer
@@ -242,7 +242,6 @@ impl Render for ImportKey {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex() v_flex()
.size_full() .size_full()
.p_4()
.gap_2() .gap_2()
.text_sm() .text_sm()
.child( .child(
@@ -293,7 +292,7 @@ impl Render for ImportKey {
div() div()
.text_xs() .text_xs()
.text_center() .text_center()
.text_color(cx.theme().danger_active) .text_color(cx.theme().text_danger)
.child(error.clone()), .child(error.clone()),
) )
}) })

View File

@@ -1,6 +1,6 @@
pub mod accounts; pub mod accounts;
pub mod connect;
pub mod import;
pub mod restore;
pub mod screening; pub mod screening;
pub mod settings; pub mod settings;
mod connect;
mod import;

View File

@@ -0,0 +1,130 @@
use std::time::Duration;
use anyhow::Error;
use device::DeviceRegistry;
use gpui::prelude::FluentBuilder;
use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
};
use nostr_connect::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{WindowExtension, v_flex};
#[derive(Debug)]
pub struct RestoreEncryption {
/// Secret key input
key_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscription
_subscription: Option<Subscription>,
}
impl RestoreEncryption {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let subscription =
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.restore(window, cx);
};
});
Self {
key_input,
error,
tasks: vec![],
_subscription: Some(subscription),
}
}
fn restore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let device = DeviceRegistry::global(cx);
let content = self.key_input.read(cx).value();
if !content.is_empty() {
self.set_error("Secret Key cannot be empty.", cx);
}
let Ok(secret) = SecretKey::parse(&content) else {
self.set_error("Secret Key is invalid.", cx);
return;
};
device.update(cx, |this, cx| {
this.set_announcement(Keys::new(secret), cx);
});
// Close the current modal
window.close_modal(cx);
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
self.tasks.push(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(())
}));
}
}
impl Render for RestoreEncryption {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Secret Key")
.child(TextInput::new(&self.key_input)),
)
.child(
Button::new("restore")
.label("Restore")
.primary()
.on_click(cx.listener(move |this, _, window, cx| {
this.restore(window, cx);
})),
)
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.child(error.clone()),
)
})
}
}

View File

@@ -2,21 +2,21 @@ use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error};
use common::RenderedTimestamp; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, relative, uniform_list, App, AppContext, Context, Div, Entity, InteractiveElement, App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{shorten_pubkey, Person, PersonRegistry}; use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
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::indicator::Indicator; use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension}; use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> { pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx)) cx.new(|cx| Screening::new(public_key, window, cx))

View File

@@ -1,17 +1,17 @@
use gpui::http_client::Url; use gpui::http_client::Url;
use gpui::{ use gpui::{
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Styled, Window, Window, div, px,
}; };
use settings::{AppSettings, AuthMode}; use settings::{AppSettings, AuthMode};
use theme::{ActiveTheme, ThemeMode}; use theme::{ActiveTheme, Theme, ThemeMode};
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::group_box::{GroupBox, GroupBoxVariants}; use ui::group_box::{GroupBox, GroupBoxVariants};
use ui::input::{InputState, TextInput}; use ui::input::{InputState, TextInput};
use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification; use ui::notification::Notification;
use ui::switch::Switch; use ui::switch::Switch;
use ui::{h_flex, v_flex, IconName, Sizable, WindowExtension}; use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx)) cx.new(|cx| Preferences::new(window, cx))
@@ -33,6 +33,7 @@ impl Preferences {
Self { file_input } Self { file_input }
} }
/// Update the file server (blossom) URL
fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.file_input.read(cx).value(); let value = self.file_input.read(cx).value();
@@ -41,10 +42,16 @@ impl Preferences {
AppSettings::update_file_server(url, cx); AppSettings::update_file_server(url, 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);
} }
} }
} }
/// Set the theme mode (light or dark)
fn set_theme_mode(mode: ThemeMode, window: &mut Window, cx: &mut App) {
AppSettings::update_theme_mode(mode, cx);
Theme::change(mode, Some(window), cx);
}
} }
impl Render for Preferences { impl Render for Preferences {
@@ -160,23 +167,16 @@ impl Render for Preferences {
.ghost_alt() .ghost_alt()
.small() .small()
.dropdown_menu(|this, _window, _cx| { .dropdown_menu(|this, _window, _cx| {
this.min_w(px(256.)) this.item(PopupMenuItem::new("Light").on_click(
.item(PopupMenuItem::new("Light").on_click( |_, window, cx| {
|_ev, _window, cx| { Self::set_theme_mode(ThemeMode::Light, window, cx);
AppSettings::update_theme_mode(
ThemeMode::Light,
cx,
);
},
))
.item(PopupMenuItem::new("Dark").on_click(
|_ev, _window, cx| {
AppSettings::update_theme_mode(
ThemeMode::Dark,
cx,
);
}, },
)) ))
.item(
PopupMenuItem::new("Dark").on_click(|_, window, cx| {
Self::set_theme_mode(ThemeMode::Dark, window, cx);
}),
)
}), }),
), ),
) )

View File

@@ -2,9 +2,9 @@ use std::sync::{Arc, Mutex};
use assets::Assets; use assets::Assets;
use gpui::{ use gpui::{
actions, point, px, size, App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString, App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
WindowOptions, actions, point, px, size,
}; };
use gpui_platform::application; use gpui_platform::application;
use state::{APP_ID, CLIENT_NAME}; use state::{APP_ID, CLIENT_NAME};
@@ -44,6 +44,7 @@ fn main() {
cx.set_menus(vec![Menu { cx.set_menus(vec![Menu {
name: "Coop".into(), name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)], items: vec![MenuItem::action("Quit", Quit)],
disabled: false,
}]); }]);
// Set up the window bounds // Set up the window bounds
@@ -86,19 +87,19 @@ fn main() {
state::init(window, cx); state::init(window, cx);
// Initialize person registry // Initialize person registry
person::init(cx); person::init(window, cx);
// Initialize relay auth registry // Initialize relay auth registry
relay_auth::init(window, cx); relay_auth::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize device signer // Initialize device signer
// //
// 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
device::init(window, cx); device::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize auto update // Initialize auto update
auto_update::init(window, cx); auto_update::init(window, cx);

View File

@@ -2,16 +2,16 @@ use std::time::Duration;
use anyhow::Error; use anyhow::Error;
use gpui::{ use gpui::{
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::KEYRING; use state::KEYRING;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
use ui::input::{InputState, TextInput}; use ui::input::{InputState, TextInput};
use ui::{divider, v_flex, IconName, Sizable, StyledExt}; use ui::{IconName, Sizable, StyledExt, divider, v_flex};
const MSG: &str = "Store your account keys in a safe location. \ const MSG: &str = "Store your account keys in a safe location. \
You can restore your account or move to another client anytime you want."; You can restore your account or move to another client anytime you want.";
@@ -84,11 +84,6 @@ impl BackupPanel {
fn copy_secret(&mut self, cx: &mut Context<Self>) { fn copy_secret(&mut self, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).value(); let value = self.nsec_input.read(cx).value();
let item = ClipboardItem::new_string(value.to_string()); let item = ClipboardItem::new_string(value.to_string());
#[cfg(target_os = "linux")]
cx.write_to_primary(item);
#[cfg(not(target_os = "linux"))]
cx.write_to_clipboard(item); cx.write_to_clipboard(item);
// Set the copied status to true // Set the copied status to true

View File

@@ -4,20 +4,20 @@ use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, Task, TextAlign, Window, div, rems,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use smallvec::{smallvec, SmallVec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
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::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension}; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
cx.new(|cx| ContactListPanel::new(window, cx)) cx.new(|cx| ContactListPanel::new(window, cx))
@@ -156,15 +156,6 @@ impl ContactListPanel {
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 Some(public_key) = signer.public_key() else {
window.push_notification("Public Key not found", cx);
return;
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
// Get contacts // Get contacts
let contacts: Vec<Contact> = self let contacts: Vec<Contact> = self
@@ -177,14 +168,12 @@ impl ContactListPanel {
self.set_updating(true, cx); self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct contact list event builder // Construct contact list event builder
let builder = EventBuilder::contact_list(contacts); let builder = EventBuilder::contact_list(contacts);
let event = client.sign_event_builder(builder).await?; let event = client.sign_event_builder(builder).await?;
// Set contact list // Set contact list
client.send_event(&event).to(urls).await?; client.send_event(&event).to_nip65().await?;
Ok(()) Ok(())
}); });
@@ -333,7 +322,7 @@ impl Render for ContactListPanel {
div() div()
.italic() .italic()
.text_xs() .text_xs()
.text_color(cx.theme().danger_active) .text_color(cx.theme().text_danger)
.child(error.clone()), .child(error.clone()),
) )
}), }),

View File

@@ -1,17 +1,14 @@
use chat::{ChatRegistry, InboxState};
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window, IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
}; };
use state::{NostrRegistry, RelayState}; use state::NostrRegistry;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement; use ui::dock::{DockPlacement, Panel, PanelEvent};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
use crate::panels::{messaging_relays, profile, relay_list}; use crate::panels::profile;
use crate::workspace::Workspace; use crate::workspace::Workspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
@@ -82,15 +79,6 @@ impl Render for GreeterPanel {
const TITLE: &str = "Welcome to Coop!"; const TITLE: &str = "Welcome to Coop!";
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr."; 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.clone();
let required_actions =
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
h_flex() h_flex()
.size_full() .size_full()
.items_center() .items_center()
@@ -130,64 +118,6 @@ impl Render for GreeterPanel {
), ),
), ),
) )
.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,
);
}),
)
}),
),
)
})
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()

View File

@@ -1,21 +1,21 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, Task, TextAlign, Window, div, rems,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
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, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension}; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
const MSG: &str = "Messaging Relays are relays that hosted all your messages. \ const MSG: &str = "Messaging Relays are relays that hosted all your messages. \
Other users will find your relays and send messages to it."; Other users will find your relays and send messages to it.";
@@ -170,15 +170,6 @@ impl MessagingRelayPanel {
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 Some(public_key) = signer.public_key() else {
window.push_notification("Public Key not found", cx);
return;
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
// Construct event tags // Construct event tags
let tags: Vec<Tag> = self let tags: Vec<Tag> = self
@@ -191,14 +182,12 @@ impl MessagingRelayPanel {
self.set_updating(true, cx); self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct nip17 event builder // Construct nip17 event builder
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags); let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let event = client.sign_event_builder(builder).await?; let event = client.sign_event_builder(builder).await?;
// Set messaging relays // Set messaging relays
client.send_event(&event).to(urls).await?; client.send_event(&event).to_nip65().await?;
Ok(()) Ok(())
}); });
@@ -349,7 +338,7 @@ impl Render for MessagingRelayPanel {
div() div()
.italic() .italic()
.text_xs() .text_xs()
.text_color(cx.theme().danger_active) .text_color(cx.theme().text_danger)
.child(error.clone()), .child(error.clone()),
) )
}), }),

View File

@@ -4,3 +4,4 @@ pub mod greeter;
pub mod messaging_relays; pub mod messaging_relays;
pub mod profile; pub mod profile;
pub mod relay_list; pub mod relay_list;
pub mod trash;

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