9 Commits

Author SHA1 Message Date
2c33670ba5 .
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m41s
2026-02-19 06:41:23 +07:00
8026a4f5a5 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m46s
2026-02-17 13:23:43 +07:00
1d8e3724a8 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m43s
2026-02-17 07:54:46 +07:00
d25080f5e7 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m18s
2026-02-16 16:53:06 +07:00
452253bece wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m25s
2026-02-15 16:52:35 +07:00
a1aaa30a48 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m59s
2026-02-14 16:43:28 +07:00
e327178161 chore: fix some performance issues (#6)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m14s
Reviewed-on: #6
2026-02-14 02:01:49 +00:00
ecd7f6aa9b Continue redesign for the v1 stable release (#5)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m32s
Reviewed-on: #5
2026-02-12 08:32:17 +00:00
32201554ec Redesign for the v1 stable release (#3)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Only half done. Will continue in another PR.

Reviewed-on: #3
2026-02-04 01:43:21 +00:00
132 changed files with 7357 additions and 12156 deletions

View File

@@ -21,7 +21,7 @@ jobs:
os: windows-11-arm os: windows-11-arm
target: aarch64-pc-windows-msvc target: aarch64-pc-windows-msvc
- platform: macos-x64 - platform: macos-x64
os: macos-15-intel os: macos-13
target: x86_64-apple-darwin target: x86_64-apple-darwin
- platform: macos-arm64 - platform: macos-arm64
os: macos-latest os: macos-latest
@@ -130,7 +130,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Make get-crate-version executable - name: Make get-crate-version executable
run: chmod +x script/get-crate-version run: chmod +x script/get-crate-version
@@ -163,6 +163,8 @@ jobs:
generate_release_notes: true generate_release_notes: true
files: | files: |
artifacts/**/* artifacts/**/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Output release info - name: Output release info
run: | run: |

View File

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

1052
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,26 +4,23 @@ members = ["crates/*"]
default-members = ["crates/coop"] default-members = ["crates/coop"]
[workspace.package] [workspace.package]
version = "1.0.0-beta" version = "0.3.0"
edition = "2021" edition = "2021"
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_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" } gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # Nostr
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-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-gossip-memory = { 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" ] }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
@@ -32,6 +29,7 @@ futures = "0.3"
itertools = "0.13.0" itertools = "0.13.0"
log = "0.4" log = "0.4"
oneshot = "0.1.10" oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] } flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0" rust-embed = "8.5.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View File

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

Before

Width:  |  Height:  |  Size: 866 B

View File

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

Before

Width:  |  Height:  |  Size: 472 B

After

Width:  |  Height:  |  Size: 550 B

View File

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

Before

Width:  |  Height:  |  Size: 898 B

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 451 B

View File

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

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 435 B

View File

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

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 459 B

View File

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

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 436 B

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Before

Width:  |  Height:  |  Size: 435 B

View File

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

Before

Width:  |  Height:  |  Size: 468 B

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 771 B

0
assets/themes/.keep Normal file
View File

View File

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

View File

@@ -1,140 +0,0 @@
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#e6e9ef",
"title_bar_inactive": "#dce0e8",
"window_border": "#9ca0b0",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_accent": "#1e66f5",
"icon": "#5c5f77",
"icon_muted": "#6c6f85",
"icon_accent": "#1e66f5",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#8839ef",
"element_active": "#1c5ce0",
"element_selected": "#1a52cc",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1a52cc",
"secondary_background": "#e6e9ef",
"secondary_hover": "#8839ef33",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#e64553",
"danger_active": "#bd0d33",
"danger_selected": "#a80b2d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#fe640b",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#ccd0da",
"ghost_element_hover": "#4c4f6933",
"ghost_element_active": "#bcc0cc",
"ghost_element_selected": "#bcc0cc",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_inactive_foreground": "#5c5f77",
"tab_active_background": "#eff1f5",
"tab_active_foreground": "#4c4f69",
"tab_hover_foreground": "#8839ef",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f6980",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#bcc0cc",
"drop_target_background": "#1e66f51a",
"cursor": "#dc8a78",
"selection": "#7c7f9340"
},
"dark": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#e6e9ef",
"title_bar_inactive": "#dce0e8",
"window_border": "#9ca0b0",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_accent": "#1e66f5",
"icon": "#5c5f77",
"icon_muted": "#6c6f85",
"icon_accent": "#1e66f5",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#8839ef",
"element_active": "#1c5ce0",
"element_selected": "#1a52cc",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1a52cc",
"secondary_background": "#e6e9ef",
"secondary_hover": "#8839ef33",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#e64553",
"danger_active": "#bd0d33",
"danger_selected": "#a80b2d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#fe640b",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#ccd0da",
"ghost_element_hover": "#4c4f6933",
"ghost_element_active": "#bcc0cc",
"ghost_element_selected": "#bcc0cc",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_inactive_foreground": "#5c5f77",
"tab_active_background": "#eff1f5",
"tab_active_foreground": "#4c4f69",
"tab_hover_foreground": "#8839ef",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f6980",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#bcc0cc",
"drop_target_background": "#1e66f51a",
"cursor": "#dc8a78",
"selection": "#7c7f9340"
}
}

View File

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

View File

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

View File

@@ -1,140 +0,0 @@
{
"id": "flexoki",
"name": "Flexoki",
"author": "Stephan Ango",
"url": "https://stephango.com/flexoki",
"light": {
"background": "#FFFCF0",
"surface_background": "#F2F0E5",
"elevated_surface_background": "#E6E4D9",
"panel_background": "#FFFCF0",
"overlay": "#100F0F1a",
"title_bar": "#F2F0E5",
"title_bar_inactive": "#E6E4D9",
"window_border": "#B7B5AC",
"border": "#CECDC3",
"border_variant": "#DAD8CE",
"border_focused": "#205EA6",
"border_selected": "#205EA6",
"border_transparent": "#00000000",
"border_disabled": "#E6E4D9",
"ring": "#205EA6",
"text": "#100F0F",
"text_muted": "#6F6E69",
"text_placeholder": "#9F9D96",
"text_accent": "#205EA6",
"icon": "#6F6E69",
"icon_muted": "#9F9D96",
"icon_accent": "#205EA6",
"element_foreground": "#FFFCF0",
"element_background": "#205EA6",
"element_hover": "#1A4F8C",
"element_active": "#163B66",
"element_selected": "#133051",
"element_disabled": "#205EA64d",
"secondary_foreground": "#163B66",
"secondary_background": "#F2F0E5",
"secondary_hover": "#205EA61a",
"secondary_active": "#E6E4D9",
"secondary_selected": "#E6E4D9",
"secondary_disabled": "#205EA64d",
"danger_foreground": "#FFFCF0",
"danger_background": "#D14D41",
"danger_hover": "#C03E35",
"danger_active": "#AF3029",
"danger_selected": "#942822",
"danger_disabled": "#D14D414d",
"warning_foreground": "#100F0F",
"warning_background": "#D0A215",
"warning_hover": "#BE9207",
"warning_active": "#AD8301",
"warning_selected": "#8E6B01",
"warning_disabled": "#D0A2154d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#E6E4D9",
"ghost_element_hover": "#100F0F1a",
"ghost_element_active": "#DAD8CE",
"ghost_element_selected": "#DAD8CE",
"ghost_element_disabled": "#100F0F0d",
"tab_inactive_background": "#F2F0E5",
"tab_inactive_foreground": "#6F6E69",
"tab_active_background": "#FFFCF0",
"tab_active_foreground": "#100F0F",
"tab_hover_foreground": "#205EA6",
"scrollbar_thumb_background": "#100F0F33",
"scrollbar_thumb_hover_background": "#100F0F4d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#DAD8CE",
"drop_target_background": "#205EA61a",
"cursor": "#205EA6",
"selection": "#205EA640"
},
"dark": {
"background": "#100F0F",
"surface_background": "#1C1B1A",
"elevated_surface_background": "#282726",
"panel_background": "#100F0F",
"overlay": "#FFFCF01a",
"title_bar": "#1C1B1A",
"title_bar_inactive": "#282726",
"window_border": "#575653",
"border": "#403E3C",
"border_variant": "#343331",
"border_focused": "#4385BE",
"border_selected": "#4385BE",
"border_transparent": "#00000000",
"border_disabled": "#282726",
"ring": "#4385BE",
"text": "#FFFCF0",
"text_muted": "#878580",
"text_placeholder": "#6F6E69",
"text_accent": "#4385BE",
"icon": "#878580",
"icon_muted": "#6F6E69",
"icon_accent": "#4385BE",
"element_foreground": "#100F0F",
"element_background": "#4385BE",
"element_hover": "#3171B2",
"element_active": "#205EA6",
"element_selected": "#1A4F8C",
"element_disabled": "#4385BE4d",
"secondary_foreground": "#205EA6",
"secondary_background": "#1C1B1A",
"secondary_hover": "#4385BE1a",
"secondary_active": "#282726",
"secondary_selected": "#282726",
"secondary_disabled": "#4385BE4d",
"danger_foreground": "#100F0F",
"danger_background": "#E8705F",
"danger_hover": "#D14D41",
"danger_active": "#C03E35",
"danger_selected": "#AF3029",
"danger_disabled": "#E8705F4d",
"warning_foreground": "#100F0F",
"warning_background": "#DFB431",
"warning_hover": "#D0A215",
"warning_active": "#BE9207",
"warning_selected": "#AD8301",
"warning_disabled": "#DFB4314d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#282726",
"ghost_element_hover": "#FFFCF01a",
"ghost_element_active": "#343331",
"ghost_element_selected": "#343331",
"ghost_element_disabled": "#FFFCF00d",
"tab_inactive_background": "#1C1B1A",
"tab_inactive_foreground": "#878580",
"tab_active_background": "#100F0F",
"tab_active_foreground": "#FFFCF0",
"tab_hover_foreground": "#4385BE",
"scrollbar_thumb_background": "#FFFCF033",
"scrollbar_thumb_hover_background": "#FFFCF04d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#343331",
"drop_target_background": "#4385BE1a",
"cursor": "#4385BE",
"selection": "#4385BE40"
}
}

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

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

View File

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

View File

@@ -1,20 +1,20 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::EventUtils; use common::EventUtils;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, App, AppContext, Context, Entity, EventEmitter, Global, 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 state::{NostrRegistry, DEVICE_GIFTWRAP, USER_GIFTWRAP};
mod message; mod message;
mod room; mod room;
@@ -50,33 +50,9 @@ enum Signal {
Eose, Eose,
} }
/// Inbox state.
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum InboxState {
#[default]
Idle,
Checking,
RelayNotAvailable,
RelayConfigured(Box<Event>),
Subscribing,
}
impl InboxState {
pub fn not_configured(&self) -> bool {
matches!(self, InboxState::RelayNotAvailable)
}
pub fn subscribing(&self) -> bool {
matches!(self, InboxState::Subscribing)
}
}
/// Chat Registry /// Chat Registry
#[derive(Debug)] #[derive(Debug)]
pub struct ChatRegistry { pub struct ChatRegistry {
/// Relay state for messaging relay list
state: Entity<InboxState>,
/// Collection of all chat rooms /// Collection of all chat rooms
rooms: Vec<Entity<Room>>, rooms: Vec<Entity<Room>>,
@@ -105,49 +81,24 @@ 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 nip17 = nostr.read(cx).nip17_state();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
// Observe the nip65 state and load chat rooms on every state change // Observe the nip17 state and load chat rooms on every state change
cx.observe(&nostr, |this, state, cx| { cx.observe(&nip17, |this, _state, cx| {
match state.read(cx).relay_list_state {
RelayState::Idle => {
this.reset(cx);
}
RelayState::Configured => {
this.get_contact_list(cx);
this.ensure_messaging_relays(cx);
}
_ => {}
}
// Load rooms on every state change
this.get_rooms(cx); this.get_rooms(cx);
}), }),
); );
subscriptions.push(
// Observe the nip17 state and load chat rooms on every state change
cx.observe(&state, |this, state, cx| {
if let InboxState::RelayConfigured(event) = state.read(cx) {
let relay_urls: Vec<_> = nip17::extract_relay_list(event).cloned().collect();
this.get_messages(relay_urls, cx);
}
}),
);
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| { cx.defer_in(window, |this, _window, cx| {
this.get_rooms(cx);
this.handle_notifications(cx); this.handle_notifications(cx);
this.tracking(cx); this.tracking(cx);
}); });
Self { Self {
state,
rooms: vec![], rooms: vec![],
tracking_flag: Arc::new(AtomicBool::new(false)), tracking_flag: Arc::new(AtomicBool::new(false)),
tasks: smallvec![], tasks: smallvec![],
@@ -192,8 +143,10 @@ impl ChatRegistry {
continue; continue;
} }
log::info!("Received gift wrap event: {:?}", event);
// 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 Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
Ok(rumor) => match rumor.created_at >= initialized_at { Ok(rumor) => match rumor.created_at >= initialized_at {
true => { true => {
let new_message = NewMessage::new(event.id, rumor); let new_message = NewMessage::new(event.id, rumor);
@@ -247,7 +200,7 @@ impl ChatRegistry {
let status = self.tracking_flag.clone(); let status = self.tracking_flag.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(10);
loop { loop {
if status.load(Ordering::Acquire) { if status.load(Ordering::Acquire) {
@@ -258,180 +211,6 @@ impl ChatRegistry {
})); }));
} }
/// Get contact list from relays
pub fn get_contact_list(&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);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let id = SubscriptionId::new("contact-list");
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Get user's write relays
let urls = write_relays.await;
// Construct filter for inbox relays
let filter = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe
client.subscribe(target).close_on(opts).with_id(id).await?;
Ok(())
});
self.tasks.push(task);
}
/// Ensure messaging relays are set up for the current user.
pub fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) {
let task = self.verify_relays(cx);
// Set state to checking
self.set_state(InboxState::Checking, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await?;
// Update state
this.update(cx, |this, cx| {
this.set_state(result, cx);
})?;
Ok(())
}));
}
// Verify messaging relay list for current user
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> {
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 Task::ready(Err(anyhow!("User not found")));
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
cx.background_spawn(async move {
let urls = write_relays.await;
// Construct filter for inbox relays
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.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
let mut stream = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await?;
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
return Ok(InboxState::RelayConfigured(Box::new(event)));
}
Err(e) => {
log::error!("Failed to receive relay list event: {e}");
}
}
}
Ok(InboxState::RelayNotAvailable)
})
}
/// Get all messages for current user
fn get_messages<I>(&mut self, relay_urls: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = RelayUrl>,
{
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 client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let urls = urls.into_iter().collect::<Vec<_>>();
cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(USER_GIFTWRAP);
// Ensure relay connections
for url in urls.iter() {
client.add_relay(url).and_connect().await?;
}
// Construct target for subscription
let target: HashMap<RelayUrl, Filter> = urls
.into_iter()
.map(|relay| (relay, filter.clone()))
.collect();
let output = client.subscribe(target).with_id(id).await?;
log::info!(
"Successfully subscribed to gift-wrap messages on: {:?}",
output.success
);
Ok(())
})
}
/// Set the state of the inbox
fn set_state(&mut self, state: InboxState, cx: &mut Context<Self>) {
self.state.update(cx, |this, cx| {
*this = state;
cx.notify();
});
}
/// Get the relay state
pub fn state(&self, cx: &App) -> InboxState {
self.state.read(cx).clone()
}
/// Get the loading status of the chat registry /// Get the loading status of the chat registry
pub fn loading(&self) -> bool { pub fn loading(&self) -> bool {
self.tracking_flag.load(Ordering::Acquire) self.tracking_flag.load(Ordering::Acquire)
@@ -575,21 +354,16 @@ impl ChatRegistry {
pub fn get_rooms(&mut self, cx: &mut Context<Self>) { pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.get_rooms_from_database(cx); let task = self.get_rooms_from_database(cx);
self.tasks.push(cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
match task.await { let rooms = task.await.ok()?;
Ok(rooms) => {
this.update(cx, |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})?;
}
Err(e) => {
log::error!("Failed to load rooms: {}", e);
}
};
Ok(()) this.update(cx, move |this, cx| {
})); this.extend_rooms(rooms, cx);
this.sort(cx);
})
.ok()
})
.detach();
} }
/// Create a task to load rooms from the database /// Create a task to load rooms from the database
@@ -602,11 +376,7 @@ impl ChatRegistry {
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
// Get contacts // Get contacts
let contacts = client let contacts = client.database().contacts_public_keys(public_key).await?;
.database()
.contacts_public_keys(public_key)
.await
.unwrap_or_default();
// Construct authored filter // Construct authored filter
let authored_filter = Filter::new() let authored_filter = Filter::new()
@@ -681,7 +451,6 @@ impl ChatRegistry {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
this.push_message(message, cx); this.push_message(message, cx);
}); });
self.sort(cx);
} }
None => { None => {
// Push the new room to the front of the list // Push the new room to the front of the list
@@ -691,158 +460,158 @@ impl ChatRegistry {
} }
/// Trigger a refresh of the opened chat rooms by their IDs /// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) { pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
for room in self.rooms.iter() { if let Some(ids) = ids {
if ids.contains(&room.read(cx).id) { for room in self.rooms.iter() {
room.update(cx, |this, cx| { if ids.contains(&room.read(cx).id) {
this.emit_refresh(cx); room.update(cx, |this, cx| {
}); this.emit_refresh(cx);
});
}
} }
} }
} }
}
/// 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>>, device_signer: &Option<Arc<dyn NostrSigner>>,
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(event) = Self::get_rumor(client, gift_wrap.id).await {
return Ok(event); return Ok(event);
}
// Try to unwrap with the available signer
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
let mut rumor_unsigned = unwrapped.rumor;
// Generate event id for the rumor if it doesn't have one
rumor_unsigned.ensure_id();
// Cache the rumor
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
Ok(rumor_unsigned)
} }
// Try to unwrap with the available signer /// Helper method to try unwrapping with different signers
let unwrapped = try_unwrap(client, device_signer, gift_wrap).await?; async fn try_unwrap(
let mut rumor = unwrapped.rumor; client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
// Generate event id for the rumor if it doesn't have one gift_wrap: &Event,
rumor.ensure_id(); ) -> Result<UnwrappedGift, Error> {
// Try with the device signer first
// Cache the rumor if let Some(signer) = device_signer {
if let Err(e) = set_rumor(client, gift_wrap.id, &rumor).await { if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await {
log::error!("Failed to cache rumor: {e:?}"); return Ok(unwrapped);
} };
Ok(rumor)
}
/// Helper method to try unwrapping with different signers
async fn try_unwrap(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
// Try with the device signer first
if let Some(signer) = device_signer {
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, signer).await {
return Ok(unwrapped);
}; };
};
// Try with the user's signer // Try with the user's signer
let user_signer = client.signer().context("Signer not found")?; let user_signer = client.signer().context("Signer not found")?;
let unwrapped = try_unwrap_with(gift_wrap, user_signer).await?; let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).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(
gift_wrap: &Event, gift_wrap: &Event,
signer: &Arc<dyn NostrSigner>, signer: &Arc<dyn NostrSigner>,
) -> Result<UnwrappedGift, Error> { ) -> 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)
.await?; .await?;
// Verify the sealed event // Verify the sealed event
let seal: Event = Event::from_json(seal)?; let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?; seal.verify_with_ctx(&SECP256K1)?;
// Get the rumor event // Get the rumor event
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?; let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
let rumor = UnsignedEvent::from_json(rumor)?; let rumor = UnsignedEvent::from_json(rumor)?;
Ok(UnwrappedGift { Ok(UnwrappedGift {
sender: seal.pubkey, sender: seal.pubkey,
rumor, rumor,
}) })
} }
/// Stores an unwrapped event in local database with reference to original /// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor.id.context("Rumor is missing an event id")?; let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey; let author = rumor.pubkey;
let conversation = conversation_id(rumor); let conversation = Self::conversation_id(rumor);
let mut tags = rumor.tags.clone().to_vec(); let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier // Add a unique identifier
tags.push(Tag::identifier(id)); tags.push(Tag::identifier(id));
// Add a reference to the rumor's author // Add a reference to the rumor's author
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[author],
));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation.to_string()],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom( tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[receiver], [author],
)); ));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation.to_string()],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[receiver],
));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(tags)
.sign(&Keys::generate())
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
} }
// Convert rumor to json /// Retrieves a previously unwrapped event from local database
let content = rumor.as_json(); async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
// Construct the event if let Some(event) = client.database().query(filter).await?.first_owned() {
let event = EventBuilder::new(Kind::ApplicationSpecificData, content) UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
.tags(tags) } else {
.sign(&Keys::generate()) Err(anyhow!("Event is not cached yet."))
.await?; }
}
// Save the event to the database /// Get the conversation ID for a given rumor (message).
client.database().save_event(&event).await?; fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
Ok(()) hasher.finish()
}
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
} }
} }
/// Get the conversation ID for a given rumor (message).
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}

View File

