Author SHA1 Message Date
reya b349656c56 . 2026-07-27 10:46:10 +07:00
reya 8bbb472103 optimize 2026-07-27 10:14:19 +07:00
reya dfda7ff157 clean up 2026-07-27 09:25:38 +07:00
reya f46f15e10c update 2026-07-27 09:23:26 +07:00
reya 2f15615d7b clean up input component 2026-07-27 08:38:25 +07:00
reya 9addbd49f4 fix web build 2026-07-27 08:09:55 +07:00
108 changed files with 3305 additions and 5203 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ dist/
# Useless stuffs
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o
.cargo/
vendor/
wasm/
node_modules/
Generated
+701 -717
View File
File diff suppressed because it is too large Load Diff
+10 -16
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.1"
version = "1.0.0-beta5"
edition = "2024"
publish = false
@@ -19,22 +19,17 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
sha2 = "0.10"
base64 = "0.22"
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
nostr-memory = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ], branch = "send-bound" }
# Others
anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] }
chrono = "0.4.38"
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
@@ -47,9 +42,8 @@ schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
-452
View File
@@ -1,452 +0,0 @@
# NIP-17 Encrypted File Messages (kind 15) — Backend Plan
Implementation plan for sending and receiving NIP-17 **file messages** in coop.
**Scope: backend only.** This document covers crypto, blob upload/download, rumor
construction, rumor parsing and local caching. No UI work (composer button, file
rendering, image cache, decryption-on-render state) — that is a separate follow-up
once these APIs exist.
---
## 1. What the protocol requires
NIP-17 file messages are **not** a new transport. They reuse everything coop already
has (NIP-44, NIP-59 seal + gift wrap, kind 10050 inbox relays) and only add:
1. A new inner **rumor kind: `15`** (`Kind::Custom(15)`), whose `.content` is the URL
of an **encrypted** blob and whose tags carry the MIME type and the decryption
material.
2. **AES-256-GCM** encryption of the file bytes before upload.
Kind 15 tags (per NIP-17):
| Tag | Required | Meaning |
|---|---|---|
| `p` | yes | receivers (as for kind 14) |
| `e` | if reply | parent message id |
| `subject` | optional | conversation title |
| `file-type` | yes | MIME type of the **plaintext** file |
| `encryption-algorithm` | yes | `aes-gcm` (only supported value) |
| `decryption-key` | yes | key for the recipient |
| `decryption-nonce` | yes | nonce for the recipient |
| `x` | yes | SHA-256 hex of the **encrypted** file |
| `ox` | expected | SHA-256 hex of the file **before** encryption |
| `size` | optional | size of the **encrypted** file in bytes |
| `dim` | optional | `<width>x<height>` in pixels |
| `thumbhash` / `blurhash` | optional | placeholder previews |
| `thumb` | optional | thumbnail URL (same key/nonce) |
| `fallback` | optional | extra file sources (same key/nonce) |
Key material travels **inside** the gift wrap, so the public blob URL is useless
without it. That property falls out of the existing seal/gift-wrap code for free.
`thumbhash`, `blurhash`, `thumb`, `fallback` are out of scope for v1 (all optional).
---
## 2. What already exists in coop (verified against the pinned deps)
| Requirement | Location | Status |
|---|---|---|
| NIP-59 seal + gift wrap | `crates/chat/src/room.rs::send_gift_wrap` (`nip59::GiftWrapBuilder`) | exists |
| Per-recipient publish + self backup | `crates/chat/src/room.rs::send` | exists |
| Inbox relays (kind 10050) | `crates/chat/src/lib.rs::handle_notifications` | exists |
| Rumor unwrap + local cache | `crates/chat/src/lib.rs::{extract_rumor,try_unwrap_with,set_rumor,get_rumor}` | exists |
| Kind 14 rumor construction | `crates/chat/src/room.rs::rumor` | exists |
| Blossom upload | `crates/state/src/blossom.rs::upload` (plaintext, random signing key) | partial |
| **Kind 15 rumor + parse** | — | **to add** |
| **AES-256-GCM encrypt/decrypt** | — | **to add** |
| **Encrypted blob upload/download** | — | **to add** |
Verified facts about the pinned SDK (`rust-nostr@b230cec`, `nostr 0.45.4`) and
`gpui@69af529`:
- rust-nostr has **no** kind-15 helper and **no** AES-GCM/AES-GCM-tag support anywhere
(`FileMetadata` in NIP-94 is kind 1063, unrelated). `nip17.rs` only covers kind 14
and 10050.
- `nip59::GiftWrapBuilder::new(receiver, rumor: UnsignedEvent)` accepts **any**
`UnsignedEvent`, so kind 15 flows through the existing wrap/send path unchanged.
- `Kind` has no named variant for 15; `Kind::Custom(15)` is required, and
`Kind: Display` prints `as_u16()`, so `rumor.kind.to_string()` yields `"15"`.
- `nostr::nips::nip94::Sha256Hash` is public (`from_byte_array`, `to_hex`, `Display`,
`from_hex`) even though it lives in the nip94 module — usable for `x`/`ox` hex
formatting without a new hashing crate.
- `gpui::App::http_client()` returns `Arc<dyn HttpClient>` and
`gpui_web/src/http_client.rs` implements it, so HTTP download is cross-platform.
`AsyncApp` exposes `update(|app| ...)`, which is how backend async code reaches it.
- `nostr-blossom` exposes `upload_blob`, `get_blob`, `has_blob`, `list_blobs`,
`delete_blob`.
- The `k` tag is currently hardcoded to `"14"` in two places
(`room.rs::send_gift_wrap`, `lib.rs::set_rumor`) and used as a room-list filter
(`lib.rs::get_rooms_task`, `custom_tag(LOWERCASE_K, "14")`).
---
## 3. Dependencies
Add to `[workspace.dependencies]` in `Cargo.toml`, then reference from the crates below.
```toml
aes-gcm = "0.10" # NEW - RustCrypto: Aes256Gcm, aead::{Aead, KeyInit, OsRng}
sha2 = "0.10" # NEW (already in Cargo.lock, cached) - SHA-256 for x / ox
base64 = "0.22" # NEW as a direct dep (already in the tree transitively)
```
- No new RNG dependency: `aes_gcm::aead::OsRng` (the wasm getrandom backends are
already configured in `web/Cargo.toml`).
- No new hashing/hex dependency: `sha2` output → `nostr::nips::nip94::Sha256Hash::from_byte_array(...).to_hex()`.
- `aes-gcm` is the only crate that needs a crates.io fetch (`aes 0.8` / `aead 0.5` are
already in the lock file), so it is a small addition to the build graph.
- Hand-rolling AES-GCM is explicitly **not** an option.
---
## 4. Layering and type ownership
`chat` depends on `state` (see `crates/chat/Cargo.toml`), never the reverse, so the
shared types and tag names must live in `state`.
- `crates/state/src/file.rs` (new) owns:
- `EncryptedFile`, `FileAttachment`, the tag-name constants, `ALGORITHM = "aes-gcm"`
- `encrypt` / `decrypt` / `sha256_hex`
- `FileAttachment::from_tags` / `FileAttachment::tags` (single source of truth for
tag names, so build and parse can't drift)
- `upload_encrypted`, `download_and_decrypt`
- re-exported from `crates/state/src/lib.rs`: `mod file; pub use file::*;`
- `crates/chat` consumes it: `message.rs` (parse into `Message`), `room.rs`
(build kind-15 rumor), `lib.rs` (cache tag + room list query).
- `crates/chat` should re-export the type for the future UI layer:
`pub use state::FileAttachment;` in `crates/chat/src/lib.rs`.
---
## 5. Data flow (backend)
```mermaid
flowchart TD
A[caller: path + blossom server] --> B[read bytes + guess mime]
B --> C[encrypt: random 32B key, 12B nonce, AES-256-GCM]
C --> D[sha256 ciphertext = x, sha256 plaintext = ox]
D --> E[Blossom upload ciphertext]
E --> F[FileAttachment]
F --> G[Room::file_rumor -> kind 15 rumor]
G --> H[Room::send -> existing NIP-59 wrap per member + self backup]
H --> I[existing extract_rumor on receiver]
I --> J[Message.file]
J --> K[download_and_decrypt: GET url, verify x, decrypt]
```
---
## 6. Implementation steps
### Step 1 — Dependencies
Add the three lines from section 3 and wire them into `crates/state/Cargo.toml`
(`aes-gcm`, `sha2`, `base64`). Run `cargo check -p state` to confirm the fetch works.
### Step 2 — `crates/state/src/file.rs` (new, ~180 LOC)
```rust
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
use nostr::nips::nip94::Sha256Hash;
use sha2::{Digest, Sha256};
pub const ALGORITHM: &str = "aes-gcm";
/// Ciphertext hash tag (NIP-17).
const TAG_SHA256: &str = "x";
const TAG_ORIGINAL_SHA256: &str = "ox";
const TAG_FILE_TYPE: &str = "file-type";
const TAG_ALGORITHM: &str = "encryption-algorithm";
const TAG_KEY: &str = "decryption-key";
const TAG_NONCE: &str = "decryption-nonce";
const TAG_SIZE: &str = "size";
const TAG_DIM: &str = "dim";
const TAG_ALT: &str = "alt";
/// Maximum blob size accepted when downloading (bytes). See edge cases.
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
/// Result of encrypting a file: ciphertext to upload plus NIP-17 key material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedFile {
pub data: Vec<u8>,
/// base64-encoded 32-byte key
pub key: String,
/// base64-encoded 12-byte nonce
pub nonce: String,
}
/// NIP-17 kind 15 attachment metadata (tags + `.content` URL).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FileAttachment {
pub url: Url,
pub mime: String,
pub key: String,
pub nonce: String,
pub sha256: Option<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
/// Non-standard display name (see "Open decisions" #2).
pub name: Option<String>,
}
impl FileAttachment {
/// Build the NIP-17 tags for a kind 15 rumor.
pub fn tags(&self) -> Vec<Tag>;
/// Parse a kind 15 rumor's tags. Returns `None` if key material is missing
/// or `encryption-algorithm` is not `aes-gcm`.
pub fn from_tags(tags: &Tags) -> Option<Self>;
pub fn is_image(&self) -> bool;
pub fn display_name(&self) -> SharedString; // falls back to mime/size
}
/// AES-256-GCM encrypt with a fresh random key and nonce.
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile>;
/// AES-256-GCM decrypt using the values from a kind 15 rumor.
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>>;
/// Lowercase hex SHA-256, matching the NIP-94 `x`/`ox` convention.
pub fn sha256_hex(data: &[u8]) -> String;
/// Read a file, encrypt it, upload the ciphertext to Blossom, return the attachment.
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_encrypted(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<FileAttachment>;
/// Fetch the blob, verify its SHA-256 against `expected_sha256`, then decrypt.
pub async fn download_and_decrypt(
url: &Url,
key: &str,
nonce: &str,
expected_sha256: Option<&str>,
cx: &AsyncApp,
) -> Result<Vec<u8>>;
```
Implementation notes, in priority order:
1. **Compute `x` and `ox` locally with `sha256_hex`.** Do **not** derive `x` from
`BlobDescriptor::sha256` — that field is a `bitcoin_hashes::sha256::Hash` whose
`Display` byte order could not be confirmed from the vendored sources, and a
reversed digest would silently break integrity checks in other clients. Use the
Blossom response only for `blob.url`. (`sha2``Sha256Hash::from_byte_array(...).to_hex()`
gives the conventional digest order and is unambiguous.)
2. **Key/nonce decoding must be tolerant.** NIP-17 does not specify an encoding:
accept base64 standard (padded and unpadded), base64url, and hex on read; emit
base64 standard on write. See "Open decisions" #1.
3. Validate sizes after decoding: key must be exactly 32 bytes, nonce exactly 12.
Return a descriptive error otherwise.
4. `decrypt` must fail closed on a bad tag (GCM authentication failure) — never
return partial plaintext.
5. `upload_encrypted` mirrors the existing `state::blossom::upload` shape:
`smol::fs::read` + `mime_guess::from_path`, `BlossomClient::new(server)`,
`upload_blob(ciphertext, Some("application/octet-stream"), None, Some(&keys))`
wrapped in `Tokio::spawn(cx, ...)`. Note the content type describes the
**ciphertext**; the plaintext MIME goes in the `file-type` tag.
On `wasm32` return `Err(anyhow!("File upload not supported on web"))`, matching
the existing stub in `blossom.rs`.
6. `download_and_decrypt` uses gpui's HTTP client
(`cx.update(|app| app.http_client())`, then
`client.get(url.as_str(), AsyncBody::default(), true)`), reads the body with
`futures::AsyncReadExt`, caps the read at `MAX_FILE_SIZE + 1` and rejects anything
larger, verifies `sha256_hex(&ciphertext)` against `expected_sha256`
(case-insensitive) when present, then decrypts. This path works on desktop and web.
7. `size` in `FileAttachment` is the **ciphertext** size (per the NIP wording), i.e.
`encrypted.data.len()`. `dim` is optional; if wanted, decode dimensions with the
`image` crate (`image::ImageReader`) — mark as a nice-to-have, not a blocker.
8. `encrypt` should take `&[u8]` (not a path) so it stays pure and testable without I/O.
### Step 3 — `crates/chat/src/message.rs` (~80 LOC)
- Add the kind constant next to `Message`:
```rust
/// NIP-17 file message (rust-nostr has no named `Kind` variant for 15).
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
```
- Add the field:
```rust
pub struct Message {
// ... existing fields ...
/// Encrypted file attachment (NIP-17 kind 15)
pub file: Option<FileAttachment>,
}
```
- In all three `From` impls (`&Event`, `&UnsignedEvent`, `&NewMessage`), parse from
tags when `kind == KIND_FILE_MESSAGE`, and when a file is present **do not** run
`extract_and_remove_media_urls` or `extract_mentions` on `.content` — for kind 15
`.content` is a ciphertext URL that must never be rendered as text or as media:
```rust
let file = (val.kind == KIND_FILE_MESSAGE)
.then(|| FileAttachment::from_tags(&val.tags))
.flatten();
let (media, content) = if file.is_some() {
(vec![], String::new())
} else {
extract_and_remove_media_urls(&val.content)
};
```
- Add a helper for non-UI consumers that display a message one-line
(reply previews, notifications, copy):
```rust
/// Single-line representation: text for kind 14, `[File] <name>` for kind 15.
pub fn preview(&self) -> SharedString;
```
- `Ord`/`Eq`/`Hash` are keyed on `id`/`created_at`, so adding the field does not
change `Message` ordering semantics.
### Step 4 — `crates/chat/src/room.rs` (~70 LOC)
- Extract the shared tag logic from `rumor()` into a private helper so kind 14 and
kind 15 cannot drift:
```rust
/// `subject` + reply `e` tags + receiver `p` tags (excluding `sender`).
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag>;
```
`rumor()` then becomes `EventBuilder::new(kind, content).tags(self.conversation_tags(...))`,
and gains no behavior change.
- Add:
```rust
/// Build a NIP-17 kind 15 (file message) rumor.
///
/// WARNING: never sign and send this event to relays; it is only ever wrapped.
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
where
I: IntoIterator<Item = EventId>,
{
// tags = conversation_tags(...) ++ file.tags()
// content = file.url.to_string()
// EventBuilder::new(KIND_FILE_MESSAGE, content).tags(tags).finalize_unsigned(sender)
// then event.ensure_id()
}
```
- Fix the gift wrap's `k` tag so relays see the real inner kind
(`send_gift_wrap`, currently hardcoded):
```rust
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
```
- `Room::send` needs **no change**: it already accepts any `UnsignedEvent`, and the
self-backup branch publishes the same rumor wrapped for the sender, so the sender's
other devices receive the file message with the same key material.
### Step 5 — `crates/chat/src/lib.rs` (~5 LOC)
- `set_rumor`: store the real kind in the local cache tag:
```rust
Tag::custom("k", [rumor.kind.to_string()]),
```
- `get_rooms_task` currently filters `custom_tag(SingleLetterTag::LOWERCASE_K, "14")`,
which would hide rooms whose only messages are file messages. Simplest fix: drop
the `k` filter and keep `.kind(Kind::ApplicationSpecificData)`; the existing
in-loop JSON parse plus `tags.public_keys().next().is_some()` check already
discards junk, and kind 30078 rows in the local DB exist only for this rumor cache.
Fallback if the extra rows are a concern: run two queries (`k = "14"` and
`k = "15"`) and merge the results.
- No changes to `handle_notifications`, `extract_rumor`, `try_unwrap_with`, `new_message`,
`Room::get_messages` — all are already kind-agnostic. (`Room::get_messages` filters
only on the `r` room tag, so file messages load with the rest of the history.)
### Step 6 — (out of scope) UI wiring
Composer entry point, decryption cache, image/file rendering live in
`crates/chat_ui`. Not part of this plan; the APIs above are what that layer needs:
`state::upload_encrypted`, `FileAttachment::tags/from_tags`, `Room::file_rumor`,
`Room::send`, `state::download_and_decrypt`.
---
## 7. Open decisions
1. **Key/nonce encoding (interop risk — decide/verify first).** NIP-17 does not specify
how `decryption-key` / `decryption-nonce` are encoded, and no reference
implementation could be reached from this environment to confirm. Plan: emit
**base64 standard**, accept base64 std/unpadded/url-safe/hex on read. Before
release, verify against another NIP-17 client (0xchat, Amethyst) and tighten if
needed. Everything else in the plan is encoding-agnostic.
2. **Display name.** The NIP defines no filename tag and Blossom URLs end in a content
hash, so a name has to come from a non-standard tag. Proposal: emit
`["alt", <file name>]` (NIP-94 tag; NIP-17 allows "rest of tags", and `alt` is a
benign a11y field), tolerate its absence, fall back to mime + size. Alternative:
ship strictly spec-shaped and show mime + size only.
3. **`thumbhash`/`blurhash`/`thumb`/`fallback`/`dim`**: all optional; recommend skipping
in v1 (and letting the UI show a placeholder until the blob is decrypted).
4. **Web (wasm)**: encrypted upload stays unsupported on web, matching today's
`state::blossom::upload` stub. Download/decrypt is cross-platform via gpui's client.
5. **Ciphertext caching**: recommend *not* persisting decrypted plaintext in the local
database in v1; decryption happens on demand from the blob URL.
---
## 8. Edge cases and failure handling
| Case | Behavior |
|---|---|
| `encryption-algorithm` != `aes-gcm` | do not decrypt; report "unsupported encryption" (forward compatibility) |
| missing `decryption-key` / `decryption-nonce` | `FileAttachment::from_tags` returns `None` → surfaced as a failed message, not a panic |
| key != 32 bytes or nonce != 12 bytes | descriptive error |
| GCM authentication failure | error; never emit partial plaintext |
| `sha256_hex(ciphertext) != x` | error (tampered or corrupted blob), checked **before** decrypting |
| blob larger than `MAX_FILE_SIZE` | rejected while reading the body |
| HTTP error / non-200 | error message including status |
| `content` not a valid URL | error; message still cached so it renders as a failed attachment |
| sender's own copy | works automatically via the existing self-backup gift wrap |
| multiple receivers | one blob, one key, key re-sent inside each recipient's gift wrap |
---
## 9. Verification
The feature cannot be exercised end-to-end without the UI step (out of scope), so
backend verification is:
1. `cargo check -p state -p chat` after each step.
2. `cargo check -p coop_web --target wasm32-unknown-unknown` to confirm the
`cfg(target_arch = "wasm32")` stubs and the new module compile for web.
3. Cheap unit checks (no network, ~15 LOC in `state/src/file.rs`), despite "no tests
required" for the feature, because these are pure functions and catch the two
riskiest bugs — encoding and tag ordering:
- `encrypt` → `decrypt` round trip returns the original bytes.
- flipping one ciphertext bit makes `decrypt` fail.
- `sha256_hex(b"abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"`
(confirms the digest order used for `x`/`ox`).
- `FileAttachment` tags → `from_tags` round trip preserves key/nonce/mime/url.
4. End-to-end (requires the UI step or a temporary call site): send to self with
`RoomConfig::backup` enabled and confirm the rumor unwraps, downloads, verifies and
decrypts; then a second account; then a cross-client check for decision #1.
---
## 10. Estimated size
| File | Change | ~LOC |
|---|---|---|
| `Cargo.toml`, `crates/state/Cargo.toml` | deps | 6 |
| `crates/state/src/file.rs` | new | 180 |
| `crates/state/src/lib.rs` | module + re-export | 2 |
| `crates/chat/src/message.rs` | parse + preview | 80 |
| `crates/chat/src/room.rs` | `file_rumor` + tag refactor + `k` tag | 70 |
| `crates/chat/src/lib.rs` | cache tag + room list query | 5 |
Order of work: 1 → 2 → 3 → 4 → 5, each step compiling on its own. Steps 24 are
independent of any UI change, so the branch stays green without touching `chat_ui`.
+120
View File
@@ -1,5 +1,125 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 626 B

+13 -4
View File
@@ -5,8 +5,17 @@ edition.workspace = true
publish.workspace = true
[dependencies]
gpui.workspace = true
instant.workspace = true
log.workspace = true
common = { path = "../common" }
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+502 -149
View File
@@ -1,11 +1,25 @@
#![cfg(not(target_arch = "wasm32"))]
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
use instant::{Duration, Instant};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
fn get_github_repo_owner() -> String {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
@@ -15,27 +29,15 @@ fn get_github_repo_name() -> String {
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
}
/// Whether updates are managed by an external distribution channel
/// (Flatpak/Snap), in which case the in-app updater must not run.
fn uses_managed_updates() -> bool {
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
std::env::var("FLATPAK_ID").is_ok()
// Allow opting out of in-app updates via an explicit environment variable.
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
fn is_flatpak_installation() -> bool {
// Check if app is installed via Flatpak
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
}
/// Initialize the auto-update system.
///
/// Skips initialization when updates are handled by an external distribution
/// channel (Flatpak/Snap). Otherwise creates the global [`AutoUpdater`]
/// entity and schedules a check for updates after a 2-minute delay.
pub fn init(window: &mut Window, cx: &mut App) {
if uses_managed_updates() {
log::info!(
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
);
// Skip auto-update initialization if installed via Flatpak
if is_flatpak_installation() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
return;
}
@@ -46,165 +48,516 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
/// The global auto-updater entity.
///
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
/// (GitHub repo, Flatpak detection, delayed auto-check).
///
/// Retrieve the global instance via [`AutoUpdater::global`].
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { download_url: String },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
pub struct AutoUpdater {
/// The underlying gpui-updater entity that does the heavy lifting.
pub updater: Entity<Updater>,
/// Currently running app version.
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
pub version: Version,
/// Keeps the observer subscription alive.
_subscription: Subscription,
/// When the last error was recorded, so we can reset to idle after 5s.
error_time: Option<Instant>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl AutoUpdater {
/// Whether auto-update is available for this installation.
///
/// Returns `false` on managed distribution channels (Flatpak/Snap), where
/// updates are handled by the channel and no global updater is created.
pub fn is_available(cx: &App) -> bool {
cx.try_global::<GlobalAutoUpdater>().is_some()
}
/// Retrieve the global auto updater instance, if one was initialized.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalAutoUpdater>()
.map(|global| global.0.clone())
}
/// Retrieve the global auto updater instance.
///
/// # Panics
///
/// Panics when auto-update is not available for this installation. Prefer
/// [`AutoUpdater::try_global`] when the installation type is not known at
/// compile time (e.g. Flatpak/Snap).
/// Retrieve the global auto updater instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
this.download_and_install(&download_url, cx);
}
}),
);
let source =
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
"macos" => "macos",
"linux" => "linux",
_ => "",
});
let updater: Entity<Updater> =
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
// When an update becomes available, automatically download and install it.
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
let status = this.updater.read(cx).status().clone();
if matches!(status, UpdateStatus::Available(_)) {
this.updater.update(cx, |updater, cx| {
updater.download_and_install(cx);
});
}
if matches!(status, UpdateStatus::Errored(_)) {
this.error_time = Some(Instant::now());
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
this.update(cx, |_this, cx| cx.notify()).ok();
})
.detach();
} else {
this.error_time = None;
}
cx.notify();
});
// Schedule an auto-check after a 2-minute delay (deferred to run at the
// end of the current frame so the window is fully set up).
cx.defer_in(window, |_this, _window, cx| {
let duration = Duration::from_secs(120);
cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
this.update(cx, |this, cx| {
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
})
.ok();
})
.detach();
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
});
Self {
updater,
status: AutoUpdateStatus::Idle,
version,
_subscription: subscription,
error_time: None,
tasks: vec![],
_subscriptions: subscriptions,
}
}
pub fn idle(&self, cx: &App) -> bool {
let status = self.updater.read(cx).status();
if status == &UpdateStatus::Idle {
return true;
}
if matches!(status, UpdateStatus::Errored(_))
&& self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
return true;
}
false
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
pub fn status(&self, cx: &App) -> SharedString {
let status = self.updater.read(cx).status();
fn check(&mut self, cx: &mut Context<Self>) {
let version = self.version.clone();
let duration = Duration::from_secs(120);
let task = self.check_for_updates(version, cx);
match status {
UpdateStatus::Idle => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
match total_mb {
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
None => format!("Downloading {downloaded_mb:.1} MB").into(),
// 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);
})?;
}
}
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
Ok(())
}));
}
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
let http_client = cx.http_client();
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
}
UpdateStatus::Errored(msg) => {
if self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
"Up to date".into()
} else {
format!("Update failed: {msg}").into()
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
// Parse version from tag (remove 'v' prefix if present)
let tag_version = release.tag_name.trim_start_matches('v');
let new_version = Version::parse(tag_version).context(format!(
"Failed to parse version from tag: {}",
release.tag_name
))?;
if new_version > version {
// Find the appropriate asset for the current platform
let current_os = std::env::consts::OS;
let asset_name = match current_os {
"macos" => "Coop.dmg",
"linux" => "coop.tar.gz",
"windows" => "Coop.exe",
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
};
let download_url = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.browser_download_url.clone())
.context(format!(
"No {} asset found in release {}",
asset_name, release.tag_name
))?;
Ok(download_url)
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let download_url = download_url.to_string();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(&download_url, &target_path, http_client).await?;
Ok((installer_dir, target_path))
});
self.tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})?;
}
}
Err(e) => {
// Update the status to error including the error message
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})?;
}
}
}
Ok(())
}),
);
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"linux" => Ok("coop.tar.gz"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
}
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
// Extract the tar.gz file
let extracted = temp_dir.path().join("coop");
smol::fs::create_dir_all(&extracted)
.await
.context("failed to create directory to extract update")?;
let output = Command::new("tar")
.arg("-xzf")
.arg(&downloaded_tar_gz)
.arg("-C")
.arg(&extracted)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to extract {:?} to {:?}: {:?}",
downloaded_tar_gz,
extracted,
String::from_utf8_lossy(&output.stderr)
);
// Find the extracted app directory
let mut entries = smol::fs::read_dir(&extracted).await?;
let mut app_dir = None;
use smol::stream::StreamExt;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
app_dir = Some(path);
break;
}
}
let from = app_dir.context("No app directory found in archive")?;
// Copy to the current installation directory
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&from)
.arg(
running_app_path
.parent()
.context("No parent directory for app")?,
)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app from {:?} to {:?}: {:?}",
from,
running_app_path.parent(),
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "browser-signer-proxy"
version = "0.1.0"
edition.workspace = true
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
license = "MIT"
repository = "https://github.com/nostrdevkit/nostr"
publish = false
[dependencies]
atomic-destructor = "0.2"
event-listener = "5"
nostr.workspace = true
opaquerr = { version = "0.1", features = ["alloc"] }
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
tracing = { version = "0.1", features = ["std"] }
uuid = { version = "1.23", features = ["serde", "v4"] }
-55
View File
@@ -1,55 +0,0 @@
# browser-signer-proxy
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
## Description
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
the browser extension.
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
## Usage
```rust
use browser_signer_proxy::prelude::*;
async fn example() -> Result<(), Error> {
// Create the proxy with default options (localhost:7400)
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
// Open the proxy URL in a browser
webbrowser::open(&proxy.url())?;
// Start the proxy server
proxy.start().await?;
// Use it as an async Nostr signer
let public_key = proxy.get_public_key_async().await?;
println!("Connected with public key: {public_key}");
Ok(())
}
```
## Differences from the tokio-based version
| Feature | tokio (original) | smol (this crate) |
|---|---|---|
| Async runtime | `tokio` | `smol` |
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
| Task spawning | `tokio::spawn` | `smol::spawn` |
## License
This project is distributed under the MIT software license.
-185
View File
@@ -1,185 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coop — Web Signer Proxy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
<style>
:root {
--brand: #F8FF37;
--ink: #111111;
--ink-soft: #333333;
--muted: #666666;
--paper: #FFFFFF;
--edge: rgba(17, 17, 17, 0.14);
--radius-sm: 1rem;
--radius-md: 1.5rem;
--radius-lg: 2.5rem;
--green: #2E8B57;
--red: #D32F2F;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background: var(--brand);
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
background: var(--paper);
border-radius: var(--radius-md);
padding: 2.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.logo__mark {
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.2rem;
color: var(--brand);
letter-spacing: -0.02em;
}
.logo__text {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.heading {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--ink-soft);
margin: 0 0 1.75rem;
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.95rem;
transition: background 300ms ease, color 300ms ease;
}
.status--checking {
background: rgba(17, 17, 17, 0.05);
color: var(--muted);
}
.status--connected {
background: rgba(46, 139, 87, 0.1);
color: var(--green);
}
.status--error {
background: rgba(211, 47, 47, 0.08);
color: var(--red);
}
.status__dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.status--checking .status__dot {
background: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
.status--connected .status__dot {
background: var(--green);
}
.status--error .status__dot {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.hint {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--edge);
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.hint strong {
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Web Signer</h1>
<p class="subtitle">
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
</p>
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
</div>
</div>
<script src="proxy.js"></script>
</body>
</html>
-156
View File
@@ -1,156 +0,0 @@
let isPolling = false;
async function pollForRequests() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch('/api/pending');
const data = await response.json();
console.log('Polled for requests, got:', data);
// Process any new requests
if (data.requests && data.requests.length > 0) {
console.log(`Processing ${data.requests.length} requests`);
for (const request of data.requests) {
await handleNip07Request(request);
}
}
} catch (error) {
console.error('Polling error:', error);
updateStatus('Error: ' + error.message, 'error');
}
isPolling = false;
}
async function handleNip07Request(request) {
console.log('Handling request:', request);
try {
let result;
if (!window.nostr) {
throw new Error('NIP-07 extension not available');
}
switch (request.method) {
case 'get_public_key':
console.log('Calling nostr.getPublicKey()');
result = await window.nostr.getPublicKey();
console.log('Got public key:', result);
break;
case 'sign_event':
console.log('Calling nostr.signEvent() with:', request.params);
result = await window.nostr.signEvent(request.params);
console.log('Got signed event:', result);
break;
case 'nip04_encrypt':
console.log('Calling nostr.nip04.encrypt()');
result = await window.nostr.nip04.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip04_decrypt':
console.log('Calling nostr.nip04.decrypt()');
result = await window.nostr.nip04.decrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_encrypt':
console.log('Calling nostr.nip44.encrypt()');
result = await window.nostr.nip44.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_decrypt':
console.log('Calling nostr.nip44.decrypt()');
result = await window.nostr.nip44.decrypt(
request.params.public_key,
request.params.content
);
break;
default:
throw new Error(`Unknown method: ${request.method}`);
}
// Send response back to server
const responsePayload = {
id: request.id,
result: result,
error: null
};
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(responsePayload)
});
console.log('Response sent successfully');
updateStatus('Request processed successfully', 'connected');
} catch (error) {
console.error('Error handling request:', error);
// Send error response back to server
const errorPayload = {
id: request.id,
result: null,
error: error.message
};
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(errorPayload)
});
updateStatus('Error: ' + error.message, 'error');
}
}
function updateStatus(message, state) {
const container = document.getElementById('nip07-status');
const textEl = document.getElementById('nip07-status-text');
if (container && textEl) {
container.className = 'status status--' + state;
textEl.textContent = message;
}
}
// Start polling when page loads
window.addEventListener('load', () => {
console.log('NIP-07 Proxy loaded');
// Check if NIP-07 extension is available
if (window.nostr) {
console.log('NIP-07 extension detected');
updateStatus('Connected — ready', 'connected');
} else {
console.log('NIP-07 extension not found');
updateStatus('No NIP-07 extension found', 'error');
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});
-51
View File
@@ -1,51 +0,0 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Error types for the browser signer proxy.
opaquerr::define_kind! {
/// Nostr browser signer proxy error kind.
pub ErrorKind {
/// Nostr protocol error.
Protocol => "nostr protocol error",
/// I/O error.
IO => "I/O error",
/// JSON error.
Json => "JSON error",
/// The operation timed out.
Timeout => "timeout",
/// The operation cannot be completed in the current state.
State => "invalid state",
/// Anything not covered by the stable categories above.
Other => "other error",
}
}
opaquerr::define_error! {
/// Nostr browser signer proxy error.
pub Error(ErrorKind)
from {
nostr::error::Error => ErrorKind::Protocol,
std::io::Error => ErrorKind::IO,
serde_json::Error => ErrorKind::Json,
}
}
impl Error {
pub(crate) fn generic<S>(message: S) -> Self
where
S: Into<String>,
{
Self::new(ErrorKind::Other, message.into())
}
pub(crate) fn timeout() -> Self {
Self::simple(ErrorKind::Timeout)
}
pub(crate) fn shutdown() -> Self {
Self::with_static_message(ErrorKind::State, "server is shutdown")
}
}
-764
View File
@@ -1,764 +0,0 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use event_listener::Event as ShutdownEvent;
use nostr::prelude::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json};
use smol::channel;
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use smol::lock::Mutex;
use smol::net::{TcpListener, TcpStream};
use uuid::Uuid;
mod error;
pub mod prelude;
pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
struct Message {
id: Uuid,
error: Option<String>,
result: Option<Value>,
}
impl Message {
fn into_result(self) -> Result<Value, String> {
if let Some(error) = self.error {
Err(error)
} else {
Ok(self.result.unwrap_or(Value::Null))
}
}
}
#[derive(Debug, Clone, Copy)]
enum RequestMethod {
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
}
impl RequestMethod {
fn as_str(&self) -> &str {
match self {
Self::GetPublicKey => "get_public_key",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
Self::Nip44Encrypt => "nip44_encrypt",
Self::Nip44Decrypt => "nip44_decrypt",
}
}
}
impl Serialize for RequestMethod {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
struct RequestData {
id: Uuid,
method: RequestMethod,
params: Value,
}
impl RequestData {
#[inline]
fn new(method: RequestMethod, params: Value) -> Self {
Self {
id: Uuid::new_v4(),
method,
params,
}
}
}
#[derive(Serialize)]
struct Requests<'a> {
requests: &'a [RequestData],
}
impl<'a> Requests<'a> {
#[inline]
fn new(requests: &'a [RequestData]) -> Self {
Self { requests }
}
#[inline]
fn len(&self) -> usize {
self.requests.len()
}
}
/// Params for NIP-04 and NIP-44 encryption/decryption
#[derive(Serialize)]
struct CryptoParams<'a> {
public_key: &'a PublicKey,
content: &'a str,
}
impl<'a> CryptoParams<'a> {
#[inline]
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
Self {
public_key,
content,
}
}
}
#[derive(Debug)]
struct ProxyState {
/// Requests waiting to be picked up by browser
pub outgoing_requests: Mutex<Vec<RequestData>>,
/// Map of request ID to response sender
pub pending_responses: Mutex<PendingResponseMap>,
/// Last time the client asked for the pending requests
pub last_pending_request: Arc<AtomicU64>,
}
/// Configuration options for [`BrowserSignerProxy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSignerProxyOptions {
/// Request timeout for the signer extension. Default is 30 seconds.
pub timeout: Duration,
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
pub addr: SocketAddr,
/// Custom HTML page.
// NOTE: not `Option` to move it between threads without reference counter
pub custom_html: &'static str,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Notification trigger for graceful shutdown
shutdown: Arc<ShutdownEvent>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flag indicating if the server is started
is_started: Arc<AtomicBool>,
}
impl AtomicDestroyer for InnerBrowserSignerProxy {
fn on_destroy(&self) {
self.shutdown();
}
}
impl InnerBrowserSignerProxy {
#[inline]
fn is_shutdown(&self) -> bool {
self.is_shutdown.load(Ordering::SeqCst)
}
fn shutdown(&self) {
// Mark the server as shutdown
self.is_shutdown.store(true, Ordering::SeqCst);
// Notify all waiters that the proxy is shutting down
self.shutdown.notify(usize::MAX);
}
}
/// Nostr Browser Signer Proxy
///
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
#[derive(Debug, Clone)]
pub struct BrowserSignerProxy {
inner: AtomicDestructor<InnerBrowserSignerProxy>,
}
impl Default for BrowserSignerProxyOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
custom_html: "",
}
}
}
impl BrowserSignerProxyOptions {
/// Sets the timeout duration.
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Sets the IP address.
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
self.addr = SocketAddr::new(new_ip, self.addr.port());
self
}
/// Sets the port number.
pub const fn port(mut self, new_port: u16) -> Self {
self.addr = SocketAddr::new(self.addr.ip(), new_port);
self
}
/// Sets a custom html page.
///
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
/// which will handle communication with the server and update the element
/// with id `nip07-proxy-status` with the status.
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
self.custom_html = custom_html;
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
shutdown: Arc::new(ShutdownEvent::new()),
is_shutdown: Arc::new(AtomicBool::new(false)),
is_started: Arc::new(AtomicBool::new(false)),
}),
}
}
/// Indicates whether the server is currently running.
#[inline]
pub fn is_started(&self) -> bool {
self.inner.is_started.load(Ordering::SeqCst)
}
/// Checks if there is an open browser tab ready to respond to requests by
/// verifying the time since the last pending request.
#[inline]
pub fn is_session_active(&self) -> bool {
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
}
/// Get the signer proxy webpage URL
#[inline]
pub fn url(&self) -> String {
format!("http://{}", self.inner.options.addr)
}
/// Start the proxy server.
///
/// If this is not called explicitly, the server will be automatically
/// started on the first interaction with the signer.
pub async fn start(&self) -> Result<(), Error> {
// Ensure is not shutdown
if self.inner.is_shutdown() {
return Err(Error::shutdown());
}
// Mark the proxy as started and check if was already started
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
// Immediately return if already started
if is_started {
return Ok(());
}
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
loop {
// Race between accepting a new connection and shutdown signal
let shutdown_listener = shutdown.listen();
enum AcceptEvent {
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
Shutdown,
}
let event = smol::future::or(
async { AcceptEvent::Connection(listener.accept().await) },
async {
shutdown_listener.await;
AcceptEvent::Shutdown
},
)
.await;
match event {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
},
async {
shutdown_listener.await;
tracing::debug!(
"Closing connection, proxy server is shutting down."
);
},
)
.await;
})
.detach();
}
AcceptEvent::Connection(Err(e)) => {
tracing::error!("Failed to accept connection: {e}");
}
AcceptEvent::Shutdown => break,
}
}
tracing::info!("Proxy server shut down.");
})
.detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
let mut pending_responses = self.inner.state.pending_responses.lock().await;
pending_responses.insert(id, tx);
}
#[inline]
async fn store_outgoing_request(&self, request: RequestData) {
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
outgoing_requests.push(request);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
{
// Start the proxy if not already started
self.start().await?;
// Construct the request
let request: RequestData = RequestData::new(method, params);
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
// Store the response sender
self.store_pending_response(request.id, tx).await;
// Add to outgoing requests queue
self.store_outgoing_request(request).await;
// Wait for response with timeout
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
}
}
#[inline]
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
self.request(RequestMethod::GetPublicKey, json!({})).await
}
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
Ok(event)
}
#[inline]
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
.await
}
}
impl AsyncGetPublicKey for BrowserSignerProxy {
type Error = Error;
#[inline]
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
Box::pin(async move { self._get_public_key().await })
}
}
impl AsyncSignEvent for BrowserSignerProxy {
type Error = Error;
#[inline]
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
Box::pin(async move { self._sign_event(unsigned).await })
}
}
impl AsyncNip04 for BrowserSignerProxy {
type Error = Error;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
}
}
impl AsyncNip44 for BrowserSignerProxy {
type Error = Error;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
let mut reader = BufReader::new(stream);
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let method = parts[0].to_uppercase();
let path = parts[1].to_string();
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
DEFAULT_HTML
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
}
// Browser polls this endpoint to get pending requests
("GET", "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests = Requests::new(&outgoing);
let json = match serde_json::to_string(&requests) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize pending requests: {e}");
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
return;
}
};
tracing::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
let mut body_bytes = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse response body: {e}");
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
return;
}
};
tracing::debug!("Received response from browser: {message:?}");
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
}
// 404 - not found
_ => {
send_response(&mut reader, 404, "Not Found", "", "").await;
}
}
}
/// Write an HTTP response to the stream.
async fn send_response(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
) -> Result<T, TimeoutError> {
enum Event<T> {
Value(T),
ChannelClosed,
Timeout,
}
let event = smol::future::or(
async {
match recv.await {
Ok(value) => Event::Value(value),
Err(_) => Event::ChannelClosed,
}
},
async {
smol::Timer::after(duration).await;
Event::Timeout
},
)
.await;
match event {
Event::Value(value) => Ok(value),
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
@@ -1,14 +0,0 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Prelude
#![allow(unknown_lints)]
#![allow(ambiguous_glob_reexports)]
#![doc(hidden)]
pub use nostr::prelude::*;
pub use crate::error::{Error, ErrorKind};
pub use crate::*;
-2
View File
@@ -13,7 +13,6 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -21,7 +20,6 @@ smallvec.workspace = true
log.workspace = true
flume.workspace = true
futures.workspace = true
fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+217 -178
View File
@@ -1,9 +1,11 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, RwLock};
use std::time::Duration;
use anyhow::{Error, anyhow};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::EventExt;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
@@ -11,7 +13,6 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity, Window,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
@@ -21,7 +22,6 @@ mod room;
pub use message::*;
pub use room::*;
pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -81,19 +81,16 @@ pub struct ChatRegistry {
/// Chat rooms
rooms: Vec<Entity<Room>>,
/// O(1) room lookup by room ID
room_index: HashMap<u64, Entity<Room>>,
/// Events that failed to unwrap for any reason
trash: Entity<BTreeSet<FailedMessage>>,
trashes: Entity<BTreeSet<FailedMessage>>,
/// Tracking events seen on which relays in the current session
seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
/// Mapping of unwrapped event ids to their gift wrap event ids
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
/// True while the initial event backlog is still loading
/// Tracking the status of unwrapping gift wrap events.
tracking: Arc<AtomicBool>,
/// Channel for sending signals to the UI.
@@ -111,31 +108,10 @@ pub struct ChatRegistry {
/// Signal consumer task (cancelled on signer change)
signal_consumer: Option<Task<Result<(), Error>>>,
/// Fuzzy matcher for room search (cached; intentionally excluded from Debug)
#[allow(dead_code)]
matcher: CachedMatcher,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
/// Wrapper to provide Debug for SkimMatcherV2
struct CachedMatcher(SkimMatcherV2);
impl std::fmt::Debug for CachedMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("CachedMatcher { .. }")
}
}
impl std::ops::Deref for CachedMatcher {
type Target = SkimMatcherV2;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl EventEmitter<ChatEvent> for ChatRegistry {}
impl ChatRegistry {
@@ -169,17 +145,16 @@ impl ChatRegistry {
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| {
this.tracking(cx);
this.get_rooms(cx);
});
Self {
rooms: vec![],
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
trashes: cx.new(|_| BTreeSet::default()),
seens: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
tracking: Arc::new(AtomicBool::new(true)),
matcher: CachedMatcher(SkimMatcherV2::default()),
tracking: Arc::new(AtomicBool::new(false)),
signal_rx: rx,
signal_tx: tx,
tasks: smallvec![],
@@ -199,10 +174,12 @@ impl ChatRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let seen = self.seen.clone();
let tracking = self.tracking.clone();
let seens = self.seens.clone();
let event_map = self.event_map.clone();
let trash = self.trash.downgrade();
let trashes = self.trashes.downgrade();
let initialized_at = Timestamp::now();
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
@@ -213,19 +190,16 @@ impl ChatRegistry {
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
const MAX_PROCESSED: usize = 10_000;
while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, relay_url } = notification else {
// Skip non-message notifications
continue;
};
match *message {
RelayMessage::Event { event, .. } => {
// Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED {
processed_events.clear();
}
// De-duplicate events by their ID
if !processed_events.insert(event.id) {
continue;
}
@@ -233,6 +207,7 @@ impl ChatRegistry {
// Handle msg relays event to determine when the app is ready to subscribe
if event.kind == Kind::InboxRelays {
let current_user = signer.get_public_key_async().await?;
// Emit the inbox ready signal
if event.pubkey == current_user {
tx.send_async(Signal::InboxReady).await?;
}
@@ -245,13 +220,14 @@ impl ChatRegistry {
// Keep track of which relays have seen this event
{
let mut seen = seen.write().unwrap();
seen.entry(event.id).or_default().insert(relay_url);
let mut seens = seens.write().unwrap();
seens.entry(event.id).or_default().insert(relay_url);
}
// Extract the rumor from the gift wrap event
match extract_rumor(&client, &signer, event.as_ref()).await {
Ok(rumor) => {
// Map the rumor id to the gift wrap event id for later lookup
let Some(rumor_id) = rumor.id else {
log::error!("Rumor missing id after ensure_id");
continue;
@@ -261,14 +237,20 @@ impl ChatRegistry {
event_map.insert(rumor_id, event.id);
}
// Check if the rumor has a recipient
if rumor.tags.is_empty() {
let signal = Signal::error(&event, "Recipient is missing");
tx.send_async(signal).await?;
}
// Emit message for both new and backlog events
let signal = Signal::message(event.id, rumor);
tx.send_async(signal).await?;
// Check if the rumor was created after the chat was initialized (for detecting new messages)
if rumor.created_at >= initialized_at {
let signal = Signal::message(event.id, rumor);
tx.send_async(signal).await?;
} else {
// Mark the chat still processing new messages
tracking.store(true, Ordering::Release);
}
}
Err(e) => {
let reason = format!("Failed to extract rumor: {e}");
@@ -303,17 +285,13 @@ impl ChatRegistry {
})?;
}
Signal::Eose => {
this.update(cx, |this, _cx| {
this.tracking.store(false, Ordering::Release);
})?;
this.update(cx, |this, cx| {
this.get_rooms(cx);
})?;
}
Signal::Error(failed) => {
trash.update(cx, |this, cx| {
this.insert(failed);
Signal::Error(trash) => {
trashes.update(cx, |this, cx| {
this.insert(trash);
cx.notify();
})?;
}
@@ -324,6 +302,27 @@ impl ChatRegistry {
}));
}
/// Check periodically whether old gift-wrap events have finished processing,
/// and refresh rooms once the backlog is caught up.
fn tracking(&mut self, cx: &mut Context<Self>) {
let status = self.tracking.clone();
let tx = self.signal_tx.clone();
let check_interval = Duration::from_secs(15);
self.tasks.push(cx.spawn(async move |_, cx| {
loop {
cx.background_executor().timer(check_interval).await;
// Only trigger a room refresh if old events were being tracked
// (i.e. the notification handler set the flag while catching up).
// `swap` atomically reads and clears the flag.
if status.swap(false, Ordering::AcqRel) {
_ = tx.send_async(Signal::Eose).await;
}
}
}));
}
/// Get all necessary metadata from relays for current user
pub fn get_metadata(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -333,29 +332,37 @@ impl ChatRegistry {
return;
};
let subscribe = cx.background_spawn({
let client = client.clone();
async move {
let opts =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let msg_relays = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let contact_list = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
client
.subscribe(vec![msg_relays, contact_list])
.close_on(opts)
.await
}
});
self.tasks.push(cx.spawn(async move |this, cx| {
// Subscribe to metadata from relays
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let msg_relays = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let contact_list = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
_ = client
.subscribe(vec![msg_relays, contact_list])
.close_on(opts)
.await;
// Wait for subscription to complete (or fail silently)
_ = subscribe.await;
// Give relays time to respond
cx.background_executor().timer(Duration::from_secs(5)).await;
// Verify inbox relays were received
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
@@ -366,8 +373,7 @@ impl ChatRegistry {
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.first_owned()
.is_some();
if !found {
@@ -399,8 +405,7 @@ impl ChatRegistry {
.database()
.query(filter)
.await?
.into_iter()
.next()
.first_owned()
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
@@ -409,7 +414,7 @@ impl ChatRegistry {
}
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(USER_GIFTWRAP);
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
let target: HashMap<RelayUrl, Filter> = relays
.into_iter()
@@ -431,8 +436,8 @@ impl ChatRegistry {
}));
}
/// Reload the chat registry, fetching messages and contact list from relays.
pub fn reload(&mut self, cx: &mut Context<Self>) {
/// Refresh the chat registry, fetching messages and contact list from relays.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self.reset(cx);
self.get_metadata(cx);
self.get_rooms(cx);
@@ -443,9 +448,12 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
}
/// Get all rooms based on the filter.
@@ -467,22 +475,22 @@ impl ChatRegistry {
/// Count the number of messages seen by a given relay.
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
self.seen
self.seens
.read()
.unwrap()
.values()
.filter(|s| s.contains(relay_url))
.filter(|seen| seen.contains(relay_url))
.count()
}
/// Count the number of trash messages.
pub fn count_trash_messages(&self, cx: &App) -> usize {
self.trash.read(cx).len()
self.trashes.read(cx).len()
}
/// Get the trash messages entity.
pub fn trash(&self) -> Entity<BTreeSet<FailedMessage>> {
self.trash.clone()
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
self.trashes.clone()
}
/// Get the relays that have seen a given rumor id.
@@ -496,7 +504,7 @@ impl ChatRegistry {
/// Get the relays that have seen a given gift wrap id.
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
self.seen
self.seens
.read()
.unwrap()
.get(id)
@@ -515,11 +523,7 @@ impl ChatRegistry {
};
let room: Room = room.into().organize(&public_key);
let room_id = room.id;
let entity = cx.new(|_| room);
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
self.rooms.insert(0, cx.new(|_| room));
cx.emit(ChatEvent::Ping);
cx.notify();
@@ -528,46 +532,36 @@ impl ChatRegistry {
/// Emit an open room event.
///
/// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: &Entity<Room>, window: &mut Window, cx: &mut Context<Self>) {
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry and index.
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
self.rooms.insert(0, entity);
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
}
// Emit the open room event deferred to avoid re-entrant reads
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::OpenRoom(id));
});
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
}
/// Close a room.
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
cx.emit(ChatEvent::CloseRoom(id));
}
}
/// Sort rooms by their created at. Only notifies if order changed.
/// Sort rooms by their created at.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
cx.notify();
}
/// Finding rooms based on a query.
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default();
if let Ok(public_key) = PublicKey::parse(query) {
self.rooms
.iter()
@@ -578,7 +572,7 @@ impl ChatRegistry {
self.rooms
.iter()
.filter(|room| {
self.matcher
matcher
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
.is_some()
})
@@ -590,8 +584,7 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.trash.update(cx, |this, cx| {
self.trashes.update(cx, |this, cx| {
this.clear();
cx.notify();
});
@@ -618,9 +611,7 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
self.rooms.push(cx.new(|_| new_room));
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -630,7 +621,13 @@ impl ChatRegistry {
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.query_chat_rooms(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task = self.get_rooms_from_database(public_key, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
@@ -651,65 +648,78 @@ impl ChatRegistry {
}));
}
/// Query the chat rooms from the database
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
/// Create a task to load rooms from the database
fn get_rooms_from_database(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
// Get contacts
let contacts = client
.database()
.query(filter)
.contacts_public_keys(public_key)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
let filter = Filter::new()
// Construct authored filter
let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
let events = client.database().query(filter).await?;
// Get all authored events
let authored = client.database().query(authored_filter).await?;
// Construct addressed filter
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
// Get all addressed events
let addressed = client.database().query(addressed_filter).await?;
// Merge authored and addressed events
let events = authored.merge(addressed);
// Collect results
let mut rooms: HashSet<Room> = HashSet::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
// Process each event and group by room hash
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some()
&& rumor.tags.public_keys().peekable().peek().is_some()
{
if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key)
{
continue;
}
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
let mut rooms = HashSet::with_capacity(grouped.len());
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
for (_id, messages) in grouped.into_iter() {
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
let room = Room::from(latest).organize(&public_key);
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts
let is_contact = room.members.iter().any(|k| contacts.contains(k));
let room = if user_sent || is_contact {
room.kind(RoomKind::Ongoing)
} else {
room
};
// Set the room's kind based on status
if user_sent || is_contact {
room = room.kind(RoomKind::Ongoing);
}
rooms.insert(room);
}
@@ -720,8 +730,8 @@ impl ChatRegistry {
/// Parse a nostr event into a message and push it to the belonging room
///
/// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages.
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -729,7 +739,7 @@ impl ChatRegistry {
return;
};
match self.room_index.get(&message.room).cloned() {
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
@@ -773,11 +783,6 @@ async fn extract_rumor(
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
let mut rumor = unwrapped.rumor;
// Verify rumor author matches the seal sender (as per mobile implementation)
if rumor.pubkey != unwrapped.sender {
return Err(anyhow!("Rumor author does not match seal sender"));
}
// Generate event id for the rumor if it doesn't have one
rumor.ensure_id();
@@ -801,7 +806,7 @@ async fn try_unwrap_with(
// Verify the sealed event
let seal: Event = Event::from_json(seal)?;
seal.verify()?;
seal.verify_with_ctx(&SECP256K1)?;
// Get the rumor event
let rumor = signer
@@ -818,20 +823,39 @@ async fn try_unwrap_with(
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = conversation_id(rumor);
let tags = vec![
Tag::identifier(id),
Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]),
Tag::custom("k", [rumor.kind.to_string()]),
];
let mut tags = rumor.tags.clone().to_vec();
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom("a", [author]));
// Add a conversation id
tags.push(Tag::custom("c", [conversation.to_string()]));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys() {
tags.push(Tag::custom("P", [receiver]));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(tags)
.finalize_async(&*LOCAL_KEYS)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
@@ -839,11 +863,26 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new().identifier(gift_wrap).limit(1);
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
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().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}
+39 -82
View File
@@ -4,9 +4,6 @@ use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
use state::FileAttachment;
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
/// Rendered message.
#[derive(Debug, Clone)]
@@ -24,90 +21,61 @@ pub struct Message {
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
/// Encrypted file attachment
pub file: Option<FileAttachment>,
}
impl From<&Event> for Message {
fn from(val: &Event) -> Self {
from_parts(
val.id,
val.pubkey,
val.created_at,
val.kind,
&val.content,
&val.tags,
)
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
id: val.id,
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
from_parts(
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known
val.id.unwrap(),
val.pubkey,
val.created_at,
val.kind,
&val.content,
&val.tags,
)
id: val.id.unwrap(),
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
from_parts(
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known
val.rumor.id.unwrap(),
val.rumor.pubkey,
val.rumor.created_at,
val.rumor.kind,
&val.rumor.content,
&val.rumor.tags,
)
}
}
fn from_parts(
id: EventId,
author: PublicKey,
created_at: Timestamp,
kind: Kind,
content: &str,
tags: &Tags,
) -> Message {
let file = if kind == KIND_FILE_MESSAGE {
FileAttachment::from_tags(content, tags)
} else {
None
};
let has_file = file.is_some();
let replies_to = extract_reply_ids(tags);
// For file messages `.content` is the encrypted blob URL, not text or media
let mentions = if has_file {
Vec::new()
} else {
extract_mentions(content)
};
let (media, content) = if has_file {
(Vec::new(), String::new())
} else {
extract_and_remove_media_urls(content)
};
Message {
id,
author,
content,
media,
created_at,
mentions,
replies_to,
file,
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: string,
media,
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
@@ -137,17 +105,6 @@ impl Hash for Message {
}
}
impl Message {
/// Single-line representation for reply previews, notifications and copy.
pub fn preview(&self) -> SharedString {
if let Some(file) = &self.file {
return format!("[File] {}", file.display_name()).into();
}
self.content.clone().into()
}
}
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
+95 -134
View File
@@ -1,18 +1,18 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use instant::Duration;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
use crate::NewMessage;
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> &[PublicKey] {
&self.members
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
}
/// Checks if the room has more than two members (group)
@@ -356,38 +356,29 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members().to_vec();
let members = self.members();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
let tasks: Vec<_> = members
.into_iter()
.map(|public_key| {
let client = client.clone();
async move {
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
for public_key in members.into_iter() {
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
let announcement = Filter::new()
.author(public_key)
.kind(Kind::Custom(10044))
.limit(1);
let announcement = Filter::new()
.author(public_key)
.kind(Kind::Custom(10044))
.limit(1);
client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.await
}
})
.collect();
for result in futures::future::join_all(tasks).await {
result?;
// Subscribe to the target
client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.await?;
}
Ok(())
@@ -398,12 +389,12 @@ impl Room {
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let room_id = self.id.to_string();
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
let messages = client
.database()
@@ -411,6 +402,10 @@ impl Room {
.await?
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.filter(|event| {
// Only process private direct messages and file messages
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
})
.sorted_by_key(|message| message.created_at)
.collect();
@@ -419,71 +414,28 @@ impl Room {
}
// Construct a rumor event for direct message
pub fn rumor<S, I>(
&self,
content: S,
replies: I,
reaction: bool,
cx: &App,
) -> Option<UnsignedEvent>
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
where
S: Into<String>,
I: IntoIterator<Item = EventId>,
{
let kind = if reaction {
Kind::Reaction
} else {
Kind::PrivateDirectMessage
};
let kind = Kind::PrivateDirectMessage;
let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect();
// Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?;
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(self.conversation_tags(&replies, sender, cx))
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
where
I: IntoIterator<Item = EventId>,
{
let replies: Vec<EventId> = replies.into_iter().collect();
// Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?;
let mut tags = self.conversation_tags(&replies, sender, cx);
tags.extend(file.tags());
// Construct a file message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
// Get current user's public key
let sender = nostr.read(cx).current_user()?;
// Get all members, excluding the sender
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx))
.collect();
// Construct event's tags
let mut tags = vec![];
@@ -494,44 +446,31 @@ impl Room {
}
// Add all reply tags
for id in replies {
tags.push(Tag::event(*id))
for id in replies.into_iter() {
tags.push(Tag::event(id))
}
// Add all receiver tags (no intermediate allocation)
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
let member = persons.read(cx).get(public_key, cx);
tags.push(Tag::from(Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}));
}
tags
}
/// Select the appropriate signer based on signer kind and available keys.
fn select_signer(
signer_kind: &SignerKind,
has_announcement: bool,
encryption_signer: &Option<UniversalSigner>,
user_signer: &UniversalSigner,
) -> UniversalSigner {
match signer_kind {
SignerKind::Auto => {
if has_announcement {
encryption_signer
.clone()
.unwrap_or_else(|| user_signer.clone())
} else {
user_signer.clone()
// Add all receiver tags
for member in members.into_iter() {
tags.push(
Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}
}
SignerKind::Encryption => encryption_signer
.clone()
.expect("encryption signer must be set"),
SignerKind::User => user_signer.clone(),
.to_tag(),
);
}
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
/// Send rumor event to all members's messaging relays
@@ -586,12 +525,23 @@ impl Room {
}
// Determine the signer to use
let signer = Self::select_signer(
signer_kind,
announcement.is_some(),
&encryption_signer,
&user_signer,
);
let signer = match signer_kind {
SignerKind::Auto => {
if announcement.is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
{
// Safe to unwrap due to earlier checks
encryption_signer
} else {
user_signer.clone()
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
}
SignerKind::User => user_signer.clone(),
};
// Send the gift wrap event and collect the report
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
@@ -611,12 +561,23 @@ impl Room {
let public_key = sender.public_key();
// Determine the signer to use
let signer = Self::select_signer(
signer_kind,
sender.announcement().is_some(),
&encryption_signer,
&user_signer,
);
let signer = match signer_kind {
SignerKind::Auto => {
if sender.announcement().is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
{
// Safe to unwrap due to earlier checks
encryption_signer
} else {
user_signer.clone()
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
}
SignerKind::User => user_signer.clone(),
};
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
Ok(report) => reports.push(report),
@@ -640,7 +601,7 @@ async fn send_gift_wrap(
rumor: &UnsignedEvent,
config: &SignerKind,
) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
let k_tag = Tag::custom("k", vec!["14"]);
let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config
+2 -2
View File
@@ -19,11 +19,11 @@ anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
-32
View File
@@ -1,32 +0,0 @@
use std::path::{Path, PathBuf};
use chat::FileAttachment;
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// A file attachment that has been uploaded, but not sent yet.
///
/// The local `path` is kept around so the composer can preview
/// the file without downloading and decrypting it again.
pub(crate) struct PendingFile {
pub file: FileAttachment,
pub path: PathBuf,
}
/// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile {
Loading,
Ready(PathBuf),
Failed(SharedString),
}
/// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded {
Url(Url),
File(FileAttachment, PathBuf),
}
/// A `file://` url for a decrypted file, so it can be opened by the OS
pub(crate) fn file_url(path: &Path) -> String {
format!("file://{}", path.display())
}
+130 -637
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -7,7 +7,6 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
nostr.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
itertools.workspace = true
+1 -1
View File
@@ -1,5 +1,5 @@
use std::marker::PhantomData;
use instant::Duration;
use std::time::Duration;
use futures::channel::oneshot;
use futures::FutureExt;
-1
View File
@@ -14,7 +14,6 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
+4 -8
View File
@@ -4,13 +4,13 @@ use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -92,6 +92,7 @@ impl DeviceRegistry {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
let signer = cx.new(|_| None);
let mut subscriptions = smallvec![];
@@ -108,7 +109,7 @@ impl DeviceRegistry {
subscriptions.push(
// Observe the user signer
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
if event.signer_changed() && nip4e_enabled {
this.get_announcement(cx);
}
}),
@@ -225,11 +226,6 @@ impl DeviceRegistry {
let keys = get_keys(&client, &signer).await?;
let content = keys.secret_key().to_bech32()?;
if cfg!(target_arch = "wasm32") {
return Err(anyhow!("Not supported"));
}
#[cfg(not(target_arch = "wasm32"))]
smol::fs::write(path, &content).await?;
Ok(())
@@ -414,7 +410,7 @@ impl DeviceRegistry {
.pubkey(app_pubkey)
.limit(1);
match client.database().query(filter).await?.into_iter().next() {
match client.database().query(filter).await?.first_owned() {
// Found an approval event
Some(event) => Ok(Some(event)),
// No approval event found, construct a request event
+1 -1
View File
@@ -10,8 +10,8 @@ state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"
+19 -15
View File
@@ -1,10 +1,10 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::time::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
@@ -35,7 +35,7 @@ pub struct PersonRegistry {
persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen
seen: RwLock<HashSet<PublicKey>>,
seens: RefCell<HashSet<PublicKey>>,
/// Sender for requesting metadata
sender: flume::Sender<PublicKey>,
@@ -62,18 +62,22 @@ impl PersonRegistry {
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100);
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
let mut tasks = smallvec![];
let client2 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_notifications(&client2, &tx).await;
tasks.push(cx.background_spawn({
let client = client.clone();
async move {
Self::handle_notifications(&client, &tx).await;
}
}));
let client3 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx).await;
tasks.push(cx.background_spawn({
let client = client.clone();
async move {
Self::handle_requests(&client, &mta_rx).await;
}
}));
tasks.push(cx.spawn(async move |this, cx| {
@@ -102,8 +106,8 @@ impl PersonRegistry {
Self {
persons: HashMap::new(),
seen: RwLock::new(HashSet::new()),
sender: metadata_tx,
seens: RefCell::new(HashSet::new()),
sender: mta_tx,
tasks,
}
}
@@ -206,7 +210,7 @@ impl PersonRegistry {
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(persons) = task.await {
this.update(cx, |this, cx| {
this.bulk_insert(persons, cx);
this.bulk_inserts(persons, cx);
})
.ok();
}
@@ -245,7 +249,7 @@ impl PersonRegistry {
}
/// Insert batch of persons
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() {
let public_key = person.public_key();
self.persons
@@ -280,7 +284,7 @@ impl PersonRegistry {
let public_key = *public_key;
if self.seen.write().unwrap().insert(public_key) {
if self.seens.borrow_mut().insert(public_key) {
let sender = self.sender.clone();
// Spawn background task to request metadata
+9 -1
View File
@@ -5,6 +5,8 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -109,7 +111,13 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
+23
View File
@@ -1,3 +1,4 @@
use std::fmt::Display;
use std::rc::Rc;
use anyhow::{Error, anyhow};
@@ -41,10 +42,28 @@ setting_accessors! {
pub hide_avatar: bool,
pub screening: bool,
pub nip4e: bool,
pub auth_mode: AuthMode,
pub trusted_relays: Vec<String>,
pub file_server: Url,
}
/// Authentication mode
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AuthMode {
#[default]
Auto,
Manual,
}
impl Display for AuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthMode::Auto => write!(f, "Auto"),
AuthMode::Manual => write!(f, "Ask every time"),
}
}
}
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
@@ -122,6 +141,9 @@ pub struct Settings {
/// Enable decoupling encryption key
pub nip4e: bool,
/// Authentication mode
pub auth_mode: AuthMode,
/// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: Vec<String>,
@@ -137,6 +159,7 @@ impl Default for Settings {
hide_avatar: false,
screening: true,
nip4e: false,
auth_mode: AuthMode::default(),
trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(),
}
-6
View File
@@ -14,7 +14,6 @@ nostr-blossom.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
instant.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
@@ -24,15 +23,10 @@ serde_json.workspace = true
mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
base64.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
+3 -4
View File
@@ -39,15 +39,14 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
"wss://user.kindpag.es",
];
-330
View File
@@ -1,330 +0,0 @@
use std::path::PathBuf;
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use anyhow::{Error, anyhow, bail};
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use futures::AsyncReadExt;
use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString};
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr::nips::nip94::Sha256Hash;
#[cfg(not(target_arch = "wasm32"))]
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
pub const ALGORITHM: &str = "aes-gcm";
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
const TAG_SHA256: &str = "x";
const TAG_ORIGINAL_SHA256: &str = "ox";
const TAG_FILE_TYPE: &str = "file-type";
const TAG_ALGORITHM: &str = "encryption-algorithm";
const TAG_KEY: &str = "decryption-key";
const TAG_NONCE: &str = "decryption-nonce";
const TAG_SIZE: &str = "size";
const TAG_DIM: &str = "dim";
const TAG_ALT: &str = "alt";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedFile {
pub data: Vec<u8>,
pub key: String,
pub nonce: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileAttachment {
pub url: Url,
pub mime: String,
pub key: String,
pub nonce: String,
pub sha256: Option<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
pub name: Option<String>,
}
impl FileAttachment {
pub fn tags(&self) -> Vec<Tag> {
let mut tags = vec![
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
Tag::custom(TAG_KEY, [self.key.clone()]),
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
];
if let Some(sha256) = &self.sha256 {
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
}
if let Some(original_sha256) = &self.original_sha256 {
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
}
if let Some(size) = self.size {
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
}
if let Some((width, height)) = self.dim {
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
}
if let Some(name) = &self.name {
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
}
tags
}
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
return None;
}
Some(Self {
url: Url::parse(content).ok()?,
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
key: tag_value(tags, TAG_KEY)?.to_string(),
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
name: tag_value(tags, TAG_ALT).map(str::to_string),
})
}
pub fn is_image(&self) -> bool {
self.mime.starts_with("image/")
}
pub fn display_name(&self) -> SharedString {
if let Some(name) = &self.name {
return name.clone().into();
}
match self.size {
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
None => self.mime.clone().into(),
}
}
}
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng);
let nonce = Aes256Gcm::generate_nonce(OsRng);
let cipher = Aes256Gcm::new(&key);
let data = cipher
.encrypt(&nonce, data)
.map_err(|_| anyhow!("Failed to encrypt file"))?;
Ok(EncryptedFile {
data,
key: STANDARD.encode(key.as_slice()),
nonce: STANDARD.encode(nonce.as_slice()),
})
}
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
let key = decode(key, 32, "decryption key")?;
let nonce = decode(nonce, 12, "decryption nonce")?;
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| anyhow!("Invalid decryption key"))?;
cipher
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file"))
}
pub fn sha256_hex(data: &[u8]) -> String {
let hash: [u8; 32] = Sha256::digest(data).into();
Sha256Hash::from_byte_array(hash).to_hex()
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_encrypted(
server: Url,
path: PathBuf,
cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
let mime = from_path(&path).first_or_octet_stream().to_string();
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned());
let data = smol::fs::read(&path).await?;
let encrypted = encrypt(&data)?;
let sha256 = Some(sha256_hex(&encrypted.data));
let original_sha256 = Some(sha256_hex(&data));
let size = Some(encrypted.data.len() as u64);
let keys = Keys::generate();
let client = BlossomClient::new(server);
let url = Tokio::spawn(cx, async move {
let blob = client
.upload_blob(
encrypted.data,
Some("application/octet-stream".to_string()),
None,
Some(&keys),
)
.await?;
Ok::<Url, Error>(blob.url)
})
.await
.map_err(|e| anyhow!("Upload error: {e}"))??;
Ok(FileAttachment {
url,
mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256,
original_sha256,
size,
dim: None,
name,
})
}
#[cfg(target_arch = "wasm32")]
pub async fn upload_encrypted(
_server: Url,
_path: PathBuf,
_cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
Err(anyhow!("File upload not supported on web"))
}
pub async fn download_and_decrypt(
url: &Url,
key: &str,
nonce: &str,
expected_sha256: Option<&str>,
cx: &AsyncApp,
) -> Result<Vec<u8>, Error> {
let client = cx.update(|app| app.http_client());
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
if !response.status().is_success() {
bail!("Failed to download file: HTTP {}", response.status());
}
let mut data = Vec::new();
response
.into_body()
.take(MAX_FILE_SIZE as u64 + 1)
.read_to_end(&mut data)
.await?;
if data.len() > MAX_FILE_SIZE {
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
}
if let Some(expected) = expected_sha256
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
{
bail!("File hash mismatch");
}
decrypt(&data, key, nonce)
}
/// Download and decrypt a file attachment into a temporary file.
///
/// The same attachment always maps to the same path, so callers can render the
/// result directly (e.g. with `img`) without downloading it more than once.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_file(
file: &FileAttachment,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let name = file
.sha256
.clone()
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
let extension = mime_guess::get_mime_extensions_str(&file.mime)
.and_then(|extensions| extensions.first())
.copied()
.unwrap_or("bin");
let path = std::env::temp_dir()
.join("coop-files")
.join(format!("{name}.{extension}"));
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
}
let data = download_and_decrypt(
&file.url,
&file.key,
&file.nonce,
file.sha256.as_deref(),
cx,
)
.await?;
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path)
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_file(
_file: &FileAttachment,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("File download not supported on web"))
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
}
fn parse_dim(value: &str) -> Option<(u32, u32)> {
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
fn decode(value: &str, expected_len: usize, label: &str) -> Result<Vec<u8>, Error> {
let decoded = STANDARD
.decode(value)
.or_else(|_| STANDARD_NO_PAD.decode(value))
.or_else(|_| URL_SAFE.decode(value))
.or_else(|_| URL_SAFE_NO_PAD.decode(value))
.map_err(|_| anyhow!("Invalid {label} encoding"))?;
if decoded.len() != expected_len {
bail!(
"Invalid {label} length: expected {expected_len} bytes, got {}",
decoded.len()
);
}
Ok(decoded)
}
+9 -107
View File
@@ -1,12 +1,9 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
@@ -17,19 +14,17 @@ use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod file;
mod nip05;
mod nip4e;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use file::*;
pub use nip4e::*;
pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
pub fn init(window: &mut Window, cx: &mut App) {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
@@ -42,7 +37,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -63,16 +58,16 @@ pub enum StateEvent {
}
impl StateEvent {
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
pub fn error<T>(error: T) -> Self
where
T: Into<String>,
{
Self::Error(error.into())
}
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
}
/// Nostr Registry
@@ -105,7 +100,7 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
@@ -135,16 +130,7 @@ impl NostrRegistry {
// Connect to bootstrap relays after the window is ready
cx.defer_in(window, |this, _window, cx| {
this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
}
this.get_user_credential(cx);
});
Self {
@@ -267,11 +253,6 @@ impl NostrRegistry {
this.set_signer(signer, cx);
cx.notify();
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
}
}
_ => {
@@ -287,10 +268,6 @@ impl NostrRegistry {
/// Get the master key that used for Nostr Connect
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
if cfg!(target_arch = "wasm32") {
return cx.background_spawn(async move { Keys::generate() });
}
let task = cx.read_credentials(MASTER_KEYRING);
cx.spawn(async move |cx| {
@@ -317,81 +294,6 @@ impl NostrRegistry {
})
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
let (tx, rx) = flume::bounded::<String>(1);
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
// Start the proxy and get the web url
proxy.start().await?;
// Notify GPUI
let url = proxy.url();
tx.send(url).ok();
Ok(())
}
}));
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
loop {
if proxy.is_session_active() {
break;
}
smol::Timer::after(Duration::from_secs(1)).await;
}
Ok(())
}
}));
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
async move |this, cx| {
while let Ok(url) = rx.recv_async().await {
this.update(cx, |this, cx| {
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
cx.background_spawn(async move { save.await.ok() }).detach();
cx.open_url(&url);
this.set_signer(proxy.clone(), cx);
})?;
}
Ok(())
}
}));
// Monitor the session, if the browser disconnects, notify user to reconnect
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
let executor = cx.background_executor().clone();
async move |this, cx| {
// Wait for the signer to be confirmed (timeout is 30s)
executor.timer(Duration::from_secs(30)).await;
loop {
executor.timer(Duration::from_secs(5)).await;
if !proxy.is_session_active() {
_ = this.update(cx, |this, cx| {
// Only notify if this proxy is still the active signer
if this.current_user.is_some() {
this.signer.swap_inner(Keys::generate());
this.current_user = None;
cx.emit(StateEvent::NoSigner);
cx.notify();
}
});
break;
}
}
Ok(())
}
}));
}
/// Get the public key of a NIP-05 address
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
-1
View File
@@ -4,7 +4,6 @@ use anyhow::Error;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, HttpClient};
use nostr_sdk::prelude::*;
use serde_json::Value;
#[allow(async_fn_in_trait)]
pub trait NostrAddress {
+2 -2
View File
@@ -112,7 +112,7 @@ impl ThemeColors {
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: neutral().light().step_3(),
title_bar_inactive: neutral().light().step_1(),
window_border: hsl(240.0, 5.9, 78.0),
@@ -198,7 +198,7 @@ impl ThemeColors {
elevated_surface_background: neutral().dark().step_3(),
panel_background: neutral().dark().step_1(),
overlay: neutral().dark_alpha().step_3(),
title_bar: neutral().dark().step_2(),
title_bar: neutral().dark().step_3(),
title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0),
+1 -1
View File
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(44.0);
pub const TABBAR_HEIGHT: Pixels = px(28.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
-1
View File
@@ -9,7 +9,6 @@ common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
instant.workspace = true
serde.workspace = true
smallvec.workspace = true
anyhow.workspace = true
+6 -4
View File
@@ -1,8 +1,8 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
px,
};
use theme::ActiveTheme;
@@ -26,7 +26,9 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
@@ -128,7 +130,7 @@ impl RenderOnce for Avatar {
self.image
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.object_fit(gpui::ObjectFit::Fill)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
+1 -1
View File
@@ -1,5 +1,5 @@
use std::rc::Rc;
use instant::Duration;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
-2
View File
@@ -214,8 +214,6 @@ impl Dock {
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
// Use defer_in (not window.defer) so the callback is cancelled
// if this Dock entity is dropped before the deferred frame runs.
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
+32 -6
View File
@@ -2,10 +2,11 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId,
EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use crate::ElementExt;
@@ -109,8 +110,15 @@ impl DockItem {
window: &mut Window,
cx: &mut App,
) -> Self {
let mut items = items;
let stack_panel = cx.new(|cx| {
let mut stack_panel = StackPanel::new(axis, window, cx);
for (i, item) in items.iter_mut().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
}
for (i, item) in items.iter().enumerate() {
let view = item.view();
@@ -737,22 +745,34 @@ impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone();
let decorations = window.window_decorations();
div()
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
this.child(zoom_view)
this.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
} else {
// render dock
this.child(
div()
.flex()
.flex_row()
.size_full()
.h_full()
// Left dock
.when_some(self.left_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
@@ -763,8 +783,14 @@ impl Render for DockArea {
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
// Top center
.child(div().flex_1().child(self.render_items(window, cx)))
.child(
div()
.flex_1()
.overflow_hidden()
.child(self.render_items(window, cx)),
)
// Bottom Dock
.when_some(self.bottom_dock.clone(), |this, dock| {
this.child(dock)
+19 -12
View File
@@ -1,12 +1,13 @@
use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
Window,
};
use smallvec::SmallVec;
use theme::{AxisExt as _, Placement};
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
use super::{DockArea, PanelEvent};
use crate::dock::panel::{Panel, PanelView};
@@ -368,20 +369,26 @@ impl Focusable for StackPanel {
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex().size_full().overflow_hidden().child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
h_flex()
.size_full()
.overflow_hidden()
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}
+19 -33
View File
@@ -7,7 +7,7 @@ use gpui::{
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
WeakEntity, Window, div, px, rems,
};
use theme::{ActiveTheme, AxisExt, Placement, TABBAR_HEIGHT};
use theme::{ActiveTheme, AxisExt, CLIENT_SIDE_DECORATION_ROUNDING, Placement, TABBAR_HEIGHT};
use crate::button::{Button, ButtonVariants as _};
use crate::dock::dock::DockPlacement;
@@ -51,7 +51,7 @@ impl Render for DragPanel {
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_sm()
.text_xs()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
@@ -312,7 +312,6 @@ impl TabPanel {
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn detach_panel(
@@ -322,22 +321,10 @@ impl TabPanel {
cx: &mut Context<Self>,
) {
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
} else if let Some(removed_ix) = removed_ix {
if removed_ix < self.active_ix {
self.active_ix = self.active_ix.saturating_sub(1);
} else if removed_ix == self.active_ix {
// The active panel was removed and another panel shifted into
// its position. Activate the new panel at the same index.
if let Some(new_active) = self.panels.get(self.active_ix) {
new_active.set_active(true, cx);
}
self.focus_active_panel(window, cx);
}
}
}
@@ -580,7 +567,6 @@ impl TabPanel {
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
let tabs_count = self.panels.len();
let is_bottom_dock = bottom_dock_button.is_some();
@@ -600,7 +586,6 @@ impl TabPanel {
.py_2()
.pl_3()
.pr_2()
.rounded_t(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.when(left_dock_button.is_some(), |this| this.pl_2())
.when(right_dock_button.is_some(), |this| this.pr_2())
@@ -626,7 +611,7 @@ impl TabPanel {
div()
.w_full()
.text_ellipsis()
.text_sm()
.text_xs()
.child(panel.title(cx)),
)
.when(state.draggable, |this| {
@@ -656,14 +641,17 @@ impl TabPanel {
TabBar::new("tab-bar")
.track_scroll(&self.tab_bar_scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.rounded_t(cx.theme().radius_lg)
.when(has_extend_dock_button, |this| {
this.prefix(
h_flex()
.items_center()
.top_0()
.right(-px(1.))
.border_r_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.pl_0p5()
.pr_1()
.children(left_dock_button)
@@ -700,7 +688,6 @@ impl TabPanel {
.on_click(cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
cx.stop_propagation();
view.remove_panel(&panel, window, cx);
}
})),
@@ -793,8 +780,12 @@ impl TabPanel {
.top_0()
.right_0()
.h_full()
.border_l_1()
.border_b_1()
.px_0p5()
.gap_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.child(self.render_toolbar(state, window, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
)
@@ -824,8 +815,10 @@ impl TabPanel {
.child(
div()
.size_full()
.rounded_b(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_b(CLIENT_SIDE_DECORATION_ROUNDING)
})
.overflow_hidden()
.child(
active_panel
@@ -1147,24 +1140,17 @@ impl Render for TabPanel {
state.closable = false;
}
div()
v_flex()
.when(!self.collapsed, |this| {
this.on_action(cx.listener(Self::on_action_toggle_zoom))
.on_action(cx.listener(Self::on_action_close_panel))
})
.id("tab-panel")
.tab_group()
.track_focus(&focus_handle)
.size_full()
.p_1()
.overflow_hidden()
.child(
v_flex()
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xs())
.size_full()
.tab_group()
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx)),
)
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx))
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
use std::fmt::Debug;
use instant::{Duration, Instant};
use std::time::{Duration, Instant};
/// A HistoryItem represents a single change in the history.
/// It must implement Clone and PartialEq to be used in the History.
-2
View File
@@ -46,7 +46,6 @@ pub enum IconName {
InboxFill,
Link,
Loader,
Lock,
Moon,
Plus,
PlusCircle,
@@ -119,7 +118,6 @@ impl IconNamed for IconName {
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
+1 -1
View File
@@ -1,4 +1,4 @@
use instant::Duration;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
+1 -1
View File
@@ -1,4 +1,4 @@
use instant::Duration;
use std::time::Duration;
use gpui::{Context, Pixels, Task, px};
+21
View File
@@ -35,6 +35,8 @@ pub struct Input {
cleanable: bool,
mask_toggle: bool,
disabled: bool,
bordered: bool,
focus_bordered: bool,
tab_index: isize,
selected: bool,
}
@@ -71,6 +73,8 @@ impl Input {
cleanable: false,
mask_toggle: false,
disabled: false,
bordered: true,
focus_bordered: true,
tab_index: 0,
selected: false,
}
@@ -104,6 +108,18 @@ impl Input {
self
}
/// Set the bordered for the input, default: true
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
/// Set focus border for the input, default is true.
pub fn focus_bordered(mut self, bordered: bool) -> Self {
self.focus_bordered = bordered;
self
}
/// Set whether to show the clear button when the input field is not empty, default is false.
pub fn cleanable(mut self, cleanable: bool) -> Self {
self.cleanable = cleanable;
@@ -316,6 +332,11 @@ impl RenderOnce for Input {
this.bg(bg)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(cx.theme().radius)
.when(self.bordered, |this| {
this.border_color(cx.theme().border)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_xs())
})
})
.items_center()
.gap(gap_x)
+1 -1
View File
@@ -383,7 +383,7 @@ impl InputState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle().tab_stop(true);
let blink_cursor = cx.new(|_| BlinkCursor::new());
let history = History::new().group_interval(instant::Duration::from_secs(1));
let history = History::new().group_interval(std::time::Duration::from_secs(1));
let _subscriptions = vec![
// Observe the blink cursor to repaint the view when it changes.
+1 -1
View File
@@ -1,4 +1,5 @@
use std::ops::Range;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -8,7 +9,6 @@ use gpui::{
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
+5 -5
View File
@@ -1,4 +1,5 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -6,7 +7,6 @@ use gpui::{
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(16.);
let mut padding_left = px(16.);
let mut padding_right = px(8.);
let mut padding_left = px(8.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_4()
.h_8()
.px_2()
.h_4()
.w_full()
.flex()
.items_center()
+1 -1
View File
@@ -1,7 +1,7 @@
use std::any::TypeId;
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use instant::Duration;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
+4 -3
View File
@@ -117,7 +117,7 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
cx.theme().border
};
let mut ele = div()
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
@@ -160,15 +160,16 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = ele.request_layout(window, cx);
let layout_id = el.request_layout(window, cx);
((layout_id, ele), state)
((layout_id, el), state)
})
}
+3 -3
View File
@@ -2,6 +2,7 @@ use std::cell::Cell;
use std::ops::Deref;
use std::panic::Location;
use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
@@ -10,7 +11,6 @@ use gpui::{
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size,
};
use instant::{Duration, Instant};
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
@@ -157,7 +157,7 @@ impl ScrollbarStateInner {
let mut state = *self;
state.hovered_axis = axis;
if axis.is_some() {
state.last_scroll_time = Some(instant::Instant::now());
state.last_scroll_time = Some(std::time::Instant::now());
}
state
}
@@ -166,7 +166,7 @@ impl ScrollbarStateInner {
let mut state = *self;
state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() {
state.last_scroll_time = Some(instant::Instant::now());
state.last_scroll_time = Some(std::time::Instant::now());
}
state
}
+1 -1
View File
@@ -1,4 +1,4 @@
use instant::Duration;
use std::time::Duration;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
+2 -2
View File
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
}
macro_rules! font_weight {
@@ -87,7 +87,7 @@ pub trait StyledExt: Styled + Sized {
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius_lg)
.rounded(cx.theme().radius)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use std::cell::RefCell;
use std::rc::Rc;
use instant::Duration;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
+496 -34
View File
@@ -2,15 +2,396 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative,
AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, px, relative,
};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use theme::ActiveTheme;
use crate::{Icon, IconName, Selectable, h_flex};
use crate::{Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
pub mod tab_bar;
/// Tab variants.
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
pub enum TabVariant {
#[default]
Tab,
Outline,
Pill,
Segmented,
Underline,
}
impl TabVariant {
fn height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Underline => px(26.),
_ => px(20.),
},
Size::Small => match self {
TabVariant::Underline => px(30.),
_ => px(24.),
},
Size::Large => match self {
TabVariant::Underline => px(44.),
_ => px(36.),
},
_ => match self {
TabVariant::Underline => px(36.),
_ => px(32.),
},
}
}
fn inner_height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(18.),
TabVariant::Segmented => px(16.),
TabVariant::Underline => px(20.),
},
Size::Small => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(22.),
TabVariant::Segmented => px(18.),
TabVariant::Underline => px(22.),
},
Size::Large => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(36.),
TabVariant::Segmented => px(28.),
TabVariant::Underline => px(32.),
},
_ => match self {
TabVariant::Tab => px(30.),
TabVariant::Outline | TabVariant::Pill => px(26.),
TabVariant::Segmented => px(24.),
TabVariant::Underline => px(26.),
},
}
}
/// Default px(12) to match panel px_3, See [`crate::dock::TabPanel`]
fn inner_paddings(&self, size: Size) -> Edges<Pixels> {
let mut padding_x = match size {
Size::XSmall => px(8.),
Size::Small => px(10.),
Size::Large => px(16.),
_ => px(12.),
};
if matches!(self, TabVariant::Underline) {
padding_x = px(0.);
}
Edges {
left: padding_x,
right: padding_x,
..Default::default()
}
}
fn inner_margins(&self, size: Size) -> Edges<Pixels> {
match size {
Size::XSmall => match self {
TabVariant::Underline => Edges {
top: px(1.),
bottom: px(2.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Small => match self {
TabVariant::Underline => Edges {
top: px(2.),
bottom: px(3.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Large => match self {
TabVariant::Underline => Edges {
top: px(5.),
bottom: px(6.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
_ => match self {
TabVariant::Underline => Edges {
top: px(3.),
bottom: px(4.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
}
}
fn normal(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().text,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn hovered(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_hover,
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn selected(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: cx.theme().tab_active_background,
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_accent,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().element_active,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().element_foreground,
bg: cx.theme().element_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
inner_bg: cx.theme().background,
shadow: true,
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: cx.theme().element_active,
..Default::default()
},
}
}
fn disabled(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: if selected {
cx.theme().element_active
} else {
cx.theme().border
},
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: if selected {
cx.theme().element_foreground.opacity(0.5)
} else {
cx.theme().text_muted
},
bg: if selected {
cx.theme().element_background.opacity(0.5)
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().text_muted,
bg: cx.theme().tab_background,
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
bottom: px(2.),
..Default::default()
},
..Default::default()
},
}
}
pub(super) fn tab_bar_radius(&self, size: Size, cx: &App) -> Pixels {
if *self != TabVariant::Segmented {
return px(0.);
}
match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
}
}
fn radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Outline | TabVariant::Pill => px(99.),
TabVariant::Segmented => match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
},
_ => px(0.),
}
}
fn inner_radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Segmented => match size {
Size::Large => self.tab_bar_radius(size, cx) - px(3.),
_ => self.tab_bar_radius(size, cx) - px(2.),
},
_ => px(0.),
}
}
}
#[allow(dead_code)]
struct TabStyle {
borders: Edges<Pixels>,
border_color: Hsla,
bg: Hsla,
fg: Hsla,
shadow: bool,
inner_bg: Hsla,
}
impl Default for TabStyle {
fn default() -> Self {
TabStyle {
borders: Edges::all(px(0.)),
border_color: gpui::transparent_white(),
bg: gpui::transparent_white(),
fg: gpui::transparent_white(),
shadow: false,
inner_bg: gpui::transparent_white(),
}
}
}
#[allow(clippy::type_complexity)]
/// A Tab element for the [`super::TabBar`].
#[derive(IntoElement)]
@@ -23,6 +404,8 @@ pub struct Tab {
pub(super) tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
children: Vec<AnyElement>,
variant: TabVariant,
size: Size,
pub(super) disabled: bool,
pub(super) selected: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -71,6 +454,8 @@ impl Default for Tab {
selected: false,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
on_click: None,
}
}
@@ -94,6 +479,36 @@ impl Tab {
self
}
/// Set Tab Variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Use Pill variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Use outline variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Use Segmented variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Use Underline variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
@@ -165,16 +580,42 @@ impl Styled for Tab {
}
}
impl Sizable for Tab {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Tab {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let fg = if self.disabled {
cx.theme().text_muted
} else if self.selected {
cx.theme().tab_active_foreground
let mut tab_style = if self.selected {
self.variant.selected(cx)
} else {
cx.theme().tab_foreground
self.variant.normal(cx)
};
let mut hover_style = self.variant.hovered(self.selected, cx);
if self.disabled {
tab_style = self.variant.disabled(self.selected, cx);
hover_style = self.variant.disabled(self.selected, cx);
}
let tab_bar_prefix = self.tab_bar_prefix.unwrap_or_default();
if !tab_bar_prefix && self.ix == 0 && self.variant == TabVariant::Tab {
tab_style.borders.left = px(0.);
hover_style.borders.left = px(0.);
}
let radius = self.variant.radius(self.size, cx);
let inner_radius = self.variant.inner_radius(self.size, cx);
let inner_paddings = self.variant.inner_paddings(self.size);
let inner_margins = self.variant.inner_margins(self.size);
let inner_height = self.variant.inner_height(self.size);
let height = self.variant.height(self.size);
self.base
.id(self.ix)
.flex()
@@ -182,37 +623,74 @@ impl RenderOnce for Tab {
.gap_1()
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.h(height)
.overflow_hidden()
.text_color(fg)
.text_sm()
.text_color(tab_style.fg)
.map(|this| match self.size {
Size::XSmall => this.text_xs(),
Size::Large => this.text_base(),
_ => this.text_sm(),
})
.bg(tab_style.bg)
.border_l(tab_style.borders.left)
.border_r(tab_style.borders.right)
.border_t(tab_style.borders.top)
.border_b(tab_style.borders.bottom)
.border_color(tab_style.border_color)
.rounded(radius)
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| {
this.text_color(hover_style.fg)
.bg(hover_style.bg)
.border_l(hover_style.borders.left)
.border_r(hover_style.borders.right)
.border_t(hover_style.borders.top)
.border_b(hover_style.borders.bottom)
.border_color(hover_style.border_color)
.rounded(radius)
})
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.flex_1()
.h(px(30.))
.h(inner_height)
.line_height(relative(1.))
.whitespace_nowrap()
.items_center()
.justify_center()
.overflow_hidden()
.margins(inner_margins)
.flex_shrink_0()
.px_3()
.map(|this| match self.icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()),
Some(icon) => {
this.w(inner_height * 1.25)
.child(icon.map(|this| match self.size {
Size::XSmall => this.size_2p5(),
Size::Small => this.size_3p5(),
Size::Large => this.size_4(),
_ => this.size_4(),
}))
}
None => this
.paddings(inner_paddings)
.map(|this| match self.label {
Some(label) => this.child(label),
None => this,
})
.children(self.children),
}),
})
.bg(tab_style.inner_bg)
.rounded(inner_radius)
.when(tab_style.shadow, |this| this.shadow_xs())
.hover(|this| this.bg(hover_style.inner_bg).rounded(inner_radius)),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().pr_2().child(suffix))
})
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
.on_mouse_down(MouseButton::Left, |_, _, cx| {
// Stop propagation behavior, for works on TitleBar.
// https://github.com/longbridge/gpui-component/issues/1836
cx.stop_propagation();
})
.when(!self.disabled, |this| {
@@ -220,21 +698,5 @@ impl RenderOnce for Tab {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
.h_0p5()
.when(self.selected && !self.disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!self.selected && !self.disabled, |this| {
this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background)
})
}),
)
}
}
+116 -8
View File
@@ -2,17 +2,19 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use super::Tab;
use super::{Tab, TabVariant};
use crate::button::{Button, ButtonVariants as _};
use crate::menu::{DropdownMenu as _, PopupMenuItem};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex};
use crate::{IconName, Selectable, Sizable, Size, StyledExt, h_flex};
#[allow(clippy::type_complexity)]
/// A TabBar element that contains multiple [`Tab`] items.
#[derive(IntoElement)]
pub struct TabBar {
@@ -24,8 +26,9 @@ pub struct TabBar {
children: SmallVec<[Tab; 2]>,
last_empty_space: AnyElement,
selected_index: Option<usize>,
variant: TabVariant,
size: Size,
menu: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
@@ -39,6 +42,8 @@ impl TabBar {
scroll_handle: None,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
last_empty_space: div().w_3().into_any_element(),
selected_index: None,
on_click: None,
@@ -46,6 +51,36 @@ impl TabBar {
}
}
/// Set the Tab variant, all children will inherit the variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Set the Tab variant to Pill, all children will inherit the variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Set the Tab variant to Outline, all children will inherit the variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Set the Tab variant to Segmented, all children will inherit the variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Set the Tab variant to Underline, all children will inherit the variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set whether to show the menu button when tabs overflow, default is false.
pub fn menu(mut self, menu: bool) -> Self {
self.menu = menu;
@@ -70,13 +105,13 @@ impl TabBar {
self
}
/// Add children of the TabBar.
/// Add children of the TabBar, all children will inherit the variant.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
/// Add child of the TabBar.
/// Add child of the TabBar, tab will inherit the variant.
pub fn child(mut self, child: impl Into<Tab>) -> Self {
self.children.push(child.into());
self
@@ -112,8 +147,60 @@ impl Styled for TabBar {
}
}
impl Sizable for TabBar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for TabBar {
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let default_gap = match self.size {
Size::Small | Size::XSmall => px(8.),
Size::Large => px(16.),
_ => px(12.),
};
let (bg, paddings, gap) = match self.variant {
TabVariant::Tab => {
let padding = Edges::all(px(0.));
(cx.theme().tab_background, padding, px(0.))
}
TabVariant::Outline => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, default_gap)
}
TabVariant::Pill => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, px(4.))
}
TabVariant::Segmented => {
let padding_x = match self.size {
Size::XSmall => px(2.),
Size::Small => px(3.),
_ => px(4.),
};
let padding = Edges {
left: padding_x,
right: padding_x,
..Default::default()
};
(cx.theme().tab_background, padding, px(2.))
}
TabVariant::Underline => {
// This gap is same as the tab inner_paddings
let gap = match self.size {
Size::XSmall => px(10.),
Size::Small => px(12.),
Size::Large => px(20.),
_ => px(16.),
};
(gpui::transparent_black(), Edges::all(px(0.)), gap)
}
};
let mut item_labels = Vec::new();
let selected_index = self.selected_index;
let on_click = self.on_click.clone();
@@ -123,6 +210,25 @@ impl RenderOnce for TabBar {
.relative()
.flex()
.items_center()
.bg(bg)
.text_color(cx.theme().tab_foreground)
.when(
self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
|this| {
this.child(
div()
.id("border-b")
.absolute()
.left_0()
.bottom_0()
.size_full()
.border_b_1()
.border_color(cx.theme().border),
)
},
)
.rounded(self.variant.tab_bar_radius(self.size, cx))
.paddings(paddings)
.refine_style(&self.style)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
@@ -133,13 +239,15 @@ impl RenderOnce for TabBar {
.when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.gap(px(0.))
.gap(gap)
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
item_labels.push((child.label.clone(), child.disabled));
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child
.ix(ix)
.tab_bar_prefix(tab_bar_prefix)
.with_variant(self.variant)
.with_size(self.size)
.when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix)
})
+2
View File
@@ -283,6 +283,8 @@ impl RenderOnce for TitleBar {
this.px_2()
}
})
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().title_bar)
.refine_style(&self.style)
.when(is_linux, |this| {
-3
View File
@@ -14,13 +14,10 @@ chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
nostr-connect.workspace = true
browser-signer-proxy = { path = "../browser-signer-proxy" }
anyhow.workspace = true
serde.workspace = true
+31 -65
View File
@@ -1,16 +1,17 @@
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
};
use instant::Duration;
use nostr_connect::prelude::*;
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
#[derive(Debug)]
pub struct ImportIdentity {
@@ -35,7 +36,7 @@ pub struct ImportIdentity {
impl ImportIdentity {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
let key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
@@ -134,6 +135,7 @@ impl ImportIdentity {
})?;
}
}
Ok(())
}));
}
@@ -164,14 +166,6 @@ impl ImportIdentity {
}));
}
#[cfg(not(target_arch = "wasm32"))]
fn proxy(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.connect_proxy(cx);
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
@@ -207,78 +201,50 @@ impl ImportIdentity {
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
let is_wasm = cfg!(target_arch = "wasm32");
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
v_flex()
.size_full()
.gap_4()
.text_sm()
.gap_2()
.child(
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Continue with existing key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Decrypt Password:")
.child(Input::new(&self.pass_input)),
)
})
.when(bunker_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(BUNKER_WARN)),
)
})
.when(key_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(KEY_WARN)),
)
}),
.gap_1()
.text_color(cx.theme().text_muted)
.child("Continue with existing key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Decrypt Password:")
.child(Input::new(&self.pass_input)),
)
})
.when(key_warning, |this| {
this.child(
div()
.v_flex()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().font_semibold().child("Warning"))
.child(div().child(MSG)),
)
})
.child(
Button::new("login")
.label("Continue")
.primary()
.font_semibold()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.child(divider(cx))
.when(!is_wasm, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension (Experimental)")
.ghost_alt()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
+1 -1
View File
@@ -1,4 +1,4 @@
use instant::Duration;
use std::time::Duration;
use anyhow::Error;
use device::DeviceRegistry;
+46 -50
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Error;
use common::TimestampExt;
@@ -7,7 +8,6 @@ use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
@@ -16,7 +16,7 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -55,7 +55,7 @@ impl Screening {
window.close_all_modals(cx);
}));
cx.defer_in(window, |this, _window, cx| {
cx.defer_in(window, move |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
@@ -84,20 +84,8 @@ impl Screening {
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
let contacts = client.database().contacts_public_keys(current_user).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
Ok(followed)
});
@@ -240,13 +228,13 @@ impl Screening {
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Tag::from(Nip56Tag::PublicKey {
let tag = Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
});
}
.to_tag();
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
let event = EventBuilder::report(vec![tag], "")
.finalize_async(&signer)
.await?;
@@ -274,7 +262,7 @@ impl Screening {
let contacts = contacts.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
@@ -353,7 +341,7 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().surface_background)
.text_sm()
.truncate()
.text_ellipsis()
@@ -366,8 +354,7 @@ impl Render for Screening {
.gap_1()
.child(
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.label("View on njump.me")
.secondary()
.small()
.rounded()
@@ -398,18 +385,21 @@ impl Render for Screening {
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
v_flex()
.text_sm()
.child(SharedString::from("Contact"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
@@ -424,7 +414,7 @@ impl Render for Screening {
.child(
h_flex()
.gap_0p5()
.child("Activity on Public Relays")
.child(SharedString::from("Activity on Public Relays"))
.child(
Button::new("active")
.icon(IconName::Info)
@@ -493,8 +483,25 @@ impl Render for Screening {
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Mutual contacts"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
@@ -506,17 +513,6 @@ impl Render for Screening {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
+49 -1
View File
@@ -3,7 +3,7 @@ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, px,
};
use settings::AppSettings;
use settings::{AppSettings, AuthMode};
use theme::{ActiveTheme, Theme, ThemeMode};
use ui::button::{Button, ButtonVariants};
use ui::group_box::{GroupBox, GroupBoxVariants};
@@ -60,11 +60,13 @@ impl Render for Preferences {
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
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 nip4e = AppSettings::get_nip4e(cx);
let auth_mode = AppSettings::get_auth_mode(cx);
let theme_mode = AppSettings::get_theme_mode(cx);
v_flex()
@@ -91,6 +93,52 @@ impl Render for Preferences {
.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(
+130 -80
View File
@@ -2,14 +2,14 @@ use std::sync::Arc;
use ::settings::AppSettings;
use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::{CoopImageCache, download_dir};
use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div,
image_cache, px, relative,
};
use nostr_sdk::prelude::*;
use person::{PersonRegistry, shorten_pubkey};
@@ -27,8 +27,7 @@ use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_fle
use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
use crate::sidebar::Sidebar;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash};
mod dialogs;
mod panels;
@@ -45,12 +44,13 @@ struct MsgRelayNotification;
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
Update,
RefreshMessagingRelays,
BackupEncryption,
ImportEncryption,
RefreshEncryption,
ResetEncryption,
ShowRelayList,
ShowMessaging,
ShowProfile,
@@ -60,7 +60,6 @@ enum Command {
}
pub struct Workspace {
sidebar: Entity<Sidebar>,
/// App's Dock Area
dock: Entity<DockArea>,
@@ -80,12 +79,20 @@ impl Workspace {
let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = cx.new(|cx| DockArea::new(window, cx));
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe sign in state changes
cx.observe_in(&nostr, window, move |this, nostr, window, cx| {
if nostr.read(cx).current_user().is_some() {
this.set_center_layout(window, cx);
}
}),
);
subscriptions.push(
// Observe system appearance and update theme
cx.observe_window_appearance(window, |_this, window, cx| {
@@ -98,7 +105,11 @@ impl Workspace {
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
match event {
StateEvent::SignerChanged => {
window.close_all_modals(cx);
this.set_center_layout(window, cx);
cx.defer_in(window, |_this, window, cx| {
window.close_all_modals(cx);
});
}
StateEvent::NoSigner => {
this.import_identity(window, cx);
@@ -219,19 +230,23 @@ impl Workspace {
}),
);
cx.defer_in(window, |this, window, cx| {
let dock = this.dock.downgrade();
let greeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeter], None, &dock, window, cx);
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
subscriptions.push(
// Observe the chat registry
cx.observe(&chat, move |this, chat, cx| {
let ids = this.panel_ids(cx);
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
chat.update(cx, |this, cx| {
this.refresh_rooms(&ids, cx);
});
}),
);
// Set the layout at the end of cycle
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
});
Self {
sidebar,
dock,
image_cache,
tasks: vec![],
@@ -255,6 +270,40 @@ impl Workspace {
}
}
/// Get all panel ids
fn panel_ids(&self, cx: &App) -> Vec<u64> {
self.dock
.read(cx)
.items
.panel_ids(cx)
.into_iter()
.filter_map(|panel| panel.parse::<u64>().ok())
.collect()
}
/// Set the dock layout
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Update the dock layout with sidebar on the left
self.dock.update(cx, |this, 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 greeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeter], 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);
});
}
/// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
@@ -276,7 +325,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
DockPlacement::Left,
DockPlacement::Right,
window,
cx,
);
@@ -287,7 +336,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(contact_list::init(window, cx)),
DockPlacement::Left,
DockPlacement::Right,
window,
cx,
);
@@ -297,7 +346,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(backup::init(window, cx)),
DockPlacement::Left,
DockPlacement::Right,
window,
cx,
);
@@ -307,7 +356,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(messaging_relays::init(window, cx)),
DockPlacement::Left,
DockPlacement::Right,
window,
cx,
);
@@ -317,7 +366,7 @@ impl Workspace {
let chat = ChatRegistry::global(cx);
// Trigger a refresh of the chat registry
chat.update(cx, |this, cx| {
this.reload(cx);
this.refresh(cx);
});
}
Command::ShowRelayList => {
@@ -379,17 +428,6 @@ impl Workspace {
Command::ImportEncryption => {
self.import_encryption(window, cx);
}
Command::Update => {
// No-op on managed distribution channels (Flatpak/Snap) where
// the in-app updater is never initialized.
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| {
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
});
}
}
}
}
@@ -445,11 +483,10 @@ impl Workspace {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(450.))
this.width(px(420.))
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Onboarding")
.title("Import Identity")
.child(import.clone())
});
}
@@ -562,7 +599,7 @@ impl Workspace {
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, cx| {
.dropdown_menu(move |this, _window, _cx| {
let avatar = avatar.clone();
let name = name.clone();
@@ -596,15 +633,7 @@ impl Workspace {
IconName::Sun,
Box::new(Command::ToggleTheme),
)
// Only offer in-app updates when auto-update is
// enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.separator()
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -617,7 +646,9 @@ impl Workspace {
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let chat = ChatRegistry::global(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx);
let trash_messages = chat.read(cx).count_trash_messages(cx);
let is_nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
@@ -628,21 +659,42 @@ impl Workspace {
let profile = persons.read(cx).get(&public_key, cx);
let announcement = profile.announcement();
// Update status is only shown when auto-update is available. On
// managed distribution channels (Flatpak/Snap) no updater exists, so
// nothing is rendered.
let updater_status = AutoUpdater::try_global(cx).and_then(|updater| {
let updater = updater.read(cx);
(!updater.idle(cx)).then(|| updater.status(cx))
});
h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_2()
.when_some(updater_status, |this, status| {
this.child(div().text_xs().italic().child(status))
.when(trash_messages > 0, |this| {
this.child(
h_flex()
.id("trash-messages")
.h_6()
.px_1()
.gap_1()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
Icon::new(IconName::Warning)
.small()
.text_color(cx.theme().text_danger),
)
.child(
div()
.text_xs()
.line_height(relative(1.))
.child(format!("{trash_messages}")),
)
.on_click(move |_ev, window, cx| {
cx.stop_propagation();
// Add the trash panel to the center workspace
Self::add_panel(
trash::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
})
.when(nip4e_enabled, |this| {
.when(is_nip4e_enabled, |this| {
this.child(
Button::new("key")
.icon(IconName::UserKey)
@@ -734,7 +786,17 @@ impl Workspace {
.w_full()
.text_sm()
.justify_between()
.child(url.clone())
.child(
h_flex()
.gap_2()
.child(
div()
.size_1p5()
.rounded_full()
.bg(cx.theme().icon_accent),
)
.child(url.clone()),
)
.child(
div()
.text_xs()
@@ -746,6 +808,11 @@ impl Workspace {
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshMessagingRelays),
)
.menu_with_icon(
"Manage gossip relays",
IconName::Relay,
@@ -753,15 +820,9 @@ impl Workspace {
)
.menu_with_icon(
"Manage messaging relays",
IconName::Relay,
IconName::Settings,
Box::new(Command::ShowMessaging),
)
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshMessagingRelays),
)
}),
)
}
@@ -773,7 +834,7 @@ impl Render for Workspace {
let notification_layer = Root::render_notification_layer(window, cx);
div()
.id("workspace")
.id(SharedString::from("workspace"))
.on_action(cx.listener(Self::on_command))
.relative()
.size_full()
@@ -790,19 +851,8 @@ impl Render for Workspace {
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Main
.child(
h_flex()
.size_full()
.child(
div()
.flex_shrink_0()
.h_full()
.w(SIDEBAR_WIDTH)
.child(self.sidebar.clone()),
)
.child(self.dock.clone()),
),
// Dock
.child(self.dock.clone()),
),
)
// Notifications
+14 -3
View File
@@ -1,9 +1,10 @@
use std::time::Duration;
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use state::USER_KEYRING;
use theme::ActiveTheme;
@@ -154,7 +155,12 @@ impl Render for BackupPanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
)
.child(Input::new(&self.npub_input).small().disabled(true)),
.child(
Input::new(&self.npub_input)
.small()
.bordered(false)
.disabled(true),
),
)
.child(
v_flex()
@@ -167,7 +173,12 @@ impl Render for BackupPanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("Secret Key:")),
)
.child(Input::new(&self.nsec_input).small().disabled(true)),
.child(
Input::new(&self.nsec_input)
.small()
.bordered(false)
.disabled(true),
),
)
.child(
Button::new("copy")
+75 -87
View File
@@ -1,4 +1,5 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::Error;
use gpui::prelude::FluentBuilder;
@@ -7,7 +8,6 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
@@ -17,7 +17,6 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::scroll::ScrollableElement;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
@@ -88,20 +87,7 @@ impl ContactListPanel {
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contact_list: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list)
});
@@ -234,7 +220,8 @@ impl ContactListPanel {
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.child(
h_flex()
.gap_2()
@@ -296,78 +283,79 @@ impl Focusable for ContactListPanel {
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()
.overflow_y_scrollbar()
.child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child("New contact:"),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).small().cleanable(true))
.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().text_danger)
.child(error.clone()),
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(
Input::new(&self.input)
.small()
.bordered(false)
.cleanable(true),
)
}),
)
.map(|this| {
if self.contacts.is_empty() {
this.child(self.render_empty(window, cx))
} else {
.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(
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.child(error.clone()),
)
}
})
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
),
)
}),
)
.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);
})),
),
)
}
}
+6 -9
View File
@@ -9,8 +9,8 @@ use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use crate::Workspace;
use crate::panels::profile;
use crate::{Command, Workspace};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
cx.new(|cx| GreeterPanel::new(window, cx))
@@ -146,20 +146,17 @@ impl Render for GreeterPanel {
.ghost()
.small()
.justify_start()
.on_click(cx.listener(move |this, _, window, cx| {
.on_click(cx.listener(move |this, _ev, window, cx| {
this.add_profile_panel(window, cx)
})),
)
.child(
Button::new("theme")
.icon(Icon::new(IconName::Moon))
.label("Change theme")
Button::new("invite")
.icon(Icon::new(IconName::Invite))
.label("Invite friends")
.ghost()
.small()
.justify_start()
.on_click(cx.listener(move |_, _, _, cx| {
cx.dispatch_action(&Command::ToggleTheme);
})),
.justify_start(),
),
),
),
@@ -1,4 +1,5 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
@@ -7,7 +8,6 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
@@ -93,7 +93,7 @@ impl MessagingRelayPanel {
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip17::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
@@ -177,7 +177,7 @@ impl MessagingRelayPanel {
let tags: Vec<Tag> = self
.relays
.iter()
.map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned())))
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
.collect();
// Set updating state
@@ -320,7 +320,12 @@ impl Render for MessagingRelayPanel {
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).small().cleanable(true))
.child(
Input::new(&self.input)
.small()
.bordered(false)
.cleanable(true),
)
.child(
Button::new("add")
.icon(IconName::Plus)
@@ -360,6 +365,7 @@ impl Render for MessagingRelayPanel {
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
+1
View File
@@ -4,3 +4,4 @@ pub mod greeter;
pub mod messaging_relays;
pub mod profile;
pub mod relay_list;
pub mod trash;
+9 -10
View File
@@ -1,12 +1,12 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::Error;
use anyhow::{Context as AnyhowContext, Error};
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window, div,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
@@ -167,15 +167,13 @@ impl ProfilePanel {
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
return Ok(());
};
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
@@ -355,7 +353,7 @@ impl Render for ProfilePanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("What should people call you?")),
)
.child(Input::new(&self.name_input).small()),
.child(Input::new(&self.name_input).bordered(false).small()),
)
.child(
v_flex()
@@ -366,7 +364,7 @@ impl Render for ProfilePanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("A short introduction about you:")),
)
.child(Input::new(&self.bio_input).small()),
.child(Input::new(&self.bio_input).bordered(false).small()),
)
.child(
v_flex()
@@ -377,7 +375,7 @@ impl Render for ProfilePanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("Website:")),
)
.child(Input::new(&self.website_input).small()),
.child(Input::new(&self.website_input).bordered(false).small()),
)
.child(
v_flex()
@@ -421,6 +419,7 @@ impl Render for ProfilePanel {
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
+10 -4
View File
@@ -1,4 +1,5 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
@@ -7,7 +8,6 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, px, rems,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
@@ -111,7 +111,7 @@ impl RelayListPanel {
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip65::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
@@ -216,7 +216,7 @@ impl RelayListPanel {
self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let event = nip65::RelayList::new(relays)
let event = EventBuilder::relay_list(relays)
.finalize_async(&signer)
.await?;
@@ -371,7 +371,12 @@ impl Render for RelayListPanel {
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).small().cleanable(true))
.child(
Input::new(&self.input)
.small()
.bordered(false)
.cleanable(true),
)
.child(
Button::new("metadata")
.map(|this| {
@@ -429,6 +434,7 @@ impl Render for RelayListPanel {
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
+152
View File
@@ -0,0 +1,152 @@
use chat::ChatRegistry;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render,
SharedString, Styled, Window, div, list, px, relative,
};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::scroll::Scrollbar;
use ui::{Icon, IconName, Sizable, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<TrashPanel> {
cx.new(|cx| TrashPanel::new(window, cx))
}
pub struct TrashPanel {
name: SharedString,
focus_handle: FocusHandle,
/// List state for messages
list_state: ListState,
}
impl TrashPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
let chat = ChatRegistry::global(cx);
let count = chat.read(cx).count_trash_messages(cx);
let list_state = ListState::new(count, ListAlignment::Bottom, px(1024.));
Self {
name: "Trash".into(),
focus_handle: cx.focus_handle(),
list_state,
}
}
fn copy(&self, ix: usize, cx: &App) {
let chat = ChatRegistry::global(cx);
let trashes = chat.read(cx).trashes();
if let Some(message) = trashes.read(cx).iter().nth(ix) {
let item = ClipboardItem::new_string(message.raw_event.to_string());
cx.write_to_clipboard(item);
}
}
fn render_list_item(
&mut self,
ix: usize,
_window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let chat = ChatRegistry::global(cx);
let trashes = chat.read(cx).trashes();
if let Some(message) = trashes.read(cx).iter().nth(ix) {
v_flex()
.id(ix)
.p_2()
.w_full()
.child(
v_flex()
.p_2()
.w_full()
.gap_1()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().surface_background)
.text_sm()
.child(
div()
.text_color(cx.theme().text_danger)
.child(message.reason.clone()),
)
.child(
h_flex()
.h_10()
.w_full()
.px_2()
.justify_between()
.bg(cx.theme().elevated_surface_background)
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(
div()
.truncate()
.text_ellipsis()
.text_xs()
.line_height(relative(1.))
.child(message.raw_event.clone()),
)
.child(
Button::new(format!("copy-{ix}"))
.icon(IconName::Copy)
.ghost()
.small()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.copy(ix, cx);
})),
),
),
)
.into_any_element()
} else {
div().id(ix).into_any_element()
}
}
}
impl Panel for TrashPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(IconName::Warning).small())
.child("Errors")
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for TrashPanel {}
impl Focusable for TrashPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TrashPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex().size_full().relative().child(
v_flex()
.flex_1()
.relative()
.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
this.render_list_item(ix, window, cx)
}),
)
.size_full(),
)
.child(Scrollbar::vertical(&self.list_state)),
)
}
}
+3 -1
View File
@@ -164,7 +164,9 @@ impl RenderOnce for RoomEntry {
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
true
// Prevent closing the modal on click
// modal will be automatically closed after closing panel
false
})
});
}
+60 -63
View File
@@ -1,5 +1,6 @@
use std::collections::HashSet;
use std::ops::Range;
use std::time::Duration;
use anyhow::Error;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
@@ -11,12 +12,11 @@ use gpui::{
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
Window, div, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
use theme::{ActiveTheme, SIDEBAR_WIDTH};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator;
@@ -29,8 +29,13 @@ mod entry;
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
cx.new(|cx| Sidebar::new(window, cx))
}
/// Sidebar.
pub struct Sidebar {
name: SharedString,
focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
@@ -75,7 +80,7 @@ pub struct Sidebar {
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let filter = cx.new(|_| RoomKind::Ongoing);
let contact_list = cx.new(|_| None);
@@ -130,6 +135,7 @@ impl Sidebar {
);
Self {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(),
find_input,
@@ -158,20 +164,7 @@ impl Sidebar {
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
let contacts = client.database().contacts_public_keys(public_key).await?;
Ok(contacts)
});
@@ -342,7 +335,7 @@ impl Sidebar {
.organize(&public_key)
.kind(RoomKind::Ongoing)
});
this.emit_room(&room, _window, cx);
this.emit_room(&room, cx);
})?;
// Reset the find panel
@@ -367,14 +360,6 @@ impl Sidebar {
cx.notify();
});
self.new_requests = false;
// Reset search state when switching to inbox/requests
self.reset(window, cx);
// Clear the find input value
self.find_input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
fn render_list_items(
@@ -394,9 +379,9 @@ impl Sidebar {
let room = item.read(cx);
let room_clone = item.clone();
let public_key = room.display_member(cx).public_key();
let handler = cx.listener(move |_this, _ev, window, cx| {
let handler = cx.listener(move |_this, _ev, _window, cx| {
ChatRegistry::global(cx).update(cx, |s, cx| {
s.emit_room(&room_clone, window, cx);
s.emit_room(&room_clone, cx);
});
});
@@ -489,7 +474,7 @@ impl Sidebar {
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
"Sidebar".into()
self.name.clone()
}
}
@@ -503,11 +488,8 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some();
let loading = chat.read(cx).loading() && logged_in;
let loading = chat.read(cx).loading();
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
// Whether the find panel should be shown
@@ -525,21 +507,27 @@ impl Render for Sidebar {
.size_full()
.gap_2()
.child(
h_flex().px_2().py_1().child(
Input::new(&self.find_input)
.small()
.text_xs()
.disabled(loading)
.when(!self.find_input.read(cx).loading, |this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
}),
),
h_flex()
.h(TABBAR_HEIGHT)
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.child(
Input::new(&self.find_input)
.appearance(false)
.bordered(false)
.small()
.text_xs()
.when(!self.find_input.read(cx).loading, |this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
}),
),
)
.child(
h_flex()
@@ -551,6 +539,7 @@ impl Render for Sidebar {
Button::new("search-results")
.icon(IconName::Search)
.tooltip("All search results")
.small()
.ghost_alt()
.font_semibold()
.flex_1()
@@ -566,8 +555,9 @@ impl Render for Sidebar {
this.icon(IconName::Inbox)
}
})
.when(!show_find_panel, |this| this.label("Inbox").small())
.when(!show_find_panel, |this| this.label("Inbox"))
.tooltip("All ongoing conversations")
.small()
.ghost_alt()
.font_semibold()
.flex_1()
@@ -587,8 +577,9 @@ impl Render for Sidebar {
this.icon(IconName::Fistbump)
}
})
.when(!show_find_panel, |this| this.label("Requests").small())
.when(!show_find_panel, |this| this.label("Requests"))
.tooltip("Incoming new conversations")
.small()
.ghost_alt()
.font_semibold()
.flex_1()
@@ -617,13 +608,17 @@ impl Render for Sidebar {
.items_center()
.justify_center()
.text_center()
.child(div().text_sm().font_semibold().child("No conversations"))
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child("Start a conversation with someone to get started."),
),
.text_sm()
.font_semibold()
.child(SharedString::from("No conversations")),
)
.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from(
"Start a conversation with someone to get started.",
),
)),
),
)
})
@@ -648,7 +643,7 @@ impl Render for Sidebar {
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child("Results"),
.child(SharedString::from("Results")),
)
.child(
uniform_list(
@@ -675,7 +670,7 @@ impl Render for Sidebar {
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small())
.child("Contacts"),
.child(SharedString::from("Suggestions")),
)
.child(
uniform_list(
@@ -712,17 +707,17 @@ impl Render for Sidebar {
this.child(
div()
.absolute()
.bottom_2()
.bottom_0()
.left_0()
.h_9()
.w_full()
.px_4()
.px_2()
.child(
Button::new("create")
.label(button_label)
.primary()
.rounded()
.shadow_md()
.small()
.shadow_lg()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.create_room(window, cx);
})),
@@ -745,13 +740,15 @@ impl Render for Sidebar {
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.when(cx.theme().shadow, |this| this.shadow_md())
.border_color(cx.theme().border_disabled)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_xs())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().icon_accent))
.child("Getting messages..."),
.child(SharedString::from("Getting messages...")),
),
)
})
+1 -2
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "1.0.1"
version = "1.0.0-beta5"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -48,4 +48,3 @@ reqwest_client.workspace = true
log.workspace = true
tracing-subscriber.workspace = true
nostr-sdk.workspace = true
-4
View File
@@ -1,7 +1,3 @@
# Snaps built by snapcraft without Snap Store credentials are unsigned and
# cannot be installed without bypassing signature checks. Use:
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
# For signed installs (`snap install coop`), publish via the Snap Store.
name: coop
title: Coop
base: core24
+26 -55
View File
@@ -7,7 +7,6 @@ use gpui::{
actions, point, px, size,
};
use gpui_platform::application;
use nostr_sdk::prelude::SecretKey;
use state::{APP_ID, CLIENT_NAME};
use ui::Root;
@@ -17,14 +16,6 @@ fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Parse CLI arguments for --sec <nsec1>
let cli_key = parse_cli_key();
if let Err(ref e) = cli_key {
eprintln!("Failed to parse --sec argument: {e}");
std::process::exit(1);
}
let cli_key = cli_key.unwrap();
// Run application
application()
.with_assets(Assets)
@@ -33,9 +24,6 @@ fn main() {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Set app identity
cx.set_app_identity(APP_ID, CLIENT_NAME);
// Register the `quit` function
cx.on_action(quit);
@@ -55,7 +43,7 @@ fn main() {
}]);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
// Set up the window options
let opts = WindowOptions {
@@ -74,39 +62,41 @@ fn main() {
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Initialize components
ui::init(cx);
// Bring the app to the foreground
cx.activate(true);
// Initialize theme registry
theme::init(cx);
cx.new(|cx| {
// Initialize components
ui::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize theme registry
theme::init(cx);
// Initialize the nostr client
state::init(window, cx, cli_key);
// Initialize settings
settings::init(window, cx);
// Initialize person registry
person::init(window, cx);
// Initialize the nostr client
state::init(window, cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize person registry
person::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize auto update
auto_update::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Root view
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
// Initialize auto update
auto_update::init(window, cx);
// Root Entity
Root::new(workspace::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
// Bring the app to the foreground
cx.activate(true);
});
}
@@ -134,25 +124,6 @@ fn load_embedded_fonts(cx: &App) {
.unwrap();
}
fn parse_cli_key() -> Result<Option<SecretKey>, String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 0;
while i < args.len() {
if args[i] == "--sec" {
if i + 1 < args.len() {
let nsec = &args[i + 1];
return SecretKey::parse(nsec)
.map(Some)
.map_err(|e| format!("Invalid nsec key '{nsec}': {e}"));
} else {
return Err("--sec requires a value (nsec1...)".to_string());
}
}
i += 1;
}
Ok(None)
}
fn quit(_ev: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

+4 -4
View File
@@ -55,8 +55,8 @@ flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
Ensure you have:
- [ ] Committed all changes
- [ ] Tagged the release: `git tag -a v1.0.0 -m "Release v1.0.0"`
- [ ] Pushed the tag: `git push origin v1.0.0`
- [ ] Tagged the release: `git tag -a v1.0.0-beta2 -m "Release v1.0.0-beta2"`
- [ ] Pushed the tag: `git push origin v1.0.0-beta2`
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
### 2. Fork and Submit
@@ -101,8 +101,8 @@ git push origin su.reya.coop
To release a new version:
1. Update version in workspace `Cargo.toml`
2. Tag the new release: `git tag -a v1.0.0 -m "Release v1.0.0"`
3. Push the tag: `git push origin v1.0.0`
2. Tag the new release: `git tag -a v1.0.0-beta3 -m "Release v1.0.0-beta3"`
3. Push the tag: `git push origin v1.0.0-beta3`
4. Run `./script/prepare-flathub.sh` to regenerate
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
6. Update the manifest with new commit/tag and hashes
-4
View File
@@ -35,7 +35,3 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
snapcraft --destructive-mode --output "$SNAP_NAME"
echo "Created snap package: $SNAP_NAME"
echo ""
echo "This snap is unsigned (built without Snap Store credentials)."
echo "To install it locally, use:"
echo " sudo snap install --dangerous ./$SNAP_NAME"
+10 -16
View File
@@ -29,29 +29,23 @@ fi
# Function to update version in a Cargo.toml file
update_version() {
local file="$1"
local tmp="${file}.tmp"
local backup="${file}.bak"
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
# suffix instead of the extended-regex flag, leaving a stray
# `Cargo.toml-E` behind and never updating the file.
# Writing to a temp file and moving it over works on both implementations.
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
&& mv "$tmp" "$file"; then
# Backup the original file
cp "$file" "$backup"
# More flexible regex that handles various version formats and whitespace
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
echo "✓ Updated version to $NEW_VERSION in $file"
else
echo "Error: Failed to update version in $file"
# Remove any partial temp file; the original file is untouched
rm -f "$tmp"
# Restore original backup
mv "$backup" "$file"
exit 1
fi
# The substitution can silently match nothing (e.g. a `version.workspace` key),
# so verify the new version actually landed before moving on.
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
echo "Error: Version line not found/updated in $file"
exit 1
fi
# Remove the backup file
rm -f "$backup"
}
# Update both Cargo.toml files
+1 -16
View File
@@ -15,26 +15,11 @@ if [ "$#" -ne 1 ]; then
exit 1
fi
# Get system architecture (same mapping as script/bundle-snap)
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_SUFFIX="x86_64" ;;
aarch64) ARCH_SUFFIX="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
if [ ! -f "$snap_file" ]; then
echo "Snap file not found: $snap_file"
echo "Build it first with: script/bundle-snap $1"
exit 1
fi
# Rerun as root
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
snap remove coop || true
mkdir -p snap
rm -rf snap/unpacked
unsquashfs -dest snap/unpacked "$snap_file"
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
snap try --classic snap/unpacked
+1 -5
View File
@@ -4,9 +4,6 @@ version.workspace = true
edition.workspace = true
publish.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
workspace = { path = "../crates/workspace" }
assets = { path = "../crates/assets" }
@@ -24,15 +21,14 @@ gpui_platform.workspace = true
gpui_web = { git = "https://github.com/zed-industries/zed" }
log.workspace = true
instant = { workspace = true, features = ["wasm-bindgen"] }
console_error_panic_hook = "0.1"
tracing-wasm = "0.2"
console_log = "1.0"
wasm-bindgen = "0.2"
universal-time = { git = "https://github.com/shadowylab/universal-time" }
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom_0_2 = { package = "getrandom", version = "0.2", features = ["js"] }
getrandom_0_3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
getrandom_0_4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }
tokio = { version = "1", default-features = false, features = ["sync", "macros", "io-util", "rt", "time"] }

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