@@ -1,7 +1,6 @@
use std::hash::Hash; use std::hash::Hash;
use std::ops::Range;
use common::{EventUtils, NostrParser}; use common::EventUtils;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
/// New message. /// New message.
@@ -92,18 +91,6 @@ impl PartialOrd for Message {
} }
} }
#[derive(Debug, Clone)]
pub struct Mention {
pub public_key: PublicKey,
pub range: Range<usize>,
}
impl Mention {
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
Self { public_key, range }
}
}
/// Rendered message. /// Rendered message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RenderedMessage { pub struct RenderedMessage {
@@ -115,7 +102,7 @@ pub struct RenderedMessage {
/// Message created time as unix timestamp /// Message created time as unix timestamp
pub created_at: Timestamp, pub created_at: Timestamp,
/// List of mentioned public keys in the message /// List of mentioned public keys in the message
pub mentions: Vec<Mention>, pub mentions: Vec<PublicKey>,
/// List of event of the message this message is a reply to /// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>, pub replies_to: Vec<EventId>,
} }
@@ -197,17 +184,20 @@ impl Hash for RenderedMessage {
} }
/// Extracts all mentions (public keys) from a content string. /// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<Mention> { fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new(); let parser = NostrParser::new();
let tokens = parser.parse(content); let tokens = parser.parse(content);
tokens tokens
.filter_map(|token| match token.value { .filter_map(|token| match token {
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)), Token::Nostr(nip21) => match nip21 {
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)), Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None, _ => None,
}) })
.collect() .collect::<Vec<_>>()
} }
/// Extracts all reply (ids) from the event tags. /// Extracts all reply (ids) from the event tags.

View File

@@ -1,26 +1,21 @@
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::{Context as AnyhowContext, Error};
use common::EventUtils; use common::EventUtils;
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::{ChatRegistry, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SendReport { pub struct SendReport {
pub receiver: PublicKey, pub receiver: PublicKey,
pub gift_wrap_id: Option<EventId>,
pub error: Option<SharedString>, pub error: Option<SharedString>,
pub output: Option<Output<EventId>>, pub output: Option<Output<EventId>>,
} }
@@ -29,18 +24,12 @@ impl SendReport {
pub fn new(receiver: PublicKey) -> Self { pub fn new(receiver: PublicKey) -> Self {
Self { Self {
receiver, receiver,
gift_wrap_id: None,
error: None, error: None,
output: None, output: None,
} }
} }
/// Set the gift wrap ID.
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
self.gift_wrap_id = Some(gift_wrap_id);
self
}
/// Set the output. /// Set the output.
pub fn output(mut self, output: Output<EventId>) -> Self { pub fn output(mut self, output: Output<EventId>) -> Self {
self.output = Some(output); self.output = Some(output);
@@ -64,7 +53,7 @@ impl SendReport {
/// 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() { if let Some(output) = self.output.as_ref() {
!output.failed.is_empty() !output.success.is_empty()
} else { } else {
false false
} }
@@ -153,7 +142,7 @@ impl From<&UnsignedEvent> for Room {
subject, subject,
members, members,
kind: RoomKind::default(), kind: RoomKind::default(),
config: RoomConfig::new(), config: RoomConfig::default(),
} }
} }
} }
@@ -226,23 +215,6 @@ impl Room {
cx.notify(); cx.notify();
} }
/// Updates the signer kind config for the room
pub fn set_signer_kind(&mut self, kind: &SignerKind, cx: &mut Context<Self>) {
self.config.set_signer_kind(kind);
cx.notify();
}
/// Updates the backup config for the room
pub fn set_backup(&mut self, cx: &mut Context<Self>) {
self.config.toggle_backup();
cx.notify();
}
/// Returns the config of the room
pub fn config(&self) -> &RoomConfig {
&self.config
}
/// Returns the members of the room /// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> { pub fn members(&self) -> Vec<PublicKey> {
self.members.clone() self.members.clone()
@@ -317,6 +289,10 @@ impl Room {
if new_message { if new_message {
self.set_created_at(created_at, cx); self.set_created_at(created_at, cx);
// Sort chats after emitting a new message
ChatRegistry::global(cx).update(cx, |this, cx| {
this.sort(cx);
});
} }
} }
@@ -326,49 +302,45 @@ impl Room {
} }
/// Get gossip relays for each member /// Get gossip relays for each member
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> { pub fn early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let members = self.members();
let sender = signer.public_key(); let subscription_id = SubscriptionId::new(format!("room-{}", self.id));
// 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 signer = client.signer().context("Signer not found")?;
let opts = SubscribeAutoCloseOptions::default() let public_key = signer.get_public_key().await?;
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct filters for each member for member in members.into_iter() {
let filters: Vec<Filter> = members if member == public_key {
.into_iter() continue;
.map(|public_key| { };
Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)
})
.collect();
// Construct target for subscription // Construct a filter for messaging relays
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS let inbox = Filter::new()
.into_iter() .kind(Kind::InboxRelays)
.map(|relay| (relay, filters.clone())) .author(member)
.collect(); .limit(1);
// Subscribe to the target // Construct a filter for announcement
client.subscribe(target).close_on(opts).with_id(id).await?; let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to get member's gossip relays
client
.subscribe(vec![inbox, announcement])
.with_id(subscription_id.clone())
.close_on(
SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE),
)
.await?;
}
Ok(()) Ok(())
}) })
@@ -414,7 +386,7 @@ impl Room {
// Get current user's public key // Get current user's public key
let sender = nostr.read(cx).signer().public_key()?; let sender = nostr.read(cx).signer().public_key()?;
// Get all members, excluding the sender // Get all members
let members: Vec<Person> = self let members: Vec<Person> = self
.members .members
.iter() .iter()
@@ -439,6 +411,11 @@ impl Room {
// Add all receiver tags // Add all receiver tags
for member in members.into_iter() { for member in members.into_iter() {
// Skip current user
if member.public_key() == sender {
continue;
}
tags.push(Tag::from_standardized_without_cell( tags.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey { TagStandard::PublicKey {
public_key: member.public_key(), public_key: member.public_key(),
@@ -461,59 +438,61 @@ impl Room {
/// Send rumor event to all members's messaging relays /// Send rumor event to all members's messaging relays
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> { pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
let config = self.config.clone();
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
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();
// Get room's config
let config = self.config.clone();
// Get current user's public key // Get current user's public key
let public_key = nostr.read(cx).signer().public_key()?; let sender = nostr.read(cx).signer().public_key()?;
let sender = persons.read(cx).get(&public_key, cx);
// Get all members (excluding sender) // Get all members (excluding sender)
let members: Vec<Person> = self let members: Vec<Person> = self
.members .members
.iter() .iter()
.filter(|public_key| public_key != &&sender.public_key()) .filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx)) .map(|member| persons.read(cx).get(member, cx))
.collect(); .collect();
Some(cx.background_spawn(async move { Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind(); let signer_kind = config.signer_kind();
let backup = config.backup();
let user_signer = signer.get().await; let user_signer = signer.get().await;
let encryption_signer = signer.get_encryption_signer().await; let encryption_signer = signer.get_encryption_signer().await;
let mut sents = 0;
let mut reports = Vec::new(); let mut reports = Vec::new();
// Process each member
for member in members { for member in members {
let relays = member.messaging_relays(); let relays = member.messaging_relays();
let announcement = member.announcement(); let announcement = member.announcement();
let public_key = member.public_key();
// Skip if member has no messaging relays
if relays.is_empty() { if relays.is_empty() {
reports.push(SendReport::new(public_key).error("No messaging relays")); reports.push(SendReport::new(member.public_key()).error("No messaging relays"));
continue; continue;
} }
// Handle encryption signer requirements // Ensure relay connections
if signer_kind.encryption() { for url in relays.iter() {
if announcement.is_none() { client
reports.push(SendReport::new(public_key).error(NO_DEKEY)); .add_relay(url)
continue; .and_connect()
} .capabilities(RelayCapabilities::GOSSIP)
if encryption_signer.is_none() { .await
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY)); .ok();
continue;
}
} }
// Determine receiver and signer // When forced to use encryption signer, skip if receiver has no announcement
let (receiver, signer) = match signer_kind { if signer_kind.encryption() && announcement.is_none() {
reports
.push(SendReport::new(member.public_key()).error("Encryption not found"));
continue;
}
// Determine receiver and signer based on signer kind
let (receiver, signer_to_use) = match signer_kind {
SignerKind::Auto => { SignerKind::Auto => {
if let Some(announcement) = announcement { if let Some(announcement) = announcement {
if let Some(enc_signer) = encryption_signer.as_ref() { if let Some(enc_signer) = encryption_signer.as_ref() {
@@ -526,79 +505,268 @@ impl Room {
} }
} }
SignerKind::Encryption => { SignerKind::Encryption => {
// Safe to unwrap due to earlier checks let Some(encryption_signer) = encryption_signer.as_ref() else {
( reports.push(
announcement.unwrap().public_key(), SendReport::new(member.public_key()).error("Encryption not found"),
encryption_signer.as_ref().unwrap().clone(), );
) continue;
};
let Some(announcement) = announcement else {
reports.push(
SendReport::new(member.public_key())
.error("Announcement not found"),
);
continue;
};
(announcement.public_key(), encryption_signer.clone())
} }
SignerKind::User => (member.public_key(), user_signer.clone()), SignerKind::User => (member.public_key(), user_signer.clone()),
}; };
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await // Create and send gift-wrapped event
{ match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await {
Ok((report, _)) => { Ok(event) => {
reports.push(report); match client
sents += 1; .send_event(&event)
.to(relays)
.ack_policy(AckPolicy::none())
.await
{
Ok(output) => {
reports.push(SendReport::new(member.public_key()).output(output));
}
Err(e) => {
reports.push(
SendReport::new(member.public_key()).error(e.to_string()),
);
}
}
} }
Err(report) => { Err(e) => {
reports.push(report); reports.push(SendReport::new(member.public_key()).error(e.to_string()));
} }
} }
} }
// Send backup to current user if needed
if backup && sents >= 1 {
let relays = sender.messaging_relays();
let public_key = sender.public_key();
let signer = encryption_signer.as_ref().unwrap_or(&user_signer);
match send_gift_wrap(&client, signer, &public_key, &rumor, relays, public_key).await
{
Ok((report, _)) => reports.push(report),
Err(report) => reports.push(report),
}
}
reports reports
})) }))
} }
}
// Helper function to send a gift-wrapped event /*
async fn send_gift_wrap<T>( * /// Create a new unsigned message event
client: &Client, pub fn create_message(
signer: &T, &self,
receiver: &PublicKey, content: &str,
rumor: &UnsignedEvent, replies: Vec<EventId>,
relays: &[RelayUrl], cx: &App,
public_key: PublicKey, ) -> Task<Result<UnsignedEvent, Error>> {
) -> Result<(SendReport, bool), SendReport> let nostr = NostrRegistry::global(cx);
where let client = nostr.read(cx).client();
T: NostrSigner + 'static,
{
// Ensure relay connections
for url in relays {
client.add_relay(url).and_connect().await.ok();
}
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await { let subject = self.subject.clone();
Ok(event) => { let content = content.to_string();
match client
.send_event(&event) let mut member_and_relay_hints = HashMap::new();
.to(relays)
.ack_policy(AckPolicy::none()) // Populate the hashmap with member and relay hint tasks
.await for member in self.members.iter() {
{ let hint = nostr.read(cx).relay_hint(member, cx);
Ok(output) => Ok(( member_and_relay_hints.insert(member.to_owned(), hint);
SendReport::new(public_key)
.gift_wrap_id(event.id)
.output(output),
true,
)),
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
}
} }
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// List of event tags for each receiver
let mut tags = vec![];
for (member, task) in member_and_relay_hints.into_iter() {
// Skip current user
if member == public_key {
continue;
}
// Get relay hint if available
let relay_url = task.await;
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member,
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
}
// Add subject tag if present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add all reply tags
for id in replies {
tags.push(Tag::event(id))
}
// Construct a direct message event
//
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tags(tags)
.build(public_key);
// Ensure the event ID has been generated
event.ensure_id();
Ok(event)
})
} }
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: &UnsignedEvent,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut members = self.members();
let rumor = rumor.to_owned();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let current_user = signer.get_public_key().await?;
// Remove the current user's public key from the list of receivers
// the current user will be handled separately
members.retain(|this| this != &current_user);
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for receiver in members.into_iter() {
// Construct the gift wrap event
let event =
EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
// Send the gift wrap event to the messaging relays
match client.send_event(&event).to_nip17().await {
Ok(output) => {
let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
let tracker = tracker().read().await;
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
// Check if event was successfully resent
if tracker.is_sent_by_coop(&id) {
let output = Output::new(id);
let report = SendReport::new(receiver).status(output);
reports.push(report);
break;
}
// Check if retry limit exceeded
if attempt == SEND_RETRY {
reports.push(report);
break;
}
smol::Timer::after(Duration::from_millis(1200)).await;
}
} else {
reports.push(report);
}
}
Err(e) => {
reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
// Construct the gift-wrapped event
let event =
EventBuilder::gift_wrap(signer, &current_user, rumor.clone(), vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) {
// Send the event to the messaging relays
match client.send_event(&event).to_nip17().await {
Ok(output) => {
reports.push(SendReport::new(current_user).status(output));
}
Err(e) => {
reports.push(SendReport::new(current_user).error(e.to_string()));
}
}
} else {
reports.push(SendReport::new(current_user).on_hold(event));
}
Ok(reports)
})
}
/// Create a task to resend a failed message
pub fn resend_message(
&self,
reports: Vec<SendReport>,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let mut resend_reports = vec![];
for report in reports.into_iter() {
let receiver = report.receiver;
// Process failed events
if let Some(output) = report.status {
let id = output.id();
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
if let Some(event) = client.database().event_by_id(id).await? {
for url in urls.into_iter() {
let relay = client.relay(url).await?.context("Relay not found")?;
let id = relay.send_event(&event).await?;
let resent: Output<EventId> = Output {
val: id,
success: HashSet::from([url.to_owned()]),
failed: HashMap::new(),
};
resend_reports.push(SendReport::new(receiver).status(resent));
}
}
}
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
// Send the event to the messaging relays
match client.send_event(&event).await {
Ok(output) => {
resend_reports.push(SendReport::new(receiver).status(output));
}
Err(e) => {
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
}
Ok(resend_reports)
})
}
*/
} }

View File

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

View File

@@ -1,15 +1,12 @@
use gpui::Action; use gpui::Action;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use settings::SignerKind;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[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(&'static str),
ChangeSigner(SignerKind),
ToggleBackup,
} }
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]

View File

@@ -1,38 +1,39 @@
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::{Message, RenderedMessage, Room, RoomEvent, SendReport};
use common::RenderedTimestamp; use common::{nip96_upload, RenderedTimestamp};
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, deferred, div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
relative, svg, white, Subscription, Task, WeakEntity, Window,
}; };
use gpui_tokio::Tokio;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry}; use person::{Person, PersonRegistry};
use settings::{AppSettings, SignerKind}; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{smallvec, SmallVec};
use smol::fs;
use smol::lock::RwLock; use smol::lock::RwLock;
use state::{NostrRegistry, upload}; 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::context_menu::ContextMenuExt;
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::menu::{ContextMenuExt, DropdownMenu};
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::popup_menu::PopupMenuExt;
use ui::{ use ui::{
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
h_flex, v_flex, WindowExtension,
}; };
use crate::text::RenderedText; use crate::text::RenderedText;
@@ -40,14 +41,6 @@ use crate::text::RenderedText;
mod actions; mod actions;
mod text; mod text;
const ANNOUNCEMENT: &str =
"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))
} }
@@ -70,14 +63,11 @@ pub struct ChatPanel {
rendered_texts_by_id: BTreeMap<EventId, RenderedText>, rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
/// 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: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
/// Input state /// Input state
input: Entity<InputState>, input: Entity<InputState>,
/// Sent message ids
sent_ids: Arc<RwLock<Vec<EventId>>>,
/// Replies to /// Replies to
replies_to: Entity<HashSet<EventId>>, replies_to: Entity<HashSet<EventId>>,
@@ -99,7 +89,6 @@ impl ChatPanel {
// Define attachments and replies_to entities // Define attachments and replies_to entities
let attachments = cx.new(|_| vec![]); let attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new()); let replies_to = cx.new(|_| HashSet::new());
let reports_by_id = cx.new(|_| BTreeMap::new());
// Define list of messages // Define list of messages
let messages = BTreeSet::from([Message::system()]); let messages = BTreeSet::from([Message::system()]);
@@ -136,9 +125,9 @@ impl ChatPanel {
// 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.subscribe_room_events(window, cx);
this.connect(window, cx); this.connect(window, cx);
this.handle_notifications(cx); this.handle_notifications(cx);
this.subscribe_room_events(window, cx);
this.get_messages(window, cx); this.get_messages(window, cx);
}); });
@@ -152,64 +141,18 @@ impl ChatPanel {
replies_to, replies_to,
attachments, attachments,
rendered_texts_by_id: BTreeMap::new(), rendered_texts_by_id: BTreeMap::new(),
reports_by_id, reports_by_id: Arc::new(RwLock::new(BTreeMap::new())),
sent_ids: Arc::new(RwLock::new(Vec::new())),
uploading: false, uploading: false,
subscriptions, subscriptions,
tasks: vec![], tasks: vec![],
} }
} }
/// Get all necessary data for each member
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok((members, connect)) = self
.room
.read_with(cx, |this, cx| (this.members(), this.connect(cx)))
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
fn handle_notifications(&mut self, cx: &mut Context<Self>) { fn handle_notifications(&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 sent_ids = self.sent_ids.clone(); let reports = self.reports_by_id.clone();
let (tx, rx) = flume::bounded::<(EventId, RelayUrl)>(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();
@@ -220,33 +163,18 @@ impl ChatPanel {
relay_url, relay_url,
} = notification } = notification
{ {
let sent_ids = sent_ids.read().await; let mut writer = reports.write().await;
if sent_ids.contains(&event_id) { for reports in writer.values_mut() {
tx.send_async((event_id, relay_url)).await.ok(); for report in reports.iter_mut() {
} if let Some(output) = report.output.as_mut() {
} if output.id() == &event_id {
} output.success.insert(relay_url.clone());
Ok(())
}));
self.tasks.push(cx.spawn(async move |this, cx| {
while let Ok((event_id, relay_url)) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.reports_by_id.update(cx, |this, cx| {
for reports in this.values_mut() {
for report in reports.iter_mut() {
if let Some(output) = report.output.as_mut() {
if output.id() == &event_id {
output.success.insert(relay_url.clone());
cx.notify();
}
} }
} }
} }
}); }
})?; }
} }
Ok(()) Ok(())
@@ -273,6 +201,15 @@ impl ChatPanel {
); );
} }
/// Get all necessary data for each member
fn connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(connect) = self.room.read_with(cx, |this, cx| this.early_connect(cx)) else {
return;
};
self.tasks.push(cx.background_spawn(connect));
}
/// Load all messages belonging to this room /// Load all messages belonging to this room
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) { fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
@@ -365,29 +302,21 @@ 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();
// 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();
// Upgrade room reference
let Some(room) = self.room.upgrade() else { let Some(room) = self.room.upgrade() else {
return; return;
}; };
// Get the send message task
let Some(task) = room.read(cx).send(rumor, cx) else { let Some(task) = room.read(cx).send(rumor, cx) else {
window.push_notification("Failed to send message", cx); window.push_notification("Failed to send message", cx);
return; return;
}; };
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Send and get reports
let outputs = task.await; let outputs = task.await;
// Add sent IDs to the list
let mut sent_ids = sent_ids.write().await;
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
// Update the state // Update the state
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.insert_reports(id, outputs, cx); this.insert_reports(id, outputs, cx);
@@ -414,12 +343,10 @@ impl ChatPanel {
}) })
} }
/// Insert reports /// Synchronously 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.write_blocking().insert(id, reports);
this.insert(id, reports); cx.notify();
cx.notify();
});
} }
/// Insert a message into the chat panel /// Insert a message into the chat panel
@@ -453,32 +380,24 @@ impl ChatPanel {
} }
/// Check if a message is pending /// Check if a message is pending
fn sent_pending(&self, id: &EventId, cx: &App) -> bool { fn sent_pending(&self, id: &EventId) -> bool {
self.reports_by_id self.reports_by_id
.read(cx) .read_blocking()
.get(id) .get(id)
.is_some_and(|reports| reports.iter().any(|r| r.pending())) .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 sent successfully by its ID
fn sent_success(&self, id: &EventId, cx: &App) -> bool { fn sent_success(&self, id: &EventId) -> bool {
self.reports_by_id self.reports_by_id
.read(cx) .read_blocking()
.get(id) .get(id)
.is_some_and(|reports| reports.iter().any(|r| r.success())) .is_some_and(|reports| reports.iter().all(|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
fn sent_reports(&self, id: &EventId, cx: &App) -> Option<Vec<SendReport>> { fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
self.reports_by_id.read(cx).get(id).cloned() self.reports_by_id.read_blocking().get(id).cloned()
} }
/// Get a message by its ID /// Get a message by its ID
@@ -528,10 +447,12 @@ impl ChatPanel {
} }
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the user's configured blossom server let nostr = NostrRegistry::global(cx);
let server = AppSettings::get_file_server(cx); let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_file_server(cx);
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions { let path = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
directories: false, directories: false,
@@ -540,27 +461,34 @@ impl ChatPanel {
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
let mut paths = path.await??.context("Not found")?; let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?; let path = paths.pop().context("No path")?;
// Upload via blossom client let upload = Tokio::spawn(cx, async move {
match upload(server, path, cx).await { let file = fs::read(path).await.ok()?;
Ok(url) => { let url = nip96_upload(&client, &nip96_server, file).await.ok()?;
this.update_in(cx, |this, _window, cx| {
this.add_attachment(url, cx); Some(url)
this.set_uploading(false, cx); });
})?;
} if let Ok(task) = upload.await {
Err(e) => { this.update(cx, |this, cx| {
this.update_in(cx, |this, window, cx| { this.set_uploading(true, cx);
this.set_uploading(false, cx); })
window.push_notification(Notification::error(e.to_string()), cx); .ok();
})?;
} this.update_in(cx, |this, _window, cx| {
match task {
Some(url) => {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
}
None => {
this.set_uploading(false, cx);
}
};
})
.ok();
} }
Ok(()) Ok(())
@@ -593,48 +521,10 @@ impl ChatPanel {
persons.read(cx).get(public_key, cx) persons.read(cx).get(public_key, cx)
} }
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
self.send_message(content, window, cx);
}
Command::ChangeSubject(subject) => {
if self
.room
.update(cx, |this, cx| {
this.set_subject(*subject, cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to change subject"), cx);
}
}
Command::ChangeSigner(kind) => {
if self
.room
.update(cx, |this, cx| {
this.set_signer_kind(kind, cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to change signer"), cx);
}
}
Command::ToggleBackup => {
if self
.room
.update(cx, |this, cx| {
this.set_backup(cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to toggle backup"), cx);
}
}
}
}
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement { fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
const MSG: &str =
"This conversation is private. Only members can see each other's messages.";
v_flex() v_flex()
.id(ix) .id(ix)
.h_40() .h_40()
@@ -653,39 +543,34 @@ impl ChatPanel {
.size_12() .size_12()
.text_color(cx.theme().ghost_element_active), .text_color(cx.theme().ghost_element_active),
) )
.child(SharedString::from(ANNOUNCEMENT)) .child(SharedString::from(MSG))
.into_any_element() .into_any_element()
} }
fn render_warning(&self, ix: usize, content: SharedString, cx: &Context<Self>) -> AnyElement { fn render_warning(&self, ix: usize, content: SharedString, cx: &Context<Self>) -> AnyElement {
div() div()
.id(ix) .id(ix)
.relative()
.w_full() .w_full()
.py_2() .py_1()
.px_3() .px_3()
.bg(cx.theme().warning_background)
.child( .child(
h_flex() h_flex()
.w_full()
.gap_3() .gap_3()
.text_sm() .text_sm()
.child( .text_color(cx.theme().warning_foreground)
h_flex() .child(Avatar::new("brand/system.png").size(rems(2.)))
.flex_shrink_0() .child(content),
.size_8() )
.justify_center() .child(
.rounded_full() div()
.bg(cx.theme().warning_background) .absolute()
.text_color(cx.theme().warning_foreground) .left_0()
.child(Icon::new(IconName::Warning).small()), .top_0()
) .w(px(2.))
.child( .h_full()
div() .bg(cx.theme().warning_active),
.flex_1()
.w_full()
.flex_initial()
.overflow_hidden()
.child(content),
),
) )
.into_any_element() .into_any_element()
} }
@@ -699,13 +584,10 @@ impl ChatPanel {
if let Some(message) = self.messages.iter().nth(ix) { if let Some(message) = self.messages.iter().nth(ix) {
match message { match message {
Message::User(rendered) => { Message::User(rendered) => {
let persons = PersonRegistry::global(cx);
let text = self let text = self
.rendered_texts_by_id .rendered_texts_by_id
.entry(rendered.id) .entry(rendered.id)
.or_insert_with(|| { .or_insert_with(|| RenderedText::new(&rendered.content, cx))
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
})
.element(ix.into(), window, cx); .element(ix.into(), window, cx);
self.render_text_message(ix, rendered, text, cx) self.render_text_message(ix, rendered, text, cx)
@@ -735,13 +617,10 @@ impl ChatPanel {
let has_replies = !replies.is_empty(); let has_replies = !replies.is_empty();
// Check if message is sent failed // Check if message is sent failed
let sent_pending = self.sent_pending(&id, cx); let sent_pending = self.sent_pending(&id);
// Check if message is sent successfully // Check if message is sent successfully
let sent_success = self.sent_success(&id, cx); let sent_success = self.sent_success(&id);
// 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);
@@ -761,7 +640,7 @@ impl ChatPanel {
this.child( this.child(
div() div()
.id(SharedString::from(format!("{ix}-avatar"))) .id(SharedString::from(format!("{ix}-avatar")))
.child(Avatar::new(author.avatar())) .child(Avatar::new(author.avatar()).size(rems(2.)))
.context_menu(move |this, _window, _cx| { .context_menu(move |this, _window, _cx| {
let view = Box::new(OpenPublicKey(public_key)); let view = Box::new(OpenPublicKey(public_key));
let copy = Box::new(CopyPublicKey(public_key)); let copy = Box::new(CopyPublicKey(public_key));
@@ -800,10 +679,8 @@ impl ChatPanel {
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| { .when(!sent_success, |this| {
this.when(failed, |this| { this.child(deferred(self.render_message_reports(&id, cx)))
this.child(deferred(self.render_message_reports(&id, cx)))
})
}), }),
), ),
) )
@@ -868,18 +745,18 @@ impl ChatPanel {
items items
} }
fn render_sent_indicator(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement { fn render_sent_indicator(&self, id: &EventId, _cx: &Context<Self>) -> impl IntoElement {
div() div()
.id(SharedString::from(id.to_hex())) .id(SharedString::from(id.to_hex()))
.child(SharedString::from("• Sent")) .child(SharedString::from("• Sent"))
.when_some(self.sent_reports(id, cx), |this, reports| { .when_some(self.sent_reports(id), |this, reports| {
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.show_close(true)
.title(SharedString::from("Sent Reports")) .title(SharedString::from("Sent Reports"))
.child(v_flex().pb_2().gap_4().children({ .child(v_flex().pb_4().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() {
@@ -897,14 +774,14 @@ impl ChatPanel {
h_flex() h_flex()
.id(SharedString::from(id.to_hex())) .id(SharedString::from(id.to_hex()))
.gap_0p5() .gap_0p5()
.text_color(cx.theme().danger_active) .text_color(cx.theme().danger_foreground)
.text_xs() .text_xs()
.italic() .italic()
.child(Icon::new(IconName::Info).xsmall()) .child(Icon::new(IconName::Info).xsmall())
.child(SharedString::from( .child(SharedString::from(
"Failed to send message. Click to see details.", "Failed to send message. Click to see details.",
)) ))
.when_some(self.sent_reports(id, cx), |this, reports| { .when_some(self.sent_reports(id), |this, reports| {
this.on_click(move |_e, window, cx| { this.on_click(move |_e, window, cx| {
let reports = reports.clone(); let reports = reports.clone();
@@ -943,7 +820,7 @@ impl ChatPanel {
h_flex() h_flex()
.gap_1() .gap_1()
.font_semibold() .font_semibold()
.child(Avatar::new(avatar).small()) .child(Avatar::new(avatar).size(rems(1.25)))
.child(name.clone()), .child(name.clone()),
), ),
) )
@@ -952,13 +829,13 @@ impl ChatPanel {
h_flex() h_flex()
.flex_wrap() .flex_wrap()
.justify_center() .justify_center()
.p_1() .p_2()
.h_16() .h_20()
.w_full() .w_full()
.text_sm() .text_sm()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.bg(cx.theme().warning_background) .bg(cx.theme().danger_background)
.text_color(cx.theme().warning_foreground) .text_color(cx.theme().danger_foreground)
.child(div().flex_1().w_full().text_center().child(error)), .child(div().flex_1().w_full().text_center().child(error)),
) )
}) })
@@ -974,10 +851,11 @@ impl ChatPanel {
items.push( items.push(
v_flex() v_flex()
.gap_0p5() .gap_0p5()
.p_1() .py_1()
.px_2()
.w_full() .w_full()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.bg(cx.theme().danger_background) .bg(cx.theme().elevated_surface_background)
.child( .child(
div() div()
.text_xs() .text_xs()
@@ -987,7 +865,7 @@ impl ChatPanel {
) )
.child( .child(
div() div()
.text_xs() .text_sm()
.text_color(cx.theme().danger_foreground) .text_color(cx.theme().danger_foreground)
.line_height(relative(1.25)) .line_height(relative(1.25))
.child(SharedString::from(msg.to_string())), .child(SharedString::from(msg.to_string())),
@@ -1004,7 +882,8 @@ impl ChatPanel {
items.push( items.push(
v_flex() v_flex()
.gap_0p5() .gap_0p5()
.p_1() .py_1()
.px_2()
.w_full() .w_full()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background) .bg(cx.theme().elevated_surface_background)
@@ -1017,7 +896,8 @@ impl ChatPanel {
) )
.child( .child(
div() div()
.text_xs() .text_sm()
.text_color(cx.theme().secondary_foreground)
.line_height(relative(1.25)) .line_height(relative(1.25))
.child(SharedString::from("Successfully")), .child(SharedString::from("Successfully")),
), ),
@@ -1086,9 +966,9 @@ impl ChatPanel {
.icon(IconName::Ellipsis) .icon(IconName::Ellipsis)
.small() .small()
.ghost() .ghost()
.dropdown_menu({ .popup_menu({
let id = id.to_owned(); let id = id.to_owned();
move |this, _window, _cx| this.menu("Seen on", Box::new(SeenOn(id))) move |this, _, _| this.menu("Seen on", Box::new(SeenOn(id)))
}), }),
) )
.group_hover("", |this| this.visible()) .group_hover("", |this| this.visible())
@@ -1210,66 +1090,23 @@ impl ChatPanel {
items items
} }
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
let (backup, signer_kind) = self match command {
.room Command::Insert(content) => {
.read_with(cx, |this, _cx| { self.send_message(content, window, cx);
(this.config().backup(), this.config().signer_kind().clone()) }
}) Command::ChangeSubject(subject) => {
.ok() if self
.unwrap_or((true, SignerKind::default())); .room
.update(cx, |this, cx| {
Button::new("encryption") this.set_subject(*subject, cx);
.icon(IconName::Settings2) })
.tooltip("Configuration") .is_err()
.ghost() {
.large() window.push_notification(Notification::error("Failed to change subject"), cx);
.dropdown_menu(move |this, _window, _cx| { }
let auto = matches!(signer_kind, SignerKind::Auto); }
let encryption = matches!(signer_kind, SignerKind::Encryption); }
let user = matches!(signer_kind, SignerKind::User);
this.label("Signer")
.menu_with_check_and_disabled(
"Auto",
auto,
Box::new(Command::ChangeSigner(SignerKind::Auto)),
auto,
)
.menu_with_check_and_disabled(
"Decoupled Encryption Key",
encryption,
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
encryption,
)
.menu_with_check_and_disabled(
"User Identity",
user,
Box::new(Command::ChangeSigner(SignerKind::User)),
user,
)
.separator()
.label("Backup")
.menu_with_check("Backup messages", backup, Box::new(Command::ToggleBackup))
})
}
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| {
this.horizontal()
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
.menu("🎉", Box::new(Command::Insert("🎉")))
.menu("😕", Box::new(Command::Insert("😕")))
.menu("❤️", Box::new(Command::Insert("❤️")))
.menu("🚀", Box::new(Command::Insert("🚀")))
.menu("👀", Box::new(Command::Insert("👀")))
})
} }
} }
@@ -1286,7 +1123,7 @@ impl Panel for ChatPanel {
h_flex() h_flex()
.gap_1p5() .gap_1p5()
.child(Avatar::new(url).small()) .child(Avatar::new(url).size(rems(1.25)))
.child(label) .child(label)
.into_any_element() .into_any_element()
}) })
@@ -1308,19 +1145,14 @@ impl Render for ChatPanel {
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.size_full() .size_full()
.child( .child(
v_flex() list(
.flex_1() self.list_state.clone(),
.relative() cx.processor(|this, ix, window, cx| {
.child( // Get and render message by index
list( this.render_message(ix, window, cx)
self.list_state.clone(), }),
cx.processor(move |this, ix, window, cx| { )
this.render_message(ix, window, cx) .flex_1(),
}),
)
.size_full(),
)
.child(Scrollbar::vertical(&self.list_state)),
) )
.child( .child(
v_flex() v_flex()
@@ -1348,15 +1180,33 @@ impl Render for ChatPanel {
.child( .child(
TextInput::new(&self.input) TextInput::new(&self.input)
.appearance(false) .appearance(false)
.text_sm() .flex_1()
.flex_1(), .text_sm(),
) )
.child( .child(
h_flex() h_flex()
.pl_1() .pl_1()
.gap_1() .gap_1()
.child(self.render_emoji_menu(window, cx)) .child(
.child(self.render_config_menu(window, cx)) Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.large()
.popup_menu_with_anchor(
gpui::Corner::BottomLeft,
move |this, _window, _cx| {
this.axis(gpui::Axis::Horizontal)
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
.menu("🎉", Box::new(Command::Insert("🎉")))
.menu("😕", Box::new(Command::Insert("😕")))
.menu("❤️", Box::new(Command::Insert("❤️")))
.menu("🚀", Box::new(Command::Insert("🚀")))
.menu("👀", Box::new(Command::Insert("👀")))
},
),
)
.child( .child(
Button::new("send") Button::new("send")
.icon(IconName::PaperPlaneFill) .icon(IconName::PaperPlaneFill)

View File

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

View File

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

View File

@@ -94,3 +94,13 @@ impl<T: AsRef<str>> TextUtils for T {
))) )))
} }
} }
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ 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 = "0.3.0"
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"]
@@ -29,7 +29,8 @@ icons = [
[dependencies] [dependencies]
assets = { path = "../assets" } assets = { path = "../assets" }
ui = { path = "../ui" } ui = { path = "../ui" }
title_bar = { path = "../title_bar" } titlebar = { path = "../titlebar" }
dock = { path = "../dock" }
theme = { path = "../theme" } theme = { path = "../theme" }
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } state = { path = "../state" }
@@ -42,10 +43,6 @@ person = { path = "../person" }
relay_auth = { path = "../relay_auth" } relay_auth = { path = "../relay_auth" }
gpui.workspace = true gpui.workspace = true
gpui_platform.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
gpui_tokio.workspace = true gpui_tokio.workspace = true
reqwest_client.workspace = true reqwest_client.workspace = true
@@ -61,11 +58,6 @@ smallvec.workspace = true
smol.workspace = true smol.workspace = true
futures.workspace = true futures.workspace = true
oneshot.workspace = true oneshot.workspace = true
webbrowser.workspace = true
indexset = "0.12.3" indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
core-text = "=21.0.0"

View File

@@ -1,256 +0,0 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension};
use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
cx.new(|cx| AccountSelector::new(window, cx))
}
/// Account selector
pub struct AccountSelector {
/// Public key currently being chosen for login
logging_in: Entity<Option<PublicKey>>,
/// The error message displayed when an error occurs.
error: Entity<Option<SharedString>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Subscription to the signer events
_subscription: Option<Subscription>,
}
impl AccountSelector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let logging_in = cx.new(|_| None);
let error = cx.new(|_| None);
// Subscribe to the signer events
let nostr = NostrRegistry::global(cx);
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event {
SignerEvent::Set => {
window.close_all_modals(cx);
window.refresh();
}
SignerEvent::Error(e) => {
this.set_error(e.to_string(), cx);
}
};
});
Self {
logging_in,
error,
tasks: vec![],
_subscription: Some(subscription),
}
}
fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool {
self.logging_in.read(cx) == &Some(*public_key)
}
fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.logging_in.update(cx, |this, cx| {
*this = Some(public_key);
cx.notify();
});
}
fn set_error<T>(&mut self, error: T, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(error.into());
cx.notify();
});
self.logging_in.update(cx, |this, cx| {
*this = None;
cx.notify();
})
}
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_signer(&public_key, cx);
// Mark the public key as being logged in
self.set_logging_in(public_key, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(signer) => {
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
};
Ok(())
}));
}
fn remove(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.remove_signer(&public_key, cx);
});
}
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportKey::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Import a Secret Key or Bunker Connection")
.show_close(true)
.pb_2()
.child(import.clone())
});
}
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Scan QR Code to Connect")
.show_close(true)
.pb_2()
.child(connect.clone())
});
}
}
impl Render for AccountSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
let loading = self.logging_in.read(cx).is_some();
v_flex()
.size_full()
.gap_2()
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.children({
let mut items = vec![];
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
let logging_in = self.logging_in(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_10()
.justify_between()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).small())
.child(div().text_sm().child(profile.name())),
)
.when(logging_in, |this| this.child(Indicator::new().small()))
.when(!logging_in, |this| {
this.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("del-{ix}"))
.icon(IconName::Close)
.ghost()
.small()
.disabled(logging_in)
.on_click(cx.listener({
let public_key = *public_key;
move |this, _ev, _window, cx| {
cx.stop_propagation();
this.remove(public_key, cx);
}
})),
),
)
})
.when(!logging_in, |this| {
let public_key = *public_key;
this.on_click(cx.listener(move |this, _ev, window, cx| {
this.login(public_key, window, cx);
}))
}),
);
}
items
})
.child(div().w_full().h_px().bg(cx.theme().border_variant))
.child(
h_flex()
.gap_1()
.justify_end()
.w_full()
.child(
Button::new("input")
.icon(Icon::new(IconName::Usb))
.label("Import")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_import(window, cx);
})),
)
.child(
Button::new("qr")
.icon(Icon::new(IconName::Scan))
.label("Scan QR to connect")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_connect(window, cx);
})),
),
)
}
}

View File

@@ -1,115 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use common::TextUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use state::{
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
NOSTR_CONNECT_TIMEOUT,
};
use theme::ActiveTheme;
use ui::v_flex;
pub struct ConnectSigner {
/// QR Code
qr_code: Option<Arc<Image>>,
/// Error message
error: Entity<Option<SharedString>>,
/// Subscription to the signer event
_subscription: Option<Subscription>,
}
impl ConnectSigner {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let error = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Subscribe to the signer event
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
});
Self {
qr_code,
error,
_subscription: Some(subscription),
}
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
}
}
impl Render for ConnectSigner {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
}
}

View File

@@ -1,301 +0,0 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{v_flex, Disableable};
#[derive(Debug)]
pub struct ImportKey {
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently loading
loading: bool,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl ImportKey {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
subscriptions.push(
// Subscribe to the nostr signer event
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
}),
);
Self {
key_input,
pass_input,
error,
countdown,
loading: false,
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.loading {
return;
};
// Prevent duplicate login requests
self.set_loading(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.bunker(&value, window, cx);
return;
}
if value.starts_with("ncryptsec1") {
self.ncryptsec(value, password, window, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
});
} else {
self.set_error("Invalid key", cx);
}
}
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(30);
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Start countdown
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})?;
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})?;
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
Ok(())
}));
}
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
where
S: Into<String>,
{
let nostr = NostrRegistry::global(cx);
let content: String = content.into();
let password: String = pwd.into();
if password.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(keys) => {
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
}
Ok(())
}));
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_loading(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
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(())
}));
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Render for ImportKey {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.p_4()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
},
)
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,242 +0,0 @@
use gpui::http_client::Url;
use gpui::{
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Window,
};
use settings::{AppSettings, AuthMode};
use theme::{ActiveTheme, ThemeMode};
use ui::button::{Button, ButtonVariants};
use ui::group_box::{GroupBox, GroupBoxVariants};
use ui::input::{InputState, TextInput};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification;
use ui::switch::Switch;
use ui::{h_flex, v_flex, IconName, Sizable, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx))
}
pub struct Preferences {
file_input: Entity<InputState>,
}
impl Preferences {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let server = AppSettings::get_file_server(cx);
let file_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(server.to_string())
.placeholder("https://myblossom.com")
});
Self { file_input }
}
fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.file_input.read(cx).value();
match Url::parse(&value) {
Ok(url) => {
AppSettings::update_file_server(url, cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
}
}
}
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str =
"When opening a request, a popup will appear to help you identify the sender.";
const AVATAR: &str =
"Hide all avatar pictures to improve performance and protect your privacy.";
const MODE: &str =
"Choose whether to use the selected light or dark theme, or to follow the OS.";
const AUTH: &str = "Choose the authentication behavior for relays.";
const RESET: &str = "Reset the theme to the default one.";
let screening = AppSettings::get_screening(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let auth_mode = AppSettings::get_auth_mode(cx);
let theme_mode = AppSettings::get_theme_mode(cx);
v_flex()
.gap_4()
.child(
GroupBox::new()
.id("general")
.title("General")
.fill()
.child(
Switch::new("screening")
.label("Screening")
.description(SCREENING)
.checked(screening)
.on_click(move |_, _window, cx| {
AppSettings::update_screening(!screening, cx);
}),
)
.child(
Switch::new("avatar")
.label("Hide user avatar")
.description(AVATAR)
.checked(hide_avatar)
.on_click(move |_, _window, cx| {
AppSettings::update_hide_avatar(!hide_avatar, cx);
}),
)
.child(
h_flex()
.gap_3()
.justify_between()
.child(
v_flex()
.child(
div()
.text_sm()
.child(SharedString::from("Relay authentication")),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(AUTH)),
),
)
.child(
Button::new("auth")
.label(auth_mode.to_string())
.ghost_alt()
.small()
.dropdown_menu(|this, _window, _cx| {
this.min_w(px(256.))
.item(
PopupMenuItem::new("Auto authentication").on_click(
|_ev, _window, cx| {
AppSettings::update_auth_mode(
AuthMode::Auto,
cx,
);
},
),
)
.item(PopupMenuItem::new("Ask every time").on_click(
|_ev, _window, cx| {
AppSettings::update_auth_mode(
AuthMode::Manual,
cx,
);
},
))
}),
),
),
)
.child(
GroupBox::new()
.id("appearance")
.title("Appearance")
.fill()
.child(
h_flex()
.gap_3()
.justify_between()
.child(
v_flex()
.child(div().text_sm().child(SharedString::from("Mode")))
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MODE)),
),
)
.child(
Button::new("theme-mode")
.label(theme_mode.name())
.ghost_alt()
.small()
.dropdown_menu(|this, _window, _cx| {
this.min_w(px(256.))
.item(PopupMenuItem::new("Light").on_click(
|_ev, _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,
);
},
))
}),
),
)
.child(
h_flex()
.gap_3()
.justify_between()
.child(
v_flex()
.child(div().text_sm().child(SharedString::from("Reset theme")))
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(RESET)),
),
)
.child(
Button::new("reset")
.label("Reset")
.ghost_alt()
.small()
.on_click(move |_ev, window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.reset_theme(window, cx);
})
}),
),
),
)
.child(
GroupBox::new()
.id("media")
.title("Media Upload Service")
.fill()
.child(
v_flex()
.gap_0p5()
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.file_input).text_xs().small())
.child(
Button::new("update-file-server")
.icon(IconName::Check)
.ghost()
.size_8()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update_file_server(window, cx)
})),
),
)
.child(
div()
.text_size(px(10.))
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from("Only support blossom service")),
),
),
)
}
}

View File

@@ -2,11 +2,10 @@ 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, actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
WindowOptions, WindowDecorations, WindowKind, WindowOptions,
}; };
use gpui_platform::application;
use state::{APP_ID, CLIENT_NAME}; use state::{APP_ID, CLIENT_NAME};
use ui::Root; use ui::Root;
@@ -21,93 +20,93 @@ fn main() {
// Initialize logging // Initialize logging
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// Run application // Initialize the Application
application() let app = Application::new()
.with_assets(Assets) .with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
.run(move |cx| {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Register the `quit` function // Run application
cx.on_action(quit); app.run(move |cx| {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Register the `quit` function with CMD+Q (macOS) // Register the `quit` function
#[cfg(target_os = "macos")] cx.on_action(quit);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Register the `quit` function with Super+Q (others) // Register the `quit` function with CMD+Q (macOS)
#[cfg(not(target_os = "macos"))] #[cfg(target_os = "macos")]
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Set menu items // Register the `quit` function with Super+Q (others)
cx.set_menus(vec![Menu { #[cfg(not(target_os = "macos"))]
name: "Coop".into(), cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window bounds // Set menu items
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx); cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window options // Set up the window bounds
let opts = WindowOptions { let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
..Default::default()
};
// Open a window with default options // Set up the window options
cx.open_window(opts, |window, cx| { let opts = WindowOptions {
// Bring the app to the foreground window_background: WindowBackgroundAppearance::Opaque,
cx.activate(true); window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(Size::new(px(640.), px(480.))),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
..Default::default()
};
cx.new(|cx| { // Open a window with default options
// Initialize the tokio runtime cx.open_window(opts, |window, cx| {
gpui_tokio::init(cx); // Bring the app to the foreground
cx.activate(true);
// Initialize components cx.new(|cx| {
ui::init(cx); // Initialize components
ui::init(cx);
// Initialize theme registry // Initialize theme registry
theme::init(cx); theme::init(cx);
// Initialize settings // Initialize settings
settings::init(window, cx); settings::init(cx);
// Initialize the nostr client // Initialize the nostr client
state::init(window, cx); state::init(window, cx);
// Initialize person registry // Initialize relay auth registry
person::init(cx); relay_auth::init(window, cx);
// Initialize relay auth registry // Initialize person registry
relay_auth::init(window, cx); person::init(cx);
// Initialize app registry // Initialize device signer
chat::init(window, cx); //
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize device signer // Initialize app registry
// chat::init(window, cx);
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize auto update // Initialize auto update
auto_update::init(window, cx); auto_update::init(cx);
// Root Entity // Root Entity
Root::new(workspace::init(window, cx).into(), window, cx) Root::new(workspace::init(window, cx).into(), window, cx)
})
}) })
.expect("Failed to open window. Please restart the application."); })
}); .expect("Failed to open window. Please restart the application.");
});
} }
fn load_embedded_fonts(cx: &App) { fn load_embedded_fonts(cx: &App) {
@@ -123,7 +122,7 @@ fn load_embedded_fonts(cx: &App) {
} }
scope.spawn(async { scope.spawn(async {
let font_bytes = asset_source.load(font_path.as_str()).unwrap().unwrap(); let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes); embedded_fonts.lock().unwrap().push(font_bytes);
}); });
} }

View File

@@ -1,207 +0,0 @@
use std::time::Duration;
use anyhow::Error;
use gpui::{
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use state::KEYRING;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::{divider, v_flex, IconName, Sizable, StyledExt};
const MSG: &str = "Store your account keys in a safe location. \
You can restore your account or move to another client anytime you want.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
cx.new(|cx| BackupPanel::new(window, cx))
}
#[derive(Debug)]
pub struct BackupPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Public key input
npub_input: Entity<InputState>,
/// Secret key input
nsec_input: Entity<InputState>,
/// Copied status
copied: bool,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl BackupPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let npub_input = cx.new(|cx| InputState::new(window, cx).disabled(true));
let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true).masked(true));
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
name: "Backup".into(),
focus_handle: cx.focus_handle(),
npub_input,
nsec_input,
copied: false,
tasks: vec![],
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let keyring = cx.read_credentials(KEYRING);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Some((_, secret)) = keyring.await? {
let secret = SecretKey::from_slice(&secret)?;
let keys = Keys::new(secret);
this.update_in(cx, |this, window, cx| {
this.npub_input.update(cx, |this, cx| {
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx);
});
this.nsec_input.update(cx, |this, cx| {
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx);
});
})?;
}
Ok(())
}));
}
fn copy_secret(&mut self, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).value();
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);
// Set the copied status to true
self.set_copied(true, cx);
}
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.set_copied(false, cx);
})?;
Ok(())
}));
}
}
impl Panel for BackupPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for BackupPanel {}
impl Focusable for BackupPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for BackupPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.p_3()
.gap_3()
.w_full()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
.child(divider(cx))
.child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
)
.child(
TextInput::new(&self.npub_input)
.small()
.bordered(false)
.disabled(true),
),
)
.child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Secret Key:")),
)
.child(
TextInput::new(&self.nsec_input)
.small()
.bordered(false)
.disabled(true),
),
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.label({
if self.copied {
"Copied"
} else {
"Copy secret key"
}
})
.primary()
.small()
.font_semibold()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.copy_secret(cx);
})),
),
)
}
}

View File

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

View File

@@ -1,369 +0,0 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
cx.new(|cx| ContactListPanel::new(window, cx))
}
#[derive(Debug)]
pub struct ContactListPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Npub input
input: Entity<InputState>,
/// Whether the panel is updating
updating: bool,
/// Error message
error: Option<SharedString>,
/// All contacts
contacts: HashSet<PublicKey>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl ContactListPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1..."));
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to user's input events
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
name: "Contact List".into(),
focus_handle: cx.focus_handle(),
input,
updating: false,
contacts: HashSet::new(),
error: None,
_subscriptions: subscriptions,
tasks: vec![],
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_keys = task.await?;
// Update state
this.update(cx, |this, cx| {
this.contacts.extend(public_keys);
cx.notify();
})?;
Ok(())
}));
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
} else {
self.set_error("Public Key is invalid", window, cx);
}
}
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.contacts.remove(public_key);
cx.notify();
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})?;
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
self.updating = updating;
cx.notify();
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.contacts.is_empty() {
self.set_error("You need to add at least 1 contact", window, cx);
return;
};
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 {
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
let contacts: Vec<Contact> = self
.contacts
.iter()
.map(|public_key| Contact::new(*public_key))
.collect();
// Set updating state
self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct contact list event builder
let builder = EventBuilder::contact_list(contacts);
let event = client.sign_event_builder(builder).await?;
// Set contact list
client.send_event(&event).to(urls).await?;
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.load(window, cx);
window.push_notification("Update successful", cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.set_error(e.to_string(), window, cx);
})?;
}
};
Ok(())
}));
}
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.flex_1()
.w_full()
.h_8()
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.child(
h_flex()
.gap_2()
.text_sm()
.child(Avatar::new(profile.avatar()).small())
.child(profile.name()),
)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let public_key = public_key.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&public_key, cx);
})
}),
),
)
}
items
}
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.h_20()
.justify_center()
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
}
impl Panel for ContactListPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ContactListPanel {}
impl Focusable for ContactListPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ContactListPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex().p_3().gap_3().w_full().child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("New contact:")),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.w_full()
.child(
TextInput::new(&self.input)
.small()
.bordered(false)
.cleanable(),
)
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add contact")
.ghost()
.size(rems(2.))
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
}),
)
.map(|this| {
if self.contacts.is_empty() {
this.child(self.render_empty(window, cx))
} else {
this.child(
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
)
}
})
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
),
)
}
}

View File

@@ -1,17 +1,16 @@
use chat::{ChatRegistry, InboxState}; use dock::dock::DockPlacement;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
IntoElement, ParentElement, Render, SharedString, Styled, Window, Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
}; };
use state::{NostrRegistry, RelayState}; use state::{NostrRegistry, RelayState};
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_area::panel::{Panel, PanelEvent};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt}; use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
use crate::panels::{messaging_relays, profile, relay_list}; use crate::panels::{connect, import, messaging_relays, profile, relay_list};
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> {
@@ -40,7 +39,7 @@ impl GreeterPanel {
cx.update(|window, cx| { cx.update(|window, cx| {
Workspace::add_panel( Workspace::add_panel(
profile::init(public_key, window, cx), profile::init(public_key, window, cx),
DockPlacement::Right, DockPlacement::Center,
window, window,
cx, cx,
); );
@@ -82,14 +81,14 @@ 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 nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).relay_list_state.clone(); let nip65_state = nostr.read(cx).nip65_state();
let nip17_state = nostr.read(cx).nip17_state();
let signer = nostr.read(cx).signer();
let owned = signer.owned();
let required_actions = let required_actions = nip65_state.read(cx) == &RelayState::NotConfigured
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable; || nip17_state.read(cx) == &RelayState::NotConfigured;
h_flex() h_flex()
.size_full() .size_full()
@@ -119,13 +118,14 @@ impl Render for GreeterPanel {
.child( .child(
div() div()
.font_semibold() .font_semibold()
.text_color(cx.theme().text) .line_height(relative(1.25))
.child(SharedString::from(TITLE)), .child(SharedString::from(TITLE)),
) )
.child( .child(
div() div()
.text_xs() .text_sm()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.line_height(relative(1.25))
.child(SharedString::from(DESCRIPTION)), .child(SharedString::from(DESCRIPTION)),
), ),
), ),
@@ -137,9 +137,9 @@ impl Render for GreeterPanel {
.w_full() .w_full()
.child( .child(
h_flex() h_flex()
.gap_2() .gap_1()
.w_full() .w_full()
.text_xs() .text_sm()
.font_semibold() .font_semibold()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(SharedString::from("Required Actions")) .child(SharedString::from("Required Actions"))
@@ -149,7 +149,7 @@ impl Render for GreeterPanel {
v_flex() v_flex()
.gap_2() .gap_2()
.w_full() .w_full()
.when(nip65.not_configured(), |this| { .when(nip65_state.read(cx).not_configured(), |this| {
this.child( this.child(
Button::new("relaylist") Button::new("relaylist")
.icon(Icon::new(IconName::Relay)) .icon(Icon::new(IconName::Relay))
@@ -167,7 +167,7 @@ impl Render for GreeterPanel {
}), }),
) )
}) })
.when(nip17.not_configured(), |this| { .when(nip17_state.read(cx).not_configured(), |this| {
this.child( this.child(
Button::new("import") Button::new("import")
.icon(Icon::new(IconName::Relay)) .icon(Icon::new(IconName::Relay))
@@ -188,15 +188,69 @@ impl Render for GreeterPanel {
), ),
) )
}) })
.when(!owned, |this| {
this.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_1()
.w_full()
.text_sm()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Use your own identity"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
),
),
)
})
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()
.w_full() .w_full()
.child( .child(
h_flex() h_flex()
.gap_2() .gap_1()
.w_full() .w_full()
.text_xs() .text_sm()
.font_semibold() .font_semibold()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(SharedString::from("Get Started")) .child(SharedString::from("Get Started"))
@@ -206,6 +260,14 @@ impl Render for GreeterPanel {
v_flex() v_flex()
.gap_2() .gap_2()
.w_full() .w_full()
.child(
Button::new("backup")
.icon(Icon::new(IconName::Shield))
.label("Backup account")
.ghost()
.small()
.justify_start(),
)
.child( .child(
Button::new("profile") Button::new("profile")
.icon(Icon::new(IconName::Profile)) .icon(Icon::new(IconName::Profile))

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,38 +2,25 @@ use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, rems, Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
Subscription, Task, TextAlign, Window, Styled, Subscription, Task, TextAlign, UniformList, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::NostrRegistry; use state::{NostrRegistry, BOOTSTRAP_RELAYS};
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::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::menu::DropdownMenu; use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
const MSG: &str = "Relay List (or Gossip Relays) are a set of relays \
where you will publish all your events. Others also publish events \
related to you here.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> {
cx.new(|cx| RelayListPanel::new(window, cx)) cx.new(|cx| RelayListPanel::new(window, cx))
} }
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = relay, no_json)]
enum SetMetadata {
Read,
Write,
}
#[derive(Debug)] #[derive(Debug)]
pub struct RelayListPanel { pub struct RelayListPanel {
name: SharedString, name: SharedString,
@@ -42,9 +29,6 @@ pub struct RelayListPanel {
/// Relay URL input /// Relay URL input
input: Entity<InputState>, input: Entity<InputState>,
/// Whether the panel is updating
updating: bool,
/// Relay metadata input /// Relay metadata input
metadata: Entity<Option<RelayMetadata>>, metadata: Entity<Option<RelayMetadata>>,
@@ -58,7 +42,7 @@ pub struct RelayListPanel {
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
// Background tasks // Background tasks
tasks: Vec<Task<Result<(), Error>>>, _tasks: SmallVec<[Task<()>; 1]>,
} }
impl RelayListPanel { impl RelayListPanel {
@@ -66,7 +50,28 @@ impl RelayListPanel {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let metadata = cx.new(|_| None); let metadata = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move { Self::load(&client).await })
.await;
if let Ok(relays) = result {
this.update(cx, |this, cx| {
this.relays.extend(relays);
cx.notify();
})
.ok();
}
}),
);
subscriptions.push( subscriptions.push(
// Subscribe to user's input events // Subscribe to user's input events
@@ -77,57 +82,32 @@ impl RelayListPanel {
}), }),
); );
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self { Self {
name: "Update Relay List".into(), name: "Update Relay List".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
input, input,
updating: false,
metadata, metadata,
relays: HashSet::new(), relays: HashSet::new(),
error: None, error: None,
_subscriptions: subscriptions, _subscriptions: subscriptions,
tasks: vec![], _tasks: tasks,
} }
} }
#[allow(clippy::type_complexity)] async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { let signer = client.signer().context("Signer not found")?;
let nostr = NostrRegistry::global(cx); let public_key = signer.get_public_key().await?;
let client = nostr.read(cx).client();
let task: Task<Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error>> = cx let filter = Filter::new()
.background_spawn(async move { .kind(Kind::RelayList)
let signer = client.signer().context("Signer not found")?; .author(public_key)
let public_key = signer.get_public_key().await?; .limit(1);
let filter = Filter::new() if let Some(event) = client.database().query(filter).await?.first_owned() {
.kind(Kind::RelayList) Ok(nip65::extract_owned_relay_list(event).collect())
.author(public_key) } else {
.limit(1); Err(anyhow!("Not found."))
}
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip65::extract_owned_relay_list(event).collect())
} else {
Err(anyhow!("Not found."))
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let relays = task.await?;
// Update state
this.update(cx, |this, cx| {
this.relays.extend(relays);
cx.notify();
})?;
Ok(())
}));
} }
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -140,7 +120,7 @@ impl RelayListPanel {
} }
if let Ok(url) = RelayUrl::parse(&value) { if let Ok(url) = RelayUrl::parse(&value) {
if self.relays.insert((url, metadata.to_owned())) { if !self.relays.insert((url, metadata.to_owned())) {
self.input.update(cx, |this, cx| { self.input.update(cx, |this, cx| {
this.set_value("", window, cx); this.set_value("", window, cx);
}); });
@@ -163,42 +143,19 @@ impl RelayListPanel {
self.error = Some(error.into()); self.error = Some(error.into());
cx.notify(); cx.notify();
self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await; cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay // Clear the error message after a delay
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.error = None; this.error = None;
cx.notify(); cx.notify();
})?; })
.ok();
Ok(()) })
})); .detach();
} }
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) { pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.updating = updating;
cx.notify();
}
fn set_metadata(&mut self, ev: &SetMetadata, _window: &mut Window, cx: &mut Context<Self>) {
match ev {
SetMetadata::Read => {
self.metadata.update(cx, |this, cx| {
*this = Some(RelayMetadata::Read);
cx.notify();
});
}
SetMetadata::Write => {
self.metadata.update(cx, |this, cx| {
*this = Some(RelayMetadata::Write);
cx.notify();
});
}
}
}
fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.relays.is_empty() { if self.relays.is_empty() {
self.set_error("You need to add at least 1 relay", window, cx); self.set_error("You need to add at least 1 relay", window, cx);
return; return;
@@ -206,104 +163,109 @@ impl RelayListPanel {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
// Get all relays
let relays = self.relays.clone(); let relays = self.relays.clone();
// Set updating state
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 builder = EventBuilder::relay_list(relays); let builder = EventBuilder::relay_list(relays);
let event = client.sign_event_builder(builder).await?; let event = client.sign_event_builder(builder).await?;
// Set relay list for current user // Set relay list for current user
client.send_event(&event).await?; client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(()) Ok(())
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
match task.await { match task.await {
Ok(_) => { Ok(_) => {
this.update_in(cx, |this, window, cx| { // TODO
this.set_updating(false, cx);
this.load(window, cx);
window.push_notification("Update successful", cx);
})?;
} }
Err(e) => { Err(e) => {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.set_error(e.to_string(), window, cx); this.set_error(e.to_string(), window, cx);
})?; })
.ok();
} }
}; };
})
Ok(()) .detach();
}));
} }
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> { fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
let mut items = Vec::new(); let relays = self.relays.clone();
let total = relays.len();
for (url, metadata) in self.relays.iter() { uniform_list(
items.push( "relays",
h_flex() total,
.id(SharedString::from(url.to_string())) cx.processor(move |_v, range, _window, cx| {
.group("") let mut items = Vec::new();
.flex_1()
.w_full() for ix in range {
.h_8() let Some((url, metadata)) = relays.iter().nth(ix) else {
.px_2() continue;
.justify_between() };
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background) items.push(
.text_color(cx.theme().secondary_foreground) div()
.child( .id(SharedString::from(url.to_string()))
h_flex() .group("")
.gap_1() .w_full()
.text_sm() .h_9()
.child(SharedString::from(url.to_string())) .py_0p5()
.child( .child(
div() h_flex()
.p_0p5() .px_2()
.rounded_xs() .flex()
.font_semibold() .justify_between()
.text_size(px(8.)) .rounded(cx.theme().radius)
.text_color(cx.theme().secondary_foreground) .bg(cx.theme().elevated_surface_background)
.map(|this| { .child(
if let Some(metadata) = metadata { div().text_sm().child(SharedString::from(url.to_string())),
this.child(SharedString::from(metadata.to_string())) )
} else { .child(
this.child("Read and Write") h_flex()
} .gap_1()
}), .text_xs()
.map(|this| {
if let Some(metadata) = metadata {
this.child(SharedString::from(
metadata.to_string(),
))
} else {
this.child(SharedString::from("Read+Write"))
}
})
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let url = url.to_owned();
cx.listener(
move |this, _ev, _window, cx| {
this.remove(&url, cx);
},
)
}),
),
),
), ),
) )
.child( }
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let url = url.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&url, cx);
})
}),
),
)
}
items items
}),
)
.h_full()
} }
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex() h_flex()
.mt_2()
.h_20() .h_20()
.justify_center() .justify_center()
.border_2() .border_2()
@@ -337,67 +299,36 @@ impl Focusable for RelayListPanel {
impl Render for RelayListPanel { impl Render for RelayListPanel {
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()
.on_action(cx.listener(Self::set_metadata)) .size_full()
.p_3() .items_center()
.gap_3() .justify_center()
.w_full() .p_2()
.gap_10()
.child( .child(
div() div()
.text_xs() .text_center()
.text_color(cx.theme().text_muted) .font_semibold()
.child(SharedString::from(MSG)), .line_height(relative(1.25))
.child(SharedString::from("Update Relay List")),
) )
.child(divider(cx))
.child( .child(
v_flex() v_flex()
.w_112()
.gap_2() .gap_2()
.flex_1()
.w_full()
.text_sm() .text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Relays:")),
)
.child( .child(
v_flex() v_flex()
.gap_1() .gap_1p5()
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
.w_full() .w_full()
.child( .child(TextInput::new(&self.input).small())
TextInput::new(&self.input)
.small()
.bordered(false)
.cleanable(),
)
.child(
Button::new("metadata")
.map(|this| {
if let Some(metadata) = self.metadata.read(cx) {
this.label(metadata.to_string())
} else {
this.label("R & W")
}
})
.tooltip("Relay metadata")
.ghost()
.h(rems(2.))
.text_xs()
.dropdown_menu(|this, _window, _cx| {
this.menu("Read", Box::new(SetMetadata::Read))
.menu("Write", Box::new(SetMetadata::Write))
}),
)
.child( .child(
Button::new("add") Button::new("add")
.icon(IconName::Plus) .icon(IconName::Plus)
.tooltip("Add relay") .label("Add")
.ghost() .ghost()
.size(rems(2.))
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx); this.add(window, cx);
})), })),
@@ -408,33 +339,23 @@ impl Render for RelayListPanel {
div() div()
.italic() .italic()
.text_xs() .text_xs()
.text_color(cx.theme().danger_active) .text_color(cx.theme().danger_foreground)
.child(error.clone()), .child(error.clone()),
) )
}), }),
) )
.map(|this| { .map(|this| {
if self.relays.is_empty() { if !self.relays.is_empty() {
this.child(self.render_empty(window, cx)) this.child(self.render_list(window, cx))
} else { } else {
this.child( this.child(self.render_empty(window, cx))
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
)
} }
}) })
.child(divider(cx))
.child( .child(
Button::new("submit") Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update") .label("Update")
.primary() .primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.set_relays(window, cx); this.set_relays(window, cx);
})), })),

View File

@@ -1,16 +1,18 @@
use std::rc::Rc; use std::rc::Rc;
use chat::RoomKind; use chat::RoomKind;
use chat_ui::{CopyPublicKey, OpenPublicKey};
use dock::ClosePanel;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, SharedString, StatefulInteractiveElement, Styled, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::dock_area::ClosePanel; use ui::context_menu::ContextMenuExt;
use ui::modal::ModalButtonProps; use ui::modal::ModalButtonProps;
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension}; use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
@@ -106,7 +108,14 @@ impl RenderOnce for RoomEntry {
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.when_some(self.avatar, |this, avatar| { this.when_some(self.avatar, |this, avatar| {
this.child(Avatar::new(avatar).small().flex_shrink_0()) this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(avatar).size(rems(1.5))),
)
}) })
}) })
.child( .child(
@@ -144,6 +153,12 @@ impl RenderOnce for RoomEntry {
), ),
) )
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.when_some(public_key, |this, public_key| {
this.context_menu(move |this, _window, _cx| {
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
})
})
.when_some(self.handler, |this, handler| { .when_some(self.handler, |this, handler| {
this.on_click(move |event, window, cx| { this.on_click(move |event, window, cx| {
handler(event, window, cx); handler(event, window, cx);

View File

@@ -2,28 +2,30 @@ use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::Error;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, RenderedTimestamp}; use common::{DebouncedDelay, RenderedTimestamp};
use dock::panel::{Panel, PanelEvent};
use entry::RoomEntry; use entry::RoomEntry;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
UniformListScrollHandle, Window, div, uniform_list, Task, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use smallvec::{SmallVec, smallvec}; use smallvec::{smallvec, SmallVec};
use state::{FIND_DELAY, NostrRegistry}; use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::divider::Divider;
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::{
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
};
mod entry; mod entry;
@@ -37,7 +39,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
pub struct Sidebar { pub struct Sidebar {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
/// Image cache /// Image cache
image_cache: Entity<RetainAllImageCache>, image_cache: Entity<RetainAllImageCache>,
@@ -119,10 +120,12 @@ impl Sidebar {
} }
} }
InputEvent::Focus => { InputEvent::Focus => {
this.set_input_focus(true, window, cx); this.set_input_focus(window, cx);
this.get_contact_list(window, cx); this.get_contact_list(window, cx);
} }
_ => {} InputEvent::Blur => {
this.set_input_focus(window, cx);
}
}; };
}), }),
); );
@@ -140,7 +143,6 @@ impl Sidebar {
Self { Self {
name: "Sidebar".into(), name: "Sidebar".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(),
image_cache: RetainAllImageCache::new(cx), image_cache: RetainAllImageCache::new(cx),
find_input, find_input,
find_debouncer: DebouncedDelay::new(), find_debouncer: DebouncedDelay::new(),
@@ -161,15 +163,7 @@ impl Sidebar {
/// Get the contact list. /// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let task = nostr.read(cx).get_contact_list(cx);
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let contacts = client.database().contacts_public_keys(public_key).await?;
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await { match task.await {
@@ -241,7 +235,6 @@ impl Sidebar {
})); }));
} }
/// Set the results of the search
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) { fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| { self.find_results.update(cx, |this, cx| {
*this = Some(results); *this = Some(results);
@@ -249,7 +242,6 @@ impl Sidebar {
}); });
} }
/// Set the finding status
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) { fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests // Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| { self.find_input.update(cx, |this, cx| {
@@ -261,14 +253,13 @@ impl Sidebar {
cx.notify(); cx.notify();
} }
/// Set the focus status of the input element. fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn set_input_focus(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) { self.find_focused = !self.find_focused;
self.find_focused = status;
cx.notify(); cx.notify();
// Focus to the input element // Reset the find panel
if !status { if !self.find_focused {
window.focus_prev(cx); self.reset(window, cx);
} }
} }
@@ -354,8 +345,7 @@ impl Sidebar {
} }
/// Set the active filter for the sidebar. /// Set the active filter for the sidebar.
fn set_filter(&mut self, kind: RoomKind, window: &mut Window, cx: &mut Context<Self>) { fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
self.set_input_focus(false, window, cx);
self.filter.update(cx, |this, cx| { self.filter.update(cx, |this, cx| {
*this = kind; *this = kind;
cx.notify(); cx.notify();
@@ -494,13 +484,12 @@ impl Render for Sidebar {
v_flex() v_flex()
.image_cache(self.image_cache.clone()) .image_cache(self.image_cache.clone())
.size_full() .size_full()
.gap_2() .relative()
.child( .child(
h_flex() h_flex()
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.border_b_1() .border_b_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.bg(cx.theme().elevated_surface_background)
.child( .child(
TextInput::new(&self.find_input) TextInput::new(&self.find_input)
.appearance(false) .appearance(false)
@@ -520,17 +509,22 @@ impl Render for Sidebar {
) )
.child( .child(
h_flex() h_flex()
.px_2() .h(TABBAR_HEIGHT)
.gap_2()
.justify_center() .justify_center()
.border_b_1()
.border_color(cx.theme().border)
.when(show_find_panel, |this| { .when(show_find_panel, |this| {
this.child( this.child(
Button::new("search-results") Button::new("search-results")
.icon(IconName::Search) .icon(IconName::Search)
.label("Search")
.tooltip("All search results") .tooltip("All search results")
.small() .small()
.ghost_alt() .underline()
.ghost()
.font_semibold() .font_semibold()
.rounded_none()
.h_full()
.flex_1() .flex_1()
.selected(true), .selected(true),
) )
@@ -547,16 +541,21 @@ impl Render for Sidebar {
.when(!show_find_panel, |this| this.label("Inbox")) .when(!show_find_panel, |this| this.label("Inbox"))
.tooltip("All ongoing conversations") .tooltip("All ongoing conversations")
.small() .small()
.ghost_alt() .underline()
.ghost()
.font_semibold() .font_semibold()
.rounded_none()
.h_full()
.flex_1() .flex_1()
.disabled(show_find_panel)
.selected( .selected(
!show_find_panel && self.current_filter(&RoomKind::Ongoing, cx), !show_find_panel && self.current_filter(&RoomKind::Ongoing, cx),
) )
.on_click(cx.listener(|this, _ev, window, cx| { .on_click(cx.listener(|this, _ev, _window, cx| {
this.set_filter(RoomKind::Ongoing, window, cx); this.set_filter(RoomKind::Ongoing, cx);
})), })),
) )
.child(Divider::vertical())
.child( .child(
Button::new("requests") Button::new("requests")
.map(|this| { .map(|this| {
@@ -569,23 +568,27 @@ impl Render for Sidebar {
.when(!show_find_panel, |this| this.label("Requests")) .when(!show_find_panel, |this| this.label("Requests"))
.tooltip("Incoming new conversations") .tooltip("Incoming new conversations")
.small() .small()
.ghost_alt() .ghost()
.underline()
.font_semibold() .font_semibold()
.rounded_none()
.h_full()
.flex_1() .flex_1()
.disabled(show_find_panel)
.selected( .selected(
!show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx), !show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx),
) )
.when(self.new_requests, |this| { .when(self.new_requests, |this| {
this.child(div().size_1().rounded_full().bg(cx.theme().cursor)) this.child(div().size_1().rounded_full().bg(cx.theme().cursor))
}) })
.on_click(cx.listener(|this, _ev, window, cx| { .on_click(cx.listener(|this, _ev, _window, cx| {
this.set_filter(RoomKind::default(), window, cx); this.set_filter(RoomKind::default(), cx);
})), })),
), ),
) )
.when(!show_find_panel && !loading && total_rooms == 0, |this| { .when(!show_find_panel && !loading && total_rooms == 0, |this| {
this.child( this.child(
div().w(SIDEBAR_WIDTH).px_2().child( div().mt_2().px_2().child(
v_flex() v_flex()
.p_3() .p_3()
.h_24() .h_24()
@@ -613,9 +616,12 @@ impl Render for Sidebar {
}) })
.child( .child(
v_flex() v_flex()
.size_full() .h_full()
.px_1p5()
.mt_2()
.flex_1() .flex_1()
.gap_1() .gap_1()
.overflow_y_hidden()
.when(show_find_panel, |this| { .when(show_find_panel, |this| {
this.gap_3() this.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| { .when_some(self.find_results.read(cx).as_ref(), |this, results| {
@@ -658,7 +664,7 @@ impl Render for Sidebar {
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small()) .child(Icon::new(IconName::ChevronDown))
.child(SharedString::from("Suggestions")), .child(SharedString::from("Suggestions")),
) )
.child( .child(
@@ -684,12 +690,9 @@ impl Render for Sidebar {
this.render_list_items(range, cx) this.render_list_items(range, cx)
}), }),
) )
.track_scroll(&self.scroll_handle)
.flex_1() .flex_1()
.h_full() .h_full(),
.px_2(),
) )
.child(Scrollbar::vertical(&self.scroll_handle))
}), }),
) )
.when(!self.selected_pkeys.read(cx).is_empty(), |this| { .when(!self.selected_pkeys.read(cx).is_empty(), |this| {
@@ -731,7 +734,7 @@ impl Render for Sidebar {
.bg(cx.theme().background.opacity(0.85)) .bg(cx.theme().background.opacity(0.85))
.border_color(cx.theme().border_disabled) .border_color(cx.theme().border_disabled)
.border_1() .border_1()
.when(cx.theme().shadow, |this| this.shadow_xs()) .when(cx.theme().shadow, |this| this.shadow_sm())
.rounded_full() .rounded_full()
.text_xs() .text_xs()
.font_semibold() .font_semibold()

View File

@@ -1,61 +1,31 @@
use std::sync::Arc; use std::sync::Arc;
use ::settings::AppSettings; use chat::{ChatEvent, ChatRegistry};
use chat::{ChatEvent, ChatRegistry, InboxState}; use dock::dock::DockPlacement;
use device::DeviceRegistry; use dock::panel::{PanelStyle, PanelView};
use dock::{ClosePanel, DockArea, DockItem};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
Render, SharedString, Styled, Subscription, Window, div, px, ParentElement, Render, SharedString, Styled, Subscription, Window,
}; };
use person::PersonRegistry; use person::PersonRegistry;
use serde::Deserialize; use smallvec::{smallvec, SmallVec};
use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, RelayState};
use state::{NostrRegistry, RelayState, SignerEvent}; use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use titlebar::TitleBar;
use title_bar::TitleBar;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement; use ui::popup_menu::PopupMenuExt;
use ui::dock_area::panel::PanelView; use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification;
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::{accounts, settings}; use crate::panels::greeter;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
use crate::sidebar; use crate::sidebar;
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \
all your encrypted messages before. This action cannot be undone.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx)) cx.new(|cx| Workspace::new(window, cx))
} }
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
ToggleAccount,
RefreshEncryption,
RefreshRelayList,
RefreshMessagingRelays,
ResetEncryption,
ShowRelayList,
ShowMessaging,
ShowProfile,
ShowSettings,
ShowBackup,
ShowContactList,
}
pub struct Workspace { pub struct Workspace {
/// App's Title Bar /// App's Title Bar
titlebar: Entity<TitleBar>, titlebar: Entity<TitleBar>,
@@ -64,44 +34,17 @@ pub struct Workspace {
dock: Entity<DockArea>, dock: Entity<DockArea>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 4]>, _subscriptions: SmallVec<[Subscription; 3]>,
} }
impl Workspace { impl Workspace {
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 npubs = nostr.read(cx).npubs();
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new()); let titlebar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx)); let dock = cx.new(|cx| DockArea::new(window, cx).style(PanelStyle::TabBar));
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push(
// Observe system appearance and update theme
cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}),
);
subscriptions.push(
// Observe the npubs entity
cx.observe_in(&npubs, window, move |this, npubs, window, cx| {
if !npubs.read(cx).is_empty() {
this.account_selector(window, cx);
}
}),
);
subscriptions.push(
// Subscribe to the signer events
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
if let SignerEvent::Set = event {
this.set_center_layout(window, cx);
}
}),
);
subscriptions.push( subscriptions.push(
// Observe all events emitted by the chat registry // Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
@@ -141,12 +84,12 @@ impl Workspace {
let ids = this.panel_ids(cx); let ids = this.panel_ids(cx);
chat.update(cx, |this, cx| { chat.update(cx, |this, cx| {
this.refresh_rooms(&ids, cx); this.refresh_rooms(ids, cx);
}); });
}), }),
); );
// Set the layout at the end of cycle // Set the default layout for app's dock
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx); this.set_layout(window, cx);
}); });
@@ -175,551 +118,140 @@ impl Workspace {
} }
/// Get all panel ids /// Get all panel ids
fn panel_ids(&self, cx: &App) -> Vec<u64> { fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
self.dock let ids: Vec<u64> = self
.dock
.read(cx) .read(cx)
.items .items
.panel_ids(cx) .panel_ids(cx)
.into_iter() .into_iter()
.filter_map(|panel| panel.parse::<u64>().ok()) .filter_map(|panel| panel.parse::<u64>().ok())
.collect() .collect();
Some(ids)
} }
/// Set the dock layout /// Set the dock layout
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
// Sidebar
let left = DockItem::panel(Arc::new(sidebar::init(window, cx))); let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Update the dock layout with sidebar on the left // Main workspace
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(greeter::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
// Update the dock layout
self.dock.update(cx, |this, cx| { self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx); this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
});
}
/// Set the center dock layout
fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dock = self.dock.downgrade();
let greeeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeeter], None, &dock, window, cx);
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
// Update the layout with center dock
self.dock.update(cx, |this, cx| {
this.set_center(center, window, cx); this.set_center(center, window, cx);
}); });
} }
/// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::ShowSettings => {
let view = settings::init(window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.show_close(true)
.pb_2()
.title("Preferences")
.child(view.clone())
});
}
Command::ShowProfile => {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
}
Command::ShowContactList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(contact_list::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowBackup => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(backup::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowMessaging => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(messaging_relays::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowRelayList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(relay_list::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::RefreshEncryption => {
let device = DeviceRegistry::global(cx);
device.update(cx, |this, cx| {
this.get_announcement(cx);
});
}
Command::RefreshRelayList => {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.ensure_relay_list(cx);
});
}
Command::ResetEncryption => {
self.confirm_reset_encryption(window, cx);
}
Command::RefreshMessagingRelays => {
let chat = ChatRegistry::global(cx);
chat.update(cx, |this, cx| {
this.ensure_messaging_relays(cx);
});
}
Command::ToggleTheme => {
self.theme_selector(window, cx);
}
Command::ToggleAccount => {
self.account_selector(window, cx);
}
}
}
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, |this, _window, cx| {
this.confirm()
.show_close(true)
.title("Reset Encryption Keys")
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from(ENC_MSG))
.child(
div()
.italic()
.text_color(cx.theme().warning_active)
.child(SharedString::from(ENC_WARN)),
),
)
.on_ok(move |_ev, window, cx| {
let device = DeviceRegistry::global(cx);
let task = device.read(cx).create_encryption(cx);
window
.spawn(cx, async move |cx| {
let result = task.await;
cx.update(|window, cx| match result {
Ok(keys) => {
device.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
});
window.close_modal(cx);
}
Err(e) => {
window
.push_notification(Notification::error(e.to_string()), cx);
}
})
.ok();
})
.detach();
// false to keep modal open
false
})
});
}
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let accounts = accounts::init(window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.title("Continue with")
.show_close(false)
.keyboard(false)
.overlay_closable(false)
.pb_2()
.child(accounts.clone())
});
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx);
let themes = registry.read(cx).themes();
this.width(px(520.))
.show_close(true)
.title("Select theme")
.pb_2()
.child(v_flex().gap_2().w_full().children({
let mut items = vec![];
for (ix, (path, theme)) in themes.iter().enumerate() {
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_8()
.w_full()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_1p5()
.flex_1()
.text_sm()
.child(theme.name.clone())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_muted)
.child(theme.author.clone()),
),
)
.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("url-{ix}"))
.icon(IconName::Link)
.ghost()
.small()
.on_click({
let theme = theme.clone();
move |_ev, _window, cx| {
cx.open_url(&theme.url);
}
}),
)
.child(
Button::new(format!("set-{ix}"))
.icon(IconName::Check)
.primary()
.small()
.on_click({
let path = path.clone();
move |_ev, window, cx| {
let settings = AppSettings::global(cx);
let path = path.clone();
settings.update(cx, |this, cx| {
this.set_theme(path, window, cx);
})
}
}),
),
),
);
}
items
}))
});
}
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement { fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).nip65_state();
let nip17 = nostr.read(cx).nip17_state();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let current_user = signer.public_key(); let current_user = signer.public_key();
h_flex() h_flex()
.h(TITLEBAR_HEIGHT)
.flex_shrink_0() .flex_shrink_0()
.justify_between()
.gap_2() .gap_2()
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Choose an account to continue...")),
)
})
.when_some(current_user.as_ref(), |this, public_key| { .when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx); let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar();
let name = profile.name();
this.child( this.child(
Button::new("current-user") Button::new("current-user")
.child(Avatar::new(avatar.clone()).xsmall()) .child(Avatar::new(profile.avatar()).size(rems(1.25)))
.small() .small()
.caret() .caret()
.compact() .compact()
.transparent() .transparent()
.dropdown_menu(move |this, _window, _cx| { .popup_menu(move |this, _window, _cx| {
let avatar = avatar.clone(); this.label(profile.name())
let name = name.clone();
this.min_w(px(256.))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(avatar.clone()).xsmall())
.child(name.clone())
}))
.separator() .separator()
.menu_with_icon( .menu("Profile", Box::new(ClosePanel))
"Profile", .menu("Backup", Box::new(ClosePanel))
IconName::Profile, .menu("Themes", Box::new(ClosePanel))
Box::new(Command::ShowProfile), .menu("Settings", Box::new(ClosePanel))
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.separator()
.menu_with_icon(
"Accounts",
IconName::Group,
Box::new(Command::ToggleAccount),
)
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}), }),
) )
}) })
.when(nostr.read(cx).creating_signer(), |this| {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Coop is creating a new identity for you..."),
))
})
.when(!nostr.read(cx).connected(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Connecting...")),
)
})
.map(|this| match nip65.read(cx) {
RelayState::Checking => this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Fetching user's relay list...")),
),
RelayState::NotConfigured => this.child(
h_flex()
.h_6()
.w_full()
.px_1()
.text_xs()
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_background)
.rounded_sm()
.child(SharedString::from("User hasn't configured a relay list")),
),
_ => this,
})
.map(|this| match nip17.read(cx) {
RelayState::Checking => {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Fetching user's messaging relay list..."),
))
}
RelayState::NotConfigured => this.child(
h_flex()
.h_6()
.w_full()
.px_1()
.text_xs()
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().warning_background)
.rounded_sm()
.child(SharedString::from(
"User hasn't configured a messaging relay list",
)),
),
_ => this,
})
} }
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement { fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
let signer = nostr.read(cx).signer();
let chat = ChatRegistry::global(cx);
let inbox_state = chat.read(cx).state(cx);
let Some(pkey) = signer.public_key() else {
return div();
};
h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_3()
.child(
Button::new("key")
.icon(IconName::UserKey)
.tooltip("Decoupled encryption key")
.small()
.ghost()
.dropdown_menu(move |this, _window, cx| {
let device = DeviceRegistry::global(cx);
let state = device.read(cx).state();
this.min_w(px(260.))
.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div()
.size_1p5()
.rounded_full()
.when(state.set(), |this| this.bg(gpui::green()))
.when(state.requesting(), |this| {
this.bg(gpui::yellow())
}),
)
.child(SharedString::from(state.to_string()))
}))
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshEncryption),
)
.menu_with_icon(
"Reset",
IconName::Warning,
Box::new(Command::ResetEncryption),
)
}),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.map(|this| match inbox_state {
InboxState::Checking => this.child(div().child(
SharedString::from("Fetching user's messaging relay list..."),
)),
InboxState::RelayNotAvailable => {
this.child(div().text_color(cx.theme().warning_active).child(
SharedString::from(
"User hasn't configured a messaging relay list",
),
))
}
_ => this,
}),
)
.child(
Button::new("inbox")
.icon(IconName::Inbox)
.tooltip("Inbox")
.small()
.ghost()
.when(inbox_state.subscribing(), |this| this.indicator())
.dropdown_menu(move |this, _window, cx| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&pkey, cx);
let urls: Vec<SharedString> = profile
.messaging_relays()
.iter()
.map(|url| SharedString::from(url.to_string()))
.collect();
// Header
let menu = this.min_w(px(260.)).label("Messaging Relays");
// Content
let menu = urls.into_iter().fold(menu, |this, url| {
this.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div().size_1p5().rounded_full().bg(gpui::green()),
)
.child(url.clone())
}))
});
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshMessagingRelays),
)
.menu_with_icon(
"Update relays",
IconName::Settings,
Box::new(Command::ShowMessaging),
)
}),
),
)
.child(
h_flex()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.map(|this| match nostr.read(cx).relay_list_state {
RelayState::Checking => this
.child(div().child(SharedString::from(
"Fetching user's relay list...",
))),
RelayState::NotConfigured => {
this.child(div().text_color(cx.theme().warning_active).child(
SharedString::from("User hasn't configured a relay list"),
))
}
_ => this,
}),
)
.child(
Button::new("relay-list")
.icon(IconName::Relay)
.tooltip("User's relay list")
.small()
.ghost()
.when(nostr.read(cx).relay_list_state.configured(), |this| {
this.indicator()
})
.dropdown_menu(move |this, _window, cx| {
let nostr = NostrRegistry::global(cx);
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
// Header
let menu = this.min_w(px(260.)).label("Relays");
// Content
let menu = urls.into_iter().fold(menu, |this, url| {
this.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div().size_1p5().rounded_full().bg(gpui::green()),
)
.child(url.clone())
}))
});
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshRelayList),
)
.menu_with_icon(
"Update relay list",
IconName::Settings,
Box::new(Command::ShowRelayList),
)
}),
),
)
} }
} }
@@ -739,7 +271,6 @@ impl Render for Workspace {
div() div()
.id(SharedString::from("workspace")) .id(SharedString::from("workspace"))
.on_action(cx.listener(Self::on_command))
.relative() .relative()
.size_full() .size_full()
.child( .child(

View File

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

View File

@@ -1,29 +1,17 @@
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)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum DeviceState { pub enum DeviceState {
#[default] #[default]
Idle, Initial,
Requesting, Requesting,
Set, 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 { impl DeviceState {
pub fn idle(&self) -> bool { pub fn initial(&self) -> bool {
matches!(self, DeviceState::Idle) matches!(self, DeviceState::Initial)
} }
pub fn requesting(&self) -> bool { pub fn requesting(&self) -> bool {

View File

@@ -1,28 +1,17 @@
use std::cell::Cell;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{ use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
div, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString,
Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{ use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT};
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
}; mod device;
use theme::ActiveTheme;
use ui::avatar::Avatar; pub use device::*;
use ui::button::{Button, ButtonVariants};
use ui::notification::Notification;
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, WindowExtension};
const IDENTIFIER: &str = "coop:device"; const IDENTIFIER: &str = "coop:device";
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it.";
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(window: &mut Window, cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
@@ -40,6 +29,9 @@ pub struct DeviceRegistry {
/// Device state /// Device state
state: DeviceState, state: DeviceState,
/// Device requests
requests: Entity<HashSet<Event>>,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -61,35 +53,46 @@ 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 nip65_state = nostr.read(cx).nip65_state();
// Construct an entity for encryption signer requests
let requests = cx.new(|_| HashSet::default());
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
// Observe the NIP-65 state // Observe the NIP-65 state
cx.observe(&nostr, |this, state, cx| { cx.observe(&nip65_state, |this, state, cx| {
if state.read(cx).relay_list_state == RelayState::Configured { match state.read(cx) {
this.get_announcement(cx); RelayState::Idle => {
this.reset(cx);
}
RelayState::Configured(_) => {
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(cx);
this.handle_notifications(window, cx);
}); });
Self { Self {
state: DeviceState::default(), state: DeviceState::default(),
requests,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn handle_notifications(&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 (tx, rx) = flume::bounded::<Event>(100); let (tx, rx) = flume::bounded::<Event>(100);
self.tasks.push(cx.background_spawn(async move { cx.background_spawn(async move {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut processed_events = HashSet::new(); let mut processed_events = HashSet::new();
@@ -107,37 +110,34 @@ impl DeviceRegistry {
match event.kind { match event.kind {
Kind::Custom(4454) => { Kind::Custom(4454) => {
if verify_author(&client, event.as_ref()).await { if verify_author(&client, event.as_ref()).await {
tx.send_async(event.into_owned()).await?; tx.send_async(event.into_owned()).await.ok();
} }
} }
Kind::Custom(4455) => { Kind::Custom(4455) => {
if verify_author(&client, event.as_ref()).await { if verify_author(&client, event.as_ref()).await {
tx.send_async(event.into_owned()).await?; tx.send_async(event.into_owned()).await.ok();
} }
} }
_ => {} _ => {}
} }
} }
} }
})
Ok(()) .detach();
}));
self.tasks.push( self.tasks.push(
// Update GPUI states // Update GPUI states
cx.spawn_in(window, async move |this, cx| { cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
match event.kind { match event.kind {
// New request event
Kind::Custom(4454) => { Kind::Custom(4454) => {
this.update_in(cx, |this, window, cx| { this.update(cx, |this, cx| {
this.ask_for_approval(event, window, cx); this.add_request(event, cx);
})?; })?;
} }
// New response event
Kind::Custom(4455) => { Kind::Custom(4455) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.extract_encryption(event, cx); this.parse_response(event, cx);
})?; })?;
} }
_ => {} _ => {}
@@ -149,19 +149,22 @@ impl DeviceRegistry {
); );
} }
/// Get the device state pub fn state(&self) -> &DeviceState {
pub fn state(&self) -> DeviceState { &self.state
self.state.clone()
} }
/// Set the device state /// Reset the device state
fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) { pub fn reset(&mut self, cx: &mut Context<Self>) {
self.state = state; self.state = DeviceState::Initial;
self.requests.update(cx, |this, cx| {
this.clear();
cx.notify();
});
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,
{ {
@@ -181,78 +184,60 @@ impl DeviceRegistry {
})); }));
} }
/// Reset the device state /// Continuously get gift wrap events for the current encryption keys
fn reset(&mut self, cx: &mut Context<Self>) {
self.state = DeviceState::Idle;
cx.notify();
}
/// Get all messages for encryption keys
fn get_messages(&mut self, cx: &mut Context<Self>) { 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 signer = nostr.read(cx).signer();
let messaging_relays = nostr.read(cx).messaging_relays(cx);
let Some(public_key) = signer.public_key() else { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
return Task::ready(Err(anyhow!("User not found"))); let encryption_signer = signer
}; .get_encryption_signer()
.await
.context("Signer not found")?;
let persons = PersonRegistry::global(cx); let public_key = encryption_signer.get_public_key().await?;
let profile = persons.read(cx).get(&public_key, cx); let urls = messaging_relays.await;
let relay_urls = profile.messaging_relays().clone();
cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(DEVICE_GIFTWRAP); let id = SubscriptionId::new(DEVICE_GIFTWRAP);
// Construct target for subscription // Construct target for subscription
let target: HashMap<RelayUrl, Filter> = relay_urls let target: HashMap<&RelayUrl, Filter> =
.into_iter() urls.iter().map(|relay| (relay, filter.clone())).collect();
.map(|relay| (relay, filter.clone()))
.collect();
let output = client.subscribe(target).with_id(id).await?; client.subscribe(target).with_id(id).await?;
log::info!("Subscribed to encryption gift-wrap messages");
log::info!(
"Successfully subscribed to encryption gift-wrap messages on: {:?}",
output.success
);
Ok(()) Ok(())
}) });
task.detach();
}
/// Set the device state
fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) {
self.state = state;
cx.notify();
}
/// Add a request for device keys
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
self.requests.update(cx, |this, cx| {
this.insert(request);
cx.notify();
});
} }
/// Get device announcement for current user /// Get device announcement for current user
pub fn get_announcement(&mut self, cx: &mut Context<Self>) { 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,13 +245,8 @@ 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
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?;
@@ -289,12 +269,12 @@ impl DeviceRegistry {
match task.await { match task.await {
Ok(event) => { Ok(event) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.new_signer(&event, cx); this.init_device_signer(&event, cx);
})?; })?;
} }
Err(_) => { Err(_) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.announce(cx); this.announce_device(cx);
})?; })?;
} }
} }
@@ -303,63 +283,47 @@ impl DeviceRegistry {
})); }));
} }
/// Create new encryption keys /// Create a new device signer and announce it
pub fn create_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> { fn announce_device(&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 Task::ready(Err(anyhow!("User not found")));
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
// Generate a new device keys
let keys = Keys::generate(); 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 { let task: Task<Result<(), Error>> = 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()), ]);
])) let event = client.sign_event_builder(builder).await?;
.await?;
// Publish announcement // Publish announcement
client.send_event(&event).to(urls).await?; client.send_event(&event).to_nip65().await?;
// Save device keys to the database // Save device keys to the database
set_keys(&client, &secret).await?; set_keys(&client, &secret).await?;
Ok(keys)
})
}
/// Create a new device signer and announce it
fn announce(&mut self, 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(()) Ok(())
})); });
cx.spawn(async move |this, cx| {
if task.await.is_ok() {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_device_request(cx);
})
.ok();
}
})
.detach();
} }
/// Initialize device signer (decoupled encryption key) for the current user /// Initialize device signer (decoupled encryption key) for the current user
pub fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) { fn init_device_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();
@@ -378,41 +342,37 @@ impl DeviceRegistry {
} }
}); });
self.tasks.push(cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
match task.await { match task.await {
Ok(keys) => { 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.listen_device_request(cx);
})?; })
.ok();
} }
Err(e) => { 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_device_keys(cx);
this.listen_approval(cx); this.listen_device_approval(cx);
})?; })
.ok();
log::warn!("Failed to initialize device signer: {e}");
} }
}; };
})
Ok(()) .detach();
}));
} }
/// Listen for device key requests on user's write relays /// Listen for device key requests on user's write relays
pub fn listen_request(&mut self, cx: &mut Context<Self>) { fn listen_device_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 Some(public_key) = signer.public_key() else {
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 urls = write_relays.await; let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Construct a filter for device key requests // Construct a filter for device key requests
let filter = Filter::new() let filter = Filter::new()
@@ -420,12 +380,8 @@ impl DeviceRegistry {
.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(filter).await?;
Ok(()) Ok(())
}); });
@@ -434,19 +390,13 @@ impl DeviceRegistry {
} }
/// Listen for device key approvals on user's write relays /// Listen for device key approvals on user's write relays
fn listen_approval(&mut self, cx: &mut Context<Self>) { fn listen_device_approval(&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 { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
return; let signer = client.signer().context("Signer not found")?;
}; let public_key = signer.get_public_key().await?;
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 // Construct a filter for device key requests
let filter = Filter::new() let filter = Filter::new()
@@ -454,33 +404,25 @@ impl DeviceRegistry {
.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(filter).await?;
Ok(()) Ok(())
})); });
task.detach();
} }
/// Request encryption keys from other device /// Request encryption keys from other device
fn request(&mut self, cx: &mut Context<Self>) { fn request_device_keys(&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 { let app_keys = nostr.read(cx).app_keys().clone();
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<Keys>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let filter = Filter::new() let filter = Filter::new()
@@ -507,49 +449,47 @@ impl DeviceRegistry {
Ok(Some(keys)) 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::client(app_name()), Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
Tag::custom(TagKind::custom("P"), vec![app_pubkey]), ]);
])) let event = client.sign_event_builder(builder).await?;
.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)
} }
} }
}); });
self.tasks.push(cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
match task.await { match task.await {
Ok(Some(keys)) => { Ok(Some(keys)) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.set_signer(keys, cx);
})?; })
.ok();
} }
Ok(None) => { Ok(None) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_state(DeviceState::Requesting, cx); this.set_state(DeviceState::Requesting, cx);
})?; })
.ok();
} }
Err(e) => { Err(e) => {
log::error!("Failed to request the encryption key: {e}"); log::error!("Failed to request the encryption key: {e}");
} }
}; };
})
Ok(()) .detach();
}));
} }
/// Parse the response event for device keys from other devices /// Parse the response event for device keys from other devices
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) { fn parse_response(&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).app_keys().clone();
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 root_device = event
@@ -568,35 +508,30 @@ impl DeviceRegistry {
Ok(keys) Ok(keys)
}); });
self.tasks.push(cx.spawn(async move |this, cx| { 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); })
})?; .ok();
}
Ok(()) Err(e) => {
})); log::error!("Error: {e}")
}
};
})
.detach();
} }
/// Approve requests for device keys from other devices /// Approve requests for device keys from other devices
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) { #[allow(dead_code)]
fn approve(&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 signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
return;
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let event = event.clone();
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; let signer = client.signer().context("Signer not found")?;
// Get device keys // Get device keys
let keys = get_keys(&client).await?; let keys = get_keys(&client).await?;
@@ -621,141 +556,15 @@ impl DeviceRegistry {
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]), Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
Tag::public_key(target), Tag::public_key(target),
]); ]);
// Sign the builder
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(())
}); });
cx.spawn_in(window, async move |_this, cx| { task.detach();
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.clear_notification(id, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let notification = self.notification(event, cx);
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
window.push_notification(notification, cx);
})
.ok();
})
.detach();
}
/// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
let request = Announcement::from(&event);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&request.public_key(), cx);
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
Notification::new()
.custom_id(SharedString::from(event.id.to_hex()))
.autohide(false)
.icon(IconName::UserKey)
.title(SharedString::from("New request"))
.content(move |_window, cx| {
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(MSG))
.child(
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Requester:")),
)
.child(
div()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).xsmall())
.child(profile.name()),
),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Client:")),
)
.child(
div()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(request.client_name()),
),
),
)
.into_any_element()
})
.action(move |_window, _cx| {
let view = entity.clone();
let event = event.clone();
Button::new("approve")
.label("Approve")
.small()
.primary()
.loading(loading.get())
.disabled(loading.get())
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
view.update(cx, |this, cx| {
this.approve(&event, window, cx);
})
.ok();
}
})
})
} }
} }
@@ -798,7 +607,8 @@ async fn get_keys(client: &Client) -> Result<Keys, Error> {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER) .identifier(IDENTIFIER)
.author(public_key); .author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first() { if let Some(event) = client.database().query(filter).await?.first() {
let content = signer.nip44_decrypt(&public_key, &event.content).await?; let content = signer.nip44_decrypt(&public_key, &event.content).await?;

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

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

View File

@@ -7,12 +7,12 @@ use gpui::{
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity, MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
Window, Window,
}; };
use ui::StyledExt;
use super::{DockArea, DockItem}; use super::{DockArea, DockItem};
use crate::dock_area::panel::PanelView; use crate::panel::PanelView;
use crate::dock_area::tab_panel::TabPanel;
use crate::resizable::{resize_handle, PANEL_MIN_SIZE}; use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
use crate::StyledExt; use crate::tab_panel::TabPanel;
#[derive(Clone, Render)] #[derive(Clone, Render)]
struct ResizePanel; struct ResizePanel;

View File

@@ -2,21 +2,22 @@ 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, actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, 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,
}; };
use theme::CLIENT_SIDE_DECORATION_ROUNDING; use ui::ElementExt;
use crate::dock_area::dock::{Dock, DockPlacement}; use crate::dock::{Dock, DockPlacement};
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView}; use crate::panel::{Panel, PanelEvent, PanelStyle, PanelView};
use crate::dock_area::stack_panel::StackPanel; use crate::stack_panel::StackPanel;
use crate::dock_area::tab_panel::TabPanel; use crate::tab_panel::TabPanel;
use crate::ElementExt;
pub mod dock; pub mod dock;
pub mod panel; pub mod panel;
pub mod resizable;
pub mod stack_panel; pub mod stack_panel;
pub mod tab;
pub mod tab_panel; pub mod tab_panel;
actions!(dock, [ToggleZoom, ClosePanel]); actions!(dock, [ToggleZoom, ClosePanel]);
@@ -203,16 +204,19 @@ impl DockItem {
/// Returns all panel ids /// Returns all panel ids
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> { pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
match self { match self {
Self::Panel { .. } => vec![],
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx), Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
Self::Split { items, .. } => items Self::Split { items, .. } => {
.iter() let mut total = vec![];
.filter_map(|item| match item {
DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)), for item in items.iter() {
_ => None, if let DockItem::Tabs { view, .. } = item {
}) total.extend(view.read(cx).panel_ids(cx));
.flatten() }
.collect(), }
total
}
Self::Panel { .. } => vec![],
} }
} }
@@ -588,13 +592,17 @@ impl DockArea {
} }
} }
DockPlacement::Right => { DockPlacement::Right => {
self.set_right_dock( if let Some(dock) = self.right_dock.as_ref() {
DockItem::tabs(vec![panel], None, &weak_self, window, cx), dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx))
Some(px(320.)), } else {
true, self.set_right_dock(
window, DockItem::tabs(vec![panel], None, &weak_self, window, cx),
cx, Some(px(320.)),
); true,
window,
cx,
);
}
} }
DockPlacement::Center => { DockPlacement::Center => {
self.items self.items
@@ -743,7 +751,6 @@ impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea { impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone(); let view = cx.entity().clone();
let decorations = window.window_decorations();
div() div()
.id("dock-area") .id("dock-area")
@@ -753,17 +760,7 @@ impl Render for DockArea {
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)) .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| { .map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() { if let Some(zoom_view) = self.zoom_view.clone() {
this.map(|this| match decorations { this.child(zoom_view)
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
} else { } else {
// render dock // render dock
this.child( this.child(

View File

@@ -2,9 +2,8 @@ use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render, AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render,
SharedString, Window, SharedString, Window,
}; };
use ui::button::Button;
use crate::button::Button; use ui::popup_menu::PopupMenu;
use crate::menu::PopupMenu;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent { pub enum PanelEvent {

View File

@@ -7,10 +7,10 @@ use gpui::{
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window,
}; };
use ui::{h_flex, v_flex, AxisExt, ElementExt};
use super::{resizable_panel, resize_handle, ResizableState}; use super::{resizable_panel, resize_handle, ResizableState};
use crate::resizable::PANEL_MIN_SIZE; use crate::resizable::PANEL_MIN_SIZE;
use crate::{h_flex, v_flex, AxisExt, ElementExt};
pub enum ResizablePanelEvent { pub enum ResizablePanelEvent {
Resized, Resized,
@@ -240,13 +240,11 @@ impl RenderOnce for ResizablePanel {
let state = self let state = self
.state .state
.expect("BUG: The `state` in ResizablePanel should be present."); .expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state let panel_state = state
.read(cx) .read(cx)
.panels .panels
.get(self.panel_ix) .get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`."); .expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone(); let size_range = self.size_range.clone();
div() div()

View File

@@ -8,9 +8,9 @@ use gpui::{
Point, Render, StatefulInteractiveElement, Styled as _, Window, Point, Render, StatefulInteractiveElement, Styled as _, Window,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::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.);
@@ -113,7 +113,7 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
let state = state.unwrap_or(ResizeHandleState::default()); let state = state.unwrap_or(ResizeHandleState::default());
let bg_color = if state.is_active() { let bg_color = if state.is_active() {
cx.theme().border_selected cx.theme().border_variant
} else { } else {
cx.theme().border cx.theme().border
}; };

View File

@@ -8,15 +8,15 @@ use gpui::{
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
use ui::{h_flex, AxisExt as _, Placement};
use super::{DockArea, PanelEvent}; use super::{DockArea, PanelEvent};
use crate::dock_area::panel::{Panel, PanelView}; use crate::panel::{Panel, PanelView};
use crate::dock_area::tab_panel::TabPanel;
use crate::resizable::{ use crate::resizable::{
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState, resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
PANEL_MIN_SIZE, PANEL_MIN_SIZE,
}; };
use crate::{h_flex, AxisExt as _, Placement}; use crate::tab_panel::TabPanel;
pub struct StackPanel { pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>, pub(super) parent: Option<WeakEntity<StackPanel>>,
@@ -371,6 +371,7 @@ impl Focusable for StackPanel {
} }
impl EventEmitter<PanelEvent> for StackPanel {} impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {} impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel { impl Render for StackPanel {

View File

@@ -4,8 +4,7 @@ use gpui::{
RenderOnce, StatefulInteractiveElement, Styled, Window, RenderOnce, StatefulInteractiveElement, Styled, Window,
}; };
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::{Selectable, Sizable, Size};
use crate::{Selectable, Sizable, Size};
pub mod tab_bar; pub mod tab_bar;

View File

@@ -7,8 +7,7 @@ use gpui::{
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::{h_flex, Sizable, Size, StyledExt};
use crate::{h_flex, Sizable, Size, StyledExt};
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct TabBar { pub struct TabBar {

View File

@@ -8,16 +8,16 @@ use gpui::{
StatefulInteractiveElement, Styled, WeakEntity, Window, StatefulInteractiveElement, Styled, WeakEntity, Window,
}; };
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT}; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants as _};
use ui::popup_menu::{PopupMenu, PopupMenuExt};
use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
use crate::button::{Button, ButtonVariants as _}; use crate::dock::DockPlacement;
use crate::dock_area::dock::DockPlacement; use crate::panel::{Panel, PanelView};
use crate::dock_area::panel::{Panel, PanelView}; use crate::stack_panel::StackPanel;
use crate::dock_area::stack_panel::StackPanel;
use crate::dock_area::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
use crate::menu::{DropdownMenu, PopupMenu};
use crate::tab::tab_bar::TabBar; 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::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
#[derive(Clone)] #[derive(Clone)]
struct TabState { struct TabState {
@@ -454,7 +454,7 @@ impl TabPanel {
.small() .small()
.ghost() .ghost()
.rounded() .rounded()
.dropdown_menu({ .popup_menu({
let zoomable = state.zoomable; let zoomable = state.zoomable;
let closable = state.closable; let closable = state.closable;
@@ -479,12 +479,12 @@ impl TabPanel {
_window: &mut Window, _window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<Button> { ) -> Option<Button> {
let dock_area = self.dock_area.upgrade()?.read(cx);
if self.zoomed { if self.zoomed {
return None; return None;
} }
let dock_area = self.dock_area.upgrade()?.read(cx);
if !dock_area.toggle_button_visible { if !dock_area.toggle_button_visible {
return None; return None;
} }
@@ -590,11 +590,10 @@ impl TabPanel {
.justify_between() .justify_between()
.items_center() .items_center()
.line_height(rems(1.0)) .line_height(rems(1.0))
.h(TABBAR_HEIGHT) .h(px(30.))
.py_2() .py_2()
.pl_3() .pl_3()
.pr_2() .pr_2()
.bg(cx.theme().panel_background)
.when(left_dock_button.is_some(), |this| this.pl_2()) .when(left_dock_button.is_some(), |this| this.pl_2())
.when(right_dock_button.is_some(), |this| this.pr_2()) .when(right_dock_button.is_some(), |this| this.pr_2())
.when(has_extend_dock_button, |this| { .when(has_extend_dock_button, |this| {
@@ -611,7 +610,6 @@ impl TabPanel {
div() div()
.id("tab") .id("tab")
.flex_1() .flex_1()
.px_2()
.min_w_16() .min_w_16()
.overflow_hidden() .overflow_hidden()
.whitespace_nowrap() .whitespace_nowrap()
@@ -640,8 +638,7 @@ impl TabPanel {
.flex_shrink_0() .flex_shrink_0()
.ml_1() .ml_1()
.gap_1() .gap_1()
.child(self.render_toolbar(state, window, cx)) .child(self.render_toolbar(state, window, cx)),
.children(right_dock_button),
) )
.into_any_element(); .into_any_element();
} }
@@ -1080,10 +1077,8 @@ impl TabPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if self.panels.len() > 1 { if let Some(panel) = self.active_panel(cx) {
if let Some(panel) = self.active_panel(cx) { self.remove_panel(&panel, window, cx);
self.remove_panel(&panel, window, cx);
}
} }
} }
} }

View File

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

View File

@@ -5,15 +5,15 @@ use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Error};
use common::EventUtils; use common::EventUtils;
use device::Announcement;
use gpui::{App, AppContext, Context, Entity, Global, Task}; use gpui::{App, AppContext, Context, Entity, Global, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
pub use person::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT}; use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
mod person; mod person;
pub use person::*;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
} }
@@ -116,7 +116,7 @@ impl PersonRegistry {
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let result = cx let result = cx
.background_executor() .background_executor()
.await_on_background(async move { load_persons(&client).await }) .await_on_background(async move { Self::load_persons(&client).await })
.await; .await;
match result { match result {
@@ -171,7 +171,7 @@ impl PersonRegistry {
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(); Self::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());
@@ -204,16 +204,70 @@ impl PersonRegistry {
batch.insert(public_key); batch.insert(public_key);
// Process the batch if it's full // Process the batch if it's full
if batch.len() >= 20 { if batch.len() >= 20 {
get_metadata(client, std::mem::take(&mut batch)).await.ok(); Self::get_metadata(client, std::mem::take(&mut batch))
.await
.ok();
} }
} }
_ => { _ => {
get_metadata(client, std::mem::take(&mut batch)).await.ok(); Self::get_metadata(client, std::mem::take(&mut batch))
.await
.ok();
} }
} }
} }
} }
/// Get metadata for all public keys in a event
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
where
I: IntoIterator<Item = PublicKey>,
{
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
let limit = authors.len();
if authors.is_empty() {
return Err(anyhow!("You need at least one public key"));
}
// Construct the subscription option
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(limit);
// Construct target for subscription
let target = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
/// Load all user profiles from the database
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)
}
/// 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) { if let Some(person) = self.persons.get(&event.pubkey) {
@@ -232,7 +286,7 @@ impl PersonRegistry {
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect(); let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
person.update(cx, |person, cx| { person.update(cx, |person, cx| {
person.set_messaging_relays(urls); person.set_messaging_relays(event.pubkey, urls);
cx.notify(); cx.notify();
}); });
} }
@@ -253,7 +307,7 @@ impl PersonRegistry {
match self.persons.get(&public_key) { match self.persons.get(&public_key) {
Some(this) => { Some(this) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_metadata(person.metadata()); *this = person;
cx.notify(); cx.notify();
}); });
} }
@@ -288,53 +342,3 @@ impl PersonRegistry {
Person::new(public_key, Metadata::default()) Person::new(public_key, Metadata::default())
} }
} }
/// Get metadata for all public keys in a event
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
where
I: IntoIterator<Item = PublicKey>,
{
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
let limit = authors.len();
if authors.is_empty() {
return Err(anyhow!("You need at least one public key"));
}
// Construct the subscription option
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(limit);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
/// 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

@@ -1,9 +1,9 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use device::Announcement;
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl"; const IMAGE_RESIZER: &str = "https://wsrv.nl";
@@ -75,11 +75,6 @@ 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()
@@ -88,6 +83,7 @@ impl Person {
/// Set profile encryption keys announcement /// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) { pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement); self.announcement = Some(announcement);
log::info!("Updated announcement for: {}", self.public_key());
} }
/// Get profile messaging relays /// Get profile messaging relays
@@ -101,11 +97,12 @@ impl Person {
} }
/// Set profile messaging relays /// Set profile messaging relays
pub fn set_messaging_relays<I>(&mut self, relays: I) pub fn set_messaging_relays<I>(&mut self, public_key: PublicKey, relays: I)
where where
I: IntoIterator<Item = RelayUrl>, I: IntoIterator<Item = RelayUrl>,
{ {
self.messaging_relays = relays.into_iter().collect(); self.messaging_relays = relays.into_iter().collect();
log::info!("Updated messaging relays for: {}", public_key);
} }
/// Get profile avatar /// Get profile avatar

View File

@@ -67,7 +67,7 @@ pub struct RelayAuth {
pending_events: HashSet<(EventId, RelayUrl)>, pending_events: HashSet<(EventId, RelayUrl)>,
/// Tasks for asynchronous operations /// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 2]>, tasks: SmallVec<[Task<()>; 2]>,
} }
impl RelayAuth { impl RelayAuth {
@@ -83,15 +83,26 @@ impl RelayAuth {
/// Create a new relay auth instance /// Create a new relay auth instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
cx.defer_in(window, |this, window, cx| {
this.handle_notifications(window, cx);
});
Self {
pending_events: HashSet::default(),
tasks: smallvec![],
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let mut tasks = smallvec![];
// Channel for communication between nostr and gpui // Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Signal>(256); let (tx, rx) = flume::bounded::<Signal>(256);
tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
log::info!("Started handling nostr notifications");
let mut notifications = client.notifications(); let mut notifications = client.notifications();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default(); let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
@@ -106,22 +117,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, ..
} => { } => {
@@ -139,7 +134,7 @@ impl RelayAuth {
} }
})); }));
tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
while let Ok(signal) = rx.recv_async().await { while let Ok(signal) = rx.recv_async().await {
match signal { match signal {
Signal::Auth(req) => { Signal::Auth(req) => {
@@ -157,11 +152,6 @@ impl RelayAuth {
} }
} }
})); }));
Self {
pending_events: HashSet::default(),
_tasks: tasks,
}
} }
/// Insert a pending event waiting for resend after authentication /// Insert a pending event waiting for resend after authentication
@@ -172,12 +162,15 @@ impl RelayAuth {
/// Get all pending events for a specific relay, /// Get all pending events for a specific relay,
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> { fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
self.pending_events let pending_events: Vec<EventId> = self
.pending_events
.iter() .iter()
.filter(|(_, pending_relay)| pending_relay == relay) .filter(|(_, pending_relay)| pending_relay == relay)
.map(|(id, _relay)| id) .map(|(id, _relay)| id)
.cloned() .cloned()
.collect() .collect();
pending_events
} }
/// Clear all pending events for a specific relay, /// Clear all pending events for a specific relay,
@@ -289,12 +282,10 @@ impl RelayAuth {
Ok(_) => { Ok(_) => {
// Clear pending events for the authenticated relay // Clear pending events for the authenticated relay
this.clear_pending_events(url, cx); this.clear_pending_events(url, cx);
// Save the authenticated relay to automatically authenticate future requests // Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| { settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx); this.add_trusted_relay(url, cx);
}); });
window.push_notification(format!("{} has been authenticated", url), cx); window.push_notification(format!("{} has been authenticated", url), cx);
} }
Err(e) => { Err(e) => {
@@ -343,8 +334,8 @@ impl RelayAuth {
.px_1p5() .px_1p5()
.rounded_sm() .rounded_sm()
.text_xs() .text_xs()
.bg(cx.theme().elevated_surface_background) .bg(cx.theme().warning_background)
.text_color(cx.theme().text_accent) .text_color(cx.theme().warning_foreground)
.child(url.clone()), .child(url.clone()),
) )
.into_any_element() .into_any_element()
@@ -361,9 +352,11 @@ impl RelayAuth {
.disabled(loading.get()) .disabled(loading.get())
.on_click({ .on_click({
let loading = Rc::clone(&loading); let loading = Rc::clone(&loading);
move |_ev, window, cx| { move |_ev, window, cx| {
// Set loading state to true // Set loading state to true
loading.set(true); loading.set(true);
// Process to approve the request // Process to approve the request
view.update(cx, |this, cx| { view.update(cx, |this, cx| {
this.response(&req, window, cx); this.response(&req, window, cx);

View File

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

View File

@@ -1,17 +1,14 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::rc::Rc;
use anyhow::{Error, anyhow}; use anyhow::{anyhow, Error};
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec}; use smallvec::{smallvec, SmallVec};
use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx) AppSettings::set_global(cx.new(AppSettings::new), cx)
} }
macro_rules! setting_accessors { macro_rules! setting_accessors {
@@ -36,8 +33,6 @@ macro_rules! setting_accessors {
} }
setting_accessors! { setting_accessors! {
pub theme: Option<String>,
pub theme_mode: ThemeMode,
pub hide_avatar: bool, pub hide_avatar: bool,
pub screening: bool, pub screening: bool,
pub auth_mode: AuthMode, pub auth_mode: AuthMode,
@@ -54,20 +49,11 @@ pub enum AuthMode {
Manual, Manual,
} }
impl Display for AuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthMode::Auto => write!(f, "Auto"),
AuthMode::Manual => write!(f, "Ask every time"),
}
}
}
/// Signer kind /// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind { pub enum SignerKind {
#[default]
Auto, Auto,
#[default]
User, User,
Encryption, Encryption,
} }
@@ -94,43 +80,18 @@ pub struct RoomConfig {
} }
impl RoomConfig { impl RoomConfig {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::Auto,
}
}
/// Get backup config
pub fn backup(&self) -> bool { pub fn backup(&self) -> bool {
self.backup self.backup
} }
/// Set backup config
pub fn toggle_backup(&mut self) {
self.backup = !self.backup;
}
/// Get signer kind config
pub fn signer_kind(&self) -> &SignerKind { pub fn signer_kind(&self) -> &SignerKind {
&self.signer_kind &self.signer_kind
} }
/// Set signer kind config
pub fn set_signer_kind(&mut self, kind: &SignerKind) {
self.signer_kind = kind.to_owned();
}
} }
/// Settings /// Settings
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
/// Theme
pub theme: Option<String>,
/// Theme mode
pub theme_mode: ThemeMode,
/// Hide user avatars /// Hide user avatars
pub hide_avatar: bool, pub hide_avatar: bool,
@@ -146,21 +107,19 @@ pub struct Settings {
/// Configuration for each chat room /// Configuration for each chat room
pub room_configs: HashMap<u64, RoomConfig>, pub room_configs: HashMap<u64, RoomConfig>,
/// Server for blossom media attachments /// File server for NIP-96 media attachments
pub file_server: Url, pub file_server: Url,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
theme: None,
theme_mode: ThemeMode::default(),
hide_avatar: false, hide_avatar: false,
screening: true, screening: true,
auth_mode: AuthMode::default(), auth_mode: AuthMode::default(),
trusted_relays: HashSet::default(), trusted_relays: HashSet::default(),
room_configs: HashMap::default(), room_configs: HashMap::default(),
file_server: Url::parse("https://blossom.band/").unwrap(), file_server: Url::parse("https://nostrmedia.com").unwrap(),
} }
} }
} }
@@ -195,7 +154,7 @@ impl AppSettings {
cx.set_global(GlobalAppSettings(state)); cx.set_global(GlobalAppSettings(state));
} }
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
@@ -205,9 +164,12 @@ impl AppSettings {
}), }),
); );
// Run at the end of current cycle cx.defer(|cx| {
cx.defer_in(window, |this, window, cx| { let settings = AppSettings::global(cx);
this.load(window, cx);
settings.update(cx, |this, cx| {
this.load(cx);
});
}); });
Self { Self {
@@ -223,7 +185,7 @@ impl AppSettings {
} }
/// Load settings /// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move { let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings"); let path = config_dir().join(".settings");
@@ -234,13 +196,12 @@ impl AppSettings {
} }
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn(async move |this, cx| {
let settings = task.await.unwrap_or(Settings::default()); let settings = task.await.unwrap_or(Settings::default());
// Update settings // Update settings
this.update_in(cx, |this, window, cx| { this.update(cx, |this, cx| {
this.set_settings(settings, cx); this.set_settings(settings, cx);
this.apply_theme(window, cx);
}) })
.ok(); .ok();
}) })
@@ -264,38 +225,6 @@ impl AppSettings {
task.detach(); task.detach();
} }
/// Set theme
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<String>,
{
// Update settings
self.values.theme = Some(theme.into());
cx.notify();
// Apply the new theme
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
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None;
cx.notify();
self.apply_theme(window, cx);
}
/// Check if the given relay is already authenticated /// Check if the given relay is already authenticated
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool { pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.iter().any(|relay| { self.values.trusted_relays.iter().any(|relay| {

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ use std::sync::OnceLock;
pub const CLIENT_NAME: &str = "Coop"; pub const CLIENT_NAME: &str = "Coop";
/// COOP's public key /// COOP's public key
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"; pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv";
/// App ID /// App ID
pub const APP_ID: &str = "su.reya.coop"; pub const APP_ID: &str = "su.reya.coop";
@@ -21,28 +21,30 @@ pub const FIND_DELAY: u64 = 600;
/// Default limit for searching /// Default limit for searching
pub const FIND_LIMIT: usize = 20; pub const FIND_LIMIT: usize = 20;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default subscription id for device gift wrap events /// Default subscription id for device gift wrap events
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events /// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps"; pub const USER_GIFTWRAP: &str = "user-gift-wraps";
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
/// Default vertex relays /// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays /// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"]; pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
/// 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,83 +0,0 @@
use std::collections::{HashMap, HashSet};
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// Gossip
#[derive(Debug, Clone, Default)]
pub struct Gossip {
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
}
impl Gossip {
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.map(|(url, _)| url.to_string().into())
.collect()
})
.unwrap_or_default()
}
/// Get read relays for a given public key
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Get write relays for a given public key
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Insert gossip relays for a public key
pub fn insert_relays(&mut self, event: &Event) {
self.relays.entry(event.pubkey).or_default().extend(
event
.tags
.iter()
.filter_map(|tag| {
if let Some(TagStandard::RelayMetadata {
relay_url,
metadata,
}) = tag.clone().to_standardized()
{
Some((relay_url, metadata))
} else {
None
}
})
.take(3),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::result::Result; use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -15,6 +16,14 @@ pub struct CoopSigner {
/// Specific signer for encryption purposes /// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>, encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
/// Whether coop is creating a new identity
creating: AtomicBool,
/// By default, Coop generates a new signer for new users.
///
/// This flag indicates whether the signer is user-owned or Coop-generated.
owned: AtomicBool,
} }
impl CoopSigner { impl CoopSigner {
@@ -26,6 +35,8 @@ impl CoopSigner {
signer: RwLock::new(signer.into_nostr_signer()), signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None), signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None), encryption_signer: RwLock::new(None),
creating: AtomicBool::new(false),
owned: AtomicBool::new(false),
} }
} }
@@ -40,15 +51,22 @@ impl CoopSigner {
} }
/// Get public key /// Get public key
///
/// Ensure to call this method after the signer has been initialized.
/// Otherwise, this method 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().to_owned()
}
/// Get the flag indicating whether the signer is creating a new identity.
pub fn creating(&self) -> bool {
self.creating.load(Ordering::SeqCst)
}
/// Get the flag indicating whether the signer is user-owned.
pub fn owned(&self) -> bool {
self.owned.load(Ordering::SeqCst)
} }
/// Switch the current signer to a new signer. /// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T) pub async fn switch<T>(&self, new: T, owned: bool)
where where
T: IntoNostrSigner, T: IntoNostrSigner,
{ {
@@ -66,6 +84,9 @@ impl CoopSigner {
// Reset the encryption signer // Reset the encryption signer
*encryption_signer = None; *encryption_signer = None;
// Update the owned flag
self.owned.store(owned, Ordering::SeqCst);
} }
/// Set the encryption signer. /// Set the encryption signer.

View File

@@ -8,13 +8,10 @@ use crate::scale::{ColorScale, ColorScaleSet, ColorScales};
pub struct ThemeColors { pub struct ThemeColors {
// Surface colors // Surface colors
pub background: Hsla, pub background: Hsla,
pub overlay: Hsla,
pub surface_background: Hsla, pub surface_background: Hsla,
pub elevated_surface_background: Hsla, pub elevated_surface_background: Hsla,
pub panel_background: Hsla, pub panel_background: Hsla,
pub overlay: Hsla,
pub title_bar: Hsla,
pub title_bar_inactive: Hsla,
pub window_border: Hsla,
// Border colors // Border colors
pub border: Hsla, pub border: Hsla,
@@ -94,109 +91,26 @@ pub struct ThemeColors {
pub drop_target_background: Hsla, pub drop_target_background: Hsla,
pub cursor: Hsla, pub cursor: Hsla,
pub selection: Hsla, pub selection: Hsla,
// System
pub titlebar: Hsla,
pub titlebar_inactive: Hsla,
} }
/// The default colors for the theme. /// The default colors for the theme.
/// ///
/// Themes that do not specify all colors are refined off of these defaults. /// Themes that do not specify all colors are refined off of these defaults.
impl ThemeColors { impl ThemeColors {
/// Returns the default colors for light themes.
///
/// Themes that do not specify all colors are refined off of these defaults.
pub fn light() -> Self {
Self {
background: neutral().light().step_1(),
surface_background: neutral().light().step_2(),
elevated_surface_background: neutral().light().step_3(),
panel_background: neutral().light().step_1(),
overlay: neutral().light_alpha().step_3(),
title_bar: neutral().light().step_2(),
title_bar_inactive: neutral().light().step_3(),
window_border: hsl(240.0, 5.9, 78.0),
border: neutral().light().step_6(),
border_variant: neutral().light().step_5(),
border_focused: brand().light().step_7(),
border_selected: brand().light().step_7(),
border_transparent: gpui::transparent_black(),
border_disabled: neutral().light().step_3(),
ring: brand().light().step_8(),
text: neutral().light().step_12(),
text_muted: neutral().light().step_11(),
text_placeholder: neutral().light().step_10(),
text_accent: brand().light().step_11(),
icon: neutral().light().step_11(),
icon_muted: neutral().light().step_10(),
icon_accent: brand().light().step_11(),
element_foreground: brand().light().step_12(),
element_background: brand().light().step_9(),
element_hover: brand().light_alpha().step_10(),
element_active: brand().light().step_10(),
element_selected: brand().light().step_11(),
element_disabled: brand().light_alpha().step_3(),
secondary_foreground: brand().light().step_11(),
secondary_background: brand().light().step_3(),
secondary_hover: brand().light_alpha().step_4(),
secondary_active: brand().light().step_5(),
secondary_selected: brand().light().step_5(),
secondary_disabled: brand().light_alpha().step_3(),
danger_foreground: danger().light().step_12(),
danger_background: danger().light().step_3(),
danger_hover: danger().light_alpha().step_4(),
danger_active: danger().light().step_5(),
danger_selected: danger().light().step_5(),
danger_disabled: danger().light_alpha().step_3(),
warning_foreground: warning().light().step_12(),
warning_background: warning().light().step_3(),
warning_hover: warning().light_alpha().step_4(),
warning_active: warning().light().step_5(),
warning_selected: warning().light().step_5(),
warning_disabled: warning().light_alpha().step_3(),
ghost_element_background: gpui::transparent_black(),
ghost_element_background_alt: neutral().light().step_3(),
ghost_element_hover: neutral().light_alpha().step_4(),
ghost_element_active: neutral().light().step_5(),
ghost_element_selected: neutral().light().step_5(),
ghost_element_disabled: neutral().light_alpha().step_2(),
tab_inactive_background: neutral().light().step_2(),
tab_inactive_foreground: neutral().light().step_11(),
tab_active_background: neutral().light().step_1(),
tab_active_foreground: neutral().light().step_12(),
tab_hover_foreground: brand().light().step_9(),
scrollbar_thumb_background: neutral().light_alpha().step_3(),
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
scrollbar_thumb_border: gpui::transparent_black(),
scrollbar_track_background: gpui::transparent_black(),
scrollbar_track_border: neutral().light().step_5(),
drop_target_background: brand().light_alpha().step_2(),
cursor: hsl(200., 100., 50.),
selection: hsl(200., 100., 50.).alpha(0.25),
}
}
/// Returns the default colors for dark themes. /// Returns the default colors for dark themes.
/// ///
/// Themes that do not specify all colors are refined off of these defaults. /// Themes that do not specify all colors are refined off of these defaults.
pub fn dark() -> Self { pub fn colors() -> Self {
Self { Self {
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_3(),
overlay: neutral().dark_alpha().step_3(), overlay: neutral().dark_alpha().step_3(),
title_bar: gpui::transparent_black(),
title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0),
border: neutral().dark().step_6(), border: neutral().dark().step_6(),
border_variant: neutral().dark().step_5(), border_variant: neutral().dark().step_5(),
@@ -265,6 +179,9 @@ impl ThemeColors {
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.),
selection: hsl(200., 100., 50.).alpha(0.25), selection: hsl(200., 100., 50.).alpha(0.25),
titlebar: neutral().dark_alpha().step_1(),
titlebar_inactive: neutral().dark_alpha().step_2(),
} }
} }
} }

View File

@@ -1,7 +1,7 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::rc::Rc; use std::rc::Rc;
use gpui::{App, Global, Pixels, SharedString, Window, px}; use gpui::{px, App, Global, Pixels, SharedString, Window};
mod colors; mod colors;
mod platform_kind; mod platform_kind;
@@ -29,12 +29,15 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Defines window titlebar height /// Defines window titlebar height
pub const TITLEBAR_HEIGHT: Pixels = px(36.0); pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height /// Defines tabbar height
pub const TABBAR_HEIGHT: Pixels = px(28.0); pub const TABBAR_HEIGHT: Pixels = px(30.);
/// Defines default sidebar width /// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.); pub const SIDEBAR_WIDTH: Pixels = px(240.);
/// Defines search input width
pub const SEARCH_INPUT_WIDTH: Pixels = px(420.);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
registry::init(cx); registry::init(cx);
@@ -82,7 +85,7 @@ pub struct Theme {
/// Show the scrollbar mode, default: scrolling /// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode, pub scrollbar_mode: ScrollbarMode,
/// Platform kind /// Platform
pub platform: PlatformKind, pub platform: PlatformKind,
} }
@@ -166,11 +169,7 @@ impl Theme {
theme.mode = mode; theme.mode = mode;
// Set the theme colors // Set the theme colors
if mode.is_dark() { theme.colors = *theme.theme.colors();
theme.colors = *theme.theme.dark();
} else {
theme.colors = *theme.theme.light();
}
// Refresh the window if available // Refresh the window if available
if let Some(window) = window { if let Some(window) = window {
@@ -183,7 +182,8 @@ impl From<ThemeFamily> for Theme {
fn from(family: ThemeFamily) -> Self { fn from(family: ThemeFamily) -> Self {
let platform = PlatformKind::platform(); let platform = PlatformKind::platform();
let mode = ThemeMode::default(); let mode = ThemeMode::default();
// Define the theme colors based on the appearance
let colors = family.colors();
// Define the font family based on the platform. // Define the font family based on the platform.
// TODO: Use native fonts on Linux too. // TODO: Use native fonts on Linux too.
let font_family = match platform { let font_family = match platform {
@@ -191,12 +191,6 @@ impl From<ThemeFamily> for Theme {
_ => ".SystemUIFont", _ => ".SystemUIFont",
}; };
// Define the theme colors based on the appearance
let colors = match mode {
ThemeMode::Light => family.light(),
ThemeMode::Dark => family.dark(),
};
Theme { Theme {
font_size: px(15.), font_size: px(15.),
font_family: font_family.into(), font_family: font_family.into(),

View File

@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::ThemeColors; use crate::ThemeColors;
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash, Deserialize, Serialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash)]
pub enum ThemeMode { pub enum ThemeMode {
#[default] #[default]
Light, Light,
@@ -18,11 +18,11 @@ impl ThemeMode {
matches!(self, Self::Dark) matches!(self, Self::Dark)
} }
/// Return theme name: `light`, `dark`. /// Return lower_case theme name: `light`, `dark`.
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
ThemeMode::Light => "Light", ThemeMode::Light => "light",
ThemeMode::Dark => "Dark", ThemeMode::Dark => "dark",
} }
} }
} }
@@ -51,37 +51,27 @@ pub struct ThemeFamily {
/// The URL of the theme. /// The URL of the theme.
pub url: String, pub url: String,
/// The light colors for the theme. /// The colors for the theme.
pub light: ThemeColors, pub colors: ThemeColors,
/// The dark colors for the theme.
pub dark: ThemeColors,
} }
impl Default for ThemeFamily { impl Default for ThemeFamily {
fn default() -> Self { fn default() -> Self {
ThemeFamily { ThemeFamily {
id: "coop".into(), id: "coop".into(),
name: "Coop Default Theme".into(), name: "Coop Dark".into(),
author: "Coop".into(), author: "Coop".into(),
url: "https://github.com/lumehq/coop".into(), url: "https://github.com/lumehq/coop".into(),
light: ThemeColors::light(), colors: ThemeColors::colors(),
dark: ThemeColors::dark(),
} }
} }
} }
impl ThemeFamily { impl ThemeFamily {
/// Returns the light colors for the theme. /// Returns the colors for the theme.
#[inline(always)] #[inline(always)]
pub fn light(&self) -> &ThemeColors { pub fn colors(&self) -> &ThemeColors {
&self.light &self.colors
}
/// Returns the dark colors for the theme.
#[inline(always)]
pub fn dark(&self) -> &ThemeColors {
&self.dark
} }
/// Load a theme family from a JSON file. /// Load a theme family from a JSON file.
@@ -153,14 +143,14 @@ impl ThemeFamily {
/// ///
/// # fn main() -> anyhow::Result<()> { /// # fn main() -> anyhow::Result<()> {
/// // Assuming the file exists at `assets/themes/my-theme.json` /// // Assuming the file exists at `assets/themes/my-theme.json`
/// let theme = ThemeFamily::from_assets("themes/my-theme.json")?; /// let theme = ThemeFamily::from_assets("my-theme")?;
/// ///
/// println!("Loaded theme: {}", theme.name); /// println!("Loaded theme: {}", theme.name);
/// # Ok(()) /// # Ok(())
/// # } /// # }
/// ``` /// ```
pub fn from_assets(target: &str) -> anyhow::Result<Self> { pub fn from_assets(name: &str) -> anyhow::Result<Self> {
let path = format!("assets/{target}"); let path = format!("assets/themes/{}.json", name);
Self::from_file(path) Self::from_file(path)
} }
} }

View File

@@ -1,172 +0,0 @@
#[cfg(target_os = "linux")]
use gpui::MouseButton;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
};
use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
use ui::h_flex;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
use crate::platforms::windows::WindowsWindowControls;
mod platforms;
/// Titlebar
pub struct TitleBar {
/// Children elements of the title bar.
children: SmallVec<[AnyElement; 2]>,
/// Whether the title bar is currently being moved.
should_move: bool,
}
impl TitleBar {
pub fn new() -> Self {
Self {
children: smallvec![],
should_move: false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn height(&self, window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(&self, _window: &mut Window) -> Pixels {
px(32.)
}
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().title_bar
} else {
cx.theme().title_bar_inactive
}
} else {
cx.theme().title_bar
}
}
pub fn set_children<T>(&mut self, children: T)
where
T: IntoIterator<Item = AnyElement>,
{
self.children = children.into_iter().collect();
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
impl Render for TitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let height = self.height(window);
let color = self.titlebar_color(window, cx);
let children = std::mem::take(&mut self.children);
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
h_flex()
.window_control_area(WindowControlArea::Drag)
.h(height)
.w_full()
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
})
.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.bg(color)
.border_b_1()
.border_color(cx.theme().border)
.content_stretch()
.child(
h_flex()
.id("title-bar")
.justify_between()
.w_full()
.when(cx.theme().platform.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.zoom_window();
}
})
})
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.children(children),
)
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
PlatformKind::Linux => {
#[cfg(target_os = "linux")]
if matches!(decorations, Decorations::Client { .. }) {
this.child(LinuxWindowControls::new(None))
.when(supported_controls.window_menu, |this| {
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
window.show_window_menu(ev.position)
})
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.should_move {
this.should_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = true;
}),
)
} else {
this
}
#[cfg(not(target_os = "linux"))]
this
}
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
PlatformKind::Mac => this,
})
}
}

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "title_bar" name = "titlebar"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
@@ -9,9 +9,7 @@ common = { path = "../common" }
theme = { path = "../theme" } theme = { path = "../theme" }
ui = { path = "../ui" } ui = { path = "../ui" }
nostr-sdk.workspace = true
gpui.workspace = true gpui.workspace = true
smol.workspace = true
smallvec.workspace = true smallvec.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true log.workspace = true

188
crates/titlebar/src/lib.rs Normal file
View File

@@ -0,0 +1,188 @@
use gpui::prelude::FluentBuilder;
#[cfg(target_os = "linux")]
use gpui::MouseButton;
#[cfg(not(target_os = "windows"))]
use gpui::Pixels;
use gpui::{
div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
};
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
use ui::h_flex;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
use crate::platforms::windows::WindowsWindowControls;
mod platforms;
/// Titlebar
pub struct TitleBar {
/// Children elements of the title bar.
children: SmallVec<[AnyElement; 2]>,
/// Whether the title bar is currently being moved.
should_move: bool,
}
impl TitleBar {
pub fn new() -> Self {
Self {
children: smallvec![],
should_move: false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn height(&self, window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(&self, _window: &mut Window) -> Pixels {
px(32.)
}
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().titlebar
} else {
cx.theme().titlebar_inactive
}
} else {
cx.theme().titlebar
}
}
pub fn set_children<T>(&mut self, children: T)
where
T: IntoIterator<Item = AnyElement>,
{
self.children = children.into_iter().collect();
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
impl Render for TitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let height = self.height(window);
let color = self.titlebar_color(window, cx);
let children = std::mem::take(&mut self.children);
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
h_flex()
.window_control_area(WindowControlArea::Drag)
.h(height)
.w_full()
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
})
.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |el| {
el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.bg(color)
.border_b_1()
.border_color(cx.theme().border)
.content_stretch()
.child(
h_flex()
.id("title-bar")
.justify_between()
.w_full()
.when(cx.theme().platform.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.zoom_window();
}
})
})
.children(children),
)
.child(
h_flex()
.absolute()
.top_0()
.right_0()
.pr_2()
.h(height)
.child(
div().when(!window.is_fullscreen(), |this| match cx.theme().platform {
PlatformKind::Linux => {
#[cfg(target_os = "linux")]
if matches!(decorations, Decorations::Client { .. }) {
this.child(LinuxWindowControls::new(None))
.when(supported_controls.window_menu, |this| {
this.on_mouse_down(
MouseButton::Right,
move |ev, window, _| {
window.show_window_menu(ev.position)
},
)
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.should_move {
this.should_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(
move |this, _ev, _window, _cx| {
this.should_move = false;
},
))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = true;
}),
)
} else {
this
}
#[cfg(not(target_os = "linux"))]
this
}
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
PlatformKind::Mac => this,
}),
),
)
}
}

View File

@@ -1,333 +0,0 @@
//! This is a fork of gpui's anchored element that adds support for offsetting
//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
use gpui::{
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
Window,
};
use smallvec::SmallVec;
use crate::Anchor;
/// The state that the anchored element element uses to track its children.
pub struct AnchoredState {
child_layout_ids: SmallVec<[LayoutId; 4]>,
}
/// An anchored element that can be used to display UI that
/// will avoid overflowing the window bounds.
pub(crate) struct Anchored {
children: SmallVec<[AnyElement; 2]>,
anchor_corner: Anchor,
fit_mode: AnchoredFitMode,
anchor_position: Option<Point<Pixels>>,
position_mode: AnchoredPositionMode,
offset: Option<Point<Pixels>>,
}
/// anchored gives you an element that will avoid overflowing the window bounds.
/// Its children should have no margin to avoid measurement issues.
pub(crate) fn anchored() -> Anchored {
Anchored {
children: SmallVec::new(),
anchor_corner: Anchor::TopLeft,
fit_mode: AnchoredFitMode::SwitchAnchor,
anchor_position: None,
position_mode: AnchoredPositionMode::Window,
offset: None,
}
}
#[allow(dead_code)]
impl Anchored {
/// Sets which corner of the anchored element should be anchored to the current position.
pub fn anchor(mut self, anchor: Anchor) -> Self {
self.anchor_corner = anchor;
self
}
/// Sets the position in window coordinates
/// (otherwise the location the anchored element is rendered is used)
pub fn position(mut self, anchor: Point<Pixels>) -> Self {
self.anchor_position = Some(anchor);
self
}
/// Offset the final position by this amount.
/// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu.
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
self.offset = Some(offset);
self
}
/// Sets the position mode for this anchored element. Local will have this
/// interpret its [`Anchored::position`] as relative to the parent element.
/// While Window will have it interpret the position as relative to the window.
pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self {
self.position_mode = mode;
self
}
/// Snap to window edge instead of switching anchor corner when an overflow would occur.
pub fn snap_to_window(mut self) -> Self {
self.fit_mode = AnchoredFitMode::SnapToWindow;
self
}
/// Snap to window edge and leave some margins.
pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
self
}
}
impl ParentElement for Anchored {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Element for Anchored {
type PrepaintState = ();
type RequestLayoutState = AnchoredState;
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let child_layout_ids = self
.children
.iter_mut()
.map(|child| child.request_layout(window, cx))
.collect::<SmallVec<_>>();
let anchored_style = Style {
position: Position::Absolute,
display: Display::Flex,
..Style::default()
};
let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
(layout_id, AnchoredState { child_layout_ids })
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
if request_layout.child_layout_ids.is_empty() {
return;
}
let mut child_min = point(Pixels::MAX, Pixels::MAX);
let mut child_max = Point::default();
for child_layout_id in &request_layout.child_layout_ids {
let child_bounds = window.layout_bounds(*child_layout_id);
child_min = child_min.min(&child_bounds.origin);
child_max = child_max.max(&child_bounds.bottom_right());
}
let size: Size<Pixels> = (child_max - child_min).into();
let (origin, mut desired) = self.position_mode.get_position_and_bounds(
self.anchor_position,
self.anchor_corner,
size,
bounds,
self.offset,
);
let limits = Bounds {
origin: Point::default(),
size: window.viewport_size(),
};
if self.fit_mode == AnchoredFitMode::SwitchAnchor {
let mut anchor_corner = self.anchor_corner;
if desired.left() < limits.left() || desired.right() > limits.right() {
let switched = Bounds::from_corner_and_size(
anchor_corner
.other_side_corner_along(Axis::Horizontal)
.into(),
origin,
size,
);
if !(switched.left() < limits.left() || switched.right() > limits.right()) {
anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal);
desired = switched
}
}
if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
let switched = Bounds::from_corner_and_size(
anchor_corner.other_side_corner_along(Axis::Vertical).into(),
origin,
size,
);
if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
desired = switched;
}
}
}
let client_inset = window.client_inset().unwrap_or(px(0.));
let edges = match self.fit_mode {
AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
_ => Edges::default(),
}
.map(|edge| *edge + client_inset);
// Snap the horizontal edges of the anchored element to the horizontal edges of the window if
// its horizontal bounds overflow, aligning to the left if it is wider than the limits.
if desired.right() > limits.right() {
desired.origin.x -= desired.right() - limits.right() + edges.right;
}
if desired.left() < limits.left() {
desired.origin.x = limits.origin.x + edges.left;
}
// Snap the vertical edges of the anchored element to the vertical edges of the window if
// its vertical bounds overflow, aligning to the top if it is taller than the limits.
if desired.bottom() > limits.bottom() {
desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
}
if desired.top() < limits.top() {
desired.origin.y = limits.origin.y + edges.top;
}
let offset = desired.origin - bounds.origin;
let offset = point(offset.x.round(), offset.y.round());
window.with_element_offset(offset, |window| {
for child in &mut self.children {
child.prepaint(window, cx);
}
})
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
for child in &mut self.children {
child.paint(window, cx);
}
}
}
impl IntoElement for Anchored {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
/// Which algorithm to use when fitting the anchored element to be inside the window.
#[allow(dead_code)]
#[derive(Copy, Clone, PartialEq)]
pub enum AnchoredFitMode {
/// Snap the anchored element to the window edge.
SnapToWindow,
/// Snap to window edge and leave some margins.
SnapToWindowWithMargin(Edges<Pixels>),
/// Switch which corner anchor this anchored element is attached to.
SwitchAnchor,
}
/// Which algorithm to use when positioning the anchored element.
#[allow(dead_code)]
#[derive(Copy, Clone, PartialEq)]
pub enum AnchoredPositionMode {
/// Position the anchored element relative to the window.
Window,
/// Position the anchored element relative to its parent.
Local,
}
impl AnchoredPositionMode {
fn get_position_and_bounds(
&self,
anchor_position: Option<Point<Pixels>>,
anchor_corner: Anchor,
size: Size<Pixels>,
bounds: Bounds<Pixels>,
offset: Option<Point<Pixels>>,
) -> (Point<Pixels>, Bounds<Pixels>) {
let offset = offset.unwrap_or_default();
match self {
AnchoredPositionMode::Window => {
let anchor_position = anchor_position.unwrap_or(bounds.origin);
let bounds =
Self::from_corner_and_size(anchor_corner, anchor_position + offset, size);
(anchor_position, bounds)
}
AnchoredPositionMode::Local => {
let anchor_position = anchor_position.unwrap_or_default();
let bounds = Self::from_corner_and_size(
anchor_corner,
bounds.origin + anchor_position + offset,
size,
);
(anchor_position, bounds)
}
}
}
// Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863
fn from_corner_and_size(
anchor: Anchor,
origin: Point<Pixels>,
size: Size<Pixels>,
) -> Bounds<Pixels> {
let origin = match anchor {
Anchor::TopLeft => origin,
Anchor::TopCenter => Point {
x: origin.x - size.width.half(),
y: origin.y,
},
Anchor::TopRight => Point {
x: origin.x - size.width,
y: origin.y,
},
Anchor::BottomLeft => Point {
x: origin.x,
y: origin.y - size.height,
},
Anchor::BottomCenter => Point {
x: origin.x - size.width.half(),
y: origin.y - size.height,
},
Anchor::BottomRight => Point {
x: origin.x - size.width,
y: origin.y - size.height,
},
};
Bounds { origin, size }
}
}

View File

@@ -1,24 +1,10 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, div, img, px, rems, AbsoluteLength, App, Hsla, ImageSource, Img, IntoElement, ParentElement,
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, RenderOnce, Styled, StyledImage, Window,
Window,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{Sizable, Size};
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
match size {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
Size::Small => px(24.).into(),
Size::XSmall => px(20.).into(),
Size::Size(size) => size.into(),
}
}
/// An element that renders a user avatar with customizable appearance options. /// An element that renders a user avatar with customizable appearance options.
/// ///
/// # Examples /// # Examples
@@ -32,10 +18,8 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ``` /// ```
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Avatar { pub struct Avatar {
base: Div,
image: Img, image: Img,
style: StyleRefinement, size: Option<AbsoluteLength>,
size: Size,
border_color: Option<Hsla>, border_color: Option<Hsla>,
} }
@@ -43,10 +27,8 @@ impl Avatar {
/// Creates a new avatar element with the specified image source. /// Creates a new avatar element with the specified image source.
pub fn new(src: impl Into<ImageSource>) -> Self { pub fn new(src: impl Into<ImageSource>) -> Self {
Avatar { Avatar {
base: div(),
image: img(src), image: img(src),
style: StyleRefinement::default(), size: None,
size: Size::Medium,
border_color: None, border_color: None,
} }
} }
@@ -74,27 +56,14 @@ impl Avatar {
self.border_color = Some(color.into()); self.border_color = Some(color.into());
self self
} }
}
impl Sizable for Avatar { /// Size overrides the avatar size. By default they are 1rem.
fn with_size(mut self, size: impl Into<Size>) -> Self { pub fn size<L: Into<AbsoluteLength>>(mut self, size: impl Into<Option<L>>) -> Self {
self.size = size.into(); self.size = size.into().map(Into::into);
self self
} }
} }
impl Styled for Avatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl InteractiveElement for Avatar {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Avatar { impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() { let border_width = if self.border_color.is_some() {
@@ -102,7 +71,8 @@ impl RenderOnce for Avatar {
} else { } else {
px(0.) px(0.)
}; };
let image_size = avatar_size(self.size);
let image_size = self.size.unwrap_or_else(|| rems(1.).into());
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
div() div()

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