Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4329385abe | ||
|
|
d926c1e3ea | ||
|
|
66f75ad105 | ||
|
|
5f2a5d7a37 | ||
|
|
319b1038d2 |
@@ -2,10 +2,7 @@
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
|
||||
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `GlobalState::global(cx)` inline.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
@@ -84,8 +81,6 @@ Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
@@ -4,21 +4,19 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0-alpha"
|
||||
version = "1.0.2"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
gpui-base = "0.7.1"
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
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" }
|
||||
@@ -27,19 +25,15 @@ 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", "nip98" ] }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
data-encoding = "2"
|
||||
hkdf = "0.12"
|
||||
# Pinned to the instances `nostr` already builds: the NIP-44 message-key disclosure
|
||||
chacha20 = "0.9"
|
||||
hmac = "0.12"
|
||||
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
||||
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
|
||||
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
@@ -47,6 +41,7 @@ chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -56,7 +51,13 @@ smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
# Concord support — backend & API plan
|
||||
|
||||
Concord is an encrypted community/channel protocol over Nostr: shared-key "Private Streams" (CORD-01), communities with a self-certifying owner id and three planes (CORD-02), public/private channels (CORD-03), an owner-rooted signed roster (CORD-04), invites (CORD-05), rekeys/refoundings (CORD-06) and disappearing messages (CORD-08).
|
||||
|
||||
Scope of this plan: **backend + public Rust API**. No views, no widgets, no copy.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- New `crates/concord` crate: derivations, stream codec, control/chat/guestbook planes, authority fold, ephemeral state, invites, rekeys, dissolution, storage, sync engine.
|
||||
- Public API for a future UI: `ConcordRegistry` + `Entity<Community>` / `Entity<Channel>` + events, mirroring the shape of `crates/chat`.
|
||||
- The minimum surgical edits to existing crates required for coexistence (see §11).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Any UI work.
|
||||
- CORD-07 audio/video. Reserve `23313`, the `concord/voice-*` labels and the `voice` metadata flag so nothing else claims them, and implement nothing.
|
||||
- Pins (CORD-04 §7) ship in the last milestone; the design accounts for `vsk 11` early so the fold is not retrofitted.
|
||||
- Cross-client interop testing (Vector/Armada/Grimoire). Tracked as follow-up work, not blocking.
|
||||
|
||||
## 2. Sources of truth
|
||||
|
||||
| Doc | What we take from it |
|
||||
| --- | --- |
|
||||
| CORD-01 | Stream event shape, seal forms 20013/20014, encoding rules, binding, deletions |
|
||||
| CORD-02 | `community_id`, `community_root`, `control_root`, epochs, 3 planes, metadata, invites, Community List, dissolution |
|
||||
| CORD-03 | Channel keying, metadata, message kinds, `channel`/`epoch` binding, threads vs quotes |
|
||||
| CORD-04 | Editions, `vac`, the roster, permission bits, banlist, the three removals, pins |
|
||||
| CORD-05 | Bundle, link (naddr + fragment), relay dictionary, Invite List, Registry, Direct Invite |
|
||||
| CORD-06 | Rekey blobs, chunking, `prevcommit` continuity, Refounding, compaction, races |
|
||||
| CORD-08 | `message_expiration`, NIP-40 tagging, ingest/purge enforcement, timer notice 1740 |
|
||||
|
||||
Appendix A (derivations) and Appendix B (kinds) of CORD-02 are **frozen**: every labeled byte and every kind number is part of the wire format. Treat both as constants with golden-vector tests.
|
||||
|
||||
Reference implementations for cross-checking behaviour (not for copying code): Vector (`crates/vector-core/src/community/v2/*`), Armada, Grimoire.
|
||||
|
||||
## 3. Reuse map — nostr-sdk APIs we build on
|
||||
|
||||
Verified against the pinned revision (`nostr` 0.45.4 / `nostr-sdk` 0.45.2, git `b230cec`).
|
||||
|
||||
| Concord need | Existing API |
|
||||
| --- | --- |
|
||||
| NIP-44 under a raw conversation key | `nostr::nips::nip44::v2::{ConversationKey, encrypt_to_bytes_with_nonce, decrypt_to_bytes}` |
|
||||
| NIP-44 conversation key for a keypair | `ConversationKey::derive(&SecretKey, &PublicKey)` (self-ECDH for streams) |
|
||||
| NIP-44 under a signer | `nip44::{encrypt, decrypt}` (already wrapped by `state::UniversalSigner`) |
|
||||
| Event id recomputation | `EventId::compute(pubkey, created_at, kind, tags, content)`, `UnsignedEvent::compute_id` |
|
||||
| Event (de)serialization | `Event::{from_json, as_json, verify}`, `UnsignedEvent::from_json` |
|
||||
| Building events | `EventBuilder::new(..).tags(..).custom_created_at(..)`, `FinalizeEvent::finalize(&Keys)`; `UnsignedEvent::new(..)` for rumors, whose tags are the author's contract and must not be normalized |
|
||||
| Tags | `Tag::{custom, identifier, public_key, expiration}`, `Tags`, `SingleLetterTag` |
|
||||
| Kinds | `Kind::GiftWrap` (1059), `Kind::Custom(21059|20013|20014|3308|…)`, `Kind::is_ephemeral` |
|
||||
| Publish | `Client::send_event(&event).to(relays).ack_policy(AckPolicy::none())` |
|
||||
| Subscribe | `Client::subscribe(target).with_id(..).close_on(..)`, `SubscribeAutoCloseOptions`, `ReqExitPolicy` |
|
||||
| Backfill | `Client::fetch_events(target)`, `Client::stream_events(target)` |
|
||||
| Local persistence | `Client::database()` → `query(Filter)`, `save_event(&Event)` |
|
||||
| Invite link parsing | `Nip19::from_bech32` → `Nip19::Coordinate(Nip19Coordinate)` |
|
||||
| Signer abstraction | `state::UniversalSigner` (`AsyncSignEvent` + `AsyncNip44`) |
|
||||
| Relay auth | `nostr_sdk::{Authenticator, SignerAuthenticator}` |
|
||||
|
||||
**Not needed.** `secp256k1` (use `nostr::SecretKey::from_slice` + `Keys::new`), `base64` (use `data_encoding::BASE64`, already a workspace dep), `bech32` (NIP-19 is in the SDK), any new storage engine (the client's LMDB database is enough), any new HTTP client.
|
||||
|
||||
**Dependencies added so far:** `hkdf = "0.12"` at M0 (already in `Cargo.lock` transitively) and `rand = "0.10"` at M1 for the NIP-44 nonce, pinned to the exact instance `nostr` already builds (`default-features = false`, features `std` + `sys_rng`) so `nostr`'s `os-rng` and ours unify on one `rand`/`getrandom`. `sha2` and `data-encoding` were already workspace deps. **Two more at M8:** the Pin List's per-message key disclosure needs `chacha20 = "0.9"` (already in the tree because we enable nostr's `nip44`, which is where `chacha20` comes from) and `hmac = "0.12"` (already in the tree via `hkdf`) — see §14.8. Zero new crates so far, and all of these are direct-dependency lines only.
|
||||
|
||||
**Why not depend on Vector's crates.** `vector-core` (published, MIT) holds the only other Rust Concord implementation, in `src/community/v2/*`. It is not reusable as a dependency, and the "reuse their crypto" argument does not hold:
|
||||
|
||||
- **No shared types.** It exact-pins the nostr family (`nostr = "=0.45.1"`, `nostr-sdk = "=0.45.1"`, `nostr-connect = "=0.45.1"`, `nostr-blossom = "=0.45.0"`) with the note that a caret range would let a consumer resolve a mixed set, while we track git master (`b230cec`, 0.45.4 / 0.45.2). A registry 0.45.1 and a git 0.45.4 cannot unify, so a build linking both carries two `nostr` crates whose `Event`/`Keys`/`PublicKey`/`Client` are unrelated types.
|
||||
- **Not wasm-buildable.** `rusqlite` (bundled C SQLite), `libc`, `rustls`, `reqwest`, `image`, `bip39`, and a `tokio` `net` + `rt-multi-thread` requirement; `VectorCore::init` installs a process-global rustls provider and raises the fd limit. Coop's `web` target is wasm32.
|
||||
- **It is an application core, not a Concord library.** 80k+ lines over 111 files, built on process-global singletons (`state::STATE`, `MY_SECRET_KEY`, one app-data dir, one live account, `traits::set_event_emitter`) and its own SQLite schema, relay pool and blocking `listen()` loop. Adopting it means handing it the nsec and letting it own the client, the database and the event loop — replacing `state`, `chat` and `person` rather than reusing a component. Its `login` stores raw secret-key bytes in that global vault, so an account whose key lives in a signer cannot drive it.
|
||||
- **There is no cryptography to share.** Both implementations call the same audited crates — `hkdf`, `sha2`, nostr's secp256k1 keypair, and nostr's NIP-44 v2. Vector's comment on that same dependency is "audited RustCrypto crate rather than a hand-rolled construction". Confirmed in M1 by reading `community/cipher.rs`: it is a ~20-line wrapper that draws an OS nonce, calls `nostr::nip44::v2::encrypt_to_bytes_with_nonce`, and base64s the result — which is precisely what `stream.rs` does. Their `stream.rs` likewise calls `nostr_sdk::prelude::nip44::v2::{decrypt_to_bytes, ConversationKey}` directly. Our `derive.rs` holds the frozen `info` layout and label table, and `stream.rs` the seal/wrap ordering; both are wire format, not primitives.
|
||||
|
||||
So Vector's crates earn their place as an **oracle, not a dependency**: the golden vectors in `derive.rs` are their published data, and their `community/v2/stream.rs` was diffed against our §7 before the codec was written. It agrees on every wire detail, and contributed the `ms` first-wins rule, the Control Plane's no-`ms` rumor shape and the `rewrap_seal` contract.
|
||||
|
||||
## 4. Crate layout
|
||||
|
||||
New crate `crates/concord`, picked up automatically by the `crates/*` workspace member glob.
|
||||
|
||||
```
|
||||
crates/concord/
|
||||
Cargo.toml
|
||||
src/lib.rs init, ConcordRegistry, ConcordEvent, signal bus, subscriptions, ingest pipeline
|
||||
src/derive.rs frozen HKDF / group_key / locators / commitments + golden vectors
|
||||
src/stream.rs CORD-01: seal + wrap + open (SealForm, OpenedStream), channel/epoch binding
|
||||
src/edition.rs CORD-04 §1: edition hash, parse, chain fold, floor-aware head selection
|
||||
src/control.rs control plane: genesis, content types, the control fold, the edition writer
|
||||
src/roles.rs CORD-04 §2–§4: permissions, roles, grants, banlist, delegation fixpoint
|
||||
src/guestbook.rs CORD-02 §5: join/leave/kick/snapshot, coalesce, complete memberlist
|
||||
src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view
|
||||
src/invite.rs CORD-05: bundle, link, registry, Invite List, Direct Invite
|
||||
src/rekey.rs CORD-06: blob codec, continuity, refounding, compaction, dissolution
|
||||
src/store.rs local persistence + opened-rumor cache + history queries
|
||||
```
|
||||
|
||||
`Community` and `Channel` GPUI entities live in `src/lib.rs` next to the registry — they are the public surface, not a separate concern. Eleven modules, each with real content; no single-fn files.
|
||||
|
||||
Dependencies: `common`, `state`, `person`, `device`, `settings`, `gpui`, `nostr` (for `nip44` features), `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow`, `flume`, `smallvec`, `itertools`, `futures`, `log`, `instant`. Everything under `cfg(not(target_arch = "wasm32"))` follows the `chat`/`state` split so the crate still builds for web.
|
||||
|
||||
Declare only what a milestone actually uses. As of M3 the crate depends on `nostr`, `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow` (plus `nostr-memory` and `smol` for tests). `rand` is pinned to the `0.10.2` instance `nostr` already builds and shares its `getrandom`, which the `web` crate already enables `wasm_js` on — so no new package and no new wasm obligation. `serde`/`serde_json` were already in the graph via `nostr`; promoting `serde_json` from dev to main for the metadata content types added no package either, only the `concord → serde` edge. M3 added no dependency of its own.
|
||||
|
||||
## 5. Core types
|
||||
|
||||
```rust
|
||||
pub struct CommunityId([u8; 32]); // sha256 commitment, never on the wire
|
||||
pub struct ChannelId([u8; 32]);
|
||||
pub struct Epoch(pub u64);
|
||||
|
||||
/// A derived stream: signing keypair + the self-ECDH conversation key that
|
||||
/// encrypts the wraps. Memoised in a bounded process-wide cache.
|
||||
pub struct GroupKey { keys: Keys, conversation: ConversationKey }
|
||||
impl GroupKey {
|
||||
pub fn pk(&self) -> PublicKey;
|
||||
pub fn pk_hex(&self) -> String; // lowercase; Debug prints only this, never key material
|
||||
pub fn keys(&self) -> &Keys;
|
||||
pub fn conversation(&self) -> &ConversationKey;
|
||||
}
|
||||
|
||||
pub enum SealForm { Encrypted, Plaintext }
|
||||
|
||||
pub struct OpenedStream {
|
||||
pub rumor_id: EventId,
|
||||
pub author: PublicKey, // the seal's verified pubkey
|
||||
pub seal_form: SealForm,
|
||||
pub seal: Event, // retained: compaction re-wraps plaintext seals verbatim
|
||||
pub wrapper_id: EventId,
|
||||
pub at_ms: u64, // created_at * 1000 + ms tag
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
```
|
||||
|
||||
Ordering everywhere uses `at_ms`, never `created_at`, and ties break on the lower inner rumor id.
|
||||
|
||||
## 6. Frozen derivations (`derive.rs`)
|
||||
|
||||
Implemented and pinned in `crates/concord/src/derive.rs`.
|
||||
|
||||
```rust
|
||||
fn build_info(label: &str, id: &[u8; 32], epoch: Option<u64>) -> Vec<u8>; // label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]?
|
||||
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32]; // HKDF-SHA256, zero-length salt, L = 32
|
||||
fn hkdf_to_secret_key(ikm: &[u8], info: &[u8]) -> Result<SecretKey>; // A.3 scalar_normalize, counter from 0
|
||||
|
||||
fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option<u64>) -> Result<GroupKey>;
|
||||
|
||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn control_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // read key
|
||||
pub fn control_signer_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // write key
|
||||
pub fn guestbook_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn channel_rekey_group_key(root: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn base_rekey_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
|
||||
pub fn dissolved_group_key(id: &CommunityId) -> Result<GroupKey>; // no epoch field
|
||||
|
||||
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId; // plain SHA-256
|
||||
pub fn verify_community_id(id: &CommunityId, owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> bool;
|
||||
pub fn epoch_key_commitment(epoch: Epoch, key: &[u8; 32]) -> [u8; 32]; // plain SHA-256
|
||||
pub fn grant_locator(id: &CommunityId, member: &[u8; 32]) -> [u8; 32];
|
||||
pub fn banlist_locator(id: &CommunityId) -> [u8; 32];
|
||||
pub fn pins_locator(id: &CommunityId, channel: &ChannelId) -> [u8; 32];
|
||||
pub fn invite_links_locator(id: &CommunityId, creator: &[u8; 32]) -> [u8; 32];
|
||||
pub fn recipient_locator(rotator: &[u8; 32], recipient: &[u8; 32], scope: &[u8; 32], epoch: Epoch) -> [u8; 32];
|
||||
pub fn invite_bundle_key(token: &[u8; 16]) -> [u8; 32]; // raw hkdf32 output; used as a NIP-44 conversation key
|
||||
pub fn clear_memo(); // drop memoised keys on signer change
|
||||
```
|
||||
|
||||
Appendix A.6, as implemented — `ikm` / `id` / `epoch`. The id is *always* present (all-zeroes where a label has no meaningful one); the epoch is the only omittable field.
|
||||
|
||||
| Label | ikm | id | epoch |
|
||||
| --- | --- | --- | --- |
|
||||
| `concord/channel` | channel key or `community_root` | `channel_id` | yes |
|
||||
| `concord/control` | `community_root` | `community_id` | yes |
|
||||
| `concord/control-signer` | `control_root` | `community_id` | yes |
|
||||
| `concord/rekey-pseudonym` | prior `community_root` | `channel_id` | new epoch |
|
||||
| `concord/base-rekey-pseudonym` | prior `community_root` | `community_id` | new epoch |
|
||||
| `concord/recipient-pseudonym` | `rotator_xonly ‖ recipient_xonly` (64 B) | scope id | new epoch |
|
||||
| `concord/guestbook` | `community_root` | `community_id` | yes |
|
||||
| `concord/dissolved` | `community_id` | zeroes | — |
|
||||
| `concord/grant` | `community_id` | member x-only | — |
|
||||
| `concord/banlist` | `community_id` | zeroes | — |
|
||||
| `concord/pins` | `community_id` | `channel_id` | — |
|
||||
| `concord/invite-links` | `community_id` | creator x-only | — |
|
||||
| `concord/invite-key` | token (16 B) | zeroes | — |
|
||||
|
||||
The CORD-07 `concord/voice-*` labels and the retired `concord/invite-locator` / `concord/invite-signer` are reserved and listed here only: they are underived, and the table stays append-only. Every label a derivation does use has a distinct pinned output, so a duplicated label cannot pass the vectors.
|
||||
|
||||
Rules that must be enforced by construction, not by convention:
|
||||
|
||||
- Hex is lowercase everywhere; pubkeys are x-only hex, never bech32; tag numbers are decimal with no leading zeros (`"4"`, never `04`/`+4`).
|
||||
- The epoch field is *omitted*, not zeroed, for labels with no epoch; a test asserts `dissolved_group_key` differs from the same derivation with `Some(0)`.
|
||||
- `scalar_normalize` retries by appending a counter byte to the same `info`, starting at `0`, and reports exhaustion instead of panicking — so plane keys return `Result<GroupKey>`.
|
||||
- Group keys are memoised by a digest of their inputs, so no deriving secret is a map key, bounded at 1024 entries.
|
||||
|
||||
**Golden vectors.** `derive.rs` pins all 18 published vectors (the seed and `pk` for channel, control, control-signer and guestbook; both keyed labels at epoch `0` and at `0x0102030405060708`; both rekey labels at epoch 1; dissolved; all four locators; the invite key; the community id; the epoch-key commitment), cross-checked against an independent Python implementation (RFC 5869 HKDF plus pure-integer secp256k1) before being frozen. One vector is missing upstream — `pins_locator` — so we mint it from our own implementation and pin it, flagged in the test as self-referential. Changing any pinned value means the wire format changed.
|
||||
|
||||
## 7. Stream codec (`stream.rs`) — implemented in M1
|
||||
|
||||
```rust
|
||||
pub const KIND_WRAP: u16 = 1059;
|
||||
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
|
||||
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
|
||||
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
|
||||
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
|
||||
|
||||
pub enum SealForm { Encrypted, Plaintext }
|
||||
|
||||
pub struct OpenedStream {
|
||||
pub rumor_id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub seal_form: SealForm,
|
||||
pub seal: Event,
|
||||
pub wrapper_id: EventId,
|
||||
pub at_ms: u64,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
pub fn split_ms(at_ms: u64) -> (u64, u16);
|
||||
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError>;
|
||||
|
||||
pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result<String, StreamError>;
|
||||
pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author: &Keys) -> Result<Event, StreamError>;
|
||||
pub fn wrap_seal(seal: &Event, group: &GroupKey, wrap_kind: u16, at: Timestamp, extra: &[Tag]) -> Result<(Event, Keys), StreamError>;
|
||||
pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, at: Timestamp) -> Result<(Event, Keys), StreamError>;
|
||||
|
||||
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError>;
|
||||
pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_signature: bool) -> Result<OpenedStream, StreamError>;
|
||||
|
||||
pub fn build_rumor_ms(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_ms: u64) -> UnsignedEvent;
|
||||
pub fn build_rumor_secs(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_secs: u64) -> UnsignedEvent;
|
||||
|
||||
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag>;
|
||||
pub fn check_channel_binding(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<(), StreamError>;
|
||||
```
|
||||
|
||||
`StreamError` is a typed enum, not `anyhow`: the caller has to tell a drop from a fatal, and M1's acceptance criterion is that rejections happen in the documented order.
|
||||
|
||||
Refinements against the draft this plan opened with, decided after diffing Vector's `crates/vector-core/src/community/v2/stream.rs` (Concord has no crate of its own there, and `envelope.rs` does not exist):
|
||||
|
||||
- `build_rumor` became `build_rumor_ms` plus `build_rumor_secs`. The Control Plane edition carries **no** `ms` tag, because editions fold by version, not by time.
|
||||
- `rewrap_seal` was added to the codec. Without it the plaintext-seal carry-forward has no expression, and M7's compaction is its only caller.
|
||||
- NIP-44 is reached through `nostr`'s own `nip44::v2::{encrypt_to_bytes_with_nonce, decrypt_to_bytes, ConversationKey}`, with a fresh OS nonce per message and `data_encoding::BASE64` for carriage. This is exactly what Vector does; there is no cryptography of theirs to reuse.
|
||||
|
||||
Design points that are easy to get wrong:
|
||||
|
||||
- The wrap is signed by the **stream key** with a random ephemeral `p` tag — NIP-59 reversed. `extra` is how the caller mirrors a NIP-40 expiration onto the wrap.
|
||||
- The seal is signed by the **real author** and carries `created_at` equal to the rumor's. It is never published bare.
|
||||
- Control plane **must** use the plaintext seal; chat, guestbook and rekey planes **must** use the encrypted one. Each plane asserts its own form at both ends.
|
||||
- The control plane is a write-restricted stream: the wrap key derives from `control_root` while the content is encrypted under the `community_root`-derived conversation key. `open_wrap_at` takes the two halves separately for this reason.
|
||||
- Open order: kind → address match → wrap signature (only when `verify_wrap_signature`) → NIP-44 open → seal kind → seal signature → rumor parse → `rumor.pubkey == seal.pubkey` → recompute the rumor id and reject a mismatch → strict `ms` resolve.
|
||||
- Enforce the 65 535-byte NIP-44 cap at every nesting layer before publishing.
|
||||
- Do not verify the wrap signature for ordinary planes: every reader holds the group key, so the signature proves nothing. It matters only for the restricted control plane and for rekeys.
|
||||
- The ephemeral wrap keypair is returned to the caller so a client may NIP-09-scrub its own wrap later.
|
||||
- **A duplicate `ms` tag takes the first value; it is not rejected.** Rejecting made Vector and Armada disagree on whether the event *exists*, and because `ms` orders messages that divergence reached membership. `ms` is the publisher's own value, so conceding a second tag grants an attacker no reach a single one did not. A present-but-valueless `ms`, or one that is not a lone canonical decimal in `0..=999`, is `BadMs` and the event is dropped, never clamped — `u64::from_str` alone would accept a leading `+`, a second encoding a strict peer rejects, so the digit check comes first.
|
||||
- A binding tag that names the same key twice is rejected outright, since first-match would then be the reader's choice rather than the author's; a valueless tag counts as absent, so a true absence reports `MissingTag`.
|
||||
|
||||
## 8. Planes, state and folds
|
||||
|
||||
### 8.1 Editions, authority and the control fold (`edition.rs`, `roles.rs`)
|
||||
|
||||
```rust
|
||||
pub const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; // frozen, cross-client (27 bytes)
|
||||
|
||||
// sha256( u64be(len(label)) ‖ label ‖ entity[32] ‖ u64be(version)
|
||||
// ‖ flag[1] ‖ prev[32] ‖ u64be(len(content)) ‖ content )
|
||||
// `prev` is always 33 bytes: 0x01 ‖ hash, or 0x00 ‖ zeroes when absent.
|
||||
// The hash commits to no actor: identity enters only via the rumor id.
|
||||
pub fn edition_hash(entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8]) -> [u8; 32];
|
||||
pub struct ParsedEdition { author: PublicKey, subkind: String, entity: [u8; 32], version: u64,
|
||||
prev: Option<[u8; 32]>, citation: Option<AuthorityCitation>,
|
||||
content: String, self_hash: [u8; 32], rumor_id: EventId };
|
||||
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError>;
|
||||
|
||||
pub struct FoldResult { pub head: Option<usize>, pub gap: bool, pub anchored: bool }
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult;
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize>; // highest, contiguity ignored
|
||||
|
||||
// One entity's committed head, and the refuse-downgrade floor a later fold is judged
|
||||
// against.
|
||||
pub struct EntityHead { entity: [u8; 32], version: u64, self_hash: [u8; 32], rumor_id: EventId }
|
||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||
pub struct HeadSelection { pub head: Option<usize>, pub gap: bool }
|
||||
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection;
|
||||
```
|
||||
|
||||
- Tag grammar: `["vsk", sub]`, `["eid", hex32]`, `["ev", decimal]`, `["ep", hex32]`, `["vac", eid, version, hash]`. Duplicates of any of the five reject the edition; `ev` must pass a decimal check before parsing. A version of `0` parses and then reads as a gap — the rule lives in the fold, not the parser.
|
||||
- **Versions start at 1, not 0** (CORD-04 §1: "climbs from 1"). Genesis is `(version 1, prev None)` for both entities.
|
||||
- **The edition hash is not the signature.** The actor's Schnorr signature covers the kind-20014 plaintext seal; `edition_hash` is a separate SHA-256 used only for chaining (`ep`, `vac`). `content` is the rumor's content string byte-verbatim, never re-serialized, which is what lets compaction re-wrap a head and preserve its hash.
|
||||
- The domain label is `vector-community/v1/edition`, not a `concord/…` label. Inconsistent with Appendix A.6, frozen anyway — do not "fix" it.
|
||||
- Tie-break at equal version is the lower **inner rumor id** (the kind-3308 rumor), never the outer wrap id and never `created_at`. Only one of the two implementations that must agree applies to a wrap, so the inner id is the only stable choice.
|
||||
- `gap` is a safety signal: a tracking client (already holds the floor) fails closed for that entity and refetches; a bootstrapping client (floor 0) may accept the highest authority-verified head, which is what makes compaction re-wrapping work. `bootstrap_head` therefore takes no floor: it *is* the floor-zero path. `fold_head` is the composition — floor 0 takes `bootstrap_head`; under a held floor the chain-anchored head wins and any upper gap is reported; everything below the floor is a stale relay, not a gap; and a head detached from the floor converges a same-version fork to its lower rumor id when that is genuinely earlier than what we hold, else fails closed as withholding.
|
||||
- **Owner anchoring is not in the fold.** `fold` is a pure function of chain shape; authority is a pre-filter the caller applies to the candidate set before folding. `community_id` proves the owner, and `is_authorized` short-circuits `owner == actor`, so the owner needs no Grant entity at all.
|
||||
- Entity coordinates are `vsk 0` → `community_id`, `1` → `role_id`, `2` → `channel_id`, `3` → `grant_locator`, `4` → `banlist_locator`, `8` → `invite_links_locator`, `11` → `pins_locator`. `5` is reserved, `6`/`9` belong to the 33301 invite marker, `7` is retired. All derive from `community_id` only, so a refounding re-wraps heads verbatim.
|
||||
- The Control Plane is **plaintext-seal only**. A 20013-encrypted control edition is rejected, because compaction re-wraps a signed plaintext seal byte-verbatim into the new epoch; accepting an encrypted one would let a later compaction fork the chain.
|
||||
- **Genesis is exactly two owner-signed editions** — community metadata (`vsk 0`, `eid = community_id`) and one public `#general` channel (`vsk 2`, fresh random `channel_id`) — at epoch 0, version 1, no `ep`, no `vac`. No default roles, no scaffolding, and no Grant for the owner. Secrets minted: `owner_salt`, `community_root`, `control_root` (deliberately not derived from `community_id`).
|
||||
|
||||
```rust
|
||||
// CORD-04 §3, frozen. 1<<7 was MANAGE_INVITES and is burned, never reassigned.
|
||||
// MANAGE_ROLES 1<<0 · MANAGE_CHANNELS 1<<1 · MANAGE_METADATA 1<<2 · KICK 1<<3 ·
|
||||
// BAN 1<<4 · MANAGE_MESSAGES 1<<5 · CREATE_INVITE 1<<6 · VIEW_AUDIT_LOG 1<<8 ·
|
||||
// MENTION_EVERYONE 1<<9 · PIN_MESSAGES 1<<11 · reserved: MANAGE_EMOJI 1<<10, MANAGE_EVENTS 1<<12
|
||||
pub struct Permissions(pub u64);
|
||||
impl Permissions {
|
||||
pub const STAFF_MASK: u64; // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|BAN|CREATE_INVITE|PIN_MESSAGES
|
||||
pub fn contains(self, bits: u64) -> bool;
|
||||
pub fn union(self, other: Self) -> Self;
|
||||
pub fn is_staff(self) -> bool;
|
||||
}
|
||||
|
||||
pub enum RoleScope { Server, Channel(ChannelId) } // {"kind":"server"} / {"kind":"channel","channel_id":…}
|
||||
pub struct Role { role_id: RoleId, name: String, position: u32, permissions: Permissions,
|
||||
scope: RoleScope, color: u32, extra: Extra }
|
||||
pub struct Grant { member: PublicKey, role_ids: Vec<RoleId>, control_wrap: Option<String>, extra: Extra }
|
||||
|
||||
pub struct CommunityRoles { roles: BTreeMap<RoleId, Role>, grants: BTreeMap<PublicKey, Grant> }
|
||||
impl CommunityRoles {
|
||||
pub fn role(&self, role_id: &RoleId) -> Option<&Role>;
|
||||
pub fn roles_of(&self, member: &PublicKey) -> impl Iterator<Item = &Role>;
|
||||
pub fn effective_permissions(&self, member: &PublicKey) -> Permissions; // union of granted role bits
|
||||
pub fn has_permission(&self, member: &PublicKey, bits: u64) -> bool;
|
||||
pub fn highest_position(&self, member: &PublicKey) -> Option<u32>; // lowest position they hold
|
||||
pub fn is_authorized(&self, actor, owner, permission: u64) -> bool; // owner == actor → true
|
||||
pub fn outranks(&self, actor, owner, target_position: u32) -> bool; // strict `<`
|
||||
pub fn can_act_on_position(&self, actor, owner, target_position: u32, permission: u64) -> bool;
|
||||
pub fn can_act_on_member(&self, actor, owner, target: &PublicKey, permission: u64) -> bool;
|
||||
pub fn is_staff(&self, member, owner) -> bool;
|
||||
}
|
||||
|
||||
// The delegation fixpoint. Content is parsed once, up front: the fixpoint revisits
|
||||
// every candidate on each pass.
|
||||
pub enum AuthorityContent { Role(Role), Grant(Grant), Banlist(Vec<PublicKey>) }
|
||||
pub struct AuthorityEdition { entity: [u8; 32], meta: EditionMeta, author: PublicKey,
|
||||
citation: Option<AuthorityCitation>, content: AuthorityContent }
|
||||
impl AuthorityEdition {
|
||||
pub fn parse(edition: &ParsedEdition, community_id: &CommunityId) -> Option<Self>;
|
||||
}
|
||||
pub struct Roster { roles: CommunityRoles, banned: BTreeSet<PublicKey>, floors: Floors, gapped: bool }
|
||||
pub fn fold_roster(owner, community_id, editions: &[AuthorityEdition], floors: &Floors,
|
||||
held_bans: &BTreeSet<PublicKey>) -> Roster;
|
||||
pub fn citation_ok(owner, community_id, author, citation: Option<&AuthorityCitation>,
|
||||
floors: &Floors) -> bool;
|
||||
```
|
||||
|
||||
Authority rules as implemented:
|
||||
|
||||
- The owner is position 0, proven by `community_id`, supreme, unremovable, and **not a Role**: no Role may claim position 0, and every gate short-circuits `owner == actor`. The owner therefore needs no Grant and cites nothing.
|
||||
- A member's rank is the **lowest** position among their Roles; a roleless member sits at `u32::MAX`. Two Roles may share a position (peers, neither acts on the other); display tie-breaks on the lower `role_id`.
|
||||
- The actor must hold the required bit **and strictly outrank** the target. Equal cannot act on equal.
|
||||
- `AuthorityEdition::parse` drops, rather than repairs: a `role_id` that is not its own coordinate, a `position` of 0, a Grant whose `member` does not hash to its entity, a `vsk 4` at a coordinate that is not this community's banlist locator, malformed JSON, and any `vsk` this type does not own. A Grant's `role_ids` truncate at 64 on read.
|
||||
- **Refuse-downgrade**: an edition below the persisted floor for its entity is never a candidate.
|
||||
- The fold is a **Jacobi fixed point** — authority propagates one delegation level per pass, bounded by `2 × (entities) + 8`. Convergence compares the roster only, not the heads. Cross-pass state is exactly the accepted roster plus its heads, and `citation_ok` reads the *previous* pass's heads, so the first pass sees none.
|
||||
- **Roles** replay each entity's versions **ascending**, one winner per version group, `admissible` collecting the winners that pass. Gates, in order: not banned; `can_act_on_position(author, owner, position, MANAGE_ROLES)`; if a predecessor was admitted, the same call against *its* position; then the citation. The highest admissible version wins. Replaying ascending is what makes the second gate work: without it an admin at position 5 republishes a position-1 role at position 9, every check passes since 9 is beneath them, and a role that outranked them ends up beneath them along with everyone holding it.
|
||||
- **Grants** take no version-group replay: the first candidate in vector order clearing every gate wins. Role references resolve **partially** — the resolvable subset is carried and the rest fold in on a later pass — because all-or-nothing resolution deadlocks the ordinary growth path (an admin creates a role, the owner grants it to them, and neither can go first, collapsing the entire roster including the owner's own grants). The final gate ranks every resolved position *and* the member.
|
||||
- A **citation** that cannot be resolved parks the edition; a missing one is tolerated only where the rank gates carry the weight. For a Role that is everywhere. For a Grant it is not: a revoke names no position, so its rank test is vacuous — hence an uncited Grant may add authority but **never remove** it.
|
||||
- The **banlist** is folded after a preliminary roster, since a ban only exists once someone authorized to place it does. Its head is the highest edition whose author currently holds `BAN` and does not already sit in the held banlist; each entry is kept only if that author strictly outranks the target, and the list caps at 500. **Withholding retains the held list** rather than un-banning nobody on a relay's word. The final roster is then re-folded with the banned set excluded, so a banned admin loses their authority in the same pass.
|
||||
- A staff-making Grant carries `control_wrap`, a NIP-44 pairwise ciphertext of `epoch_be[8] ‖ control_root[32]`, adopted **only if it derives to the `control_pk` the member already holds** for the named epoch. Delivery, never authority.
|
||||
- Caps: **100 Roles per community**, by the 100 lowest `role_id`, applied *after* authorization so forged low ids cannot evict a real role; the grants then shed the dropped ids. **64 Roles per member**, at parse. **500 banlist entries**, at fold.
|
||||
|
||||
**Two deliberate divergences from the reference implementation** (Vector is an oracle, not a specification):
|
||||
|
||||
1. **The role fork winner.** Vector's role branch walks version groups with `.iter().rev()`, taking each group's *highest* inner id, while its own adjacent comment says forks break on the lowest and the rest of its codebase (`fold_head`, `version::fold`, its invite-registry test) does use the lowest. No test in Vector pins the branch. We implement **lowest inner id**, per CORD-04 §1 — and our tests pin it.
|
||||
2. **Banlist candidates must be `vsk 4`.** Vector collects banlist candidates from every edition sitting at the banlist locator regardless of `vsk`, so a `vsk 1` forged there can win the banlist head, parse to an empty list, and clear the ban. We require `vsk::BANLIST` for a candidate at all.
|
||||
|
||||
One reference limitation we **reproduce and do not fix** (recorded here rather than silently diverging): the grant rank gate reads the *previous* pass's roster, so a mid-rank `MANAGE_ROLES` holder who cites a real, folded grant of their own can revoke a higher-ranked member whose authority is still propagating. Fixing it means resolving a Grant's target rank against the same pass, which changes the fixpoint's convergence argument. Revisit only with a spec amendment.
|
||||
|
||||
### 8.2 Communities, channels, metadata
|
||||
|
||||
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`. Every content struct carries `#[serde(flatten)] extra`, so a field one client does not model still round-trips and a rename by an older client cannot wipe another client's `custom` keys.
|
||||
|
||||
The Control Plane's whole projection is one call:
|
||||
|
||||
```rust
|
||||
pub struct ControlFold {
|
||||
pub roles: CommunityRoles,
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
pub community: Option<CommunityMetadata>,
|
||||
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||
pub floors: Floors,
|
||||
pub gapped: bool,
|
||||
}
|
||||
pub fn fold_control(owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition],
|
||||
floors: &Floors, held_bans: &BTreeSet<PublicKey>) -> ControlFold;
|
||||
```
|
||||
|
||||
- The roster is folded first, and `vsk 0` / `vsk 2` are then judged against it: the head of each entity is the highest edition whose author *currently* holds `MANAGE_METADATA` / `MANAGE_CHANNELS`, is not banned, and either is the owner or cites their own folded Grant. Pre-filtering before the chain fold is what stops a demoted admin's later, higher-version edition from being the head.
|
||||
- A `vsk 2` whose entity is the community's own id is excluded, and a `vsk 0` at any other coordinate with it: the floor row keys on the entity alone, so the two would otherwise share and corrupt one chain.
|
||||
- `None` means "this client saw no authorized edition", never "the value is gone": a caller keeps what it holds rather than walking the community backwards. That is also how a withheld or downgraded entity reads.
|
||||
- A `deleted` channel is reported as metadata with `deleted: true`; the policy of dropping it belongs to the store.
|
||||
|
||||
Writes go through one primitive, so every edition names the head it supersedes and a client cannot silently fork a chain it cannot see:
|
||||
|
||||
```rust
|
||||
pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str,
|
||||
head: Option<&'a EntityHead>, citation: Option<AuthorityCitation> }
|
||||
pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey }
|
||||
impl ControlWriter {
|
||||
pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
|
||||
}
|
||||
```
|
||||
|
||||
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`.
|
||||
|
||||
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key.
|
||||
|
||||
### 8.3 Guestbook and member list (`guestbook.rs`)
|
||||
|
||||
```rust
|
||||
pub enum GuestbookEntry {
|
||||
Join { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> },
|
||||
Leave { member: PublicKey, at_ms: u64 },
|
||||
Kick { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option<AuthorityCitation> },
|
||||
Snapshot { refounder: PublicKey, members: Vec<PublicKey>, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 },
|
||||
}
|
||||
|
||||
pub fn coalesce(events: &[GuestbookEvent], now_ms: u64, snapshot_authority: Option<&PublicKey>,
|
||||
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool)
|
||||
-> BTreeMap<PublicKey, MemberState>;
|
||||
|
||||
pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
|
||||
observed: &BTreeMap<PublicKey, u64>, // author → newest ms published
|
||||
banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>,
|
||||
refound: Option<&Refound>) -> BTreeSet<PublicKey>;
|
||||
```
|
||||
|
||||
- Entries dated more than an hour ahead of local time are dropped. An `ms` outside `0..999` drops the entry rather than being interpreted.
|
||||
- Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id.
|
||||
- A Kick counts only when its signer holds `KICK` and outranks the target.
|
||||
- A Snapshot counts only from the npub whose Refounding minted the epoch. There is deliberately no owner fallback.
|
||||
- The member list is `coalesced Joined ∪ observed authors − banlist`, and observation counts **forward only** (an author re-enters on activity newer than their latest Leave/Kick/Ban). A Grant holder with `ms == 0` is present by construction.
|
||||
|
||||
### 8.4 Chat plane (`chat.rs`)
|
||||
|
||||
```rust
|
||||
pub struct ChatMessage {
|
||||
pub id: EventId, // recomputed rumor id
|
||||
pub author: PublicKey,
|
||||
pub channel: ChannelId,
|
||||
pub epoch: Epoch,
|
||||
pub kind: Kind, // 9 | 1111 | 3302 | 1740 | 15
|
||||
pub content: String,
|
||||
pub media: Vec<SharedUri>,
|
||||
pub mentions: Vec<Mention>,
|
||||
pub reply_to: Option<EventId>, // lowercase `e`/`q`
|
||||
pub thread_root: Option<EventId>, // uppercase `E` for 1111
|
||||
pub at_ms: u64,
|
||||
pub expiration: Option<Timestamp>,
|
||||
pub edited_at: Option<u64>, // folded from 3302
|
||||
pub deleted: bool, // folded from 5
|
||||
pub reactions: BTreeMap<PublicKey, String>,
|
||||
}
|
||||
```
|
||||
|
||||
Sends funnel through one function so the rules cannot drift:
|
||||
|
||||
```rust
|
||||
fn publish_chat(store, client, community, channel, epoch, group, rumor, at_ms, ephemeral) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
```
|
||||
|
||||
It builds the seal + wrap, mirrors the NIP-40 tag onto the wrap for durable kinds, publishes via `send_event(..).to(relays)`, retains the ephemeral wrap key for later NIP-09 scrubbing, and locally echoes its own wrap through the same ingest path so send-then-read works without waiting on a relay round-trip.
|
||||
|
||||
Disappearing messages (CORD-08) live here: `message_expiration` is read from the folded metadata, `["expiration", created_at + t]` is attached to every durable Chat rumor and to the wrap, kinds 5 and 1740 are exempt, ingest refuses an already-expired rumor, a periodic sweep purges stored ones, and the kind 1740 timer notice renders only when its author holds `MANAGE_METADATA`.
|
||||
|
||||
### 8.5 Invites (`invite.rs`)
|
||||
|
||||
```rust
|
||||
pub struct CommunityInvite { community_id, owner, owner_salt, community_root, root_epoch,
|
||||
control_pk: Option<String>, channels: Vec<ChannelGrant>,
|
||||
relays: Vec<String>, name: String, icon: Option<ImageRef>,
|
||||
expires_at: Option<u64>, creator_npub: Option<String>, label: Option<String>,
|
||||
extra: Map<String, Value> }
|
||||
impl CommunityInvite { pub fn validate(&self) -> Result<(), InviteError>; // recompute community_id
|
||||
pub fn expired(&self, now_ms: u64) -> bool; }
|
||||
|
||||
pub fn build_bundle(token: &[u8; 16], link_signer: &Keys, invite: &CommunityInvite) -> Result<Event, InviteError>; // 33301, d = ""
|
||||
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError>; // vsk 9
|
||||
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError>;
|
||||
pub fn encode_fragment(relays: &[RelayUrl], token: &[u8; 16]) -> String; // version byte 4, flags, ≤ 3 relays, base64url
|
||||
pub fn decode_fragment(fragment: &str) -> Result<(Vec<RelayUrl>, [u8; 16]), InviteError>;
|
||||
pub fn build_direct_invite(receiver: &PublicKey, invite: &CommunityInvite, signer: &UniversalSigner) -> Task<Result<Event, Error>>; // 3313 rumor → 13 seal → k-tagged 1059
|
||||
```
|
||||
|
||||
The link rides `naddr` (`Nip19Coordinate` for kind 33301, link signer, empty `d`) in the path and the token + bootstrap relays in the fragment. A fragment is never sent to a server. The bundle is decrypted with `invite_bundle_key(token)`, and the joiner must recompute `community_id` from `owner` + `owner_salt`.
|
||||
|
||||
Bounds before allocation: reject a bundle with more than 256 channels, truncate the relay list to 5, refuse an expired one.
|
||||
|
||||
### 8.6 Rekeys and refoundings (`rekey.rs`)
|
||||
|
||||
```rust
|
||||
pub enum RekeyScope { Channel(ChannelId), Base }
|
||||
pub fn encode_blob_plaintext(scope, epoch, new_root, control_pk, control_root) -> Vec<u8>; // 72 | 104 | 136 bytes
|
||||
pub fn parse_blob_plaintext(bytes: &[u8], scope, epoch) -> Result<KeyDelivery, RekeyError>;
|
||||
pub fn build_rekey_rumor(rotator, scope, new_epoch, prev_epoch, prev_commit, blobs, chunk) -> UnsignedEvent;
|
||||
pub fn plan_refounding(fold, removed: &[PublicKey]) -> Result<Refounding, RekeyError>;
|
||||
pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // re-wrap heads verbatim, plaintext seals preserved
|
||||
```
|
||||
|
||||
- The subscription for rekeys is precomputed from the *next* epoch's address, per private channel and once for the base.
|
||||
- A receiver accepts a key only after: locating its blob, decrypting with the rotator↔recipient conversation key, checking the bound `scope` and `epoch` inside the plaintext, and matching `prevcommit` against the key it currently holds.
|
||||
- Only after holding **all** `n` chunks of one `(rotator, newepoch, prevcommit)` set, with none containing its locator, may a client conclude it was removed.
|
||||
- Send cap 80 blobs per event, accept cap 120 (Vector's documented erratum: the CORD-01 double envelope pushes 120 blobs past a 64 KB relay limit). Record the reason in a comment so nobody "fixes" it back.
|
||||
- Compacted control heads are re-wrapped with their original signature intact, which is exactly why the control plane uses the plaintext seal.
|
||||
- Two concurrent refoundings converge on the lexicographically lowest new base key; the heal is down-only.
|
||||
- Authority: a channel rekey needs `MANAGE_CHANNELS`, a refounding needs `BAN`, and in both the rotator must strictly outrank every removed target. Holding a key is never authority.
|
||||
|
||||
Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored.
|
||||
|
||||
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3
|
||||
|
||||
Three layers, no new storage engine:
|
||||
|
||||
1. **Raw wraps** (kind 1059) are persisted automatically by the SDK's relay pool when a subscription or fetch matches a filter. Nothing to write.
|
||||
2. **Opened rumors** are cached locally as NIP-78 `Kind::ApplicationSpecificData` events signed by a session-local keypair, exactly like `chat::set_rumor`. Tags: `["d", rumor_id]` (replace key), `["c", channel_hex]`, `["p", author]`, `["k", kind]`, `["e", wrap_id]`, `["t", "concord"]`. Contents are the rumor JSON.
|
||||
- The `c`/`t` keys deliberately differ from chat's `r` key so the two message namespaces can never collide in one database.
|
||||
- `created_at` is the **message's own second** (from `at_ms`), not the wall clock. Otherwise `until` and the ordering would page on cache time rather than message time.
|
||||
- The read path dedupes by rumor id and keeps the newest `created_at`, because the local signing key changes per session and each session leaves its own copy. The query therefore carries no filter `limit` — every copy has to be in hand before they can be collapsed — and the cap is applied to the deduplicated result instead.
|
||||
- The layer takes `&dyn NostrDatabase`, not `&Client`: it is local-only, which keeps it testable without a relay or a GPUI context.
|
||||
|
||||
```rust
|
||||
pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<()>;
|
||||
pub async fn query_rumors(database: &dyn NostrDatabase, channel: &ChannelId, until: Option<Timestamp>, limit: usize) -> Result<Vec<UnsignedEvent>>;
|
||||
```
|
||||
|
||||
`query_rumors` returns `UnsignedEvent`, not `Event`: the cached payload *is* a rumor, which is also what `OpenedStream` carries, so the caller never has to re-parse.
|
||||
|
||||
**Deferred to M4:** the relay-paging `backfill`. It is network history paging whose "step past the same-second wall" policy belongs with the sync engine, and M1 has no subscription to test it against.
|
||||
|
||||
3. **Community state** — one local document per community, `Kind::ApplicationSpecificData` with `["d", "concord/<community_id>"]`:
|
||||
|
||||
```rust
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
pub control_root: Option<[u8; 32]>, // present iff the holder is staff
|
||||
pub control_pks: BTreeMap<u64, PublicKey>, // epoch → the plane's signer address
|
||||
pub channels: Vec<ChannelKeyRef>, // id, name, private, epoch
|
||||
pub relays: Vec<RelayUrl>,
|
||||
pub heads: Vec<EntityHead>, // entity, version, self_hash, inner id
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
```
|
||||
|
||||
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key.
|
||||
|
||||
M3 added the two bridges between this document and the fold:
|
||||
|
||||
```rust
|
||||
impl CommunityState {
|
||||
pub fn floors(&self) -> Floors; // the fold's input
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold); // the fold's output
|
||||
}
|
||||
```
|
||||
|
||||
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it.
|
||||
|
||||
Writes are debounced (a fold head changes on every edition); reads load once at init.
|
||||
|
||||
**Decision, stated for the record:** this document stores raw community keys unencrypted in a plaintext local database. That matches the existing posture — `chat` already caches decrypted message rumors in the same LMDB. If that posture ever changes, the state document is the one to wrap with NIP-44-to-self, since it is the only local artifact holding keys.
|
||||
|
||||
## 10. Sync engine and GPUI conventions
|
||||
|
||||
`ConcordRegistry` mirrors `ChatRegistry`'s shape exactly: a foreground GPUI entity holding `Entity<Community>` handles, a `flume` signal bus, one background notification listener, one foreground consumer, and task slots that are cleared when the signer changes.
|
||||
|
||||
**Subscription.** Community relays come from the folded metadata. `init`/`join` add them to the client (`client.add_relay(url).and_connect()`), then:
|
||||
|
||||
```rust
|
||||
let filter = Filter::new()
|
||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||
.authors(plane_authors) // guestbook, control signer, all held channel planes,
|
||||
// rekey addresses for epoch + 1, dissolved address
|
||||
.since(Timestamp::from_secs(now - FRESH_WINDOW))
|
||||
.limit(0); // live tail only; history comes from backfill
|
||||
client.subscribe(target).with_id(SubscriptionId::new(CONCORD_SUB)).await?;
|
||||
```
|
||||
|
||||
Targeted subscribe against the community relays, with a pool-wide subscribe as the fallback path. Rebuild idempotently whenever a plane's address changes (join, channel added, rekey folded).
|
||||
|
||||
**Routing.** `dispatch` matches on the `subscription_id` carried in `RelayMessage::Event`, dedupes by wrap id (both subscriptions and several relays deliver the same wrap), then recognises the plane by **wrap author** against the derived addresses it holds — never by trial decryption. Recognition order: held channel planes → guestbook → control signer → rekey addresses → dissolved.
|
||||
|
||||
**Ingest pipeline.** Unwrap, verify and fold all happen inside `cx.background_spawn`, never on the foreground thread: secp256k1 verification per edition and per seal is far too expensive for the UI thread.
|
||||
|
||||
```rust
|
||||
enum Signal {
|
||||
Chat { community: CommunityId, channel: ChannelId, message: Box<ChatMessage> },
|
||||
Control { community: CommunityId, heads: Vec<EntityHead>, roster: Box<FoldedRoster> },
|
||||
Guestbook { community: CommunityId, members: BTreeSet<PublicKey> },
|
||||
Rekey { community: CommunityId, scope: RekeyScope, epoch: Epoch },
|
||||
Dissolved(CommunityId),
|
||||
Eose(SubscriptionId),
|
||||
Error(ConcordError),
|
||||
}
|
||||
```
|
||||
|
||||
The consumer is `cx.spawn(async move |this, cx| { while let Ok(signal) = rx.recv_async().await { this.update(cx, |this, cx| this.apply(signal, cx))?; } })`, which updates entities and calls `cx.notify()`.
|
||||
|
||||
Rules taken from the project guidelines:
|
||||
|
||||
- Crypto, folding, database queries and network I/O only in `cx.background_spawn`.
|
||||
- Foreground tasks are `cx.spawn` with `this.update(cx, ..)`; any entity update happens there, and the inner `cx` is always used.
|
||||
- Tasks are stored in fields (`tasks`, `listener`, `consumer`) so they are cancelled on signer change and dropped with the registry. `detach()` only for genuinely fire-and-forget work such as the local state save.
|
||||
- Long-running paging is bounded by explicit page and step caps, not by unbounded loops.
|
||||
- Every fallible path returns `Result` and surfaces through `ConcordEvent::Error`; nothing is silently swallowed.
|
||||
|
||||
**Registry API.**
|
||||
|
||||
```rust
|
||||
pub fn init(window: &mut Window, cx: &mut App);
|
||||
pub struct ConcordRegistry { /* … */ }
|
||||
impl ConcordRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self>;
|
||||
pub fn loading(&self) -> bool;
|
||||
pub fn communities(&self) -> Vec<Entity<Community>>;
|
||||
pub fn community(&self, id: &CommunityId, cx: &App) -> Option<WeakEntity<Community>>;
|
||||
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Community>>;
|
||||
|
||||
pub fn create(&mut self, params: CommunityParams, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
|
||||
pub fn join(&mut self, link: &str, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
|
||||
pub fn accept_direct_invite(&mut self, rumor: &UnsignedEvent, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
|
||||
pub fn leave(&mut self, id: &CommunityId, cx: &mut Context<Self>);
|
||||
pub fn discard_invite(&mut self, id: &CommunityId, cx: &mut Context<Self>);
|
||||
pub fn refresh(&mut self, id: &CommunityId, cx: &mut Context<Self>);
|
||||
pub fn shutdown(&mut self, cx: &mut Context<Self>); // halt subscriptions, keep our own state
|
||||
}
|
||||
```
|
||||
|
||||
**Community API** (`Entity<Community>`, `EventEmitter<CommunityEvent>`):
|
||||
|
||||
```rust
|
||||
pub fn id(&self) -> CommunityId;
|
||||
pub fn owner(&self) -> PublicKey;
|
||||
pub fn name(&self) -> SharedString; pub fn description(&self) -> Option<SharedString>;
|
||||
pub fn icon(&self) -> Option<ImageRef>;
|
||||
pub fn relays(&self) -> Vec<RelayUrl>;
|
||||
pub fn epoch(&self) -> Epoch;
|
||||
pub fn dissolved(&self) -> bool;
|
||||
pub fn channels(&self) -> Vec<Entity<Channel>>;
|
||||
pub fn channel(&self, id: &ChannelId, cx: &App) -> Option<WeakEntity<Channel>>;
|
||||
pub fn members(&self) -> BTreeSet<PublicKey>;
|
||||
pub fn banned(&self) -> BTreeSet<PublicKey>;
|
||||
pub fn roles(&self) -> &CommunityRoles;
|
||||
pub fn permissions(&self, member: &PublicKey) -> u64;
|
||||
pub fn is_staff(&self, member: &PublicKey) -> bool;
|
||||
pub fn message_expiration(&self) -> Option<u64>;
|
||||
|
||||
// authority actions — each returns a publish task and nothing optimistic
|
||||
pub fn set_metadata(&mut self, meta: CommunityMetadata, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn create_channel(&mut self, name: &str, private: bool, cx: &mut Context<Self>) -> Task<Result<ChannelId, Error>>;
|
||||
pub fn edit_channel(&mut self, id: &ChannelId, meta: ChannelMetadata, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn create_role(&mut self, role: Role, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn assign_roles(&mut self, member: &PublicKey, roles: &[[u8; 32]], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn ban(&mut self, members: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn unban(&mut self, members: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn kick(&mut self, member: &PublicKey, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn rekey_channel(&mut self, id: &ChannelId, removed: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn refound(&mut self, removed: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn dissolve(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn create_invite(&mut self, params: InviteParams, cx: &mut Context<Self>) -> Task<Result<String, Error>>;
|
||||
pub fn revoke_invite(&mut self, token: &[u8; 16], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn direct_invite(&mut self, receiver: &PublicKey, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
|
||||
pub fn save_community_list(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>>; // kind 13302, multi-device sync
|
||||
```
|
||||
|
||||
**Channel API** (`Entity<Channel>`): `id`, `name`, `private`, `epoch`, `deleted`, plus
|
||||
|
||||
```rust
|
||||
pub fn messages(&self, until: Option<Timestamp>, limit: usize, cx: &App) -> Task<Result<Vec<ChatMessage>, Error>>;
|
||||
pub fn send(&self, content: &str, reply_to: Option<EventId>, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
pub fn send_file(&self, file: FileAttachment, reply_to: Option<EventId>, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
pub fn edit(&self, id: EventId, content: &str, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
pub fn delete(&self, id: EventId, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
pub fn react(&self, id: EventId, emoji: &str, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
pub fn typing(&self, cx: &App) -> Task<Result<(), Error>>; // kind 23311, ephemeral
|
||||
pub fn pin(&self, id: EventId, cx: &App) -> Task<Result<(), Error>>; // vsk 11, PIN_MESSAGES
|
||||
```
|
||||
|
||||
`CommunityEvent` and `ChannelEvent` mirror `ChatEvent`: one variant per thing the UI has to react to (`Updated`, `Members`, `Added`, `Removed`, `Dissolved`, `Error`, plus channel-level `Incoming`, `Reload`).
|
||||
|
||||
## 11. Integration with existing crates
|
||||
|
||||
1. **`crates/chat/src/lib.rs` — required fix, moved from M2 to the milestone that first subscribes.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-59 gift wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` recipient heuristic. **M2 and M3 did not apply it**: the crate has no subscription and no `ConcordRegistry` yet, so no concord wrap can reach that handler and the change would be untestable. It lands with the sync engine (§10), as does the `concord::init` wiring in `desktop` and `web`.
|
||||
2. **`desktop/src/main.rs` and `web/src/lib.rs`** — add `concord::init(window, cx)` after `chat::init(window, cx)`.
|
||||
3. **`Cargo.toml`** — add `hkdf = "0.12"` to `[workspace.dependencies]`; add the crate to `desktop` and `web` dependencies. No other workspace changes.
|
||||
4. **No changes** to `state`, `person`, `device`, `settings`, `common`, or `ui`.
|
||||
|
||||
## 12. Security invariants to test, not to assume
|
||||
|
||||
Each of these has burned a real implementation, or is a documented cross-client trap:
|
||||
|
||||
- Recompute every rumor id and reject a claimed mismatch; never trust an embedded `id`.
|
||||
- Require `rumor.pubkey == seal.pubkey`.
|
||||
- Require the plaintext seal form on Control and the encrypted form on Chat/Guestbook/Rekey — a strict reader must drop a mis-sealed edition rather than fold a chain a later compaction would fork.
|
||||
- Check `channel` **and** `epoch` against the plane whose key opened the wrap; reject duplicates of either tag.
|
||||
- Reject duplicate `vsk`/`eid`/`ev`/`ep`/`vac` tags; require decimal-with-no-leading-zeros on every numeric tag. The one exception is `ms`, which takes its first value rather than erroring — see §7 for why rejecting it reached membership.
|
||||
- Refuse a tombstone whose `eid` is not this community's id.
|
||||
- Adopt a `control_root` from a Grant only if it derives to the `control_pk` held for that epoch; adopt a rekey blob only if its bound plaintext matches the scope and epoch and its `prevcommit` matches the key currently held.
|
||||
- Never conclude removal from a partial rekey chunk set.
|
||||
- Drop guestbook entries more than an hour in the future; treat an out-of-range `ms` as malformed, not as an interpretation opportunity.
|
||||
- Never honour a Snapshot from anyone but the refounder of that epoch.
|
||||
- Refuse to write a Pin List from a list the writer could not read.
|
||||
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points.
|
||||
- Lowercase hex only; x-only pubkeys only; no version tag anywhere.
|
||||
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate. Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts.
|
||||
|
||||
## 13. Milestones
|
||||
|
||||
| # | Deliverable | Done when |
|
||||
| --- | --- | --- |
|
||||
| M0 | Crate skeleton, `derive.rs`, golden vectors, workspace wiring | ✅ `cargo test -p concord` pins every derivation; all labels match Appendix A.6 |
|
||||
| M1 | `stream.rs` + `store.rs` | ✅ seal/wrap/open round-trips for both seal forms; hostile wraps rejected in the documented order; the local cache reads back with the group key gone |
|
||||
| M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 |
|
||||
| M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (15 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
|
||||
| M4 | Chat plane | send/receive/edit/delete/reaction across two identities; history pages backwards across an epoch boundary (relay `backfill` lands here); binding checks reject a foreign channel/epoch |
|
||||
| M5 | Guestbook + member list + moderation | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test |
|
||||
| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 |
|
||||
| M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused |
|
||||
| M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet |
|
||||
|
||||
Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone.
|
||||
|
||||
M1 closed with `cargo test -p concord` (5 tests), `cargo clippy -p concord --all-targets`, and `cargo fmt -p concord --check` all clean. `rand` was added to the workspace pinned to the same `0.10.2` instance `nostr` already builds, so `Cargo.lock` gained no package.
|
||||
|
||||
M2 closed the same way at 7 tests, with `serde` added to the crate's dependencies (`serde_json` promoted from dev to main for the metadata content types) — `Cargo.lock` gained no package again, only the `concord → serde` edge.
|
||||
|
||||
M3 closed at 15 tests with no dependency change at all, and `Cargo.lock` untouched. New: `src/roles.rs` (permissions, Role/Grant/banlist content, `CommunityRoles`, the delegation fixpoint) and, in `src/control.rs`, `ControlFold` / `fold_control`, the metadata-and-channel fold, `ControlWriter` and its `Edition` input. `EntityHead` and `Floors` moved from `store.rs` into `edition.rs`, where `fold_head` now composes `fold` and `bootstrap_head` for the floor-aware case.
|
||||
|
||||
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`.
|
||||
|
||||
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
|
||||
|
||||
## 14. Open questions and risks
|
||||
|
||||
1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. We implement `13302` per spec, enforce the 50-membership cap and pre-publish size check, and treat `33302` as an interop follow-up. Confirm with Armada before writing the multi-device code.
|
||||
2. **NIP-42 for stream-authored REQs.** Relays that gate kind 1059 by author (for example `ditto-relay`'s `AUTH_KINDS`) need an AUTH event signed by that plane's derived key. `nostr-sdk`'s `Authenticator` is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Vector's answer is a dedicated stream-auth responder installed on the client (`community/v2/streamauth`), primed before any relay interaction, which retains the relay's challenge so plane keys registering later can still answer it: a gating relay challenges once per connection and will not re-challenge an authed one, so a responder attached later never gets the chance. Read that module before deciding; the alternative options remain a per-REQ auth hook upstream or documenting the limitation.
|
||||
3. **`invite_bundle_key` — resolved in M0.** Appendix A.6 was read in full: the raw HKDF output *is* the NIP-44 conversation key, and the derivation is now pinned by a vector.
|
||||
4. **`pins_locator` has no upstream vector.** Resolved in M0 by minting one from our own implementation and flagging it self-referential in the test.
|
||||
5. **Relay set.** Up to 5 recommended, and both reads and writes fan out across them. Coop's client is a gossip client with `no_background_refresh`, so community relays must be added explicitly and re-added on metadata change.
|
||||
6. **Local plaintext state.** §9 records the decision. Revisit only if the local database stops being treated as trusted.
|
||||
7. **Was a `community_id` ever hashed into a tag?** No — it must never appear on the wire. A lint-style test should assert it appears in no published event other than inside an invite bundle and a direct invite.
|
||||
8. **The Pin List's message-key disclosure has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync).
|
||||
9. **A remote signer is not plumbed.** `ControlWriter::publish` and `stream`'s seal builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4–M8 depends on it except the UX of using a remote signer at all.
|
||||
10. **The fold is not incremental.** `fold_control` re-parses and re-folds the whole control edition window on every call, and each fold is up to `2 × entities + 8` passes. That is fine at the caps the spec sets (100 roles, 400-odd grants) and it is the simplest thing that is correct, but if the sync engine ends up calling it per event rather than per batch, the candidate maps and their parse belong in a cache keyed by edition id. Measure before optimizing.
|
||||
|
||||
## 15. Test strategy
|
||||
|
||||
- **Unit, pure:** derivations against golden vectors, edition hash, fold, coalesce, memberlist, blob codec, caps and rejection paths. These need no GPUI context and should be exhaustive — they are where cross-client divergence is caught.
|
||||
- **Integration, GPUI:** `TestAppContext` with two registries sharing an in-memory database, driving wraps through the ingest path; timeouts and delays use `cx.background_executor().timer(..)` per the project guidelines, never `smol::Timer`.
|
||||
- **Round-trip:** every builder paired with its parser, asserting the parse produces the identical structure, including unknown-field round-tripping on all content types.
|
||||
- **Negative:** every bullet in §12 gets a test that constructs the hostile input and asserts the drop.
|
||||
|
Before Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 571 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C9.44568 21.25 7.13318 20.2147 5.45926 18.5407C3.78534 16.8668 2.75 14.5543 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C14.5543 2.75 16.8668 3.78534 18.5407 5.45926C20.2147 7.13318 21.25 9.44568 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/><path d="M18 6L6 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/> </svg>
|
||||
|
Before Width: | Height: | Size: 493 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1014 B After Width: | Height: | Size: 866 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 377 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M12.75 11.2499V8.24994L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L15.75 11.2499H12.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.25 3.75H6.95C5.8299 3.75 5.26984 3.75 4.84202 3.96799C4.46569 4.15973 4.15973 4.46569 3.96799 4.84202C3.75 5.26984 3.75 5.8299 3.75 6.95V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.8299 20.25 6.95 20.25H17.05C18.1701 20.25 18.7302 20.25 19.158 20.032C19.5343 19.8403 19.8403 19.5343 20.032 19.158C20.25 18.7302 20.25 18.1701 20.25 17.05V12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
Before Width: | Height: | Size: 857 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 5.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H19.25C20.3546 19.25 21.25 18.3546 21.25 17.25V8.75C21.25 7.64543 20.3546 6.75 19.25 6.75H13.0704C12.4017 6.75 11.7772 6.4158 11.4063 5.8594L10.5937 4.6406C10.2228 4.0842 9.59834 3.75 8.92963 3.75H4.75C3.64543 3.75 2.75 4.64543 2.75 5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 473 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8.75 3.75L6.75 20.25M17.25 3.75L15.25 20.25M3.75 7.75H20.25M20.25 16.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 600 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M14.75 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25H14.75M15 12H3.75M15 12L11.5 15.5M15 12L11.5 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
Before Width: | Height: | Size: 335 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M20.25 12L9 12M20.25 12L15.75 16.5M20.25 12L15.75 7.5M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25L3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75L11.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
Before Width: | Height: | Size: 346 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 520 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM3.5 6.75C3.5 6.05964 4.05964 5.5 4.75 5.5H10.5V18.5H4.75C4.05964 18.5 3.5 17.9404 3.5 17.25V6.75Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 9.5C6.44772 9.5 6 9.05228 6 8.5C6 7.94772 6.44772 7.5 7 7.5C7.55228 7.5 8 7.94772 8 8.5C8 9.05228 7.55228 9.5 7 9.5ZM7 13C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11C7.55228 11 8 11.4477 8 12C8 12.5523 7.55228 13 7 13ZM7 16.5C6.44772 16.5 6 16.0523 6 15.5C6 14.9477 6.44772 14.5 7 14.5C7.55228 14.5 8 14.9477 8 15.5C8 16.0523 7.55228 16.5 7 16.5Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 396 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M11.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H11.25M11.25 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H11.25M11.25 4.75V19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M6.25 8.5C6.25 8.91421 6.58579 9.25 7 9.25C7.41421 9.25 7.75 8.91421 7.75 8.5C7.75 8.08579 7.41421 7.75 7 7.75C6.58579 7.75 6.25 8.08579 6.25 8.5ZM6.25 12C6.25 12.4142 6.58579 12.75 7 12.75C7.41421 12.75 7.75 12.4142 7.75 12C7.75 11.5858 7.41421 11.25 7 11.25C6.58579 11.25 6.25 11.5858 6.25 12ZM6.25 15.5C6.25 15.9142 6.58579 16.25 7 16.25C7.41421 16.25 7.75 15.9142 7.75 15.5C7.75 15.0858 7.41421 14.75 7 14.75C6.58579 14.75 6.25 15.0858 6.25 15.5Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
|
||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 931 B After Width: | Height: | Size: 433 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
Before Width: | Height: | Size: 624 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 849 B After Width: | Height: | Size: 718 B |
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "aurora",
|
||||
"name": "Aurora",
|
||||
"author": "Coop",
|
||||
"url": "https://coopchat.xyz",
|
||||
"light": {
|
||||
"background": "#fdfcfeff",
|
||||
"surface_background": "#f8f8ffff",
|
||||
"elevated_surface_background": "#f0f1feff",
|
||||
"panel_background": "#fdfcfeff",
|
||||
"overlay": "#211f4300",
|
||||
"title_bar": "#f0f1feff",
|
||||
"title_bar_inactive": "#fdfcfeff",
|
||||
"window_border": "#dadcffff",
|
||||
"border": "#dadcffff",
|
||||
"border_variant": "#cbcdffff",
|
||||
"border_focused": "#5b5bd6ff",
|
||||
"border_selected": "#5b5bd6ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e6e7ffff",
|
||||
"ring": "#5151cdff",
|
||||
"text": "#1f2d5cff",
|
||||
"text_muted": "#5753c6ff",
|
||||
"text_placeholder": "#9b9ef0ff",
|
||||
"text_accent": "#5b5bd6ff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#5753c6ff",
|
||||
"icon_muted": "#9b9ef0ff",
|
||||
"icon_accent": "#5151cdff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#5b5bd6ff",
|
||||
"element_hover": "#5151cdff",
|
||||
"element_active": "#6e56cfff",
|
||||
"element_selected": "#654dc4ff",
|
||||
"element_disabled": "#5b5bd64d",
|
||||
"secondary_foreground": "#1f2d5cff",
|
||||
"secondary_background": "#f0f1feff",
|
||||
"secondary_hover": "#e6e7ffff",
|
||||
"secondary_active": "#dadcffff",
|
||||
"secondary_selected": "#dadcffff",
|
||||
"secondary_disabled": "#5b5bd64d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f0f1feff",
|
||||
"ghost_element_hover": "#211f430d",
|
||||
"ghost_element_active": "#211f431a",
|
||||
"ghost_element_selected": "#211f431a",
|
||||
"ghost_element_disabled": "#211f4305",
|
||||
"tab_background": "#f0f1feff",
|
||||
"tab_foreground": "#5753c6ff",
|
||||
"tab_hover_background": "#211f430d",
|
||||
"tab_active_background": "#fdfcfeff",
|
||||
"tab_active_foreground": "#1f2d5cff",
|
||||
"scrollbar_thumb_background": "#211f431a",
|
||||
"scrollbar_thumb_hover_background": "#211f4326",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#5b5bd61a",
|
||||
"cursor": "#5b5bd6ff",
|
||||
"selection": "#5b5bd640"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#14121fff",
|
||||
"surface_background": "#1b1525ff",
|
||||
"elevated_surface_background": "#291f43ff",
|
||||
"panel_background": "#14121fff",
|
||||
"overlay": "#baa7ff1a",
|
||||
"title_bar": "#291f43ff",
|
||||
"title_bar_inactive": "#14121fff",
|
||||
"window_border": "#473876ff",
|
||||
"border": "#473876ff",
|
||||
"border_variant": "#3c2e69ff",
|
||||
"border_focused": "#7d66d9ff",
|
||||
"border_selected": "#7d66d9ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#33255bff",
|
||||
"ring": "#6e56cfff",
|
||||
"text": "#e2ddfeff",
|
||||
"text_muted": "#baa7ffff",
|
||||
"text_placeholder": "#6958adff",
|
||||
"text_accent": "#baa7ffff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#baa7ffff",
|
||||
"icon_muted": "#6958adff",
|
||||
"icon_accent": "#6e56cfff",
|
||||
"element_foreground": "#14121fff",
|
||||
"element_background": "#7d66d9ff",
|
||||
"element_hover": "#baa7ffff",
|
||||
"element_active": "#6e56cfff",
|
||||
"element_selected": "#654dc4ff",
|
||||
"element_disabled": "#7d66d94d",
|
||||
"secondary_foreground": "#e2ddfeff",
|
||||
"secondary_background": "#291f43ff",
|
||||
"secondary_hover": "#33255bff",
|
||||
"secondary_active": "#3c2e69ff",
|
||||
"secondary_selected": "#3c2e69ff",
|
||||
"secondary_disabled": "#7d66d94d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#291f43ff",
|
||||
"ghost_element_hover": "#baa7ff0d",
|
||||
"ghost_element_active": "#baa7ff1a",
|
||||
"ghost_element_selected": "#baa7ff1a",
|
||||
"ghost_element_disabled": "#baa7ff05",
|
||||
"tab_background": "#291f43ff",
|
||||
"tab_foreground": "#baa7ffff",
|
||||
"tab_hover_background": "#baa7ff0d",
|
||||
"tab_active_background": "#14121fff",
|
||||
"tab_active_foreground": "#e2ddfeff",
|
||||
"scrollbar_thumb_background": "#baa7ff1a",
|
||||
"scrollbar_thumb_hover_background": "#baa7ff26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#baa7ff1a",
|
||||
"cursor": "#baa7ffff",
|
||||
"selection": "#baa7ff40"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "catppuccin-frappe",
|
||||
"name": "Catppuccin Frappé",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#232634",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#51576d",
|
||||
"border": "#51576d",
|
||||
"border_variant": "#414559",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#c6d0f500",
|
||||
"border_disabled": "#292c3c",
|
||||
"ring": "#babbf1",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#a5adce",
|
||||
"text_placeholder": "#838ba7",
|
||||
"text_accent": "#8caaee",
|
||||
"text_danger": "#e78284",
|
||||
"text_warning": "#ef9f76",
|
||||
"icon": "#a5adce",
|
||||
"icon_muted": "#838ba7",
|
||||
"icon_accent": "#babbf1",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#babbf1",
|
||||
"element_active": "#99d1db",
|
||||
"element_selected": "#85c1dc",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#c6d0f5",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#51576d",
|
||||
"secondary_active": "#626880",
|
||||
"secondary_selected": "#626880",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#ea999c",
|
||||
"danger_active": "#ef9f76",
|
||||
"danger_selected": "#e5c890",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#ef9f76",
|
||||
"warning_hover": "#e5c890",
|
||||
"warning_active": "#a6d189",
|
||||
"warning_selected": "#81c8be",
|
||||
"warning_disabled": "#ef9f764d",
|
||||
"ghost_element_background": "#c6d0f500",
|
||||
"ghost_element_background_alt": "#292c3c",
|
||||
"ghost_element_hover": "#c6d0f50d",
|
||||
"ghost_element_active": "#c6d0f51a",
|
||||
"ghost_element_selected": "#c6d0f51a",
|
||||
"ghost_element_disabled": "#c6d0f505",
|
||||
"tab_background": "#232634",
|
||||
"tab_foreground": "#a5adce",
|
||||
"tab_hover_background": "#c6d0f50d",
|
||||
"tab_active_background": "#303446",
|
||||
"tab_active_foreground": "#c6d0f5",
|
||||
"scrollbar_thumb_background": "#c6d0f51a",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||
"scrollbar_thumb_border": "#c6d0f500",
|
||||
"scrollbar_track_background": "#c6d0f500",
|
||||
"scrollbar_track_border": "#c6d0f500",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#8caaee",
|
||||
"selection": "#8caaee40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#232634",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#51576d",
|
||||
"border": "#51576d",
|
||||
"border_variant": "#414559",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#c6d0f500",
|
||||
"border_disabled": "#292c3c",
|
||||
"ring": "#babbf1",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#a5adce",
|
||||
"text_placeholder": "#838ba7",
|
||||
"text_accent": "#8caaee",
|
||||
"text_danger": "#e78284",
|
||||
"text_warning": "#ef9f76",
|
||||
"icon": "#a5adce",
|
||||
"icon_muted": "#838ba7",
|
||||
"icon_accent": "#babbf1",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#babbf1",
|
||||
"element_active": "#99d1db",
|
||||
"element_selected": "#85c1dc",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#c6d0f5",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#51576d",
|
||||
"secondary_active": "#626880",
|
||||
"secondary_selected": "#626880",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#ea999c",
|
||||
"danger_active": "#ef9f76",
|
||||
"danger_selected": "#e5c890",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#ef9f76",
|
||||
"warning_hover": "#e5c890",
|
||||
"warning_active": "#a6d189",
|
||||
"warning_selected": "#81c8be",
|
||||
"warning_disabled": "#ef9f764d",
|
||||
"ghost_element_background": "#c6d0f500",
|
||||
"ghost_element_background_alt": "#292c3c",
|
||||
"ghost_element_hover": "#c6d0f50d",
|
||||
"ghost_element_active": "#c6d0f51a",
|
||||
"ghost_element_selected": "#c6d0f51a",
|
||||
"ghost_element_disabled": "#c6d0f505",
|
||||
"tab_background": "#232634",
|
||||
"tab_foreground": "#a5adce",
|
||||
"tab_hover_background": "#c6d0f50d",
|
||||
"tab_active_background": "#303446",
|
||||
"tab_active_foreground": "#c6d0f5",
|
||||
"scrollbar_thumb_background": "#c6d0f51a",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||
"scrollbar_thumb_border": "#c6d0f500",
|
||||
"scrollbar_track_background": "#c6d0f500",
|
||||
"scrollbar_track_border": "#c6d0f500",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#8caaee",
|
||||
"selection": "#8caaee40"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "catppuccin-latte",
|
||||
"name": "Catppuccin Latte",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#dce0e8",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#bcc0cc",
|
||||
"border": "#bcc0cc",
|
||||
"border_variant": "#ccd0da",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#4c4f6900",
|
||||
"border_disabled": "#e6e9ef",
|
||||
"ring": "#7287fd",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#6c6f85",
|
||||
"text_placeholder": "#8c8fa1",
|
||||
"text_accent": "#1e66f5",
|
||||
"text_danger": "#d20f39",
|
||||
"text_warning": "#fe640b",
|
||||
"icon": "#6c6f85",
|
||||
"icon_muted": "#8c8fa1",
|
||||
"icon_accent": "#7287fd",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#7287fd",
|
||||
"element_active": "#04a5e5",
|
||||
"element_selected": "#209fb5",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#4c4f69",
|
||||
"secondary_background": "#ccd0da",
|
||||
"secondary_hover": "#bcc0cc",
|
||||
"secondary_active": "#acb0be",
|
||||
"secondary_selected": "#acb0be",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#e64553",
|
||||
"danger_active": "#fe640b",
|
||||
"danger_selected": "#df8e1d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#eff1f5",
|
||||
"warning_background": "#fe640b",
|
||||
"warning_hover": "#df8e1d",
|
||||
"warning_active": "#40a02b",
|
||||
"warning_selected": "#179299",
|
||||
"warning_disabled": "#fe640b4d",
|
||||
"ghost_element_background": "#4c4f6900",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f690d",
|
||||
"ghost_element_active": "#4c4f691a",
|
||||
"ghost_element_selected": "#4c4f691a",
|
||||
"ghost_element_disabled": "#4c4f6905",
|
||||
"tab_background": "#e6e9ef",
|
||||
"tab_foreground": "#6c6f85",
|
||||
"tab_hover_background": "#4c4f690d",
|
||||
"tab_active_background": "#eff1f5",
|
||||
"tab_active_foreground": "#4c4f69",
|
||||
"scrollbar_thumb_background": "#4c4f691a",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||
"scrollbar_thumb_border": "#4c4f6900",
|
||||
"scrollbar_track_background": "#4c4f6900",
|
||||
"scrollbar_track_border": "#4c4f6900",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#1e66f5",
|
||||
"selection": "#1e66f540"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#dce0e8",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#bcc0cc",
|
||||
"border": "#bcc0cc",
|
||||
"border_variant": "#ccd0da",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#4c4f6900",
|
||||
"border_disabled": "#e6e9ef",
|
||||
"ring": "#7287fd",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#6c6f85",
|
||||
"text_placeholder": "#8c8fa1",
|
||||
"text_accent": "#1e66f5",
|
||||
"text_danger": "#d20f39",
|
||||
"text_warning": "#fe640b",
|
||||
"icon": "#6c6f85",
|
||||
"icon_muted": "#8c8fa1",
|
||||
"icon_accent": "#7287fd",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#7287fd",
|
||||
"element_active": "#04a5e5",
|
||||
"element_selected": "#209fb5",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#4c4f69",
|
||||
"secondary_background": "#ccd0da",
|
||||
"secondary_hover": "#bcc0cc",
|
||||
"secondary_active": "#acb0be",
|
||||
"secondary_selected": "#acb0be",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#e64553",
|
||||
"danger_active": "#fe640b",
|
||||
"danger_selected": "#df8e1d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#eff1f5",
|
||||
"warning_background": "#fe640b",
|
||||
"warning_hover": "#df8e1d",
|
||||
"warning_active": "#40a02b",
|
||||
"warning_selected": "#179299",
|
||||
"warning_disabled": "#fe640b4d",
|
||||
"ghost_element_background": "#4c4f6900",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f690d",
|
||||
"ghost_element_active": "#4c4f691a",
|
||||
"ghost_element_selected": "#4c4f691a",
|
||||
"ghost_element_disabled": "#4c4f6905",
|
||||
"tab_background": "#e6e9ef",
|
||||
"tab_foreground": "#6c6f85",
|
||||
"tab_hover_background": "#4c4f690d",
|
||||
"tab_active_background": "#eff1f5",
|
||||
"tab_active_foreground": "#4c4f69",
|
||||
"scrollbar_thumb_background": "#4c4f691a",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||
"scrollbar_thumb_border": "#4c4f6900",
|
||||
"scrollbar_track_background": "#4c4f6900",
|
||||
"scrollbar_track_border": "#4c4f6900",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#1e66f5",
|
||||
"selection": "#1e66f540"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "catppuccin-macchiato",
|
||||
"name": "Catppuccin Macchiato",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#181926",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#494d64",
|
||||
"border": "#494d64",
|
||||
"border_variant": "#363a4f",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#cad3f500",
|
||||
"border_disabled": "#1e2030",
|
||||
"ring": "#b7bdf8",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#a5adcb",
|
||||
"text_placeholder": "#8087a2",
|
||||
"text_accent": "#8aadf4",
|
||||
"text_danger": "#ed8796",
|
||||
"text_warning": "#f5a97f",
|
||||
"icon": "#a5adcb",
|
||||
"icon_muted": "#8087a2",
|
||||
"icon_accent": "#b7bdf8",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#b7bdf8",
|
||||
"element_active": "#91d7e3",
|
||||
"element_selected": "#7dc4e4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#cad3f5",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#494d64",
|
||||
"secondary_active": "#5b6078",
|
||||
"secondary_selected": "#5b6078",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ee99a0",
|
||||
"danger_active": "#f5a97f",
|
||||
"danger_selected": "#eed49f",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#f5a97f",
|
||||
"warning_hover": "#eed49f",
|
||||
"warning_active": "#a6da95",
|
||||
"warning_selected": "#8bd5ca",
|
||||
"warning_disabled": "#f5a97f4d",
|
||||
"ghost_element_background": "#cad3f500",
|
||||
"ghost_element_background_alt": "#1e2030",
|
||||
"ghost_element_hover": "#cad3f50d",
|
||||
"ghost_element_active": "#cad3f51a",
|
||||
"ghost_element_selected": "#cad3f51a",
|
||||
"ghost_element_disabled": "#cad3f505",
|
||||
"tab_background": "#181926",
|
||||
"tab_foreground": "#a5adcb",
|
||||
"tab_hover_background": "#cad3f50d",
|
||||
"tab_active_background": "#24273a",
|
||||
"tab_active_foreground": "#cad3f5",
|
||||
"scrollbar_thumb_background": "#cad3f51a",
|
||||
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||
"scrollbar_thumb_border": "#cad3f500",
|
||||
"scrollbar_track_background": "#cad3f500",
|
||||
"scrollbar_track_border": "#cad3f500",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#8aadf4",
|
||||
"selection": "#8aadf440"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#181926",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#494d64",
|
||||
"border": "#494d64",
|
||||
"border_variant": "#363a4f",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#cad3f500",
|
||||
"border_disabled": "#1e2030",
|
||||
"ring": "#b7bdf8",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#a5adcb",
|
||||
"text_placeholder": "#8087a2",
|
||||
"text_accent": "#8aadf4",
|
||||
"text_danger": "#ed8796",
|
||||
"text_warning": "#f5a97f",
|
||||
"icon": "#a5adcb",
|
||||
"icon_muted": "#8087a2",
|
||||
"icon_accent": "#b7bdf8",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#b7bdf8",
|
||||
"element_active": "#91d7e3",
|
||||
"element_selected": "#7dc4e4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#cad3f5",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#494d64",
|
||||
"secondary_active": "#5b6078",
|
||||
"secondary_selected": "#5b6078",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ee99a0",
|
||||
"danger_active": "#f5a97f",
|
||||
"danger_selected": "#eed49f",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#f5a97f",
|
||||
"warning_hover": "#eed49f",
|
||||
"warning_active": "#a6da95",
|
||||
"warning_selected": "#8bd5ca",
|
||||
"warning_disabled": "#f5a97f4d",
|
||||
"ghost_element_background": "#cad3f500",
|
||||
"ghost_element_background_alt": "#1e2030",
|
||||
"ghost_element_hover": "#cad3f50d",
|
||||
"ghost_element_active": "#cad3f51a",
|
||||
"ghost_element_selected": "#cad3f51a",
|
||||
"ghost_element_disabled": "#cad3f505",
|
||||
"tab_background": "#181926",
|
||||
"tab_foreground": "#a5adcb",
|
||||
"tab_hover_background": "#cad3f50d",
|
||||
"tab_active_background": "#24273a",
|
||||
"tab_active_foreground": "#cad3f5",
|
||||
"scrollbar_thumb_background": "#cad3f51a",
|
||||
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||
"scrollbar_thumb_border": "#cad3f500",
|
||||
"scrollbar_track_background": "#cad3f500",
|
||||
"scrollbar_track_border": "#cad3f500",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#8aadf4",
|
||||
"selection": "#8aadf440"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "catppuccin-mocha",
|
||||
"name": "Catppuccin Mocha",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#11111b",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#45475a",
|
||||
"border": "#45475a",
|
||||
"border_variant": "#313244",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#cdd6f400",
|
||||
"border_disabled": "#181825",
|
||||
"ring": "#b4befe",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#a6adc8",
|
||||
"text_placeholder": "#7f849c",
|
||||
"text_accent": "#89b4fa",
|
||||
"text_danger": "#f38ba8",
|
||||
"text_warning": "#fab387",
|
||||
"icon": "#a6adc8",
|
||||
"icon_muted": "#7f849c",
|
||||
"icon_accent": "#b4befe",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#b4befe",
|
||||
"element_active": "#89dceb",
|
||||
"element_selected": "#74c7ec",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#cdd6f4",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#45475a",
|
||||
"secondary_active": "#585b70",
|
||||
"secondary_selected": "#585b70",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#eba0ac",
|
||||
"danger_active": "#fab387",
|
||||
"danger_selected": "#f9e2af",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#fab387",
|
||||
"warning_hover": "#f9e2af",
|
||||
"warning_active": "#a6e3a1",
|
||||
"warning_selected": "#94e2d5",
|
||||
"warning_disabled": "#fab3874d",
|
||||
"ghost_element_background": "#cdd6f400",
|
||||
"ghost_element_background_alt": "#181825",
|
||||
"ghost_element_hover": "#cdd6f40d",
|
||||
"ghost_element_active": "#cdd6f41a",
|
||||
"ghost_element_selected": "#cdd6f41a",
|
||||
"ghost_element_disabled": "#cdd6f405",
|
||||
"tab_background": "#11111b",
|
||||
"tab_foreground": "#a6adc8",
|
||||
"tab_hover_background": "#cdd6f40d",
|
||||
"tab_active_background": "#1e1e2e",
|
||||
"tab_active_foreground": "#cdd6f4",
|
||||
"scrollbar_thumb_background": "#cdd6f41a",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||
"scrollbar_thumb_border": "#cdd6f400",
|
||||
"scrollbar_track_background": "#cdd6f400",
|
||||
"scrollbar_track_border": "#cdd6f400",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#89b4fa",
|
||||
"selection": "#89b4fa40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#11111b",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#45475a",
|
||||
"border": "#45475a",
|
||||
"border_variant": "#313244",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#cdd6f400",
|
||||
"border_disabled": "#181825",
|
||||
"ring": "#b4befe",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#a6adc8",
|
||||
"text_placeholder": "#7f849c",
|
||||
"text_accent": "#89b4fa",
|
||||
"text_danger": "#f38ba8",
|
||||
"text_warning": "#fab387",
|
||||
"icon": "#a6adc8",
|
||||
"icon_muted": "#7f849c",
|
||||
"icon_accent": "#b4befe",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#b4befe",
|
||||
"element_active": "#89dceb",
|
||||
"element_selected": "#74c7ec",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#cdd6f4",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#45475a",
|
||||
"secondary_active": "#585b70",
|
||||
"secondary_selected": "#585b70",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#eba0ac",
|
||||
"danger_active": "#fab387",
|
||||
"danger_selected": "#f9e2af",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#fab387",
|
||||
"warning_hover": "#f9e2af",
|
||||
"warning_active": "#a6e3a1",
|
||||
"warning_selected": "#94e2d5",
|
||||
"warning_disabled": "#fab3874d",
|
||||
"ghost_element_background": "#cdd6f400",
|
||||
"ghost_element_background_alt": "#181825",
|
||||
"ghost_element_hover": "#cdd6f40d",
|
||||
"ghost_element_active": "#cdd6f41a",
|
||||
"ghost_element_selected": "#cdd6f41a",
|
||||
"ghost_element_disabled": "#cdd6f405",
|
||||
"tab_background": "#11111b",
|
||||
"tab_foreground": "#a6adc8",
|
||||
"tab_hover_background": "#cdd6f40d",
|
||||
"tab_active_background": "#1e1e2e",
|
||||
"tab_active_foreground": "#cdd6f4",
|
||||
"scrollbar_thumb_background": "#cdd6f41a",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||
"scrollbar_thumb_border": "#cdd6f400",
|
||||
"scrollbar_track_background": "#cdd6f400",
|
||||
"scrollbar_track_border": "#cdd6f400",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#89b4fa",
|
||||
"selection": "#89b4fa40"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "flexoki",
|
||||
"name": "Flexoki",
|
||||
"author": "Steph Ango (ported by Coop)",
|
||||
"url": "https://stephango.com/flexoki",
|
||||
"light": {
|
||||
"background": "#FFFCF0",
|
||||
"surface_background": "#F2F0E5",
|
||||
"elevated_surface_background": "#E6E4D9",
|
||||
"panel_background": "#FFFCF0",
|
||||
"overlay": "#100F0F1A",
|
||||
"title_bar": "#E6E4D9",
|
||||
"title_bar_inactive": "#FFFCF0",
|
||||
"window_border": "#CECDC3",
|
||||
"border": "#CECDC3",
|
||||
"border_variant": "#DAD8CE",
|
||||
"border_focused": "#24837B",
|
||||
"border_selected": "#24837B",
|
||||
"border_transparent": "#100F0F00",
|
||||
"border_disabled": "#E6E4D9",
|
||||
"ring": "#3AA99F",
|
||||
"text": "#100F0F",
|
||||
"text_muted": "#6F6E69",
|
||||
"text_placeholder": "#B7B5AC",
|
||||
"text_accent": "#24837B",
|
||||
"text_danger": "#AF3029",
|
||||
"text_warning": "#BC5215",
|
||||
"icon": "#6F6E69",
|
||||
"icon_muted": "#B7B5AC",
|
||||
"icon_accent": "#3AA99F",
|
||||
"element_foreground": "#FFFCF0",
|
||||
"element_background": "#24837B",
|
||||
"element_hover": "#3AA99F",
|
||||
"element_active": "#1C1B1A",
|
||||
"element_selected": "#100F0F",
|
||||
"element_disabled": "#24837B4D",
|
||||
"secondary_foreground": "#100F0F",
|
||||
"secondary_background": "#E6E4D9",
|
||||
"secondary_hover": "#DAD8CE",
|
||||
"secondary_active": "#CECDC3",
|
||||
"secondary_selected": "#CECDC3",
|
||||
"secondary_disabled": "#24837B4D",
|
||||
"danger_foreground": "#FFFCF0",
|
||||
"danger_background": "#AF3029",
|
||||
"danger_hover": "#D14D41",
|
||||
"danger_active": "#1C1B1A",
|
||||
"danger_selected": "#100F0F",
|
||||
"danger_disabled": "#AF30294D",
|
||||
"warning_foreground": "#FFFCF0",
|
||||
"warning_background": "#BC5215",
|
||||
"warning_hover": "#DA702C",
|
||||
"warning_active": "#1C1B1A",
|
||||
"warning_selected": "#100F0F",
|
||||
"warning_disabled": "#BC52154D",
|
||||
"ghost_element_background": "#100F0F00",
|
||||
"ghost_element_background_alt": "#F2F0E5",
|
||||
"ghost_element_hover": "#100F0F0D",
|
||||
"ghost_element_active": "#100F0F1A",
|
||||
"ghost_element_selected": "#100F0F1A",
|
||||
"ghost_element_disabled": "#100F0F05",
|
||||
"tab_background": "#E6E4D9",
|
||||
"tab_foreground": "#6F6E69",
|
||||
"tab_hover_background": "#100F0F0D",
|
||||
"tab_active_background": "#FFFCF0",
|
||||
"tab_active_foreground": "#100F0F",
|
||||
"scrollbar_thumb_background": "#100F0F1A",
|
||||
"scrollbar_thumb_hover_background": "#100F0F26",
|
||||
"scrollbar_thumb_border": "#100F0F00",
|
||||
"scrollbar_track_background": "#100F0F00",
|
||||
"scrollbar_track_border": "#100F0F00",
|
||||
"drop_target_background": "#24837B1A",
|
||||
"cursor": "#24837B",
|
||||
"selection": "#24837B40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#100F0F",
|
||||
"surface_background": "#1C1B1A",
|
||||
"elevated_surface_background": "#282726",
|
||||
"panel_background": "#100F0F",
|
||||
"overlay": "#FFFCF01A",
|
||||
"title_bar": "#282726",
|
||||
"title_bar_inactive": "#100F0F",
|
||||
"window_border": "#403E3C",
|
||||
"border": "#403E3C",
|
||||
"border_variant": "#343331",
|
||||
"border_focused": "#3AA99F",
|
||||
"border_selected": "#3AA99F",
|
||||
"border_transparent": "#100F0F00",
|
||||
"border_disabled": "#282726",
|
||||
"ring": "#24837B",
|
||||
"text": "#CECDC3",
|
||||
"text_muted": "#878580",
|
||||
"text_placeholder": "#575653",
|
||||
"text_accent": "#3AA99F",
|
||||
"text_danger": "#D14D41",
|
||||
"text_warning": "#DA702C",
|
||||
"icon": "#878580",
|
||||
"icon_muted": "#575653",
|
||||
"icon_accent": "#24837B",
|
||||
"element_foreground": "#100F0F",
|
||||
"element_background": "#3AA99F",
|
||||
"element_hover": "#24837B",
|
||||
"element_active": "#CECDC3",
|
||||
"element_selected": "#F2F0E5",
|
||||
"element_disabled": "#3AA99F4D",
|
||||
"secondary_foreground": "#CECDC3",
|
||||
"secondary_background": "#1C1B1A",
|
||||
"secondary_hover": "#282726",
|
||||
"secondary_active": "#343331",
|
||||
"secondary_selected": "#343331",
|
||||
"secondary_disabled": "#3AA99F4D",
|
||||
"danger_foreground": "#100F0F",
|
||||
"danger_background": "#D14D41",
|
||||
"danger_hover": "#AF3029",
|
||||
"danger_active": "#CECDC3",
|
||||
"danger_selected": "#F2F0E5",
|
||||
"danger_disabled": "#D14D414D",
|
||||
"warning_foreground": "#100F0F",
|
||||
"warning_background": "#DA702C",
|
||||
"warning_hover": "#BC5215",
|
||||
"warning_active": "#CECDC3",
|
||||
"warning_selected": "#F2F0E5",
|
||||
"warning_disabled": "#DA702C4D",
|
||||
"ghost_element_background": "#100F0F00",
|
||||
"ghost_element_background_alt": "#1C1B1A",
|
||||
"ghost_element_hover": "#FFFCF00D",
|
||||
"ghost_element_active": "#FFFCF01A",
|
||||
"ghost_element_selected": "#FFFCF01A",
|
||||
"ghost_element_disabled": "#FFFCF005",
|
||||
"tab_background": "#282726",
|
||||
"tab_foreground": "#878580",
|
||||
"tab_hover_background": "#FFFCF00D",
|
||||
"tab_active_background": "#100F0F",
|
||||
"tab_active_foreground": "#CECDC3",
|
||||
"scrollbar_thumb_background": "#FFFCF01A",
|
||||
"scrollbar_thumb_hover_background": "#FFFCF026",
|
||||
"scrollbar_thumb_border": "#100F0F00",
|
||||
"scrollbar_track_background": "#100F0F00",
|
||||
"scrollbar_track_border": "#100F0F00",
|
||||
"drop_target_background": "#3AA99F1A",
|
||||
"cursor": "#3AA99F",
|
||||
"selection": "#3AA99F40"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "forest",
|
||||
"name": "Forest",
|
||||
"author": "Coop",
|
||||
"url": "https://coopchat.xyz",
|
||||
"light": {
|
||||
"background": "#fbfefcff",
|
||||
"surface_background": "#f4fbf6ff",
|
||||
"elevated_surface_background": "#e9f6e9ff",
|
||||
"panel_background": "#fbfefcff",
|
||||
"overlay": "#193b2d1a",
|
||||
"title_bar": "#e9f6e9ff",
|
||||
"title_bar_inactive": "#fbfefcff",
|
||||
"window_border": "#c4e8d1ff",
|
||||
"border": "#c4e8d1ff",
|
||||
"border_variant": "#b2ddb5ff",
|
||||
"border_focused": "#30a46cff",
|
||||
"border_selected": "#30a46cff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e0f3e6ff",
|
||||
"ring": "#2b9a66ff",
|
||||
"text": "#193b2dff",
|
||||
"text_muted": "#2f7c57ff",
|
||||
"text_placeholder": "#8eceaaff",
|
||||
"text_accent": "#30a46cff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#2f7c57ff",
|
||||
"icon_muted": "#8eceaaff",
|
||||
"icon_accent": "#2b9a66ff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#30a46cff",
|
||||
"element_hover": "#2b9a66ff",
|
||||
"element_active": "#2a7e3bff",
|
||||
"element_selected": "#218358ff",
|
||||
"element_disabled": "#30a46c4d",
|
||||
"secondary_foreground": "#193b2dff",
|
||||
"secondary_background": "#e9f6e9ff",
|
||||
"secondary_hover": "#daf1dbff",
|
||||
"secondary_active": "#c4e8d1ff",
|
||||
"secondary_selected": "#c4e8d1ff",
|
||||
"secondary_disabled": "#30a46c4d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e9f6e9ff",
|
||||
"ghost_element_hover": "#193b2d0d",
|
||||
"ghost_element_active": "#193b2d1a",
|
||||
"ghost_element_selected": "#193b2d1a",
|
||||
"ghost_element_disabled": "#193b2d05",
|
||||
"tab_background": "#e9f6e9ff",
|
||||
"tab_foreground": "#2f7c57ff",
|
||||
"tab_hover_background": "#193b2d0d",
|
||||
"tab_active_background": "#fbfefcff",
|
||||
"tab_active_foreground": "#193b2dff",
|
||||
"scrollbar_thumb_background": "#193b2d1a",
|
||||
"scrollbar_thumb_hover_background": "#193b2d26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#30a46c1a",
|
||||
"cursor": "#30a46cff",
|
||||
"selection": "#30a46c40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0e1512ff",
|
||||
"surface_background": "#121b17ff",
|
||||
"elevated_surface_background": "#132d21ff",
|
||||
"panel_background": "#0e1512ff",
|
||||
"overlay": "#b1f1cb1a",
|
||||
"title_bar": "#132d21ff",
|
||||
"title_bar_inactive": "#0e1512ff",
|
||||
"window_border": "#20573eff",
|
||||
"border": "#20573eff",
|
||||
"border_variant": "#174933ff",
|
||||
"border_focused": "#33b074ff",
|
||||
"border_selected": "#33b074ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#113b29ff",
|
||||
"ring": "#33b074ff",
|
||||
"text": "#b1f1cbff",
|
||||
"text_muted": "#71d083ff",
|
||||
"text_placeholder": "#2f7c57ff",
|
||||
"text_accent": "#3dd68cff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#71d083ff",
|
||||
"icon_muted": "#2f7c57ff",
|
||||
"icon_accent": "#33b074ff",
|
||||
"element_foreground": "#0e1512ff",
|
||||
"element_background": "#3dd68cff",
|
||||
"element_hover": "#71d083ff",
|
||||
"element_active": "#33b074ff",
|
||||
"element_selected": "#30a46cff",
|
||||
"element_disabled": "#3dd68c4d",
|
||||
"secondary_foreground": "#b1f1cbff",
|
||||
"secondary_background": "#132d21ff",
|
||||
"secondary_hover": "#113b29ff",
|
||||
"secondary_active": "#174933ff",
|
||||
"secondary_selected": "#174933ff",
|
||||
"secondary_disabled": "#3dd68c4d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#132d21ff",
|
||||
"ghost_element_hover": "#b1f1cb0d",
|
||||
"ghost_element_active": "#b1f1cb1a",
|
||||
"ghost_element_selected": "#b1f1cb1a",
|
||||
"ghost_element_disabled": "#b1f1cb05",
|
||||
"tab_background": "#132d21ff",
|
||||
"tab_foreground": "#71d083ff",
|
||||
"tab_hover_background": "#b1f1cb0d",
|
||||
"tab_active_background": "#0e1512ff",
|
||||
"tab_active_foreground": "#b1f1cbff",
|
||||
"scrollbar_thumb_background": "#b1f1cb1a",
|
||||
"scrollbar_thumb_hover_background": "#b1f1cb26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#3dd68c1a",
|
||||
"cursor": "#3dd68cff",
|
||||
"selection": "#3dd68c40"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"id": "ocean",
|
||||
"name": "Ocean",
|
||||
"author": "Coop",
|
||||
"url": "https://coopchat.xyz",
|
||||
"light": {
|
||||
"background": "#fafefeff",
|
||||
"surface_background": "#f2fbfaff",
|
||||
"elevated_surface_background": "#e6f7f7ff",
|
||||
"panel_background": "#fafefeff",
|
||||
"overlay": "#00333f1a",
|
||||
"title_bar": "#e6f7f7ff",
|
||||
"title_bar_inactive": "#fafefeff",
|
||||
"window_border": "#cce5e9ff",
|
||||
"border": "#cce5e9ff",
|
||||
"border_variant": "#b8dde3ff",
|
||||
"border_focused": "#00a2c7ff",
|
||||
"border_selected": "#00a2c7ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e0f0f2ff",
|
||||
"ring": "#0797b9ff",
|
||||
"text": "#0d3c48ff",
|
||||
"text_muted": "#107d98ff",
|
||||
"text_placeholder": "#60b3d7ff",
|
||||
"text_accent": "#00a2c7ff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#107d98ff",
|
||||
"icon_muted": "#60b3d7ff",
|
||||
"icon_accent": "#0797b9ff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#00a2c7ff",
|
||||
"element_hover": "#0797b9ff",
|
||||
"element_active": "#12667eff",
|
||||
"element_selected": "#0d4a5cff",
|
||||
"element_disabled": "#00a2c74d",
|
||||
"secondary_foreground": "#0d4a5cff",
|
||||
"secondary_background": "#ddf9f2ff",
|
||||
"secondary_hover": "#c8f4e9ff",
|
||||
"secondary_active": "#b3ecdeff",
|
||||
"secondary_selected": "#b3ecdeff",
|
||||
"secondary_disabled": "#00a2c74d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6f7f7ff",
|
||||
"ghost_element_hover": "#00333f0d",
|
||||
"ghost_element_active": "#00333f1a",
|
||||
"ghost_element_selected": "#00333f1a",
|
||||
"ghost_element_disabled": "#00333f05",
|
||||
"tab_background": "#e6f7f7ff",
|
||||
"tab_foreground": "#107d98ff",
|
||||
"tab_hover_background": "#00333f0d",
|
||||
"tab_active_background": "#fafefeff",
|
||||
"tab_active_foreground": "#0d3c48ff",
|
||||
"scrollbar_thumb_background": "#00333f1a",
|
||||
"scrollbar_thumb_hover_background": "#00333f26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#00a2c71a",
|
||||
"cursor": "#00a2c7ff",
|
||||
"selection": "#00a2c740"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0b161aff",
|
||||
"surface_background": "#101b20ff",
|
||||
"elevated_surface_background": "#082c36ff",
|
||||
"panel_background": "#0b161aff",
|
||||
"overlay": "#c2f3ff1a",
|
||||
"title_bar": "#082c36ff",
|
||||
"title_bar_inactive": "#0b161aff",
|
||||
"window_border": "#1b537bff",
|
||||
"border": "#1b537bff",
|
||||
"border_variant": "#154467ff",
|
||||
"border_focused": "#00a2c7ff",
|
||||
"border_selected": "#00a2c7ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#112840ff",
|
||||
"ring": "#23afd0ff",
|
||||
"text": "#b6ecf7ff",
|
||||
"text_muted": "#4ccce6ff",
|
||||
"text_placeholder": "#197caeff",
|
||||
"text_accent": "#7ce2feff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#4ccce6ff",
|
||||
"icon_muted": "#197caeff",
|
||||
"icon_accent": "#23afd0ff",
|
||||
"element_foreground": "#0b161aff",
|
||||
"element_background": "#7ce2feff",
|
||||
"element_hover": "#a8eeffff",
|
||||
"element_active": "#23afd0ff",
|
||||
"element_selected": "#00a2c7ff",
|
||||
"element_disabled": "#7ce2fe4d",
|
||||
"secondary_foreground": "#adf0ddff",
|
||||
"secondary_background": "#0d2d2aff",
|
||||
"secondary_hover": "#023b37ff",
|
||||
"secondary_active": "#084843ff",
|
||||
"secondary_selected": "#084843ff",
|
||||
"secondary_disabled": "#7ce2fe4d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#082c36ff",
|
||||
"ghost_element_hover": "#c2f3ff0d",
|
||||
"ghost_element_active": "#c2f3ff1a",
|
||||
"ghost_element_selected": "#c2f3ff1a",
|
||||
"ghost_element_disabled": "#c2f3ff05",
|
||||
"tab_background": "#082c36ff",
|
||||
"tab_foreground": "#4ccce6ff",
|
||||
"tab_hover_background": "#c2f3ff0d",
|
||||
"tab_active_background": "#0b161aff",
|
||||
"tab_active_foreground": "#b6ecf7ff",
|
||||
"scrollbar_thumb_background": "#c2f3ff1a",
|
||||
"scrollbar_thumb_hover_background": "#c2f3ff26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#7ce2fe1a",
|
||||
"cursor": "#7ce2feff",
|
||||
"selection": "#7ce2fe40"
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
@@ -7,6 +7,7 @@ use rust_embed::RustEmbed;
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -29,3 +30,22 @@ impl AssetSource for Assets {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
futures.workspace = true
|
||||
ureq.workspace = true
|
||||
|
||||
gpui-updater-pre = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest_client.workspace = true
|
||||
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
use gpui_updater_pre::{EngineConfig, UpdateStatus, Updater, Verification, Version};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||
use instant::Duration;
|
||||
|
||||
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||
|
||||
mod source;
|
||||
|
||||
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
|
||||
|
||||
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
||||
const GITEA_REPO_OWNER: &str = "reya";
|
||||
const GITEA_REPO_NAME: &str = "coop";
|
||||
@@ -27,11 +31,11 @@ fn uses_managed_updates() -> bool {
|
||||
// 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")
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
pub fn init(cx: &mut App) {
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||
@@ -56,7 +60,10 @@ pub fn init(cx: &mut App) {
|
||||
return;
|
||||
};
|
||||
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
|
||||
AutoUpdater::set_global(
|
||||
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
@@ -64,86 +71,69 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
pub struct AutoUpdater {
|
||||
updater: Entity<Updater>,
|
||||
/// The blocking engine, driven on the background executor.
|
||||
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||
status: UpdateStatus,
|
||||
/// The pending revert of an `Errored` status back to `Idle`, if any.
|
||||
error_revert: Option<Task<()>>,
|
||||
/// The newer release found by the last successful check, if any.
|
||||
available: Option<Release>,
|
||||
/// Currently running app version.
|
||||
pub version: Version,
|
||||
/// The in-flight check or download, if any.
|
||||
task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Whether auto-update is available for this installation.
|
||||
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.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
}
|
||||
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
||||
let source = GiteaSource::new(
|
||||
GITEA_API_BASE,
|
||||
GITEA_REPO_OWNER,
|
||||
GITEA_REPO_NAME,
|
||||
filter,
|
||||
cx.http_client(),
|
||||
);
|
||||
let config = EngineConfig::new(version).verification(Verification::Checksum);
|
||||
let updater = cx.new(|cx| Updater::new(source, config, cx));
|
||||
|
||||
cx.observe(&updater, |this, _, cx| this.observe_status(cx))
|
||||
.detach();
|
||||
fn new(
|
||||
window: &mut Window,
|
||||
version: Version,
|
||||
filter: AssetFilter,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay
|
||||
let entity = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
cx.spawn(async move |cx| {
|
||||
cx.defer_in(window, |_this, _window, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||
entity.update(cx, |this, cx| this.check(cx)).ok();
|
||||
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Self {
|
||||
updater,
|
||||
engine,
|
||||
status: UpdateStatus::Idle,
|
||||
error_revert: None,
|
||||
available: None,
|
||||
version,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_status(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.updater.read(cx).status().clone();
|
||||
self.status = status.clone();
|
||||
|
||||
self.error_revert = if matches!(status, UpdateStatus::Errored(_)) {
|
||||
Some(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||
this.update(cx, |this, cx| {
|
||||
if matches!(this.status, UpdateStatus::Errored(_)) {
|
||||
this.status = UpdateStatus::Idle;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// The adapter stops at `Available`; continue into the download.
|
||||
if matches!(status, UpdateStatus::Available(_)) {
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
updater.download_and_install(cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Whether the running version is the newest release, so the status line can be hidden.
|
||||
pub fn up_to_date(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
||||
/// Whether nothing is happening, so the UI can hide the status line.
|
||||
pub fn idle(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Idle)
|
||||
}
|
||||
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
@@ -175,9 +165,136 @@ impl AutoUpdater {
|
||||
|
||||
/// Check the release host for a newer version, then download and install it.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
if self.status.is_busy() {
|
||||
return;
|
||||
}
|
||||
self.set_status(UpdateStatus::Checking, cx);
|
||||
|
||||
let engine = self.engine.clone();
|
||||
|
||||
self.task = Some(cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.spawn(async move { engine.check() })
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
match result {
|
||||
Ok(Some(release)) => {
|
||||
log::info!("Update {} is available", release.version);
|
||||
let version = release.version.clone();
|
||||
this.available = Some(release);
|
||||
this.set_status(UpdateStatus::Available(version), cx);
|
||||
this.download_and_install(cx);
|
||||
}
|
||||
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
||||
Err(error) => {
|
||||
log::warn!("Update check failed: {error}");
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Download the available update, verify it, and swap it into place.
|
||||
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||
if self.status.is_busy() {
|
||||
return;
|
||||
}
|
||||
let Some(release) = self.available.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let engine = self.engine.clone();
|
||||
self.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
self.task = Some(cx.spawn(async move |this, cx| {
|
||||
let downloaded = Arc::new(AtomicU64::new(0));
|
||||
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let download_task = {
|
||||
let (engine, release) = (engine.clone(), release.clone());
|
||||
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||
cx.background_executor().spawn(async move {
|
||||
let result = engine.download(&release, |got, expected| {
|
||||
downloaded.store(got, Ordering::Relaxed);
|
||||
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||
});
|
||||
done.store(true, Ordering::Relaxed);
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
loop {
|
||||
let got = downloaded.load(Ordering::Relaxed);
|
||||
let total = total.load(Ordering::Relaxed);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: got,
|
||||
total: (total != 0).then_some(total),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
if done.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(120))
|
||||
.await;
|
||||
}
|
||||
|
||||
let artifact = match download_task.await {
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
log::warn!("Update download failed: {error}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
||||
|
||||
let installed = {
|
||||
let engine = engine.clone();
|
||||
cx.background_executor()
|
||||
.spawn(async move { engine.install(&artifact) })
|
||||
.await
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
match installed {
|
||||
Ok(installed) => {
|
||||
if let Some(path) = installed.restart_path {
|
||||
cx.set_restart_path(path);
|
||||
}
|
||||
let version = release.version.clone();
|
||||
this.set_status(UpdateStatus::Staged(version), cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Relaunch into the staged update.
|
||||
@@ -186,8 +303,24 @@ impl AutoUpdater {
|
||||
log::warn!("Ignoring restart request: no update is staged");
|
||||
return;
|
||||
}
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
updater.restart(cx);
|
||||
});
|
||||
cx.restart();
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||
self.status = status;
|
||||
|
||||
if errored {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(UpdateStatus::Idle, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::AsyncReadExt;
|
||||
use futures::executor::block_on;
|
||||
use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, RedirectPolicy, Request};
|
||||
use gpui_updater_pre::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||
const RELEASE_PAGE_SIZE: usize = 20;
|
||||
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||
/// Deadline for the complete metadata request, including its body.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Which published artifact belongs to a target platform.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -49,38 +39,12 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||
Some(AssetFilter { extension, arch })
|
||||
}
|
||||
|
||||
/// A release as returned by the Gitea API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<GiteaAsset>,
|
||||
}
|
||||
|
||||
/// A release asset as returned by the Gitea API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Reads releases from a Gitea repository's Releases.
|
||||
pub struct GiteaSource {
|
||||
api_base: String,
|
||||
owner: String,
|
||||
repo: String,
|
||||
filter: AssetFilter,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
}
|
||||
|
||||
impl GiteaSource {
|
||||
@@ -91,14 +55,12 @@ impl GiteaSource {
|
||||
owner: impl Into<String>,
|
||||
repo: impl Into<String>,
|
||||
filter: AssetFilter,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
filter,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,49 +70,11 @@ impl GiteaSource {
|
||||
self.api_base, self.owner, self.repo
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
|
||||
let request = Request::get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.follow_redirects(RedirectPolicy::FollowAll)
|
||||
.body(AsyncBody::empty())
|
||||
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||
|
||||
let mut response = self
|
||||
.http_client
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(Error::Http(format!("GET {url} -> {status}")));
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
|
||||
let body = self.get_bytes(url).await?;
|
||||
serde_json::from_slice(&body).map_err(|error| Error::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
async fn get_string(&self, url: &str) -> Result<String> {
|
||||
let body = self.get_bytes(url).await?;
|
||||
String::from_utf8(body).map_err(|error| Error::Parse(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateSource for GiteaSource {
|
||||
fn fetch_latest(&self) -> Result<Release> {
|
||||
let releases: Vec<GiteaRelease> = block_on(self.get_json(&self.releases_url()))?;
|
||||
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
||||
let release = newest_published(&releases)
|
||||
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||
|
||||
@@ -168,27 +92,26 @@ impl UpdateSource for GiteaSource {
|
||||
.assets
|
||||
.iter()
|
||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||
.map(|sums| block_on(self.get_string(&sums.browser_download_url)))
|
||||
.map(|sums| http::get_string(&sums.browser_download_url))
|
||||
.transpose()?
|
||||
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||
|
||||
let notes = release
|
||||
.body
|
||||
.clone()
|
||||
.filter(|body| !body.trim().is_empty())
|
||||
.or_else(|| release.name.clone());
|
||||
|
||||
let mut update = Release::new(
|
||||
parse_tag(&release.tag_name)?,
|
||||
Asset::new(&asset.name, &asset.browser_download_url, asset.size),
|
||||
);
|
||||
if let Some(notes) = notes {
|
||||
update = update.with_notes(notes);
|
||||
}
|
||||
if let Some(sha256) = sha256 {
|
||||
update = update.with_sha256(sha256);
|
||||
}
|
||||
Ok(update)
|
||||
Ok(Release {
|
||||
version: parse_tag(&release.tag_name)?,
|
||||
notes: release
|
||||
.body
|
||||
.clone()
|
||||
.filter(|body| !body.trim().is_empty())
|
||||
.or_else(|| release.name.clone()),
|
||||
asset: Asset {
|
||||
name: asset.name.clone(),
|
||||
url: asset.browser_download_url.clone(),
|
||||
size: asset.size,
|
||||
},
|
||||
signature: None,
|
||||
signature_url: None,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,9 +139,83 @@ fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A release as returned by the Gitea API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<GiteaAsset>,
|
||||
}
|
||||
|
||||
/// A release asset as returned by the Gitea API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Blocking HTTP helpers for release metadata.
|
||||
mod http {
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_updater_core::{Error, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use ureq::Agent;
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
|
||||
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn agent() -> Agent {
|
||||
Agent::config_builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.tls_config(
|
||||
TlsConfig::builder()
|
||||
.root_certs(RootCerts::PlatformVerifier)
|
||||
.build(),
|
||||
)
|
||||
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
||||
let mut response = agent().get(url).call().map_err(|error| match error {
|
||||
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
||||
other => Error::Http(other.to_string()),
|
||||
})?;
|
||||
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_vec()
|
||||
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
||||
}
|
||||
|
||||
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
||||
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn get_string(url: &str) -> Result<String> {
|
||||
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui_updater_pre::Version;
|
||||
use gpui_updater_core::Version;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -347,13 +344,7 @@ abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
||||
fn live_release_source_resolves_the_running_platform() {
|
||||
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||
.expect("this platform should be supported");
|
||||
let source = GiteaSource::new(
|
||||
"https://git.reya.info/api/v1",
|
||||
"reya",
|
||||
"coop",
|
||||
filter,
|
||||
Arc::new(reqwest_client::ReqwestClient::new()),
|
||||
);
|
||||
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
||||
|
||||
let release = source
|
||||
.fetch_latest()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -49,6 +49,8 @@ impl Message {
|
||||
enum RequestMethod {
|
||||
GetPublicKey,
|
||||
SignEvent,
|
||||
Nip04Encrypt,
|
||||
Nip04Decrypt,
|
||||
Nip44Encrypt,
|
||||
Nip44Decrypt,
|
||||
}
|
||||
@@ -58,6 +60,8 @@ impl RequestMethod {
|
||||
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",
|
||||
}
|
||||
@@ -201,6 +205,36 @@ impl Default for BrowserSignerProxyOptions {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -221,8 +255,14 @@ impl BrowserSignerProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if there is an open browser tab ready to respond
|
||||
/// to requests by verifying the time since the last pending request.
|
||||
/// 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
|
||||
@@ -379,6 +419,20 @@ impl BrowserSignerProxy {
|
||||
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);
|
||||
@@ -417,6 +471,26 @@ impl AsyncSignEvent for BrowserSignerProxy {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
nip4e = { path = "../nip4e" }
|
||||
device = { path = "../device" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
@@ -17,7 +17,6 @@ instant.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
rand.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
|
||||
@@ -9,30 +9,25 @@ use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
WeakEntity, Window,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
mod search;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
pub use search::SearchMessage;
|
||||
pub use state::FileAttachment;
|
||||
|
||||
use crate::search::MessageSearchIndex;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
@@ -42,8 +37,15 @@ impl Global for GlobalChatRegistry {}
|
||||
/// Chat event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ChatEvent {
|
||||
/// An event to open a room by its ID
|
||||
OpenRoom(u64),
|
||||
/// An event to close a room by its ID
|
||||
CloseRoom(u64),
|
||||
/// An event to notify UI about a new chat request
|
||||
Ping,
|
||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
||||
InboxRelayNotFound,
|
||||
/// An error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -78,30 +80,41 @@ impl Signal {
|
||||
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>>,
|
||||
|
||||
/// Tracking events seen on which relays in the current session
|
||||
seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
|
||||
|
||||
/// Mapping of unwrapped event ids to their gift wrap event ids
|
||||
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
|
||||
/// In-memory message content index for search
|
||||
search: Arc<RwLock<MessageSearchIndex>>,
|
||||
|
||||
/// True while the initial event backlog is still loading
|
||||
tracking: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
|
||||
/// Channel for receiving signals from the UI.
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
/// Notification listener task (cancelled on signer change)
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// 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]>,
|
||||
}
|
||||
@@ -137,36 +150,26 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer event
|
||||
cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&device, |this, _device, event, cx| {
|
||||
if matches!(event, DeviceEvent::Set) {
|
||||
this.handle_notifications(cx);
|
||||
this.get_messages(cx);
|
||||
this.reprocess_trash(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -175,7 +178,6 @@ impl ChatRegistry {
|
||||
trash: cx.new(|_| BTreeSet::default()),
|
||||
seen: Arc::new(RwLock::new(HashMap::default())),
|
||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||
search: Arc::default(),
|
||||
tracking: Arc::new(AtomicBool::new(true)),
|
||||
matcher: CachedMatcher(SkimMatcherV2::default()),
|
||||
signal_rx: rx,
|
||||
@@ -197,9 +199,6 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
let seen = self.seen.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trash = self.trash.downgrade();
|
||||
@@ -222,26 +221,11 @@ impl ChatRegistry {
|
||||
};
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
..
|
||||
} => {
|
||||
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
|
||||
let device_sub = subscription_id.as_str() != sub_id2.as_str();
|
||||
|
||||
// Concord wraps are also kind 1059.
|
||||
//
|
||||
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
|
||||
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
|
||||
continue;
|
||||
}
|
||||
|
||||
RelayMessage::Event { event, .. } => {
|
||||
// Prune the dedup set before it grows unbounded
|
||||
if processed_events.len() >= MAX_PROCESSED {
|
||||
processed_events.clear();
|
||||
}
|
||||
|
||||
if !processed_events.insert(event.id) {
|
||||
continue;
|
||||
}
|
||||
@@ -266,14 +250,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(
|
||||
&client,
|
||||
&signer,
|
||||
device_signer.as_ref(),
|
||||
event.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
Ok(rumor) => {
|
||||
let Some(rumor_id) = rumor.id else {
|
||||
log::error!("Rumor missing id after ensure_id");
|
||||
@@ -409,9 +386,6 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -444,20 +418,6 @@ impl ChatRegistry {
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
// Also pick up gift wraps addressed to this account's decoupled encryption key
|
||||
if let Some(device_signer) = device_signer.as_ref() {
|
||||
let device_public_key = device_signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_public_key);
|
||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
|
||||
let target: HashMap<RelayUrl, Filter> = nip17::extract_relay_list(&event)
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -471,49 +431,6 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Retry unwrapping trashed gift wraps with the newly available encryption key.
|
||||
fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let device_signer = DeviceRegistry::global(cx).read(cx).signer(cx);
|
||||
|
||||
let failed: Vec<FailedMessage> = self.trash.read(cx).iter().cloned().collect();
|
||||
|
||||
if failed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
for message in failed {
|
||||
let Ok(event) = Event::from_json(message.raw_event.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if extract_rumor(&client, &signer, device_signer.as_ref(), &event)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(rumor) = get_rumor(&client, event.id).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.trash.update(cx, |trash, cx| {
|
||||
trash.remove(&message);
|
||||
cx.notify();
|
||||
});
|
||||
this.new_message(NewMessage::new(event.id, rumor), cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reload the chat registry, fetching messages and contact list from relays.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
@@ -548,6 +465,26 @@ impl ChatRegistry {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of messages seen by a given relay.
|
||||
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
|
||||
self.seen
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|s| s.contains(relay_url))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of trash messages.
|
||||
pub fn count_trash_messages(&self, cx: &App) -> usize {
|
||||
self.trash.read(cx).len()
|
||||
}
|
||||
|
||||
/// Get the trash messages entity.
|
||||
pub fn trash(&self) -> Entity<BTreeSet<FailedMessage>> {
|
||||
self.trash.clone()
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given rumor id.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
self.event_map
|
||||
@@ -558,7 +495,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given gift wrap id.
|
||||
pub(crate) fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
self.seen
|
||||
.read()
|
||||
.unwrap()
|
||||
@@ -568,7 +505,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub(crate) fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room>,
|
||||
{
|
||||
@@ -580,7 +517,6 @@ impl ChatRegistry {
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
let room_id = room.id;
|
||||
let entity = cx.new(|_| room);
|
||||
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
||||
|
||||
self.room_index.insert(room_id, entity.clone());
|
||||
self.rooms.insert(0, entity);
|
||||
@@ -589,21 +525,39 @@ impl ChatRegistry {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Track a room so it is listed and can be looked up by id.
|
||||
pub fn track_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||
/// 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>) {
|
||||
// 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) {
|
||||
room.update(cx, |room, cx| room.ensure_directory(cx));
|
||||
e.insert(room.clone());
|
||||
let entity = room.to_owned();
|
||||
e.insert(entity.clone());
|
||||
self.rooms.insert(0, entity);
|
||||
}
|
||||
|
||||
self.rooms.insert(0, room.clone());
|
||||
cx.notify();
|
||||
// Emit the open room event deferred to avoid re-entrant reads
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort rooms by their created at. Only notifies if order changed.
|
||||
pub(crate) fn sort(&mut self, cx: &mut Context<Self>) {
|
||||
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();
|
||||
@@ -633,16 +587,10 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the content of all indexed messages, newest first.
|
||||
pub fn search_messages(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
|
||||
self.search.read().unwrap().search(terms, limit)
|
||||
}
|
||||
|
||||
/// Reset the registry.
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.clear();
|
||||
self.room_index.clear();
|
||||
self.search.write().unwrap().clear();
|
||||
self.trash.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
@@ -671,7 +619,6 @@ impl ChatRegistry {
|
||||
} else {
|
||||
let new_room_id = new_room.id;
|
||||
let entity = cx.new(|_| new_room);
|
||||
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
||||
self.room_index.insert(new_room_id, entity.clone());
|
||||
self.rooms.push(entity);
|
||||
|
||||
@@ -682,7 +629,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub(crate) fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.query_chat_rooms(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -709,7 +656,6 @@ impl ChatRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let search = self.search.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -741,7 +687,6 @@ impl ChatRegistry {
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||
&& rumor.tags.public_keys().next().is_some()
|
||||
{
|
||||
search.write().unwrap().insert(&rumor);
|
||||
if rumor.pubkey != public_key
|
||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
||||
{
|
||||
@@ -777,9 +722,7 @@ impl ChatRegistry {
|
||||
///
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
self.search.write().unwrap().insert(&message.rumor);
|
||||
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
@@ -802,33 +745,32 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor<S, D>(
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
device_signer: Option<&D>,
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
D: AsyncNip44 + ?Sized,
|
||||
{
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
// Try to get cached rumor first
|
||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(rumor);
|
||||
}
|
||||
|
||||
let unwrapped = match try_unwrap_with(signer, gift_wrap).await {
|
||||
Ok(unwrapped) => unwrapped,
|
||||
Err(error) => {
|
||||
let Some(device_signer) = device_signer else {
|
||||
return Err(error);
|
||||
};
|
||||
try_unwrap_with(device_signer, gift_wrap).await?
|
||||
}
|
||||
};
|
||||
// Try to unwrap with the available signer
|
||||
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)
|
||||
@@ -848,10 +790,10 @@ where
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
async fn try_unwrap_with(
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
@@ -861,17 +803,9 @@ where
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify()?;
|
||||
|
||||
let sender_encryption_key = seal
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.unwrap_or(seal.pubkey);
|
||||
|
||||
// Get the rumor event
|
||||
let rumor = signer
|
||||
.nip44_decrypt_async(&sender_encryption_key, &seal.content)
|
||||
.nip44_decrypt_async(&seal.pubkey, &seal.content)
|
||||
.await?;
|
||||
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use std::hash::Hash;
|
||||
use std::ops::Range;
|
||||
|
||||
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||
const NEVENT_PREFIX: &str = "nevent1";
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -25,8 +22,6 @@ pub struct Message {
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<Mention>,
|
||||
/// Custom emoji (NIP-30) declared by this message's `emoji` tags
|
||||
pub emojis: Vec<(String, Url)>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
/// Encrypted file attachment
|
||||
@@ -90,7 +85,6 @@ fn from_parts(
|
||||
let has_file = file.is_some();
|
||||
|
||||
let replies_to = extract_reply_ids(tags);
|
||||
let emojis = emojis_from_tags(tags);
|
||||
|
||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||
let mentions = if has_file {
|
||||
@@ -112,7 +106,6 @@ fn from_parts(
|
||||
media,
|
||||
created_at,
|
||||
mentions,
|
||||
emojis,
|
||||
replies_to,
|
||||
file,
|
||||
}
|
||||
@@ -151,40 +144,7 @@ impl Message {
|
||||
return format!("[File] {}", file.display_name()).into();
|
||||
}
|
||||
|
||||
// Media-only messages carry their URLs outside `.content`
|
||||
if self.content.is_empty() && !self.media.is_empty() {
|
||||
return self
|
||||
.media
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.into();
|
||||
}
|
||||
|
||||
let references = nevent_refs(&self.content);
|
||||
if references.is_empty() {
|
||||
return self.content.clone().into();
|
||||
}
|
||||
|
||||
let mut preview = String::with_capacity(self.content.len());
|
||||
let mut last = 0;
|
||||
|
||||
for reference in references {
|
||||
preview.push_str(&self.content[last..reference.range.start]);
|
||||
preview.push_str(&reference.label);
|
||||
last = reference.range.end;
|
||||
}
|
||||
preview.push_str(&self.content[last..]);
|
||||
|
||||
preview.into()
|
||||
}
|
||||
|
||||
/// Image URL of a `:shortcode:` reaction, if the shortcode is declared
|
||||
pub fn reaction_image(content: &str, emojis: &[(String, Url)]) -> Option<Url> {
|
||||
let shortcode = content.strip_prefix(':')?.strip_suffix(':')?;
|
||||
let (_, url) = emojis.iter().find(|(name, _)| name == shortcode)?;
|
||||
Some(url.clone())
|
||||
self.content.clone().into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,26 +152,31 @@ impl Message {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
pub room: u64,
|
||||
pub gift_wrap: EventId,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn new(_gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
let room = rumor.uniq_id();
|
||||
|
||||
Self { room, rumor }
|
||||
Self {
|
||||
room,
|
||||
gift_wrap,
|
||||
rumor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trash message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct FailedMessage {
|
||||
pub struct FailedMessage {
|
||||
pub raw_event: SharedString,
|
||||
pub reason: SharedString,
|
||||
}
|
||||
|
||||
impl FailedMessage {
|
||||
pub(crate) fn new<T>(event: &Event, reason: T) -> Self
|
||||
pub fn new<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
@@ -229,7 +194,7 @@ pub struct Mention {
|
||||
}
|
||||
|
||||
impl Mention {
|
||||
pub(crate) fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
Self { public_key, range }
|
||||
}
|
||||
}
|
||||
@@ -248,61 +213,6 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `nevent` reference found in a content string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NeventRef {
|
||||
/// Range of the reference, including the optional `nostr:` scheme
|
||||
pub range: Range<usize>,
|
||||
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
||||
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
||||
let mut references = Vec::new();
|
||||
|
||||
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
||||
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
||||
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
||||
match_start - SCHEME_WITH_COLON.len()
|
||||
} else {
|
||||
match_start
|
||||
};
|
||||
|
||||
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
||||
if content[..start].ends_with(char::is_alphanumeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = match_start
|
||||
+ content[match_start..]
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_alphanumeric)
|
||||
.count();
|
||||
|
||||
// The bech32 token excludes the optional `nostr:` scheme
|
||||
let bech32 = &content[match_start..end];
|
||||
if Nip19Event::from_bech32(bech32).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
references.push(NeventRef {
|
||||
range: start..end,
|
||||
label: nevent_label(bech32),
|
||||
});
|
||||
}
|
||||
|
||||
references
|
||||
}
|
||||
|
||||
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
||||
fn nevent_label(bech32: &str) -> String {
|
||||
match bech32.len() {
|
||||
0..=4 => format!("https://njump.me/{bech32}"),
|
||||
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all reply (ids) from the event tags.
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
let mut replies_to = vec![];
|
||||
@@ -321,78 +231,3 @@ fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
|
||||
replies_to
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rumor(kind: Kind, content: &str, tags: Tags) -> UnsignedEvent {
|
||||
let sender = Keys::generate();
|
||||
|
||||
let mut rumor = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender.public_key());
|
||||
rumor.ensure_id();
|
||||
rumor
|
||||
}
|
||||
|
||||
fn emoji_tag(shortcode: &str, url: &str) -> Tag {
|
||||
Tag::parse(vec!["emoji", shortcode, url]).expect("valid emoji tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_parses_emoji_tags() {
|
||||
let tags = Tags::from_list(vec![emoji_tag(
|
||||
"soapbox",
|
||||
"https://example.com/soapbox.png",
|
||||
)]);
|
||||
|
||||
let message = Message::from(&rumor(Kind::PrivateDirectMessage, "hi :soapbox:", tags));
|
||||
|
||||
assert_eq!(message.emojis.len(), 1);
|
||||
assert_eq!(message.emojis[0].0, "soapbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaction_image_matches_declared_shortcode() {
|
||||
let emojis = vec![(
|
||||
String::from("soapbox"),
|
||||
Url::parse("https://example.com/soapbox.png").unwrap(),
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
Message::reaction_image(":soapbox:", &emojis),
|
||||
Some(Url::parse("https://example.com/soapbox.png").unwrap())
|
||||
);
|
||||
assert_eq!(Message::reaction_image("😂", &emojis), None);
|
||||
assert_eq!(Message::reaction_image(":unknown:", &emojis), None);
|
||||
}
|
||||
|
||||
fn valid_nevent() -> String {
|
||||
Nip19Event::new(EventId::from_byte_array([7; 32]))
|
||||
.to_bech32()
|
||||
.expect("valid nevent")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_only_messages_preview_their_url() {
|
||||
let url = "https://example.com/picture.png";
|
||||
let message = Message::from(&rumor(Kind::PrivateDirectMessage, url, Tags::new()));
|
||||
|
||||
assert!(message.content.is_empty());
|
||||
assert_eq!(message.preview().to_string(), url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_compacts_nevent_references() {
|
||||
let nevent = valid_nevent();
|
||||
let message = Message::from(&rumor(
|
||||
Kind::PrivateDirectMessage,
|
||||
&format!("see nostr:{nevent}"),
|
||||
Tags::new(),
|
||||
));
|
||||
|
||||
let label = format!("https://njump.me/nevent1...{}", &nevent[nevent.len() - 4..]);
|
||||
assert_eq!(message.preview().to_string(), format!("see {label}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,53 +3,100 @@ use std::hash::{Hash, Hasher};
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task};
|
||||
use device::DeviceRegistry;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use instant::Duration;
|
||||
use itertools::Itertools;
|
||||
use nip4e::DeviceRegistry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person};
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, 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 yet. Set one up in the settings to message them.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||
|
||||
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
/// The receiver the rumor was addressed to.
|
||||
pub receiver: PublicKey,
|
||||
/// The per-relay send output, or the error that prevented the send.
|
||||
result: Result<Output<EventId, EventSendStatus>, SharedString>,
|
||||
pub gift_wrap_id: Option<EventId>,
|
||||
pub error: Option<SharedString>,
|
||||
pub output: Option<Output<EventId, EventSendStatus>>,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
/// The error that prevented the send, if any.
|
||||
pub fn error(&self) -> Option<&SharedString> {
|
||||
self.result.as_ref().err()
|
||||
}
|
||||
|
||||
/// The per-relay send output, if the gift wrap was dispatched.
|
||||
pub fn output(&self) -> Option<&Output<EventId, EventSendStatus>> {
|
||||
self.result.as_ref().ok()
|
||||
}
|
||||
|
||||
/// Whether at least one relay accepted the event.
|
||||
pub fn success(&self) -> bool {
|
||||
self.output()
|
||||
.is_some_and(|output| !output.success.is_empty())
|
||||
}
|
||||
|
||||
/// Whether no relay accepted the event.
|
||||
pub fn failed(&self) -> bool {
|
||||
match &self.result {
|
||||
Err(_) => true,
|
||||
Ok(output) => output.success.is_empty(),
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
gift_wrap_id: None,
|
||||
error: None,
|
||||
output: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the gift wrap ID.
|
||||
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
|
||||
self.gift_wrap_id = Some(gift_wrap_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the output.
|
||||
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
|
||||
self.output = Some(output);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the error message.
|
||||
pub fn error<T>(mut self, error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns true if the send is pending.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.error.is_none()
|
||||
&& self
|
||||
.output
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send was successful.
|
||||
pub fn success(&self) -> bool {
|
||||
self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send failed.
|
||||
pub fn failed(&self) -> bool {
|
||||
self.error.is_some() && self.output.as_ref().is_some_and(|o| !o.failed.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SendStatus {
|
||||
Ok {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
},
|
||||
Failed {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SendStatus {
|
||||
pub fn ok(id: EventId, relay: RelayUrl) -> Self {
|
||||
Self::Ok { id, relay }
|
||||
}
|
||||
|
||||
pub fn failed(id: EventId, relay: RelayUrl, message: String) -> Self {
|
||||
Self::Failed { id, relay, message }
|
||||
}
|
||||
}
|
||||
|
||||
/// Room event.
|
||||
@@ -78,7 +125,7 @@ pub struct Room {
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// Subject of the room
|
||||
pub(crate) subject: Option<SharedString>,
|
||||
pub subject: Option<SharedString>,
|
||||
|
||||
/// All members of the room
|
||||
pub(super) members: Vec<PublicKey>,
|
||||
@@ -88,9 +135,6 @@ pub struct Room {
|
||||
|
||||
/// Configuration
|
||||
config: RoomConfig,
|
||||
|
||||
/// Person directory, attached when the room is registered
|
||||
directory: Option<Entity<Directory>>,
|
||||
}
|
||||
|
||||
impl Ord for Room {
|
||||
@@ -139,7 +183,6 @@ impl From<&UnsignedEvent> for Room {
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
config: RoomConfig::new(),
|
||||
directory: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,16 +233,13 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
pub(crate) fn set_created_at<T>(&mut self, created_at: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<Timestamp>,
|
||||
{
|
||||
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
|
||||
self.created_at = created_at.into();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -240,32 +280,6 @@ impl Room {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// The room's person directory, attached when the room is registered
|
||||
pub fn directory(&self) -> Option<Entity<Directory>> {
|
||||
self.directory.clone()
|
||||
}
|
||||
|
||||
/// Attach the person directory, tracking the room's members
|
||||
pub(crate) fn ensure_directory(&mut self, cx: &mut Context<Self>) {
|
||||
if self.directory.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
let members = self.members.clone();
|
||||
directory.update(cx, |directory, cx| directory.track(members, cx));
|
||||
self.directory = Some(directory);
|
||||
}
|
||||
|
||||
/// The person for a room member
|
||||
fn member(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
let Some(directory) = self.directory.as_ref() else {
|
||||
return Person::new(*public_key, Metadata::default());
|
||||
};
|
||||
|
||||
directory.read(cx).person(public_key, cx)
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(value) = self.subject.clone() {
|
||||
@@ -275,21 +289,12 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display picture for the room, if it has one
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if self.is_group() {
|
||||
None
|
||||
} else {
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable seed for the room's generated avatar
|
||||
pub fn display_image_seed(&self, cx: &App) -> String {
|
||||
if self.is_group() {
|
||||
self.id.to_string()
|
||||
} else {
|
||||
self.display_member(cx).avatar_seed()
|
||||
SharedString::from("brand/group.png")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,16 +302,19 @@ impl Room {
|
||||
///
|
||||
/// Display member is always different from the current user.
|
||||
pub fn display_member(&self, cx: &App) -> Person {
|
||||
self.member(&self.members[0], cx)
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.members[0], cx)
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
fn merged_name(&self, cx: &App) -> SharedString {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
if self.is_group() {
|
||||
let profiles: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|public_key| self.member(public_key, cx))
|
||||
.map(|public_key| persons.read(cx).get(public_key, cx))
|
||||
.collect();
|
||||
|
||||
let mut name = profiles
|
||||
@@ -327,7 +335,7 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Push a new message to the current room
|
||||
pub(crate) fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let created_at = message.rumor.created_at;
|
||||
let new_message = created_at > self.created_at;
|
||||
|
||||
@@ -339,6 +347,11 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a signal to reload the current room's messages.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomEvent::Reload);
|
||||
}
|
||||
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -431,13 +444,9 @@ impl Room {
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct a direct message rumor event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(self.member(&sender, cx).emoji_tags(&content));
|
||||
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.tags(self.conversation_tags(&replies, sender, cx))
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
@@ -474,6 +483,8 @@ impl Room {
|
||||
|
||||
// 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);
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
@@ -489,7 +500,7 @@ impl Room {
|
||||
|
||||
// Add all receiver tags (no intermediate allocation)
|
||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||
let member = self.member(public_key, cx);
|
||||
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(),
|
||||
@@ -499,7 +510,31 @@ impl Room {
|
||||
tags
|
||||
}
|
||||
|
||||
/// Send the rumor to every member's messaging relays.
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
fn select_signer(
|
||||
signer_kind: &SignerKind,
|
||||
has_announcement: bool,
|
||||
encryption_signer: &Option<UniversalSigner>,
|
||||
user_signer: &UniversalSigner,
|
||||
) -> UniversalSigner {
|
||||
match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if has_announcement {
|
||||
encryption_signer
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_signer.clone())
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => encryption_signer
|
||||
.clone()
|
||||
.expect("encryption signer must be set"),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let config = self.config.clone();
|
||||
|
||||
@@ -512,47 +547,84 @@ impl Room {
|
||||
let current_user = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get sender's profile
|
||||
let sender = self.member(¤t_user, cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let sender = persons.read(cx).get(¤t_user, cx);
|
||||
|
||||
// Get all members (excluding sender)
|
||||
let receivers: Vec<Person> = self
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender.public_key())
|
||||
.map(|member| self.member(member, cx))
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.collect();
|
||||
|
||||
Some(cx.background_spawn(async move {
|
||||
let signer_kind = config.signer_kind();
|
||||
let backup = config.backup();
|
||||
|
||||
// Deliver to every receiver concurrently
|
||||
let sends = receivers.into_iter().map(|receiver| {
|
||||
deliver(
|
||||
&client,
|
||||
let mut sents = 0;
|
||||
let mut reports = Vec::new();
|
||||
|
||||
// Process each member
|
||||
for member in members {
|
||||
let announcement = member.announcement();
|
||||
let public_key = member.public_key();
|
||||
|
||||
// Handle encryption signer requirements
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = Self::select_signer(
|
||||
signer_kind,
|
||||
receiver,
|
||||
&rumor,
|
||||
encryption_signer.as_ref(),
|
||||
announcement.is_some(),
|
||||
&encryption_signer,
|
||||
&user_signer,
|
||||
)
|
||||
});
|
||||
|
||||
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await;
|
||||
let minimum_success = reports.iter().any(SendReport::success);
|
||||
|
||||
// Back up the message to ourselves once at least one receiver got it
|
||||
if config.backup() && minimum_success {
|
||||
reports.push(
|
||||
deliver(
|
||||
&client,
|
||||
signer_kind,
|
||||
sender,
|
||||
&rumor,
|
||||
encryption_signer.as_ref(),
|
||||
&user_signer,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||
Ok(report) => {
|
||||
reports.push(report);
|
||||
sents += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send backup to current user if needed
|
||||
if backup && sents >= 1 {
|
||||
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,
|
||||
);
|
||||
|
||||
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reports
|
||||
@@ -560,151 +632,55 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver a rumor to a single receiver.
|
||||
async fn deliver<S>(
|
||||
client: &Client,
|
||||
signer_kind: &SignerKind,
|
||||
receiver: Person,
|
||||
rumor: &UnsignedEvent,
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> SendReport
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
let public_key = receiver.public_key();
|
||||
let announcement = receiver.announcement();
|
||||
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
return SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(NO_DEKEY.into()),
|
||||
};
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
return SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(USER_NO_DEKEY.into()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match send_gift_wrap(
|
||||
client,
|
||||
&receiver,
|
||||
rumor,
|
||||
signer_kind,
|
||||
encryption_signer,
|
||||
user_signer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Ok(output),
|
||||
},
|
||||
Err(error) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(error.to_string().into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the gift-wrapped event for a rumor and send it.
|
||||
async fn send_gift_wrap<S>(
|
||||
// Helper function to send a gift-wrapped event
|
||||
async fn send_gift_wrap(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> Result<Output<EventId, EventSendStatus>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
let mut extra_tags = vec![Tag::custom("k", [rumor.kind.to_string()])];
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
|
||||
// Determine the receiver public key based on the config
|
||||
let receiver_key = match config {
|
||||
SignerKind::Auto => match receiver.announcement().as_ref() {
|
||||
Some(announcement) => {
|
||||
let receiver = match config {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
receiver.public_key()
|
||||
}
|
||||
None => receiver.public_key(),
|
||||
},
|
||||
SignerKind::Encryption => match receiver.announcement().as_ref() {
|
||||
Some(announcement) => {
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
return Err(anyhow!("User has no encryption announcement"));
|
||||
}
|
||||
None => return Err(anyhow!("User has no encryption announcement")),
|
||||
},
|
||||
}
|
||||
SignerKind::User => receiver.public_key(),
|
||||
};
|
||||
|
||||
// Make sure the rumor carries an event ID before it's serialized
|
||||
let mut rumor = rumor.clone();
|
||||
rumor.ensure_id();
|
||||
// Construct the gift wrap event
|
||||
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
|
||||
.extra_tags(extra_tags)
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
let rumor_json = rumor.as_json();
|
||||
let has_encryption_announcement = receiver.announcement().is_some();
|
||||
|
||||
let seal: Event = match (encryption_signer, has_encryption_announcement) {
|
||||
(Some(encryption_signer), true) => {
|
||||
let encryption_pubkey = encryption_signer.get_public_key_async().await?;
|
||||
let content = encryption_signer
|
||||
.nip44_encrypt_async(&receiver_key, &rumor_json)
|
||||
.await?;
|
||||
|
||||
EventBuilder::new(Kind::Seal, content)
|
||||
.tags(vec![Tag::custom("n", [encryption_pubkey.to_hex()])])
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize_async(user_signer)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
let content = user_signer
|
||||
.nip44_encrypt_async(&receiver_key, &rumor_json)
|
||||
.await?;
|
||||
|
||||
EventBuilder::new(Kind::Seal, content)
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize_async(user_signer)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let ephemeral_keys = Keys::generate();
|
||||
let content = nip44::encrypt(
|
||||
ephemeral_keys.secret_key(),
|
||||
&receiver_key,
|
||||
seal.as_json(),
|
||||
nip44::Version::default(),
|
||||
)?;
|
||||
extra_tags.push(Tag::public_key(receiver_key));
|
||||
|
||||
let event = EventBuilder::new(Kind::GiftWrap, content)
|
||||
.tags(extra_tags)
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize(&ephemeral_keys)?;
|
||||
|
||||
// Send to the receiver's NIP-17 relays.
|
||||
client
|
||||
// Send the gift wrap event and collect the report
|
||||
let report = client
|
||||
.send_event(&event)
|
||||
.to_nip17()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
.map(|output| {
|
||||
SendReport::new(receiver)
|
||||
.gift_wrap_id(event.id)
|
||||
.output(output)
|
||||
})?;
|
||||
|
||||
/// A tweaked timestamp to thwart time-analysis attacks, per NIP-59
|
||||
fn tweaked_timestamp() -> Timestamp {
|
||||
const MAX_TWEAK_SECONDS: u64 = 2 * 24 * 60 * 60;
|
||||
|
||||
let seconds = rand::random_range(0..=MAX_TWEAK_SECONDS);
|
||||
Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(seconds))
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::EventExt;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A chat message kept in memory for content search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchMessage {
|
||||
/// ID of the room the message belongs to
|
||||
pub room: u64,
|
||||
/// Author of the message
|
||||
pub author: PublicKey,
|
||||
/// The content of the message
|
||||
pub content: Arc<str>,
|
||||
/// When the message was created
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IndexedMessage {
|
||||
message: SearchMessage,
|
||||
/// Lowercase content so repeated searches don't re-allocate per message
|
||||
normalized: Arc<str>,
|
||||
}
|
||||
|
||||
/// In-memory index of chat message rumors, keyed by room and rumor id.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageSearchIndex(BTreeMap<u64, BTreeMap<EventId, Arc<IndexedMessage>>>);
|
||||
|
||||
impl MessageSearchIndex {
|
||||
/// Index a rumor for search, ignoring reactions and empty content.
|
||||
pub fn insert(&mut self, rumor: &UnsignedEvent) {
|
||||
if !is_chat(rumor.kind) || rumor.content.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(id) = rumor.id else { return };
|
||||
|
||||
let indexed = Arc::new(IndexedMessage {
|
||||
message: SearchMessage {
|
||||
room: rumor.uniq_id(),
|
||||
author: rumor.pubkey,
|
||||
content: rumor.content.as_str().into(),
|
||||
created_at: rumor.created_at,
|
||||
},
|
||||
normalized: rumor.content.to_lowercase().into(),
|
||||
});
|
||||
|
||||
self.0
|
||||
.entry(rumor.uniq_id())
|
||||
.or_default()
|
||||
.entry(id)
|
||||
.or_insert(indexed);
|
||||
}
|
||||
|
||||
/// Find messages whose content contains all of the terms, newest first.
|
||||
pub fn search(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
|
||||
let mut matches: Vec<&Arc<IndexedMessage>> = self
|
||||
.0
|
||||
.values()
|
||||
.flat_map(|messages| messages.values())
|
||||
.filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term)))
|
||||
.collect();
|
||||
|
||||
matches.sort_by_key(|indexed| Reverse(indexed.message.created_at));
|
||||
matches.truncate(limit);
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|indexed| indexed.message.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop all indexed messages.
|
||||
pub fn clear(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chat(kind: Kind) -> bool {
|
||||
kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent {
|
||||
let sender = Keys::generate();
|
||||
let peer = Keys::generate().public_key();
|
||||
|
||||
let mut rumor = EventBuilder::new(kind, content)
|
||||
.tag(Tag::public_key(peer))
|
||||
.finalize_unsigned(sender.public_key());
|
||||
rumor.ensure_id();
|
||||
rumor.created_at = Timestamp::from(created_at);
|
||||
rumor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_all_terms_newest_first_and_deduplicates() {
|
||||
let mut index = MessageSearchIndex::default();
|
||||
let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10);
|
||||
let second = rumor(Kind::PrivateDirectMessage, "hello again", 20);
|
||||
let reaction = rumor(Kind::Reaction, "hello world", 30);
|
||||
|
||||
index.insert(&first);
|
||||
index.insert(&first);
|
||||
index.insert(&second);
|
||||
index.insert(&reaction);
|
||||
|
||||
let hits = index.search(&["hello".to_owned()], 10);
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(&*hits[0].content, "hello again");
|
||||
assert_eq!(hits[0].created_at, Timestamp::from(20));
|
||||
|
||||
let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10);
|
||||
assert_eq!(hits.len(), 1);
|
||||
|
||||
let hits = index.search(&["hello".to_owned()], 1);
|
||||
assert_eq!(hits.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_not_indexed() {
|
||||
let mut index = MessageSearchIndex::default();
|
||||
index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10));
|
||||
|
||||
assert!(index.search(&["hello".to_owned()], 10).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ nostr-sdk.workspace = true
|
||||
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"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use settings::SignerKind;
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub enum Command {
|
||||
Insert(String),
|
||||
Insert(&'static str),
|
||||
ChangeSubject(String),
|
||||
ChangeSigner(SignerKind),
|
||||
ToggleBackup,
|
||||
|
||||
@@ -1,63 +1,488 @@
|
||||
use chat::{Mention, nevent_refs};
|
||||
use gpui::{App, Entity};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use person::Directory;
|
||||
use ui::markdown::InlineReplacement;
|
||||
pub use ui::markdown::RenderedText;
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
pub fn rendered_text(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
emojis: &[(String, Url)],
|
||||
persons: &Entity<Directory>,
|
||||
cx: &App,
|
||||
) -> RenderedText {
|
||||
let mut replacements = mentions
|
||||
.iter()
|
||||
.map(|mention| {
|
||||
InlineReplacement::new(
|
||||
mention.range.clone(),
|
||||
format!(
|
||||
"@{}",
|
||||
persons.read(cx).person(&mention.public_key, cx).name()
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
use chat::Mention;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
replacements.extend(emoji_replacements(content, emojis));
|
||||
replacements.extend(
|
||||
nevent_refs(content)
|
||||
.into_iter()
|
||||
.map(|reference| InlineReplacement::new(reference.range, reference.label)),
|
||||
);
|
||||
replacements.sort_by_key(|replacement| replacement.range.start);
|
||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||
|
||||
RenderedText::new(content, &replacements)
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Highlight(HighlightStyle),
|
||||
Mention,
|
||||
}
|
||||
|
||||
fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec<InlineReplacement> {
|
||||
let mut replacements = Vec::new();
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Highlight(style)
|
||||
}
|
||||
}
|
||||
|
||||
for (shortcode, url) in emojis {
|
||||
let pattern = format!(":{shortcode}:");
|
||||
for (start, _) in content.match_indices(&pattern) {
|
||||
let range = start..start + pattern.len();
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
// Skip matches glued to word characters, like the `30` in `12:30:45`
|
||||
if content[..start].ends_with(char::is_alphanumeric)
|
||||
|| content[range.end..].starts_with(char::is_alphanumeric)
|
||||
{
|
||||
continue;
|
||||
impl RenderedText {
|
||||
pub fn new(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
markdown: bool,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
Self::render(content, mentions, markdown, |mention| {
|
||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
||||
})
|
||||
}
|
||||
|
||||
fn render(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
markdown,
|
||||
resolve_mention,
|
||||
);
|
||||
|
||||
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||
let trimmed_len = text.trim_end().len();
|
||||
|
||||
// Retain highlights and link ranges that are within the trimmed text.
|
||||
if trimmed_len < text.len() {
|
||||
highlights.retain_mut(|(range, _)| {
|
||||
range.end = range.end.min(trimmed_len);
|
||||
range.start < range.end
|
||||
});
|
||||
|
||||
let mut ix = 0;
|
||||
|
||||
while ix < link_ranges.len() {
|
||||
let range = &mut link_ranges[ix];
|
||||
range.end = range.end.min(trimmed_len);
|
||||
if range.start < range.end {
|
||||
ix += 1;
|
||||
} else {
|
||||
link_ranges.remove(ix);
|
||||
link_urls.remove(ix);
|
||||
}
|
||||
}
|
||||
|
||||
replacements.push(InlineReplacement::image(
|
||||
range,
|
||||
shortcode.clone(),
|
||||
url.to_string(),
|
||||
));
|
||||
text.truncate(trimmed_len);
|
||||
}
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
replacements
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let code_background = cx.theme().elevated_surface_background;
|
||||
let color = cx.theme().text_accent;
|
||||
let code_font = if cfg!(target_os = "macos") {
|
||||
"Menlo"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Consolas"
|
||||
} else {
|
||||
"monospace"
|
||||
};
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone())
|
||||
.with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||
|(range, highlight)| match highlight {
|
||||
Highlight::Code | Highlight::InlineCode(_) => {
|
||||
Some((range.clone(), code_font.into()))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if WEB_URL.is_match(url) {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_text_mut(
|
||||
block: &str,
|
||||
mut mentions: &[Mention],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
let mut bold_depth = 0;
|
||||
let mut italic_depth = 0;
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
let mut code_block = false;
|
||||
|
||||
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||
} else {
|
||||
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||
};
|
||||
|
||||
for (event, source_range) in events {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
if code_block {
|
||||
text.push_str(t.as_ref());
|
||||
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||
continue;
|
||||
}
|
||||
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
while let Some(mention) = mentions.first() {
|
||||
if mention.range.start >= source_range.end {
|
||||
break;
|
||||
}
|
||||
|
||||
mentions = &mentions[1..];
|
||||
if mention.range.start < source_range.start
|
||||
|| mention.range.end > source_range.end
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(token) = block.get(mention.range.clone()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mention_start_in_text = last_processed + offset;
|
||||
let mention_end_in_text = mention_start_in_text + token.len();
|
||||
|
||||
// Add text before this mention
|
||||
if mention_start_in_text > last_processed {
|
||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||
process_text_segment(
|
||||
before_mention,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// Process the mention replacement
|
||||
let replacement_text = resolve_mention(mention);
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
if last_processed < t_str.len() {
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
text.push_str(t.as_ref());
|
||||
let is_link = link_url.is_some();
|
||||
|
||||
if let Some(link_url) = link_url.clone() {
|
||||
link_ranges.push(prev_len..text.len());
|
||||
link_urls.push(link_url);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
code_block = true;
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => {
|
||||
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||
}
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
Tag::Item => {
|
||||
let len = list_stack.len();
|
||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||
*has_content = false;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..len - 1 {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if let Some(number) = list_number {
|
||||
text.push_str(&format!("{}. ", number));
|
||||
*number += 1;
|
||||
*has_content = false;
|
||||
} else {
|
||||
text.push_str("- ");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::CodeBlock => code_block = false,
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||
TagEnd::Link => link_url = None,
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||
Event::Rule => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
text.push_str("────────\n");
|
||||
}
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
link_url: Option<String>,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
if bold_depth > 0 {
|
||||
style.font_weight = Some(FontWeight::BOLD);
|
||||
}
|
||||
if italic_depth > 0 {
|
||||
style.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
if strikethrough_depth > 0 {
|
||||
style.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Ranges always refer to the rendered text, including replaced mentions.
|
||||
let segment_start = text.len();
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||
}
|
||||
} else {
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder
|
||||
.links(segment)
|
||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||
{
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
// Add non-link text before this link
|
||||
if start > last_link_pos {
|
||||
let non_link_start = segment_start + last_link_pos;
|
||||
let non_link_end = segment_start + start;
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
highlights.push((range, Highlight::Highlight(link_style)));
|
||||
|
||||
last_link_pos = end;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last link
|
||||
if last_link_pos < segment.len() {
|
||||
let remaining_start = segment_start + last_link_pos;
|
||||
let remaining_end = segment_start + segment.len();
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
} else {
|
||||
*has_content = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ nostr-sdk.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
regex = "1.10"
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use futures::FutureExt;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{Context, Task, Window};
|
||||
use instant::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
use gpui::{Context, Task, Window};
|
||||
|
||||
pub struct DebouncedDelay<E: 'static> {
|
||||
task: Option<Task<()>>,
|
||||
cancel_channel: Option<oneshot::Sender<()>>,
|
||||
_phantom_data: PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> DebouncedDelay<E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -58,9 +64,3 @@ impl<E: 'static> DebouncedDelay<E> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::QrCode;
|
||||
use qrcode::render::svg;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
@@ -55,3 +59,25 @@ impl TimestampExt for Timestamp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StringExt {
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> StringExt for T {
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
let svg = code
|
||||
.render()
|
||||
.min_dimensions(256, 256)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#FFFFFF"))
|
||||
.build();
|
||||
|
||||
Some(Arc::new(Image::from_bytes(
|
||||
ImageFormat::Svg,
|
||||
svg.into_bytes(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,53 +51,3 @@ impl EventExt for UnsignedEvent {
|
||||
public_keys.into_iter().unique().sorted().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
|
||||
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
||||
let mut emojis: Vec<(String, Url)> = Vec::new();
|
||||
|
||||
for tag in tags.iter() {
|
||||
let Ok(Nip30Tag::Emoji {
|
||||
shortcode,
|
||||
image_url,
|
||||
..
|
||||
}) = Nip30Tag::try_from(tag)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !emojis.iter().any(|(existing, _)| *existing == shortcode) {
|
||||
emojis.push((shortcode, image_url));
|
||||
}
|
||||
}
|
||||
|
||||
emojis
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn emojis_from_tags_parses_and_deduplicates() {
|
||||
let tags = Tags::from_list(vec![
|
||||
Tag::parse(vec!["emoji", "soapbox", "https://example.com/soapbox.png"]).unwrap(),
|
||||
Tag::parse(vec!["emoji", "soapbox", "https://example.com/other.png"]).unwrap(),
|
||||
Tag::parse(vec![
|
||||
"emoji",
|
||||
"bad shortcode",
|
||||
"https://example.com/bad.png",
|
||||
])
|
||||
.unwrap(),
|
||||
]);
|
||||
|
||||
let emojis = emojis_from_tags(&tags);
|
||||
|
||||
assert_eq!(emojis.len(), 1);
|
||||
assert_eq!(emojis[0].0, "soapbox");
|
||||
assert_eq!(
|
||||
emojis[0].1,
|
||||
Url::parse("https://example.com/soapbox.png").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub use event::*;
|
||||
pub use media_extractor::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
@@ -11,3 +12,4 @@ mod event;
|
||||
mod media_extractor;
|
||||
mod parser;
|
||||
mod paths;
|
||||
mod range;
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
use gpui::SharedUri;
|
||||
use regex::Regex;
|
||||
|
||||
/// Extracts media URLs from a string and returns both
|
||||
/// the extracted URLs and the string with media URLs removed
|
||||
struct MediaExtractor {
|
||||
/// Extracts media URLs from a string and returns both the extracted URLs
|
||||
/// and the string with media URLs removed
|
||||
pub struct MediaExtractor {
|
||||
image_regex: Regex,
|
||||
video_regex: Regex,
|
||||
}
|
||||
|
||||
impl MediaExtractor {
|
||||
/// Creates a new MediaExtractor with compiled regex patterns
|
||||
fn new() -> Self {
|
||||
pub fn new() -> Self {
|
||||
MediaExtractor {
|
||||
// Match common image extensions
|
||||
image_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
// Match common video extensions
|
||||
video_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all media URLs from a string
|
||||
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
// Extract image URLs
|
||||
@@ -27,22 +32,30 @@ impl MediaExtractor {
|
||||
urls.push(capture.as_str().to_string().into());
|
||||
}
|
||||
|
||||
// Extract video URLs
|
||||
// for capture in self.video_regex.find_iter(text) {
|
||||
// urls.push(capture.as_str().to_string().into());
|
||||
// }
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
/// Removes all media URLs from a string and returns the cleaned text
|
||||
fn remove_media_urls(&self, text: &str) -> String {
|
||||
pub fn remove_media_urls(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove image URLs
|
||||
result = self.image_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove video URLs
|
||||
// result = self.video_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Clean up extra whitespace that might result from removal
|
||||
self.cleanup_text(&result)
|
||||
}
|
||||
|
||||
/// Extracts media URLs and removes them from the string, returning both
|
||||
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
let urls = self.extract_media_urls(text);
|
||||
let cleaned_text = self.remove_media_urls(text);
|
||||
(urls, cleaned_text)
|
||||
@@ -56,6 +69,33 @@ impl MediaExtractor {
|
||||
let re = Regex::new(r"\s+").unwrap();
|
||||
re.replace_all(text, " ").trim().to_string()
|
||||
}
|
||||
|
||||
/// Validates if a URL is a valid media URL
|
||||
pub fn is_media_url(&self, url: &str) -> bool {
|
||||
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
||||
}
|
||||
|
||||
/// Categorizes extracted URLs into images and videos
|
||||
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
||||
let mut images = Vec::new();
|
||||
let mut videos = Vec::new();
|
||||
|
||||
for url in urls {
|
||||
if self.image_regex.is_match(url) {
|
||||
images.push(url.clone());
|
||||
} else if self.video_regex.is_match(url) {
|
||||
videos.push(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(images, videos)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for one-time extraction and removal
|
||||
@@ -63,3 +103,15 @@ pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_and_remove(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just extracting media URLs
|
||||
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_media_urls(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just removing media URLs
|
||||
pub fn remove_media_urls(text: &str) -> String {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.remove_media_urls(text)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ struct Match {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NostrParser;
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrParser {
|
||||
/// Create new parser
|
||||
pub const fn new() -> Self {
|
||||
@@ -38,12 +44,6 @@ impl NostrParser {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct FindMatches<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns the path to the user's home directory.
|
||||
fn home_dir() -> &'static PathBuf {
|
||||
pub fn home_dir() -> &'static PathBuf {
|
||||
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
||||
}
|
||||
@@ -36,3 +36,30 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
home_dir().join(".config").join("coop")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the support directory used by Coop.
|
||||
pub fn support_dir() -> &'static PathBuf {
|
||||
static SUPPORT_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
SUPPORT_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
return home_dir().join("Library/Application Support/Coop");
|
||||
}
|
||||
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
return if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
|
||||
flatpak_xdg_data.into()
|
||||
} else {
|
||||
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
|
||||
}
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
return dirs::data_local_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
config_dir().clone()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::cmp::{self};
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
|
||||
pub trait RangeExt<T> {
|
||||
fn sorted(&self) -> Self;
|
||||
fn to_inclusive(&self) -> RangeInclusive<T>;
|
||||
fn overlaps(&self, other: &Range<T>) -> bool;
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool;
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.start.clone()..=self.end.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start < other.end && other.start < self.end
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start <= other.start && other.end <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start() < &other.end && &other.start <= self.end()
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start() <= &other.start && &other.end <= self.end()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "concord"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
smol.workspace = true
|
||||
@@ -0,0 +1,814 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::derive::{
|
||||
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
|
||||
};
|
||||
use crate::edition::{
|
||||
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
|
||||
build_edition, fold_head, parse_edition, vsk,
|
||||
};
|
||||
use crate::roles::{
|
||||
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster,
|
||||
};
|
||||
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
|
||||
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
|
||||
pub const MAX_RELAYS: usize = 5;
|
||||
|
||||
pub const GENERAL_CHANNEL: &str = "general";
|
||||
pub const ROOT_EPOCH: Epoch = Epoch(0);
|
||||
|
||||
const GENESIS_VERSION: u64 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImageRef {
|
||||
pub url: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub hash: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityMetadata {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub relays: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<ImageRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<ImageRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<Extra>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChannelMetadata {
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deleted: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<Extra>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommunityIdentity {
|
||||
pub community_id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
}
|
||||
|
||||
impl CommunityIdentity {
|
||||
pub fn verify(&self) -> bool {
|
||||
verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommunityGenesis {
|
||||
pub identity: CommunityIdentity,
|
||||
pub community_root: [u8; 32],
|
||||
pub control_root: [u8; 32],
|
||||
pub channel_id: ChannelId,
|
||||
pub wraps: Vec<Event>,
|
||||
}
|
||||
|
||||
pub fn genesis(
|
||||
owner: &Keys,
|
||||
metadata: &CommunityMetadata,
|
||||
at_secs: u64,
|
||||
) -> Result<CommunityGenesis> {
|
||||
let metadata_content = encode_metadata(metadata)?;
|
||||
let owner_salt = random_32()?;
|
||||
|
||||
let identity = CommunityIdentity {
|
||||
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
||||
owner: owner.public_key(),
|
||||
owner_salt,
|
||||
};
|
||||
|
||||
let community_root = random_32()?;
|
||||
let control_root = random_32()?;
|
||||
let channel_id = ChannelId::from_bytes(random_32()?);
|
||||
|
||||
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
|
||||
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
|
||||
|
||||
let channel_content = serde_json::to_string(&ChannelMetadata {
|
||||
name: GENERAL_CHANNEL.to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
})?;
|
||||
|
||||
let editions = [
|
||||
build_edition(EditionFields {
|
||||
author: identity.owner,
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *identity.community_id.as_bytes(),
|
||||
version: GENESIS_VERSION,
|
||||
prev: None,
|
||||
citation: None,
|
||||
content: &metadata_content,
|
||||
at_secs,
|
||||
}),
|
||||
build_edition(EditionFields {
|
||||
author: identity.owner,
|
||||
subkind: vsk::CHANNEL_METADATA,
|
||||
entity: *channel_id.as_bytes(),
|
||||
version: GENESIS_VERSION,
|
||||
prev: None,
|
||||
citation: None,
|
||||
content: &channel_content,
|
||||
at_secs,
|
||||
}),
|
||||
];
|
||||
|
||||
let mut wraps = Vec::with_capacity(editions.len());
|
||||
|
||||
for edition in &editions {
|
||||
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
|
||||
}
|
||||
|
||||
Ok(CommunityGenesis {
|
||||
identity,
|
||||
community_root,
|
||||
control_root,
|
||||
channel_id,
|
||||
wraps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens a Control Plane wrap from its reading key alone.
|
||||
pub fn open_edition(
|
||||
wrap: &Event,
|
||||
read: &GroupKey,
|
||||
address: &PublicKey,
|
||||
verify_wrap_signature: bool,
|
||||
) -> Result<ParsedEdition> {
|
||||
let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?;
|
||||
|
||||
if opened.seal_form != SealForm::Plaintext {
|
||||
bail!("control editions require a plaintext seal");
|
||||
}
|
||||
|
||||
Ok(parse_edition(&opened.rumor)?)
|
||||
}
|
||||
|
||||
/// Appends editions to entity chains.
|
||||
pub struct ControlWriter {
|
||||
pub author: PublicKey,
|
||||
pub read: GroupKey,
|
||||
pub signer: GroupKey,
|
||||
}
|
||||
|
||||
pub struct Edition<'a> {
|
||||
pub subkind: &'a str,
|
||||
pub entity: [u8; 32],
|
||||
pub content: &'a str,
|
||||
/// The head this edition supersedes.
|
||||
///
|
||||
/// `None` starts the chain.
|
||||
pub head: Option<&'a EntityHead>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
}
|
||||
|
||||
impl ControlWriter {
|
||||
pub fn publish(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
edition: Edition<'_>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
let rumor = build_edition(EditionFields {
|
||||
author: self.author,
|
||||
subkind: edition.subkind,
|
||||
entity: edition.entity,
|
||||
version: edition
|
||||
.head
|
||||
.map_or(GENESIS_VERSION, |head| head.version + 1),
|
||||
prev: edition.head.map(|head| head.self_hash),
|
||||
citation: edition.citation,
|
||||
content: edition.content,
|
||||
at_secs,
|
||||
});
|
||||
|
||||
let parsed = parse_edition(&rumor)?;
|
||||
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
|
||||
|
||||
Ok((wrap, EntityHead::from(&parsed)))
|
||||
}
|
||||
|
||||
pub fn set_community_metadata(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
community_id: &CommunityId,
|
||||
metadata: &CommunityMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
let content = encode_metadata(metadata)?;
|
||||
|
||||
self.publish(
|
||||
keys,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_channel_metadata(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
channel: &ChannelId,
|
||||
metadata: &ChannelMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
let content = serde_json::to_string(metadata)?;
|
||||
|
||||
self.publish(
|
||||
keys,
|
||||
Edition {
|
||||
subkind: vsk::CHANNEL_METADATA,
|
||||
entity: *channel.as_bytes(),
|
||||
content: &content,
|
||||
head,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
|
||||
if metadata.name.len() > MAX_NAME_BYTES {
|
||||
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
|
||||
if metadata
|
||||
.description
|
||||
.as_ref()
|
||||
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
|
||||
{
|
||||
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
|
||||
}
|
||||
|
||||
let mut metadata = metadata.clone();
|
||||
metadata.relays.truncate(MAX_RELAYS);
|
||||
|
||||
Ok(serde_json::to_string(&metadata)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ControlFold {
|
||||
pub roles: CommunityRoles,
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
pub community: Option<CommunityMetadata>,
|
||||
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||
pub floors: Floors,
|
||||
pub gapped: bool,
|
||||
}
|
||||
|
||||
pub fn fold_control(
|
||||
owner: &PublicKey,
|
||||
community_id: &CommunityId,
|
||||
editions: &[ParsedEdition],
|
||||
floors: &Floors,
|
||||
held_bans: &BTreeSet<PublicKey>,
|
||||
) -> ControlFold {
|
||||
let authority: Vec<AuthorityEdition> = editions
|
||||
.iter()
|
||||
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
|
||||
.collect();
|
||||
|
||||
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
|
||||
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
|
||||
|
||||
let mut floors = roster.floors;
|
||||
floors.extend(metadata.floors);
|
||||
|
||||
ControlFold {
|
||||
roles: roster.roles,
|
||||
banned: roster.banned,
|
||||
community: metadata.community,
|
||||
channels: metadata.channels,
|
||||
floors,
|
||||
gapped: roster.gapped || metadata.gapped,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct MetadataFold {
|
||||
community: Option<CommunityMetadata>,
|
||||
channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||
floors: Floors,
|
||||
gapped: bool,
|
||||
}
|
||||
|
||||
fn fold_metadata(
|
||||
owner: &PublicKey,
|
||||
community_id: &CommunityId,
|
||||
editions: &[ParsedEdition],
|
||||
roster: &Roster,
|
||||
floors: &Floors,
|
||||
) -> MetadataFold {
|
||||
let judge = Judge {
|
||||
owner,
|
||||
community_id,
|
||||
roster,
|
||||
floors,
|
||||
};
|
||||
let community_entity = *community_id.as_bytes();
|
||||
let mut community: Vec<&ParsedEdition> = Vec::new();
|
||||
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
|
||||
|
||||
for edition in editions {
|
||||
match edition.subkind.as_str() {
|
||||
// A channel addressed at the community's own coordinate would share, and
|
||||
// corrupt, the metadata chain's floor.
|
||||
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
|
||||
community.push(edition)
|
||||
}
|
||||
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
|
||||
channels.entry(edition.entity).or_default().push(edition);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut fold = MetadataFold::default();
|
||||
|
||||
if let Some(head) = authorized_head(
|
||||
&judge,
|
||||
community_entity,
|
||||
&community,
|
||||
Permissions::MANAGE_METADATA,
|
||||
&mut fold.gapped,
|
||||
) {
|
||||
fold.community = serde_json::from_str(&head.content).ok();
|
||||
fold.floors.insert(head.entity, EntityHead::from(head));
|
||||
}
|
||||
|
||||
for (entity, candidates) in &channels {
|
||||
let Some(head) = authorized_head(
|
||||
&judge,
|
||||
*entity,
|
||||
candidates,
|
||||
Permissions::MANAGE_CHANNELS,
|
||||
&mut fold.gapped,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
fold.floors.insert(*entity, EntityHead::from(head));
|
||||
|
||||
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
|
||||
fold.channels
|
||||
.insert(ChannelId::from_bytes(*entity), metadata);
|
||||
}
|
||||
}
|
||||
|
||||
fold
|
||||
}
|
||||
|
||||
struct Judge<'a> {
|
||||
owner: &'a PublicKey,
|
||||
community_id: &'a CommunityId,
|
||||
roster: &'a Roster,
|
||||
floors: &'a Floors,
|
||||
}
|
||||
|
||||
fn authorized_head<'a>(
|
||||
judge: &Judge<'_>,
|
||||
entity: [u8; 32],
|
||||
candidates: &[&'a ParsedEdition],
|
||||
permission: u64,
|
||||
gapped: &mut bool,
|
||||
) -> Option<&'a ParsedEdition> {
|
||||
let authorized: Vec<&ParsedEdition> = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|edition| {
|
||||
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
|
||||
!judge.roster.banned.contains(&edition.author)
|
||||
&& judge
|
||||
.roster
|
||||
.roles
|
||||
.is_authorized(&edition.author, judge.owner, permission)
|
||||
&& citation_ok(
|
||||
judge.owner,
|
||||
judge.community_id,
|
||||
&edition.author,
|
||||
edition.citation.as_ref(),
|
||||
&judge.roster.floors,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if authorized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let metas: Vec<EditionMeta> = authorized
|
||||
.iter()
|
||||
.map(|edition| EditionMeta::from(*edition))
|
||||
.collect();
|
||||
|
||||
let selection = fold_head(&metas, judge.floors.get(&entity));
|
||||
*gapped |= selection.gap;
|
||||
|
||||
selection.head.map(|index| authorized[index])
|
||||
}
|
||||
|
||||
fn seal_edition(
|
||||
edition: &UnsignedEvent,
|
||||
owner: &Keys,
|
||||
read: &GroupKey,
|
||||
signer: &GroupKey,
|
||||
at_secs: u64,
|
||||
) -> Result<Event> {
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
||||
|
||||
let (wrap, _) = wrap_seal_with(
|
||||
&seal,
|
||||
read.conversation(),
|
||||
signer.keys(),
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(at_secs),
|
||||
&[],
|
||||
)?;
|
||||
|
||||
Ok(wrap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::derive::grant_locator;
|
||||
use crate::edition::fold;
|
||||
use crate::roles::{Grant, Role, RoleScope};
|
||||
use crate::store::{CommunityState, load_state, save_state};
|
||||
use crate::{Extra, RoleId};
|
||||
|
||||
const AT: u64 = 1_700_000_000;
|
||||
|
||||
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
|
||||
let community_id = minted.identity.community_id;
|
||||
|
||||
(
|
||||
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
|
||||
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
|
||||
.expect("derives"),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
|
||||
wraps
|
||||
.iter()
|
||||
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn metadata(name: &str) -> CommunityMetadata {
|
||||
CommunityMetadata {
|
||||
name: name.to_owned(),
|
||||
..CommunityMetadata::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_reopens_for_a_second_holder() {
|
||||
let owner = Keys::generate();
|
||||
let community_metadata = CommunityMetadata {
|
||||
name: "coop".to_owned(),
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
|
||||
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||
|
||||
// Only what an invite hands over: the roots, the community id and the owner salt.
|
||||
let (read, signer) = holder(&minted);
|
||||
let editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
|
||||
assert_eq!(editions.len(), 2);
|
||||
|
||||
let community = &editions[0];
|
||||
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
|
||||
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
|
||||
assert_eq!(community.author, owner.public_key());
|
||||
assert_eq!((community.version, community.prev), (1, None));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CommunityMetadata>(&community.content)
|
||||
.expect("parses")
|
||||
.name,
|
||||
"coop"
|
||||
);
|
||||
|
||||
let channel = &editions[1];
|
||||
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
|
||||
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
|
||||
|
||||
for edition in &editions {
|
||||
let folded = fold(&[EditionMeta::from(edition)], 0, None);
|
||||
assert_eq!(folded.head, Some(0));
|
||||
assert!(
|
||||
folded.anchored && !folded.gap,
|
||||
"genesis anchors at its floor"
|
||||
);
|
||||
}
|
||||
|
||||
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
|
||||
|
||||
smol::block_on(async {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
save_state(&database, &state).await.expect("saves");
|
||||
let loaded = load_state(&database, &minted.identity.community_id)
|
||||
.await
|
||||
.expect("loads")
|
||||
.expect("present");
|
||||
|
||||
assert_eq!(loaded.community_root, minted.community_root);
|
||||
assert_eq!(loaded.control_root, Some(minted.control_root));
|
||||
assert_eq!(loaded.channels.len(), 1);
|
||||
assert_eq!(loaded.heads.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||
let owner = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
|
||||
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
let roster = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&genesis_editions,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
roster.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop")
|
||||
);
|
||||
|
||||
let writer = ControlWriter {
|
||||
author: owner_pk,
|
||||
read: read.clone(),
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
|
||||
let channel_head = roster
|
||||
.floors
|
||||
.get(minted.channel_id.as_bytes())
|
||||
.expect("head");
|
||||
|
||||
let (community_wrap, _) = writer
|
||||
.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
&CommunityMetadata {
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..metadata("coop two")
|
||||
},
|
||||
Some(community_head),
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (channel_wrap, _) = writer
|
||||
.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "lobby".to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
Some(channel_head),
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
let mut edited = genesis_editions.clone();
|
||||
edited.extend(open_all(
|
||||
&[community_wrap, channel_wrap],
|
||||
&read,
|
||||
&signer.pk(),
|
||||
));
|
||||
|
||||
let folded = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&edited,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop two")
|
||||
);
|
||||
assert_eq!(
|
||||
folded
|
||||
.channels
|
||||
.get(&minted.channel_id)
|
||||
.map(|channel| channel.name.as_str()),
|
||||
Some("lobby")
|
||||
);
|
||||
|
||||
// A relay serving only the editions a client already folded past must not walk
|
||||
// the community backwards.
|
||||
let stale = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&genesis_editions,
|
||||
&folded.floors,
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert!(stale.community.is_none());
|
||||
assert!(stale.channels.is_empty());
|
||||
|
||||
let mut state =
|
||||
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
|
||||
state.apply_fold(&folded);
|
||||
assert_eq!(state.channels.len(), 1);
|
||||
assert_eq!(state.channels[0].name, "lobby");
|
||||
assert_eq!(state.relays.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
||||
let owner = Keys::generate();
|
||||
let member = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
|
||||
let writer = ControlWriter {
|
||||
author: owner_pk,
|
||||
read: read.clone(),
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let role_id = RoleId::from_bytes([0x07; 32]);
|
||||
let role = Role {
|
||||
role_id,
|
||||
name: "Mod".to_owned(),
|
||||
position: 1,
|
||||
permissions: Permissions(Permissions::MANAGE_METADATA),
|
||||
scope: RoleScope::Server,
|
||||
color: 0,
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let (role_wrap, _) = writer
|
||||
.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::ROLE,
|
||||
entity: *role_id.as_bytes(),
|
||||
content: &role.to_content().expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (grant_wrap, _) = writer
|
||||
.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::GRANT,
|
||||
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
|
||||
content: &Grant {
|
||||
member: member.public_key(),
|
||||
role_ids: vec![role_id],
|
||||
control_wrap: None,
|
||||
extra: Extra::default(),
|
||||
}
|
||||
.to_content()
|
||||
.expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
let mut base = open_all(&minted.wraps, &read, &signer.pk());
|
||||
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
|
||||
|
||||
let roster = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&base,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
|
||||
|
||||
let grant = roster
|
||||
.floors
|
||||
.get(&grant_locator(
|
||||
&community_id,
|
||||
&member.public_key().to_bytes(),
|
||||
))
|
||||
.expect("the member's grant folded");
|
||||
let head = roster.floors.get(community_id.as_bytes()).expect("head");
|
||||
|
||||
// The member seals with their own keys and wraps with the staff write key.
|
||||
let member_writer = ControlWriter {
|
||||
author: member.public_key(),
|
||||
read,
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
|
||||
|
||||
let (uncited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: None,
|
||||
},
|
||||
AT + 3,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (cited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: Some(AuthorityCitation {
|
||||
entity: grant.entity,
|
||||
version: grant.version,
|
||||
hash: grant.self_hash,
|
||||
}),
|
||||
},
|
||||
AT + 4,
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
// Uncited, the edit claims an authority the member never showed.
|
||||
let mut forged = base.clone();
|
||||
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
|
||||
let folded = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&forged,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop")
|
||||
);
|
||||
|
||||
let mut edited_editions = base;
|
||||
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
|
||||
let folded = fold_control(
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&edited_editions,
|
||||
&Floors::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||
Some("coop by mod")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex, PoisonError};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use hkdf::Hkdf;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
|
||||
pub const TOKEN_LEN: usize = 16;
|
||||
|
||||
const LABEL_CHANNEL: &str = "concord/channel";
|
||||
const LABEL_CONTROL: &str = "concord/control";
|
||||
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
|
||||
const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
|
||||
const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
|
||||
const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
|
||||
const LABEL_GUESTBOOK: &str = "concord/guestbook";
|
||||
const LABEL_DISSOLVED: &str = "concord/dissolved";
|
||||
const LABEL_GRANT: &str = "concord/grant";
|
||||
const LABEL_BANLIST: &str = "concord/banlist";
|
||||
const LABEL_PINS: &str = "concord/pins";
|
||||
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
|
||||
const LABEL_INVITE_KEY: &str = "concord/invite-key";
|
||||
|
||||
const LABEL_COMMUNITY: &str = "concord/community";
|
||||
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
|
||||
|
||||
const ZERO32: [u8; 32] = [0u8; 32];
|
||||
|
||||
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
|
||||
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
|
||||
info.extend_from_slice(label.as_bytes());
|
||||
info.push(0x00);
|
||||
info.extend_from_slice(id32);
|
||||
|
||||
if let Some(epoch) = epoch {
|
||||
info.extend_from_slice(&epoch.to_be_bytes());
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
||||
let mut okm = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(None, ikm)
|
||||
.expand(info, &mut okm)
|
||||
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
|
||||
okm
|
||||
}
|
||||
|
||||
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
|
||||
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
|
||||
return Ok(secret_key);
|
||||
}
|
||||
|
||||
for counter in 0u8..=u8::MAX {
|
||||
let mut info = Vec::with_capacity(base_info.len() + 1);
|
||||
info.extend_from_slice(base_info);
|
||||
info.push(counter);
|
||||
|
||||
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
|
||||
return Ok(secret_key);
|
||||
}
|
||||
}
|
||||
|
||||
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupKey {
|
||||
keys: Keys,
|
||||
conversation: ConversationKey,
|
||||
}
|
||||
|
||||
impl GroupKey {
|
||||
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
|
||||
let key = memo_key(label, secret, id32, epoch);
|
||||
|
||||
if let Some(hit) = lock_memo().get(&key) {
|
||||
return Ok(hit.clone());
|
||||
}
|
||||
|
||||
let info = build_info(label, id32, epoch);
|
||||
let secret_key = hkdf_to_secret_key(secret, &info)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
||||
let group_key = Self { keys, conversation };
|
||||
|
||||
let mut memo = lock_memo();
|
||||
|
||||
if memo.len() >= 1024 {
|
||||
memo.clear();
|
||||
}
|
||||
|
||||
memo.insert(key, group_key.clone());
|
||||
|
||||
Ok(group_key)
|
||||
}
|
||||
|
||||
pub fn pk(&self) -> PublicKey {
|
||||
self.keys.public_key()
|
||||
}
|
||||
|
||||
pub fn pk_hex(&self) -> String {
|
||||
self.keys.public_key().to_hex()
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> &Keys {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
pub fn conversation(&self) -> &ConversationKey {
|
||||
&self.conversation
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GroupKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GroupKey")
|
||||
.field("pk", &self.pk_hex())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
|
||||
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn clear_memo() {
|
||||
lock_memo().clear()
|
||||
}
|
||||
|
||||
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(label.as_bytes());
|
||||
hasher.update([0x00]);
|
||||
hasher.update(secret);
|
||||
hasher.update(id32);
|
||||
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
|
||||
hasher.update([epoch.is_some() as u8]);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// `secret` is the `community_root` for a public channel.
|
||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
|
||||
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
|
||||
}
|
||||
|
||||
/// The plane's read key: its conversation key encrypts the wraps for every member.
|
||||
pub fn control_group_key(
|
||||
community_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_CONTROL,
|
||||
community_root,
|
||||
community_id.as_bytes(),
|
||||
Some(epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// The plane's address and wrap signer, held only by staff.
|
||||
/// Wraps still encrypt under [`control_group_key`].
|
||||
pub fn control_signer_group_key(
|
||||
control_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_CONTROL_SIGNER,
|
||||
control_root,
|
||||
community_id.as_bytes(),
|
||||
Some(epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Member-writable, unlike the Control Plane:
|
||||
///
|
||||
/// - A join or a leave is each member's own word.
|
||||
pub fn guestbook_group_key(
|
||||
community_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_GUESTBOOK,
|
||||
community_root,
|
||||
community_id.as_bytes(),
|
||||
Some(epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Keyed by the prior `community_root` rather than the channel key,
|
||||
/// so any retained member recovers any epoch's rekey without a ratchet.
|
||||
pub fn channel_rekey_group_key(
|
||||
prior_root: &[u8; 32],
|
||||
channel: &ChannelId,
|
||||
new_epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_REKEY_PSEUDONYM,
|
||||
prior_root,
|
||||
channel.as_bytes(),
|
||||
Some(new_epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn base_rekey_group_key(
|
||||
prior_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
new_epoch: Epoch,
|
||||
) -> Result<GroupKey> {
|
||||
GroupKey::derive(
|
||||
LABEL_BASE_REKEY_PSEUDONYM,
|
||||
prior_root,
|
||||
community_id.as_bytes(),
|
||||
Some(new_epoch.0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
|
||||
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
|
||||
}
|
||||
|
||||
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(LABEL_COMMUNITY.as_bytes());
|
||||
hasher.update(owner_xonly);
|
||||
hasher.update(owner_salt);
|
||||
CommunityId::from_bytes(hasher.finalize().into())
|
||||
}
|
||||
|
||||
pub fn verify_community_id(
|
||||
community_id: &CommunityId,
|
||||
owner_xonly: &[u8; 32],
|
||||
owner_salt: &[u8; 32],
|
||||
) -> bool {
|
||||
community_id_of(owner_xonly, owner_salt) == *community_id
|
||||
}
|
||||
|
||||
/// The continuity a rekey blob must satisfy against the key currently held.
|
||||
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
|
||||
hasher.update(previous_epoch.0.to_be_bytes());
|
||||
hasher.update(previous_key);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
|
||||
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_GRANT, member_xonly, None),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_BANLIST, &ZERO32, None),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_PINS, channel.as_bytes(), None),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bound to the creator, so each creator owns exactly their own registry.
|
||||
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
|
||||
hkdf32(
|
||||
community_id.as_bytes(),
|
||||
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
|
||||
)
|
||||
}
|
||||
|
||||
/// Built from public inputs only, so a locator match proves nothing about authenticity
|
||||
pub fn recipient_locator(
|
||||
rotator_xonly: &[u8; 32],
|
||||
recipient_xonly: &[u8; 32],
|
||||
scope_id: &[u8; 32],
|
||||
new_epoch: Epoch,
|
||||
) -> [u8; 32] {
|
||||
let mut ikm = [0u8; 64];
|
||||
ikm[..32].copy_from_slice(rotator_xonly);
|
||||
ikm[32..].copy_from_slice(recipient_xonly);
|
||||
hkdf32(
|
||||
&ikm,
|
||||
&build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
|
||||
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
|
||||
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CHANNEL_E0_SEED: &str =
|
||||
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
|
||||
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
|
||||
const CHANNEL_EMULTI_PK: &str =
|
||||
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
|
||||
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
|
||||
const CONTROL_SIGNER_E0_SEED: &str =
|
||||
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
|
||||
const CONTROL_SIGNER_E0_PK: &str =
|
||||
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
|
||||
const CONTROL_SIGNER_EMULTI_PK: &str =
|
||||
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
|
||||
const GUESTBOOK_E0_PK: &str =
|
||||
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
|
||||
const CHANNEL_REKEY_E1_PK: &str =
|
||||
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
|
||||
const BASE_REKEY_E1_PK: &str =
|
||||
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
|
||||
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
|
||||
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
|
||||
const BANLIST_LOCATOR: &str =
|
||||
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
|
||||
const INVITE_LINKS_LOCATOR: &str =
|
||||
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
|
||||
const RECIPIENT_LOCATOR: &str =
|
||||
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
|
||||
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
|
||||
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
|
||||
const EPOCH_COMMITMENT: &str =
|
||||
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
|
||||
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
|
||||
const EPOCH_MULTI: u64 = 0x0102030405060708;
|
||||
|
||||
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
|
||||
fn secret() -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
for (index, byte) in key.iter_mut().enumerate() {
|
||||
*byte = index as u8;
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
fn id32() -> [u8; 32] {
|
||||
let mut id = [0u8; 32];
|
||||
for (index, byte) in id.iter_mut().enumerate() {
|
||||
*byte = 255 - index as u8;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
data_encoding::HEXLOWER.encode(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_vectors() {
|
||||
let secret = secret();
|
||||
let id = id32();
|
||||
let alt = [0x11u8; 32];
|
||||
let community_id = CommunityId::from_bytes(id);
|
||||
let channel = ChannelId::from_bytes(id);
|
||||
|
||||
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
|
||||
assert_eq!(
|
||||
hex(channel_e0.keys().secret_key().as_secret_bytes()),
|
||||
CHANNEL_E0_SEED
|
||||
);
|
||||
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
|
||||
assert_eq!(
|
||||
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CHANNEL_EMULTI_PK
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
control_group_key(&secret, &community_id, Epoch(0))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CONTROL_E0_PK
|
||||
);
|
||||
|
||||
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
|
||||
assert_eq!(
|
||||
hex(signer.keys().secret_key().as_secret_bytes()),
|
||||
CONTROL_SIGNER_E0_SEED
|
||||
);
|
||||
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
|
||||
assert_eq!(
|
||||
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CONTROL_SIGNER_EMULTI_PK
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
guestbook_group_key(&secret, &community_id, Epoch(0))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
GUESTBOOK_E0_PK
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel_rekey_group_key(&secret, &channel, Epoch(1))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
CHANNEL_REKEY_E1_PK
|
||||
);
|
||||
assert_eq!(
|
||||
base_rekey_group_key(&secret, &community_id, Epoch(1))
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
BASE_REKEY_E1_PK
|
||||
);
|
||||
assert_eq!(
|
||||
dissolved_group_key(&community_id)
|
||||
.expect("derives")
|
||||
.pk_hex(),
|
||||
DISSOLVED_PK
|
||||
);
|
||||
|
||||
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
|
||||
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
|
||||
assert_eq!(
|
||||
hex(&invite_links_locator(&community_id, &alt)),
|
||||
INVITE_LINKS_LOCATOR
|
||||
);
|
||||
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
|
||||
assert_eq!(
|
||||
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
|
||||
RECIPIENT_LOCATOR
|
||||
);
|
||||
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
|
||||
|
||||
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
|
||||
assert_eq!(
|
||||
hex(&epoch_key_commitment(Epoch(2), &secret)),
|
||||
EPOCH_COMMITMENT
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::decode_hex_32;
|
||||
use crate::stream::build_rumor_secs;
|
||||
|
||||
pub const KIND_CONTROL: u16 = 3308;
|
||||
|
||||
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
|
||||
|
||||
/// Entity types an edition can address (CORD-02 Appendix B).
|
||||
pub mod vsk {
|
||||
pub const COMMUNITY_METADATA: &str = "0";
|
||||
pub const ROLE: &str = "1";
|
||||
pub const CHANNEL_METADATA: &str = "2";
|
||||
pub const GRANT: &str = "3";
|
||||
pub const BANLIST: &str = "4";
|
||||
pub const INVITE_LIVE: &str = "6";
|
||||
pub const INVITE_LINKS: &str = "8";
|
||||
pub const INVITE_REVOKED: &str = "9";
|
||||
pub const DISSOLVED: &str = "10";
|
||||
pub const PINS: &str = "11";
|
||||
}
|
||||
|
||||
const TAG_SUBKIND: &str = "vsk";
|
||||
const TAG_ENTITY: &str = "eid";
|
||||
const TAG_VERSION: &str = "ev";
|
||||
const TAG_PREV: &str = "ep";
|
||||
const TAG_CITATION: &str = "vac";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EditionError {
|
||||
BadKind(u16),
|
||||
BadField(&'static str),
|
||||
Duplicate(&'static str),
|
||||
Missing(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for EditionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
|
||||
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
|
||||
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
|
||||
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EditionError {}
|
||||
|
||||
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
|
||||
/// coordinate, version and hash. It is a sync floor, not the verdict.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AuthorityCitation {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub hash: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedEdition {
|
||||
pub author: PublicKey,
|
||||
pub subkind: String,
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
pub content: String,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
pub struct EditionFields<'a> {
|
||||
pub author: PublicKey,
|
||||
pub subkind: &'a str,
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
pub content: &'a str,
|
||||
pub at_secs: u64,
|
||||
}
|
||||
|
||||
fn signing_bytes(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
content: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut bytes =
|
||||
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
|
||||
|
||||
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(EDITION_LABEL);
|
||||
bytes.extend_from_slice(entity);
|
||||
bytes.extend_from_slice(&version.to_be_bytes());
|
||||
|
||||
match prev {
|
||||
Some(prev) => {
|
||||
bytes.push(1);
|
||||
bytes.extend_from_slice(prev);
|
||||
}
|
||||
None => {
|
||||
bytes.push(0);
|
||||
bytes.extend_from_slice(&[0u8; 32]);
|
||||
}
|
||||
}
|
||||
|
||||
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(content);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn edition_hash(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
content: &[u8],
|
||||
) -> [u8; 32] {
|
||||
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
|
||||
}
|
||||
|
||||
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_SUBKIND, [fields.subkind]),
|
||||
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
|
||||
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
|
||||
];
|
||||
|
||||
if let Some(prev) = fields.prev {
|
||||
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
|
||||
}
|
||||
|
||||
if let Some(citation) = fields.citation {
|
||||
tags.push(Tag::custom(
|
||||
TAG_CITATION,
|
||||
[
|
||||
HEXLOWER.encode(&citation.entity),
|
||||
citation.version.to_string(),
|
||||
HEXLOWER.encode(&citation.hash),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
build_rumor_secs(
|
||||
KIND_CONTROL,
|
||||
fields.author,
|
||||
fields.content,
|
||||
tags,
|
||||
fields.at_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if kind != KIND_CONTROL {
|
||||
return Err(EditionError::BadKind(kind));
|
||||
}
|
||||
|
||||
let subkind = value(rumor, TAG_SUBKIND)?
|
||||
.ok_or(EditionError::Missing(TAG_SUBKIND))?
|
||||
.to_owned();
|
||||
|
||||
if canonical_decimal(&subkind).is_none() {
|
||||
return Err(EditionError::BadField(TAG_SUBKIND));
|
||||
}
|
||||
|
||||
let entity = hex32(
|
||||
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
|
||||
TAG_ENTITY,
|
||||
)?;
|
||||
|
||||
let version =
|
||||
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
|
||||
.ok_or(EditionError::BadField(TAG_VERSION))?;
|
||||
|
||||
let prev = match value(rumor, TAG_PREV)? {
|
||||
Some(raw) => Some(hex32(raw, TAG_PREV)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let citation = match fields(rumor, TAG_CITATION)? {
|
||||
Some(fields) if fields.len() == 4 => Some(AuthorityCitation {
|
||||
entity: hex32(&fields[1], TAG_CITATION)?,
|
||||
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
|
||||
hash: hex32(&fields[3], TAG_CITATION)?,
|
||||
}),
|
||||
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
|
||||
|
||||
Ok(ParsedEdition {
|
||||
author: rumor.pubkey,
|
||||
subkind,
|
||||
entity,
|
||||
version,
|
||||
prev,
|
||||
citation,
|
||||
content: rumor.content.clone(),
|
||||
self_hash,
|
||||
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EditionMeta {
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub tiebreak_id: EventId,
|
||||
}
|
||||
|
||||
impl From<&ParsedEdition> for EditionMeta {
|
||||
fn from(edition: &ParsedEdition) -> Self {
|
||||
Self {
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
prev: edition.prev,
|
||||
tiebreak_id: edition.rumor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// The highest version whose chain is intact, given a held floor.
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
|
||||
for (index, edition) in editions.iter().enumerate() {
|
||||
if edition.version < floor {
|
||||
continue;
|
||||
}
|
||||
|
||||
match by_version.get(&edition.version) {
|
||||
Some(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
|
||||
_ => {
|
||||
by_version.insert(edition.version, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
|
||||
return FoldResult::default();
|
||||
};
|
||||
|
||||
let lowest = editions[lowest_index];
|
||||
|
||||
let anchored = if floor == 0 {
|
||||
lowest_version == 1 && lowest.prev.is_none()
|
||||
} else if lowest_version == floor {
|
||||
floor_hash == Some(&lowest.self_hash)
|
||||
} else if lowest_version == floor + 1 {
|
||||
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut head = Some(lowest_index);
|
||||
let mut gap = !anchored;
|
||||
let mut previous_version = lowest_version;
|
||||
let mut previous_hash = lowest.self_hash;
|
||||
|
||||
for (&version, &index) in by_version.range(lowest_version + 1..) {
|
||||
let edition = editions[index];
|
||||
|
||||
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
|
||||
head = Some(index);
|
||||
previous_version = version;
|
||||
previous_hash = edition.self_hash;
|
||||
} else {
|
||||
gap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
FoldResult {
|
||||
head,
|
||||
gap,
|
||||
anchored,
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest version overall, ignoring contiguity.
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.reduce(|(best_index, best), (index, candidate)| {
|
||||
let supersedes = candidate.version > best.version
|
||||
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
|
||||
|
||||
if supersedes {
|
||||
(index, candidate)
|
||||
} else {
|
||||
(best_index, best)
|
||||
}
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct HeadSelection {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
}
|
||||
|
||||
/// The head to prefer for one entity, given what this client already committed to.
|
||||
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
|
||||
let Some(floor) = floor else {
|
||||
return HeadSelection {
|
||||
head: bootstrap_head(editions),
|
||||
gap: false,
|
||||
};
|
||||
};
|
||||
|
||||
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
|
||||
|
||||
if anchored.anchored {
|
||||
return HeadSelection {
|
||||
head: anchored.head,
|
||||
gap: anchored.gap,
|
||||
};
|
||||
}
|
||||
|
||||
if anchored.head.is_none() && !anchored.gap {
|
||||
return HeadSelection::default();
|
||||
}
|
||||
|
||||
let fork = editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, edition)| edition.version == floor.version)
|
||||
.min_by_key(|(_, edition)| edition.tiebreak_id);
|
||||
|
||||
let winner = match fork {
|
||||
Some((_, edition))
|
||||
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
|
||||
{
|
||||
edition.self_hash
|
||||
}
|
||||
_ => {
|
||||
return HeadSelection {
|
||||
head: None,
|
||||
gap: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let refolded = fold(editions, floor.version, Some(&winner));
|
||||
|
||||
if refolded.anchored {
|
||||
HeadSelection {
|
||||
head: refolded.head,
|
||||
gap: refolded.gap,
|
||||
}
|
||||
} else {
|
||||
HeadSelection {
|
||||
head: None,
|
||||
gap: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityHead {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
impl From<&ParsedEdition> for EntityHead {
|
||||
fn from(edition: &ParsedEdition) -> Self {
|
||||
Self {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entity's committed head, keyed by coordinate.
|
||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||
|
||||
fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if raw.len() > 1 && raw.starts_with('0') {
|
||||
return None;
|
||||
}
|
||||
|
||||
raw.parse().ok()
|
||||
}
|
||||
|
||||
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
|
||||
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
|
||||
}
|
||||
|
||||
fn fields<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, EditionError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for tag in rumor.tags.iter() {
|
||||
let tag_fields = tag.as_slice();
|
||||
|
||||
if tag_fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(EditionError::Duplicate(name));
|
||||
}
|
||||
|
||||
found = Some(tag_fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a str>, EditionError> {
|
||||
match fields(rumor, name)? {
|
||||
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
|
||||
Some(_) => Err(EditionError::BadField(name)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
|
||||
EditionMeta {
|
||||
version,
|
||||
self_hash: [hash; 32],
|
||||
prev,
|
||||
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
|
||||
EntityHead {
|
||||
entity: [0x11; 32],
|
||||
version,
|
||||
self_hash: [hash; 32],
|
||||
rumor_id: EventId::from_byte_array([rumor; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_picks_the_head_from_the_chain_and_the_floor() {
|
||||
let chain = [
|
||||
meta(1, None, 0xa1, 1),
|
||||
meta(2, Some([0xa1; 32]), 0xa2, 2),
|
||||
meta(3, Some([0xa2; 32]), 0xa3, 3),
|
||||
];
|
||||
|
||||
let folded = fold(&chain, 0, None);
|
||||
assert_eq!(folded.head, Some(2));
|
||||
assert!(!folded.gap && folded.anchored);
|
||||
|
||||
// A missing link stops the walk at the last contiguous edition.
|
||||
let gapped = fold(&[chain[0], chain[2]], 0, None);
|
||||
assert_eq!(gapped.head, Some(0));
|
||||
assert!(gapped.gap && gapped.anchored);
|
||||
|
||||
// Everything below the held floor is a stale relay, not a gap.
|
||||
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
|
||||
assert_eq!(stale.head, None);
|
||||
assert!(!stale.gap && !stale.anchored);
|
||||
|
||||
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
|
||||
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
|
||||
assert_eq!(
|
||||
fold(&fork, 0, None).head,
|
||||
Some(1),
|
||||
"the lower rumor id wins"
|
||||
);
|
||||
let forked = [fork[0], fork[1], chain[1], chain[2]];
|
||||
assert_eq!(fold(&forked, 0, None).head, Some(3));
|
||||
|
||||
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
|
||||
// longer resolves is a withholding.
|
||||
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
|
||||
assert_eq!(
|
||||
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
|
||||
Some(0)
|
||||
);
|
||||
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
|
||||
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
|
||||
assert_eq!(refused.head, None);
|
||||
assert!(refused.gap);
|
||||
|
||||
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
|
||||
assert_eq!(bootstrap_head(&[dangling]), Some(0));
|
||||
assert_eq!(fold_head(&[dangling], None).head, Some(0));
|
||||
|
||||
// A fork at the floor's own version converges to the lower rumor id when that is
|
||||
// genuinely earlier than what we hold, and the chain above it re-anchors.
|
||||
let forked = [
|
||||
meta(2, Some([0xa1; 32]), 0xb2, 3),
|
||||
meta(3, Some([0xb2; 32]), 0xb3, 4),
|
||||
];
|
||||
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
|
||||
assert_eq!(converged.head, Some(1));
|
||||
assert!(!converged.gap);
|
||||
|
||||
// A fork that is not earlier than the held head is refused.
|
||||
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edition_hash_matches_the_cross_client_vector() {
|
||||
let entity = [0x11u8; 32];
|
||||
|
||||
assert_eq!(
|
||||
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
|
||||
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
||||
);
|
||||
|
||||
// The golden vector only exercises the absent-prev encoding; pin the
|
||||
// present-prev branch structurally so a swapped flag stays visible.
|
||||
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
|
||||
);
|
||||
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
|
||||
assert_eq!(
|
||||
bytes[8 + EDITION_LABEL.len() + 32..][..8],
|
||||
1u64.to_be_bytes()
|
||||
);
|
||||
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
pub mod control;
|
||||
pub mod derive;
|
||||
pub mod edition;
|
||||
pub mod roles;
|
||||
pub mod store;
|
||||
pub mod stream;
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
pub use derive::GroupKey;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
|
||||
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
|
||||
|
||||
macro_rules! hex_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct $name([u8; 32]);
|
||||
|
||||
impl $name {
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; 32]> for $name {
|
||||
fn from(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}({})", stringify!($name), self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for $name {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
Ok(Self(decode_hex_32(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for $name {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
/// A self-certifying commitment to the owner's key, carried inside invites and
|
||||
/// never on the wire.
|
||||
CommunityId
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
ChannelId
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
/// Both a Role's entity coordinate and the field it repeats in its own content.
|
||||
RoleId
|
||||
}
|
||||
|
||||
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
|
||||
#[derive(
|
||||
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct Epoch(pub u64);
|
||||
|
||||
impl From<u64> for Epoch {
|
||||
fn from(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Epoch> for u64 {
|
||||
fn from(value: Epoch) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Epoch {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.map_err(|error| anyhow!("invalid hex: {error}"))?;
|
||||
|
||||
let decoded: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
|
||||
|
||||
if HEXLOWER.encode(&decoded) != value {
|
||||
bail!("hex must be lowercase and canonical");
|
||||
}
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
|
||||
SysRng
|
||||
.try_fill_bytes(bytes)
|
||||
.map_err(|error| anyhow!("os rng: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn random_32() -> Result<[u8; 32]> {
|
||||
let mut bytes = [0u8; 32];
|
||||
fill_random(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::stream::OpenedStream;
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
|
||||
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||
const MARK_VALUE: &str = "concord";
|
||||
const WRAP_TAG: &str = "e";
|
||||
const KIND_TAG: &str = "k";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
pub async fn cache_rumor(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
opened: &OpenedStream,
|
||||
) -> Result<()> {
|
||||
let tags = vec![
|
||||
Tag::identifier(opened.rumor_id),
|
||||
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
|
||||
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
|
||||
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
|
||||
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
|
||||
Tag::public_key(opened.author),
|
||||
];
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn query_rumors(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<UnsignedEvent>> {
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
if let Some(until) = until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
||||
for event in database.query(filter).await? {
|
||||
let Some(rumor_id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match newest.get(&rumor_id) {
|
||||
Some(existing) if existing.created_at >= event.created_at => {}
|
||||
_ => {
|
||||
newest.insert(rumor_id, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut events: Vec<Event> = newest.into_values().collect();
|
||||
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
|
||||
let mut rumors = Vec::with_capacity(events.len());
|
||||
for event in events {
|
||||
let rumor = UnsignedEvent::from_json(event.content)
|
||||
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
|
||||
rumors.push(rumor);
|
||||
}
|
||||
|
||||
Ok(rumors)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelKeyRef>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub heads: Vec<EntityHead>,
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
|
||||
impl CommunityState {
|
||||
pub fn from_genesis(
|
||||
genesis: &CommunityGenesis,
|
||||
editions: &[ParsedEdition],
|
||||
added_at_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
});
|
||||
|
||||
match edition.subkind.as_str() {
|
||||
vsk::COMMUNITY_METADATA => {
|
||||
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||
relays.extend(
|
||||
metadata
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
channels.push(ChannelKeyRef {
|
||||
id: ChannelId::from_bytes(edition.entity),
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let control_pks = BTreeMap::from([(
|
||||
ROOT_EPOCH.0,
|
||||
control_signer_group_key(
|
||||
&genesis.control_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)?
|
||||
.pk(),
|
||||
)]);
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
root_epoch: ROOT_EPOCH,
|
||||
control_root: Some(genesis.control_root),
|
||||
control_pks,
|
||||
channels,
|
||||
relays,
|
||||
heads,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
|
||||
pub fn floors(&self) -> Floors {
|
||||
self.heads
|
||||
.iter()
|
||||
.map(|head| (head.entity, head.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||
self.heads = fold.floors.values().cloned().collect();
|
||||
|
||||
if let Some(community) = &fold.community {
|
||||
self.relays = community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
if metadata.deleted.unwrap_or(false) {
|
||||
self.channels.retain(|channel| channel.id != *id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||
Some(channel) => {
|
||||
channel.name = metadata.name.clone();
|
||||
|
||||
if !metadata.private {
|
||||
channel.private = false;
|
||||
}
|
||||
}
|
||||
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||
id: *id,
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.limit(1);
|
||||
|
||||
match database.query(filter).await?.into_iter().next() {
|
||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::Epoch;
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::stream::{
|
||||
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
|
||||
};
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
|
||||
#[test]
|
||||
fn rumors_read_back_after_a_restart() {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let channel = ChannelId::from_bytes([0xabu8; 32]);
|
||||
let author = Keys::generate();
|
||||
|
||||
smol::block_on(async {
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
|
||||
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
|
||||
let rumor = build_rumor_ms(
|
||||
9,
|
||||
author.public_key(),
|
||||
content,
|
||||
channel_binding_tags(&channel, Epoch(0)),
|
||||
at_ms,
|
||||
);
|
||||
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
|
||||
let (wrap, _) = wrap_seal(
|
||||
&seal,
|
||||
&group,
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(at_ms / 1000),
|
||||
&[],
|
||||
)
|
||||
.expect("wraps");
|
||||
|
||||
let opened = open_wrap(&wrap, &group).expect("opens");
|
||||
cache_rumor(&database, &channel, &opened)
|
||||
.await
|
||||
.expect("caches");
|
||||
}
|
||||
|
||||
// The group key is gone; only the local cache stands in for it.
|
||||
let rumors = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(rumors.len(), 2, "both messages come back");
|
||||
assert_eq!(rumors[0].content, "second", "newest first");
|
||||
assert_eq!(rumors[1].content, "first");
|
||||
|
||||
// A page boundary in message time, not in cache time.
|
||||
let until = Timestamp::from_secs(1_500);
|
||||
let page = query_rumors(&database, &channel, Some(until), 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].content, "first");
|
||||
|
||||
let capped = query_rumors(&database, &channel, None, 1)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(capped.len(), 1);
|
||||
assert_eq!(capped[0].content, "second");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::BASE64;
|
||||
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
|
||||
use nostr_sdk::prelude::{
|
||||
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
|
||||
UnsignedEvent,
|
||||
};
|
||||
|
||||
use crate::derive::GroupKey;
|
||||
use crate::{ChannelId, Epoch};
|
||||
|
||||
pub const KIND_WRAP: u16 = 1059;
|
||||
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
|
||||
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
|
||||
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
|
||||
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
|
||||
|
||||
const TAG_MS: &str = "ms";
|
||||
const TAG_CHANNEL: &str = "channel";
|
||||
const TAG_EPOCH: &str = "epoch";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SealForm {
|
||||
Encrypted,
|
||||
Plaintext,
|
||||
}
|
||||
|
||||
impl SealForm {
|
||||
pub fn kind(self) -> u16 {
|
||||
match self {
|
||||
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
|
||||
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_kind(kind: u16) -> Option<Self> {
|
||||
match kind {
|
||||
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
|
||||
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StreamError {
|
||||
Sign(String),
|
||||
Encrypt(String),
|
||||
Decrypt(String),
|
||||
Parse(String),
|
||||
Oversize(usize),
|
||||
BadWrapKind(u16),
|
||||
WrongStream,
|
||||
BadWrapSignature,
|
||||
BadSealKind(u16),
|
||||
BadSealSignature,
|
||||
AuthorMismatch,
|
||||
BadRumorId,
|
||||
BadMs,
|
||||
ChannelMismatch,
|
||||
EpochMismatch,
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
NotRewrappable,
|
||||
}
|
||||
|
||||
impl fmt::Display for StreamError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
StreamError::Sign(error) => write!(f, "sign: {error}"),
|
||||
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
|
||||
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
|
||||
StreamError::Parse(error) => write!(f, "parse: {error}"),
|
||||
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
|
||||
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
|
||||
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
|
||||
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
|
||||
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
|
||||
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
|
||||
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
|
||||
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
|
||||
StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"),
|
||||
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
|
||||
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
|
||||
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
|
||||
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
|
||||
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StreamError {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenedStream {
|
||||
pub rumor_id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub seal_form: SealForm,
|
||||
pub seal: Event,
|
||||
pub wrapper_id: EventId,
|
||||
pub at_ms: u64,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
pub fn split_ms(at_ms: u64) -> (u64, u16) {
|
||||
(at_ms / 1000, (at_ms % 1000) as u16)
|
||||
}
|
||||
|
||||
/// Build a rumor carrying a full epoch-ms time: `created_at`
|
||||
/// holds the seconds and an `["ms", 0..=999]` tag the remainder.
|
||||
pub fn build_rumor_ms(
|
||||
kind: u16,
|
||||
author: PublicKey,
|
||||
content: &str,
|
||||
mut tags: Vec<Tag>,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let (seconds, offset) = split_ms(at_ms);
|
||||
tags.push(Tag::custom(TAG_MS, [offset.to_string()]));
|
||||
build_rumor_secs(kind, author, content, tags, seconds)
|
||||
}
|
||||
|
||||
/// Build a rumor with a plain seconds timestamp and no `ms` tag.
|
||||
pub fn build_rumor_secs(
|
||||
kind: u16,
|
||||
author: PublicKey,
|
||||
content: &str,
|
||||
tags: Vec<Tag>,
|
||||
at_secs: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut rumor = UnsignedEvent::new(
|
||||
author,
|
||||
Timestamp::from_secs(at_secs),
|
||||
Kind::Custom(kind),
|
||||
tags,
|
||||
content,
|
||||
);
|
||||
rumor.ensure_id();
|
||||
rumor
|
||||
}
|
||||
|
||||
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
|
||||
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
|
||||
let mut tag: Option<Option<String>> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
if fields.first().map(String::as_str) == Some(TAG_MS) {
|
||||
tag = Some(fields.get(1).cloned());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(raw) = tag else {
|
||||
return Ok(seconds);
|
||||
};
|
||||
let raw = raw.ok_or(StreamError::BadMs)?;
|
||||
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(StreamError::BadMs);
|
||||
}
|
||||
|
||||
let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
|
||||
|
||||
if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) {
|
||||
return Err(StreamError::BadMs);
|
||||
}
|
||||
|
||||
Ok(seconds.saturating_add(offset))
|
||||
}
|
||||
|
||||
pub fn seal_content(
|
||||
rumor: &UnsignedEvent,
|
||||
form: SealForm,
|
||||
group: &GroupKey,
|
||||
) -> Result<String, StreamError> {
|
||||
let json = rumor.as_json();
|
||||
check_plaintext_cap(json.len())?;
|
||||
|
||||
match form {
|
||||
SealForm::Plaintext => Ok(json),
|
||||
SealForm::Encrypted => Ok(BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_seal(
|
||||
rumor: &UnsignedEvent,
|
||||
form: SealForm,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
) -> Result<Event, StreamError> {
|
||||
let content = seal_content(rumor, form, group)?;
|
||||
EventBuilder::new(Kind::Custom(form.kind()), content)
|
||||
.custom_created_at(rumor.created_at)
|
||||
.finalize(author)
|
||||
.map_err(|error| StreamError::Sign(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn wrap_seal(
|
||||
seal: &Event,
|
||||
group: &GroupKey,
|
||||
wrap_kind: u16,
|
||||
at: Timestamp,
|
||||
extra: &[Tag],
|
||||
) -> Result<(Event, Keys), StreamError> {
|
||||
wrap_seal_with(
|
||||
seal,
|
||||
group.conversation(),
|
||||
group.keys(),
|
||||
wrap_kind,
|
||||
at,
|
||||
extra,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn wrap_seal_with(
|
||||
seal: &Event,
|
||||
conversation: &ConversationKey,
|
||||
signer: &Keys,
|
||||
wrap_kind: u16,
|
||||
at: Timestamp,
|
||||
extra: &[Tag],
|
||||
) -> Result<(Event, Keys), StreamError> {
|
||||
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
||||
return Err(StreamError::BadWrapKind(wrap_kind));
|
||||
}
|
||||
|
||||
let json = seal.as_json();
|
||||
check_plaintext_cap(json.len())?;
|
||||
|
||||
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
|
||||
let ephemeral = Keys::generate();
|
||||
|
||||
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
|
||||
tags.extend_from_slice(extra);
|
||||
|
||||
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize(signer)
|
||||
.map_err(|error| StreamError::Sign(error.to_string()))?;
|
||||
|
||||
Ok((wrap, ephemeral))
|
||||
}
|
||||
|
||||
pub fn rewrap_seal(
|
||||
seal: &Event,
|
||||
new_group: &GroupKey,
|
||||
at: Timestamp,
|
||||
) -> Result<(Event, Keys), StreamError> {
|
||||
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
|
||||
return Err(StreamError::NotRewrappable);
|
||||
}
|
||||
wrap_seal(seal, new_group, KIND_WRAP, at, &[])
|
||||
}
|
||||
|
||||
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
|
||||
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
|
||||
}
|
||||
|
||||
/// Open and verify a wrap against a stream read view: the address to check and
|
||||
/// the conversation key that opens the wraps, with no signing secret required.
|
||||
pub fn open_wrap_at(
|
||||
wrap: &Event,
|
||||
address: &PublicKey,
|
||||
conversation: &ConversationKey,
|
||||
verify_wrap_signature: bool,
|
||||
) -> Result<OpenedStream, StreamError> {
|
||||
let wrap_kind = wrap.kind.as_u16();
|
||||
|
||||
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
||||
return Err(StreamError::BadWrapKind(wrap_kind));
|
||||
}
|
||||
|
||||
if wrap.pubkey != *address {
|
||||
return Err(StreamError::WrongStream);
|
||||
}
|
||||
|
||||
if verify_wrap_signature && wrap.verify().is_err() {
|
||||
return Err(StreamError::BadWrapSignature);
|
||||
}
|
||||
|
||||
let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?)
|
||||
.map_err(|error| StreamError::Parse(error.to_string()))?;
|
||||
let seal_kind = seal.kind.as_u16();
|
||||
let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?;
|
||||
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
|
||||
|
||||
let rumor_json = match seal_form {
|
||||
SealForm::Plaintext => seal.content.clone(),
|
||||
SealForm::Encrypted => decode_content(conversation, &seal.content)?,
|
||||
};
|
||||
|
||||
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes())
|
||||
.map_err(|error| StreamError::Parse(error.to_string()))?;
|
||||
|
||||
if rumor.pubkey != seal.pubkey {
|
||||
return Err(StreamError::AuthorMismatch);
|
||||
}
|
||||
|
||||
let computed = rumor.compute_id();
|
||||
if let Some(claimed) = rumor.id
|
||||
&& claimed != computed
|
||||
{
|
||||
return Err(StreamError::BadRumorId);
|
||||
}
|
||||
rumor.id = Some(computed);
|
||||
|
||||
let at_ms = resolve_ms_strict(&rumor)?;
|
||||
|
||||
Ok(OpenedStream {
|
||||
rumor_id: computed,
|
||||
author: seal.pubkey,
|
||||
seal_form,
|
||||
seal,
|
||||
wrapper_id: wrap.id,
|
||||
at_ms,
|
||||
rumor,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::custom(TAG_CHANNEL, [channel.to_hex()]),
|
||||
Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn check_channel_binding(
|
||||
rumor: &UnsignedEvent,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
) -> Result<(), StreamError> {
|
||||
match unique_tag(rumor, TAG_CHANNEL)? {
|
||||
Some(value) if value == channel.to_hex() => {}
|
||||
Some(_) => return Err(StreamError::ChannelMismatch),
|
||||
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
|
||||
}
|
||||
|
||||
match unique_tag(rumor, TAG_EPOCH)? {
|
||||
Some(value) if value == epoch.0.to_string() => {}
|
||||
Some(_) => return Err(StreamError::EpochMismatch),
|
||||
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
|
||||
let mut nonce = [0u8; 32];
|
||||
|
||||
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||
|
||||
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
||||
}
|
||||
|
||||
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
|
||||
let payload = BASE64
|
||||
.decode(content.as_bytes())
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||
|
||||
let plaintext = decrypt_to_bytes(conversation, &payload)
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
|
||||
if len > NIP44_MAX_PLAINTEXT {
|
||||
return Err(StreamError::Oversize(len));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
|
||||
let mut found: Option<String> = None;
|
||||
|
||||
for tag in rumor.tags.iter() {
|
||||
let fields = tag.as_slice();
|
||||
if fields.len() >= 2 && fields[0] == name {
|
||||
if found.is_some() {
|
||||
return Err(StreamError::DuplicateTag(name));
|
||||
}
|
||||
found = Some(fields[1].clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::derive::channel_group_key;
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
|
||||
|
||||
fn channel() -> ChannelId {
|
||||
ChannelId::from_bytes([0xabu8; 32])
|
||||
}
|
||||
|
||||
fn group(epoch: u64) -> GroupKey {
|
||||
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
|
||||
}
|
||||
|
||||
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
|
||||
wrap.tags
|
||||
.iter()
|
||||
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
|
||||
.and_then(|tag| tag.as_slice().get(1).cloned())
|
||||
}
|
||||
|
||||
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
|
||||
build_rumor_ms(
|
||||
9,
|
||||
author,
|
||||
content,
|
||||
channel_binding_tags(&channel(), Epoch(0)),
|
||||
at_ms,
|
||||
)
|
||||
}
|
||||
|
||||
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
|
||||
build_seal(rumor, form, &group(0), author).expect("seals")
|
||||
}
|
||||
|
||||
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
|
||||
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
|
||||
.expect("wraps")
|
||||
.0
|
||||
}
|
||||
|
||||
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
|
||||
let rumor = bound_rumor(content, author.public_key(), at_ms);
|
||||
wrapped(
|
||||
&sealed(&rumor, SealForm::Encrypted, author),
|
||||
kind,
|
||||
at_ms / 1000,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_seal_forms_round_trip() {
|
||||
let author = Keys::generate();
|
||||
let at_ms = 1_686_840_217_417;
|
||||
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
|
||||
|
||||
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
|
||||
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
|
||||
|
||||
let opened = open_wrap(&wrap, &group(0)).expect("opens");
|
||||
assert_eq!(opened.author, author.public_key());
|
||||
assert_eq!(opened.rumor.content, "Hey chat!");
|
||||
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
|
||||
assert_eq!(opened.wrapper_id, wrap.id);
|
||||
assert_eq!(opened.at_ms, at_ms);
|
||||
assert_eq!(opened.seal_form, SealForm::Encrypted);
|
||||
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
|
||||
|
||||
// The wrap's `p` tag must identify neither the stream nor the author.
|
||||
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
|
||||
assert_ne!(p, group(0).pk_hex());
|
||||
assert_ne!(p, author.public_key().to_hex());
|
||||
|
||||
// Ephemeral actions ride the same structure at a kind relays must drop.
|
||||
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
|
||||
assert_eq!(typing.kind.as_u16(), 21059);
|
||||
assert_eq!(
|
||||
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
|
||||
"typing"
|
||||
);
|
||||
|
||||
// The plaintext form carries the rumor's bytes verbatim, which is what
|
||||
// lets a compaction re-wrap the signed edition into a later epoch.
|
||||
let edition = build_rumor_secs(
|
||||
3308,
|
||||
author.public_key(),
|
||||
"an edition",
|
||||
vec![],
|
||||
1_700_000_000,
|
||||
);
|
||||
let seal = sealed(&edition, SealForm::Plaintext, &author);
|
||||
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
|
||||
|
||||
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
|
||||
assert_eq!(opened.seal_form, SealForm::Plaintext);
|
||||
|
||||
let (rewrapped, _) =
|
||||
rewrap_seal(&opened.seal, &group(1), Timestamp::from_secs(2)).expect("rewraps");
|
||||
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
|
||||
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
|
||||
assert_eq!(reopened.author, author.public_key());
|
||||
assert_eq!(
|
||||
reopened.seal.sig, opened.seal.sig,
|
||||
"the signature rides whole"
|
||||
);
|
||||
assert_ne!(reopened.wrapper_id, opened.wrapper_id);
|
||||
|
||||
assert!(matches!(
|
||||
rewrap_seal(
|
||||
&sealed(&edition, SealForm::Encrypted, &author),
|
||||
&group(1),
|
||||
Timestamp::from_secs(2)
|
||||
),
|
||||
Err(StreamError::NotRewrappable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_wraps_are_dropped_in_order() {
|
||||
let author = Keys::generate();
|
||||
let impostor = Keys::generate();
|
||||
|
||||
// Kind and address are settled before any decryption is attempted.
|
||||
let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
|
||||
wrong_kind.kind = Kind::Custom(1058);
|
||||
assert!(matches!(
|
||||
open_wrap(&wrong_kind, &group(0)),
|
||||
Err(StreamError::BadWrapKind(1058))
|
||||
));
|
||||
|
||||
let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives");
|
||||
let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
|
||||
assert!(matches!(
|
||||
open_wrap(&wrap, &foreign),
|
||||
Err(StreamError::WrongStream)
|
||||
));
|
||||
|
||||
// A flipped ciphertext byte fails the NIP-44 MAC.
|
||||
let mut payload = BASE64
|
||||
.decode(wrap.content.as_bytes())
|
||||
.expect("content is base64");
|
||||
payload[40] ^= 0x01;
|
||||
let mut tampered = wrap.clone();
|
||||
tampered.content = BASE64.encode(&payload);
|
||||
assert!(matches!(
|
||||
open_wrap(&tampered, &group(0)),
|
||||
Err(StreamError::Decrypt(_))
|
||||
));
|
||||
|
||||
// A seal claiming an author it holds no signature for.
|
||||
let seal = sealed(
|
||||
&bound_rumor("spoof", author.public_key(), 1_000),
|
||||
SealForm::Encrypted,
|
||||
&impostor,
|
||||
);
|
||||
let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json");
|
||||
swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
|
||||
let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses");
|
||||
assert!(matches!(
|
||||
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
||||
Err(StreamError::BadSealSignature)
|
||||
));
|
||||
|
||||
// A seal that does not vouch for the rumor's author.
|
||||
let seal = sealed(
|
||||
&bound_rumor("spoof", impostor.public_key(), 1_000),
|
||||
SealForm::Encrypted,
|
||||
&author,
|
||||
);
|
||||
assert!(matches!(
|
||||
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
||||
Err(StreamError::AuthorMismatch)
|
||||
));
|
||||
|
||||
// A claimed id the rumor's own bytes do not hash to. The plaintext seal
|
||||
// smuggles the forgery through verbatim.
|
||||
let rumor = bound_rumor("real", author.public_key(), 1_000);
|
||||
let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json");
|
||||
forged["id"] = serde_json::Value::String("00".repeat(32));
|
||||
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string())
|
||||
.custom_created_at(rumor.created_at)
|
||||
.finalize(&author)
|
||||
.expect("seals");
|
||||
assert!(matches!(
|
||||
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
||||
Err(StreamError::BadRumorId)
|
||||
));
|
||||
|
||||
// Binding splices: another channel, another epoch, a duplicate or none.
|
||||
let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat();
|
||||
let rumor = bound_rumor("x", author.public_key(), 1_000);
|
||||
|
||||
assert!(matches!(
|
||||
check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)),
|
||||
Err(StreamError::ChannelMismatch)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
check_channel_binding(&rumor, &channel(), Epoch(1)),
|
||||
Err(StreamError::EpochMismatch)
|
||||
));
|
||||
|
||||
let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000);
|
||||
assert!(matches!(
|
||||
check_channel_binding(&duplicate, &channel(), Epoch(0)),
|
||||
Err(StreamError::DuplicateTag(_))
|
||||
));
|
||||
|
||||
let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000);
|
||||
assert!(matches!(
|
||||
check_channel_binding(&unbound, &channel(), Epoch(0)),
|
||||
Err(StreamError::MissingTag(_))
|
||||
));
|
||||
|
||||
let oversize = build_rumor_ms(
|
||||
9,
|
||||
author.public_key(),
|
||||
&"x".repeat(NIP44_MAX_PLAINTEXT + 1),
|
||||
vec![],
|
||||
1_000,
|
||||
);
|
||||
assert!(matches!(
|
||||
seal_content(&oversize, SealForm::Encrypted, &group(0)),
|
||||
Err(StreamError::Oversize(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ms_is_a_drop_gate() {
|
||||
let author = Keys::generate();
|
||||
|
||||
let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000);
|
||||
assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000);
|
||||
|
||||
let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999);
|
||||
assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999);
|
||||
|
||||
for malformed in ["1000", "007", "abc", "+5", ""] {
|
||||
let rumor = build_rumor_secs(
|
||||
9,
|
||||
author.public_key(),
|
||||
"x",
|
||||
vec![Tag::custom(TAG_MS, [malformed.to_string()])],
|
||||
1_000,
|
||||
);
|
||||
assert!(
|
||||
matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)),
|
||||
"{malformed:?} must be malformed"
|
||||
);
|
||||
}
|
||||
|
||||
// Present but valueless is malformed, not an offset-0 default.
|
||||
let valueless = build_rumor_secs(
|
||||
9,
|
||||
author.public_key(),
|
||||
"x",
|
||||
vec![Tag::custom(TAG_MS, Vec::<String>::new())],
|
||||
1_000,
|
||||
);
|
||||
assert!(matches!(
|
||||
resolve_ms_strict(&valueless),
|
||||
Err(StreamError::BadMs)
|
||||
));
|
||||
|
||||
// A valued duplicate takes the first, matching Armada.
|
||||
let repeated = build_rumor_secs(
|
||||
9,
|
||||
author.public_key(),
|
||||
"x",
|
||||
vec![
|
||||
Tag::custom(TAG_MS, ["1".to_string()]),
|
||||
Tag::custom(TAG_MS, ["2".to_string()]),
|
||||
],
|
||||
1_000,
|
||||
);
|
||||
assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "nip4e"
|
||||
name = "device"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
@@ -13,6 +14,7 @@ settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
@@ -8,14 +8,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window, div, px, relative,
|
||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::Button;
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
@@ -23,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
@@ -39,13 +40,13 @@ pub enum DeviceEvent {
|
||||
/// User have not setup encryption key
|
||||
NotSet,
|
||||
/// The device is requesting an encryption key
|
||||
Requesting(SharedString),
|
||||
Requesting,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
impl DeviceEvent {
|
||||
pub(crate) fn error<T>(error: T) -> Self
|
||||
pub fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
@@ -58,16 +59,19 @@ impl DeviceEvent {
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
signer: Entity<Option<UniversalSigner>>,
|
||||
/// Whether there is a pending request for encryption key approval
|
||||
pub(crate) pending_request: bool,
|
||||
pub pending_request: bool,
|
||||
|
||||
/// Whether an announcement has been made for this device
|
||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||
/// Person directory for requesters shown in notifications
|
||||
directory: Entity<Directory>,
|
||||
/// Relay notification listener
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
pub announcement_existed: Arc<AtomicBool>,
|
||||
|
||||
/// Signer
|
||||
signer: Entity<Option<UniversalSigner>>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscription
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
@@ -85,8 +89,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
|
||||
@@ -106,34 +109,25 @@ impl DeviceRegistry {
|
||||
// Observe the user signer
|
||||
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
|
||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||
// The old account's state must not leak into the new one
|
||||
this.announcement_existed.store(false, Ordering::Relaxed);
|
||||
this.handle_notifications(cx);
|
||||
this.get_announcement(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| this.handle_notifications(cx))
|
||||
.ok();
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
signer,
|
||||
pending_request: false,
|
||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||
directory: Directory::new(cx),
|
||||
notification_listener: None,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -141,7 +135,7 @@ impl DeviceRegistry {
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
let current_user = signer.get_public_key_async().await.ok();
|
||||
@@ -174,18 +168,18 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
Kind::Custom(10044) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_encryption(&event, cx);
|
||||
})?;
|
||||
}
|
||||
// New request event from other device
|
||||
Kind::Custom(4454) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.ask_for_approval(event, cx);
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event from the master device
|
||||
@@ -248,41 +242,39 @@ impl DeviceRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(current_user)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe(filter)
|
||||
.close_on(opts)
|
||||
.with_id(SubscriptionId::new("nip4e"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
// Wait for 5 seconds
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(current_user)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe(filter)
|
||||
.close_on(opts)
|
||||
.with_id(SubscriptionId::new("nip4e"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
// Then check if the msg relays have been found
|
||||
if announcement_existed.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !announcement_existed.load(Ordering::Acquire) {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
}
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -322,13 +314,13 @@ impl DeviceRegistry {
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
||||
.tag(Tag::custom("n", vec![n]))
|
||||
.tags(vec![
|
||||
Tag::custom("n", vec![n]),
|
||||
Tag::custom("client", vec![CLIENT_NAME]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Persist the key locally before announcing it
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
|
||||
// Publish announcement
|
||||
client
|
||||
.send_event(&event)
|
||||
@@ -336,21 +328,20 @@ impl DeviceRegistry {
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Save device keys to the database
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
|
||||
Ok(keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// Set encryption key from the announcement event
|
||||
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
log::warn!("Ignoring malformed encryption announcement");
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let announcement = Announcement::from(event);
|
||||
let device_pubkey = announcement.public_key();
|
||||
|
||||
// Get encryption key from the database and compare with the announcement
|
||||
@@ -390,8 +381,11 @@ impl DeviceRegistry {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let id = SubscriptionId::new("dekey-requests");
|
||||
|
||||
// Construct a filter for encryption key requests.
|
||||
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
|
||||
// Construct a filter for encryption key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4454))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(vec![filter]).with_id(id).await?;
|
||||
@@ -401,7 +395,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Request encryption keys from other device
|
||||
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -420,27 +414,26 @@ impl DeviceRegistry {
|
||||
.pubkey(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
// Only reuse the stored approval if its key is still valid,
|
||||
// otherwise it's stale and a fresh request is needed
|
||||
if decrypt_approval(&client, &app_keys, public_key, &event)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Some(event));
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
// Found an approval event
|
||||
Some(event) => Ok(Some(event)),
|
||||
// No approval event found, construct a request event
|
||||
None => {
|
||||
// Construct an event for device key request
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![
|
||||
Tag::custom("P", vec![app_pubkey]),
|
||||
Tag::custom("client", vec![CLIENT_NAME]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// No usable approval event found, construct a request event
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![Tag::custom("P", vec![app_pubkey])])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -470,45 +463,20 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let app_keys = match app_keys_task.await {
|
||||
Ok(app_keys) => app_keys,
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
cx.emit(DeviceEvent::Requesting);
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::Requesting(authorization_code(
|
||||
&app_keys.public_key(),
|
||||
)));
|
||||
})?;
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
// Construct a filter for device key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Only approvals addressed to this device's app key matter
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.pubkey(app_keys.public_key());
|
||||
|
||||
// Subscribe to the device key approvals on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -517,16 +485,22 @@ impl DeviceRegistry {
|
||||
/// Parse the approval event to get encryption key then set it
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let approval_id = event.id;
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let identity = signer.get_public_key_async().await?;
|
||||
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let payload = event.content.as_str();
|
||||
let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
Ok(keys)
|
||||
});
|
||||
@@ -536,8 +510,6 @@ impl DeviceRegistry {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
// Clean up our request and the approval event from relays
|
||||
this.cleanup_sync_events(approval_id, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -550,47 +522,6 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Delete our key request and the consumed approval event from relays
|
||||
fn cleanup_sync_events(&mut self, approval_id: EventId, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let result: Result<(), Error> = async {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Collect our key request events
|
||||
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
|
||||
|
||||
let mut ids: Vec<EventId> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
ids.push(approval_id);
|
||||
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(Tags::from_list(ids.into_iter().map(Tag::event).collect()))
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
client.send_event(&deletion).to_nip65().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to delete sync events: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Approve requests for device keys from other devices
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -636,14 +567,9 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
// Allow future requests to surface again
|
||||
this.set_pending_request(false, cx);
|
||||
})?;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.clear_notification_by_id::<DeviceNotification>(id, cx);
|
||||
})
|
||||
@@ -665,89 +591,57 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Ignore if there is already a pending request
|
||||
if self.pending_request {
|
||||
return;
|
||||
}
|
||||
self.set_pending_request(true, cx);
|
||||
|
||||
let notification = match self.notification(event, cx) {
|
||||
Ok(notification) => notification,
|
||||
Err(e) => {
|
||||
log::warn!("Ignoring invalid encryption key request: {e}");
|
||||
self.set_pending_request(false, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(window) = cx.windows().first().copied() {
|
||||
if let Err(error) = window.update(cx, |_view, window, cx| {
|
||||
window.push_notification(notification, cx);
|
||||
}) {
|
||||
log::warn!("Failed to show encryption key request: {error}");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to show encryption key request: no open window");
|
||||
}
|
||||
// Show notification
|
||||
let notification = self.notification(event, cx);
|
||||
window.push_notification(notification, cx);
|
||||
}
|
||||
|
||||
/// Build a notification for the encryption request.
|
||||
fn notification(
|
||||
&mut self,
|
||||
event: Event,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<Notification, Error> {
|
||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
||||
const MSG: &str = "You've requested an encryption key from another device. \
|
||||
Approve to allow Coop to share with it. Before approving, \
|
||||
compare the code with the one shown on the requesting \
|
||||
device.";
|
||||
Approve to allow Coop to share with it.";
|
||||
|
||||
let requester = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Request has no valid requester key")?;
|
||||
|
||||
let code = authorization_code(&requester);
|
||||
let directory = self.directory.clone();
|
||||
directory.update(cx, |directory, cx| directory.track([requester], cx));
|
||||
let code_label = code.clone();
|
||||
let request = Announcement::from(&event);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&request.public_key(), cx);
|
||||
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
let key = SharedString::from(event.id.to_hex());
|
||||
|
||||
Ok(Notification::new()
|
||||
Notification::new()
|
||||
.type_id::<DeviceNotification>(key)
|
||||
.autohide(false)
|
||||
.with_kind(NotificationKind::Info)
|
||||
.title("Encryption Key Request")
|
||||
.content(move |_this, _window, cx| {
|
||||
let profile = directory.read(cx).person(&requester, cx);
|
||||
|
||||
v_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_sm()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("From:")),
|
||||
)
|
||||
@@ -756,16 +650,12 @@ impl DeviceRegistry {
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).xsmall())
|
||||
.child(profile.name()),
|
||||
),
|
||||
),
|
||||
@@ -773,22 +663,22 @@ impl DeviceRegistry {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Code:")),
|
||||
.child(SharedString::from("Client:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.child(code_label.clone()),
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(request.client_name()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -814,19 +704,12 @@ impl DeviceRegistry {
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// The first eight hexadecimal characters of the client public key.
|
||||
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
||||
let hex = public_key.to_hex();
|
||||
let code = hex[..8].to_uppercase();
|
||||
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
|
||||
}
|
||||
|
||||
/// Get or create new app keys (async, returns a task)
|
||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let read = cx.read_credentials(CLIENT_NAME);
|
||||
@@ -857,56 +740,8 @@ fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt and validate an approval event against the current announcement.
|
||||
async fn decrypt_approval(
|
||||
client: &Client,
|
||||
app_keys: &Keys,
|
||||
identity: PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<Keys, Error> {
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let decrypted = app_keys
|
||||
.nip44_decrypt_async(&master, event.content.as_str())
|
||||
.await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
// Reject approvals carrying a key that no longer matches the published announcement.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(identity)
|
||||
.limit(1);
|
||||
|
||||
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
|
||||
let announced = announcement
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Announcement has no valid encryption key")?;
|
||||
|
||||
if keys.public_key() != announced {
|
||||
return Err(anyhow!("Approval key doesn't match the announcement"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Result<(), Error> {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
|
||||
|
||||
@@ -923,10 +758,7 @@ where
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "gpui_tokio"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_util.workspace = true
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
|
||||
@@ -1,84 +0,0 @@
|
||||
use std::future::Future;
|
||||
|
||||
use gpui::{App, AppContext, Global, Task};
|
||||
use gpui_util::defer;
|
||||
use tokio::task::JoinError;
|
||||
|
||||
/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads.
|
||||
pub fn init(cx: &mut App) {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
// Since we now have two executors, let's try to keep our footprint small
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to initialize Tokio");
|
||||
|
||||
let handle = runtime.handle().clone();
|
||||
cx.set_global(GlobalTokio {
|
||||
owned_runtime: Some(runtime),
|
||||
handle,
|
||||
});
|
||||
}
|
||||
|
||||
struct GlobalTokio {
|
||||
owned_runtime: Option<tokio::runtime::Runtime>,
|
||||
handle: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl Global for GlobalTokio {}
|
||||
|
||||
impl Drop for GlobalTokio {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.owned_runtime.take() {
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tokio {}
|
||||
|
||||
impl Tokio {
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn<C, Fut, R>(cx: &C, f: Fut) -> Task<Result<R, JoinError>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = R> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn_result<C, Fut, R>(cx: &C, f: Fut) -> Task<anyhow::Result<R>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = anyhow::Result<R>> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await?;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "nip29"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -1,287 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use futures::future::join_all;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList, GroupMetadata};
|
||||
use crate::utils::parse_members;
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct Discovery {
|
||||
refs: HashMap<RelayUrl, HashSet<GroupId>>,
|
||||
discovered: Vec<GroupCandidate>,
|
||||
members: HashMap<GroupKey, (Timestamp, usize)>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
/// The discovered groups, sorted by name.
|
||||
pub fn discovered(&self) -> &[GroupCandidate] {
|
||||
&self.discovered
|
||||
}
|
||||
|
||||
/// The member count of a group, when its member list is known.
|
||||
pub fn members(&self, key: &GroupKey) -> Option<usize> {
|
||||
self.members.get(key).map(|(_, members)| *members)
|
||||
}
|
||||
|
||||
/// The (relay, group) references, for asking the relays for metadata.
|
||||
pub(crate) fn refs(&self) -> &HashMap<RelayUrl, HashSet<GroupId>> {
|
||||
&self.refs
|
||||
}
|
||||
|
||||
/// Absorb the references of a group list, skipping the joined groups.
|
||||
pub(crate) fn absorb_list(&mut self, list: &GroupList, joined: &HashSet<(RelayUrl, GroupId)>) {
|
||||
for entry in list.entries() {
|
||||
if joined.contains(&(entry.relay.clone(), entry.id.clone())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.refs
|
||||
.entry(entry.relay.clone())
|
||||
.or_default()
|
||||
.insert(entry.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb the newest group list of every author.
|
||||
pub(crate) fn absorb_lists(
|
||||
&mut self,
|
||||
lists: Vec<Event>,
|
||||
joined: &HashSet<(RelayUrl, GroupId)>,
|
||||
) {
|
||||
let newest: HashMap<PublicKey, Event> =
|
||||
lists.into_iter().fold(HashMap::new(), |mut newest, event| {
|
||||
match newest.get(&event.pubkey) {
|
||||
Some(current) if current.created_at >= event.created_at => newest,
|
||||
_ => {
|
||||
newest.insert(event.pubkey, event);
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for event in newest.into_values() {
|
||||
let Ok(list) = GroupList::parse(&event) else {
|
||||
log::warn!("ignoring a malformed group list from {}", event.pubkey);
|
||||
continue;
|
||||
};
|
||||
|
||||
self.absorb_list(&list, joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a discovered group, keeping the newest metadata.
|
||||
pub(crate) fn upsert(&mut self, candidate: GroupCandidate) {
|
||||
match self.discovered.iter_mut().find(|d| d.key == candidate.key) {
|
||||
Some(existing) if existing.created_at <= candidate.created_at => {
|
||||
*existing = candidate;
|
||||
}
|
||||
Some(_) => return,
|
||||
None => self.discovered.push(candidate),
|
||||
}
|
||||
|
||||
self.discovered.sort_by(|a, b| {
|
||||
a.display_name()
|
||||
.to_lowercase()
|
||||
.cmp(&b.display_name().to_lowercase())
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply a member count, keeping the newest list.
|
||||
pub fn set_members(&mut self, key: GroupKey, created_at: Timestamp, members: usize) {
|
||||
if self
|
||||
.members
|
||||
.get(&key)
|
||||
.is_some_and(|(current, _)| *current > created_at)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.members.insert(key, (created_at, members));
|
||||
}
|
||||
|
||||
/// Seed the discoveries from the cached group metadata.
|
||||
pub(crate) fn absorb_cached_previews(&mut self, previews: Vec<Event>) {
|
||||
let newest: HashMap<GroupId, (Timestamp, GroupMetadata)> =
|
||||
previews
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut newest, event| {
|
||||
let metadata = match GroupMetadata::parse(&event) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
log::warn!("ignoring malformed cached group metadata: {error}");
|
||||
return newest;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(id) = metadata.id().cloned() else {
|
||||
return newest;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some((created_at, _)) if *created_at >= event.created_at => newest,
|
||||
_ => {
|
||||
newest.insert(id, (event.created_at, metadata));
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (relay, ids) in self.refs.clone() {
|
||||
for id in ids {
|
||||
let Some((created_at, metadata)) = newest.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Hidden groups are not discoverable (NIP-29).
|
||||
if metadata.hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.upsert(GroupCandidate {
|
||||
key: GroupKey::new(relay.clone(), id.clone()),
|
||||
created_at: *created_at,
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the member counts from the cached member lists.
|
||||
pub(crate) fn absorb_cached_members(&mut self, members: Vec<Event>) {
|
||||
let newest: HashMap<GroupId, (Timestamp, usize)> =
|
||||
members
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut newest, event| {
|
||||
let Some((id, created_at, count)) = parse_members(&event) else {
|
||||
return newest;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some((current, _)) if *current >= created_at => newest,
|
||||
_ => {
|
||||
newest.insert(id, (created_at, count));
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (relay, ids) in self.refs.clone() {
|
||||
for id in ids {
|
||||
let Some((created_at, count)) = newest.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.set_members(
|
||||
GroupKey::new(relay.clone(), id.clone()),
|
||||
*created_at,
|
||||
*count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the discoverable groups from the local database only.
|
||||
pub async fn cached(client: Client, joined: HashSet<(RelayUrl, GroupId)>) -> Self {
|
||||
let mut discovery = Self::default();
|
||||
|
||||
// The group lists of every cached author.
|
||||
let lists: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::SimpleGroups))
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_lists(lists, &joined);
|
||||
|
||||
let refs = discovery.refs().clone();
|
||||
|
||||
if refs.is_empty() {
|
||||
return discovery;
|
||||
}
|
||||
|
||||
let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect();
|
||||
|
||||
// The cached metadata of the referenced groups.
|
||||
let previews: Vec<Event> = client
|
||||
.database()
|
||||
.query(
|
||||
Filter::new()
|
||||
.kind(Kind::GroupMetadata)
|
||||
.identifiers(ids.clone()),
|
||||
)
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_cached_previews(previews);
|
||||
|
||||
// The cached member lists of the referenced groups.
|
||||
let members: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::GroupMembers).identifiers(ids))
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_cached_members(members);
|
||||
|
||||
discovery
|
||||
}
|
||||
|
||||
/// Reconcile the discoverable groups with the relays.
|
||||
pub async fn sync(
|
||||
&mut self,
|
||||
client: Client,
|
||||
me: PublicKey,
|
||||
joined: HashSet<(RelayUrl, GroupId)>,
|
||||
) {
|
||||
let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1);
|
||||
|
||||
let authors: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|events| events.into_iter().next())
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||
let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups);
|
||||
client.sync(filter.clone()).with(urls).await.ok();
|
||||
|
||||
let lists: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.absorb_lists(lists, &joined);
|
||||
|
||||
// The pool-wide sweep of the group metadata and members.
|
||||
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||
let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]);
|
||||
client.sync(filter).with(urls).await.ok();
|
||||
|
||||
// The previews cover the relays the lists just referenced.
|
||||
let fetches = self.refs.clone().into_iter().map(|(relay, ids)| {
|
||||
let client = client.clone();
|
||||
|
||||
async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds([Kind::GroupMetadata, Kind::GroupMembers])
|
||||
.identifiers(ids.iter().map(GroupId::as_str));
|
||||
|
||||
let urls = vec![relay];
|
||||
client.sync(filter).with(urls).await.ok();
|
||||
}
|
||||
});
|
||||
|
||||
join_all(fetches).await;
|
||||
}
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chat::Message;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::LOCAL_KEYS;
|
||||
use crate::protocol::{
|
||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
||||
TimelineWindow, tag_value,
|
||||
};
|
||||
|
||||
const REPLAY_LIMIT: usize = 100;
|
||||
const PAGE_LIMIT: usize = 50;
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LEAVE_WINDOW_SECS: u64 = 5 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Absorbed {
|
||||
Ignored,
|
||||
Shown { fresh: bool, updated: bool },
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reaction {
|
||||
pub id: EventId,
|
||||
pub emoji: String,
|
||||
pub image: Option<SharedUri>,
|
||||
pub author: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Invite {
|
||||
pub id: EventId,
|
||||
pub code: String,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Row {
|
||||
Message(Box<Message>),
|
||||
Activity(Activity),
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn id(&self) -> EventId {
|
||||
match self {
|
||||
Self::Message(message) => message.id,
|
||||
Self::Activity(activity) => activity.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn author(&self) -> PublicKey {
|
||||
match self {
|
||||
Self::Message(message) => message.author,
|
||||
Self::Activity(activity) => activity.author,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> Timestamp {
|
||||
match self {
|
||||
Self::Message(message) => message.created_at,
|
||||
Self::Activity(activity) => activity.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GroupEvent {
|
||||
Incoming(Event),
|
||||
Sent(Event),
|
||||
Updated,
|
||||
Eose,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub struct Group {
|
||||
key: GroupKey,
|
||||
metadata: Option<GroupMetadata>,
|
||||
members: GroupMembers,
|
||||
admins: GroupAdmins,
|
||||
roles: GroupRoles,
|
||||
pins: GroupPins,
|
||||
membership: Membership,
|
||||
membership_events: Vec<Event>,
|
||||
elsewhere: Option<GroupKey>,
|
||||
state_ids: HashMap<Kind, EventId>,
|
||||
rows: Vec<Row>,
|
||||
index: HashSet<EventId>,
|
||||
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||
/// Active invite codes; revocations arrive as 9005 deletions.
|
||||
invites: Vec<Invite>,
|
||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||
window: TimelineWindow,
|
||||
oldest: Option<Timestamp>,
|
||||
loading: bool,
|
||||
loading_more: bool,
|
||||
active: bool,
|
||||
refused: Option<String>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupEvent> for Group {}
|
||||
|
||||
impl Group {
|
||||
pub(crate) fn new(key: GroupKey) -> Self {
|
||||
Self {
|
||||
key,
|
||||
metadata: None,
|
||||
members: GroupMembers::default(),
|
||||
admins: GroupAdmins::default(),
|
||||
roles: GroupRoles::default(),
|
||||
pins: GroupPins::default(),
|
||||
membership: Membership::Unknown,
|
||||
membership_events: Vec::new(),
|
||||
elsewhere: None,
|
||||
state_ids: HashMap::new(),
|
||||
rows: Vec::new(),
|
||||
index: HashSet::new(),
|
||||
reactions: HashMap::new(),
|
||||
invites: Vec::new(),
|
||||
leave_requests: HashMap::new(),
|
||||
window: TimelineWindow::new(),
|
||||
oldest: None,
|
||||
loading: true,
|
||||
loading_more: false,
|
||||
active: false,
|
||||
refused: None,
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &GroupKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> Option<&GroupMetadata> {
|
||||
self.metadata.as_ref()
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
let name = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.unwrap_or_else(|| self.key.id().as_str());
|
||||
|
||||
SharedString::from(name.to_owned())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.picture.clone())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
pub fn total_members(&self) -> usize {
|
||||
self.members.len()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &GroupMembers {
|
||||
&self.members
|
||||
}
|
||||
|
||||
pub fn admins(&self) -> &GroupAdmins {
|
||||
&self.admins
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &GroupRoles {
|
||||
&self.roles
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &GroupPins {
|
||||
&self.pins
|
||||
}
|
||||
|
||||
pub fn membership(&self) -> &Membership {
|
||||
&self.membership
|
||||
}
|
||||
|
||||
pub fn elsewhere(&self) -> Option<&GroupKey> {
|
||||
self.elsewhere.as_ref()
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> &[Row] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn message(&self, id: EventId) -> Option<&Message> {
|
||||
self.rows.iter().find_map(|row| match row {
|
||||
Row::Message(message) if message.id == id => Some(message.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reactions(&self, id: &EventId) -> &[Reaction] {
|
||||
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn invites(&self) -> &[Invite] {
|
||||
&self.invites
|
||||
}
|
||||
|
||||
pub fn loading_more(&self) -> bool {
|
||||
self.loading_more
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub(crate) fn activate(&mut self) {
|
||||
self.active = true;
|
||||
}
|
||||
|
||||
pub fn oldest(&self) -> Option<Timestamp> {
|
||||
self.oldest
|
||||
}
|
||||
|
||||
pub fn refused(&self) -> Option<&str> {
|
||||
self.refused.as_deref()
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
emojis: Vec<Tag>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(
|
||||
move |id, prev| id.message(content, &replies, &emojis, prev),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn send_reaction(
|
||||
&mut self,
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
emojis: Vec<Tag>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(
|
||||
move |id, prev| id.reaction(target, &emoji, &emojis, prev),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remove_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(
|
||||
&mut self,
|
||||
metadata: &GroupMetadata,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_group(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(GroupId::delete_group, cx)
|
||||
}
|
||||
|
||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||
}
|
||||
|
||||
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
|
||||
}
|
||||
|
||||
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
|
||||
where
|
||||
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
|
||||
};
|
||||
|
||||
let builder = build(self.key.id(), self.window.previous_tag(me));
|
||||
let published = self.publish(builder, cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let event = published.await?;
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(GroupEvent::Sent(event));
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task<Result<Event>> {
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.client();
|
||||
let signer = nostr.signer();
|
||||
let relay = self.key.relay().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).to([relay]).await?;
|
||||
|
||||
if let Some(reason) = output.failed.into_values().next() {
|
||||
bail!("{reason}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context<Self>) {
|
||||
if self.membership != Membership::Member {
|
||||
self.membership = Membership::Pending { since };
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_member(&mut self, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Member;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Refused {
|
||||
reason: Some(reason),
|
||||
};
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_elsewhere(&mut self, elsewhere: Option<GroupKey>, cx: &mut Context<Self>) {
|
||||
if self.elsewhere == elsewhere {
|
||||
return;
|
||||
}
|
||||
self.elsewhere = elsewhere;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = self.key.clone();
|
||||
let id = crate::subscription_id(&key);
|
||||
let relay = key.relay().clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let filters = [
|
||||
key.id().timeline_filter(REPLAY_LIMIT),
|
||||
key.id().membership_filter(me),
|
||||
key.id().moderation_filter(REPLAY_LIMIT),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(key.id().state_filters())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, filters))
|
||||
.with_id(id)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to {} failed: {error}", key.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(until) = self.oldest else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_more = true;
|
||||
cx.notify();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = crate::page_subscription(&self.key);
|
||||
let relay = self.key.relay().clone();
|
||||
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, [filter]))
|
||||
.with_id(id)
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: loading older messages failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading_more = false;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let tags = vec![
|
||||
Tag::identifier(event.id),
|
||||
Tag::custom("r", [cache_tag]),
|
||||
Tag::custom("k", [event.kind.to_string()]),
|
||||
];
|
||||
|
||||
let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&envelope).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task<Result<Vec<Event>>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
if state_only {
|
||||
let kinds = STATE_KINDS
|
||||
.iter()
|
||||
.chain(MEMBERSHIP_KINDS.iter())
|
||||
.map(|kind| kind.to_string());
|
||||
|
||||
filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds);
|
||||
}
|
||||
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|envelope| Event::from_json(&envelope.content).ok())
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate(&mut self, events: Vec<Event>, me: PublicKey, cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
for event in &events {
|
||||
match self.absorb(event, me) {
|
||||
Absorbed::Shown {
|
||||
fresh: true,
|
||||
updated,
|
||||
} => {
|
||||
rendered.push(event.clone());
|
||||
changed |= updated;
|
||||
}
|
||||
Absorbed::Shown {
|
||||
fresh: false,
|
||||
updated,
|
||||
} => changed |= updated,
|
||||
Absorbed::Updated => changed = true,
|
||||
Absorbed::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
for event in rendered {
|
||||
cx.emit(GroupEvent::Incoming(event));
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
if self.retract(id) {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an event and everything attached to it.
|
||||
fn retract(&mut self, id: EventId) -> bool {
|
||||
self.window.remove(id);
|
||||
|
||||
if !self.index.remove(&id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.rows.retain(|row| row.id() != id);
|
||||
self.invites.retain(|invite| invite.id != id);
|
||||
self.reactions.remove(&id);
|
||||
|
||||
for reactions in self.reactions.values_mut() {
|
||||
reactions.retain(|reaction| reaction.id != id);
|
||||
}
|
||||
|
||||
self.reactions.retain(|_, reactions| !reactions.is_empty());
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
cx.emit(GroupEvent::Eose);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
self.refused = Some(reason.clone());
|
||||
cx.emit(GroupEvent::Refused(reason));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||
if STATE_KINDS.contains(&event.kind) {
|
||||
self.window.note(event);
|
||||
|
||||
return if self.absorb_state(event, me) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
// NIP-29: relays must reject events whose
|
||||
// `previous` references are absent from their database.
|
||||
let previous = event.tags.iter().find(|tag| tag.kind() == "previous");
|
||||
if self.window.references_seen(previous) == Some(false) {
|
||||
log::warn!(
|
||||
"nip29: event {} carries previous references not seen on {}",
|
||||
event.id,
|
||||
self.key.relay()
|
||||
);
|
||||
}
|
||||
|
||||
self.track(event);
|
||||
|
||||
let updated = self.absorb_membership(event, me);
|
||||
|
||||
if ACTIVITY_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_activity(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::Reaction {
|
||||
return if self.push_reaction(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::GroupCreateInvite {
|
||||
return if self.push_invite(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::GroupDeleteEvent {
|
||||
let retracted = event
|
||||
.tags
|
||||
.event_ids()
|
||||
.next()
|
||||
.is_some_and(|target| self.retract(target));
|
||||
|
||||
return if retracted {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if RENDER_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_row(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if updated {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_membership(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self
|
||||
.membership_events
|
||||
.iter()
|
||||
.any(|existing| existing.id == event.id)
|
||||
{
|
||||
self.membership_events.push(event.clone());
|
||||
}
|
||||
|
||||
self.refresh_membership(me)
|
||||
}
|
||||
|
||||
fn track(&mut self, event: &Event) {
|
||||
self.window.push(event.clone());
|
||||
|
||||
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
|
||||
self.oldest = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||
match event.kind {
|
||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||
Ok(admins) => {
|
||||
self.admins = admins;
|
||||
self.adopt_roster_membership(me);
|
||||
}
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||
Ok(members) => {
|
||||
self.members = members;
|
||||
self.adopt_roster_membership(me);
|
||||
}
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||
Ok(roles) => self.roles = roles,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::Custom(PIN_LIST) => match GroupPins::parse(event) {
|
||||
Ok(pins) => self.pins = pins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
||||
}
|
||||
|
||||
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
|
||||
if self.membership != Membership::Unknown {
|
||||
return false;
|
||||
}
|
||||
|
||||
let listed = self.admins.contains(&me) || self.members.iter().any(|member| *member == me);
|
||||
|
||||
if !listed {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.membership = Membership::Member;
|
||||
true
|
||||
}
|
||||
|
||||
fn push_row(&mut self, event: &Event) -> bool {
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let message = Message::from(event);
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= message.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Message(Box::new(message)));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_reaction(&mut self, event: &Event) -> bool {
|
||||
let Some(target) = event.tags.event_ids().next() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let emojis = emojis_from_tags(&event.tags);
|
||||
let image = Message::reaction_image(&event.content, &emojis)
|
||||
.map(|url| SharedUri::from(url.to_string()));
|
||||
|
||||
self.reactions.entry(target).or_default().push(Reaction {
|
||||
id: event.id,
|
||||
emoji: event.content.clone(),
|
||||
image,
|
||||
author: event.pubkey,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_invite(&mut self, event: &Event) -> bool {
|
||||
let Some(code) = tag_value(&event.tags, "code") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.invites.push(Invite {
|
||||
id: event.id,
|
||||
code: code.to_owned(),
|
||||
created_at: event.created_at,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_activity(&mut self, event: &Event) -> bool {
|
||||
let activity = match Activity::parse(event) {
|
||||
Ok(activity) => activity,
|
||||
Err(error) => {
|
||||
log::warn!("nip29: ignoring a malformed group activity: {error}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if activity.kind == Kind::GroupPutUser && activity.roles.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupRemoveUser
|
||||
&& self
|
||||
.leave_requests
|
||||
.get(&activity.subject())
|
||||
.is_some_and(|left| {
|
||||
activity.created_at >= *left
|
||||
&& activity.created_at.as_secs().saturating_sub(left.as_secs())
|
||||
<= LEAVE_WINDOW_SECS
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.index.insert(activity.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupLeaveRequest {
|
||||
self.leave_requests
|
||||
.insert(activity.subject(), activity.created_at);
|
||||
}
|
||||
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= activity.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Activity(activity));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn refresh_membership(&mut self, me: PublicKey) -> bool {
|
||||
let derived = Membership::from_events(&self.membership_events, me);
|
||||
|
||||
if derived != Membership::Unknown && derived != self.membership {
|
||||
self.membership = derived;
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::{GroupId, GroupKey};
|
||||
|
||||
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
||||
const FORK_SUBSCRIPTION: &str = "nip29-forks";
|
||||
const GROUP_PREFIX: &str = "grp-";
|
||||
const BROWSE_PREFIX: &str = "brw-";
|
||||
const PAGE_PREFIX: &str = "pg-";
|
||||
|
||||
pub(crate) fn list_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub(crate) fn fork_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(FORK_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relay.hash(&mut hasher);
|
||||
|
||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn is_page(id: &SubscriptionId) -> bool {
|
||||
id.as_str().starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_subscription(id: &SubscriptionId) -> bool {
|
||||
let id = id.as_str();
|
||||
id == LIST_SUBSCRIPTION
|
||||
|| id == FORK_SUBSCRIPTION
|
||||
|| id.starts_with(GROUP_PREFIX)
|
||||
|| id.starts_with(BROWSE_PREFIX)
|
||||
|| id.starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> {
|
||||
if event.kind != Kind::GroupMembers {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = GroupId::new(event.tags.identifier()?).ok()?;
|
||||
let members = event.tags.public_keys().collect::<HashSet<_>>().len();
|
||||
|
||||
Some((id, event.created_at, members))
|
||||
}
|
||||
|
||||
const ONSETS: [&str; 34] = [
|
||||
"b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", "br", "cr",
|
||||
"dr", "fr", "gr", "pr", "tr", "bl", "cl", "fl", "gl", "pl", "sl", "sh", "ch", "th", "st", "sk",
|
||||
];
|
||||
const NUCLEI: [&str; 9] = ["a", "e", "i", "o", "u", "ae", "ei", "io", "ou"];
|
||||
const CODAS: [&str; 11] = ["", "", "", "n", "r", "s", "t", "d", "l", "m", "k"];
|
||||
|
||||
/// A short pronounceable id for new groups, seeded from a fresh key.
|
||||
pub(crate) fn readable_id() -> String {
|
||||
let mut picks = SecretKey::generate().secret_bytes().into_iter();
|
||||
|
||||
let mut id = String::with_capacity(18);
|
||||
|
||||
for _ in 0..3 {
|
||||
let onset = ONSETS[usize::from(picks.next().unwrap_or(0)) % ONSETS.len()];
|
||||
let nucleus = NUCLEI[usize::from(picks.next().unwrap_or(0)) % NUCLEI.len()];
|
||||
let coda = CODAS[usize::from(picks.next().unwrap_or(0)) % CODAS.len()];
|
||||
|
||||
id.push_str(onset);
|
||||
id.push_str(nucleus);
|
||||
id.push_str(coda);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_readable_id() {
|
||||
for _ in 0..32 {
|
||||
let id = readable_id();
|
||||
|
||||
assert!((6..=15).contains(&id.len()));
|
||||
assert!(id.chars().all(|character| character.is_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "nip29_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
common = { path = "../common" }
|
||||
nip29 = { path = "../nip29" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
state = { path = "../state" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -1,215 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||
relative,
|
||||
};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
.show_close(true)
|
||||
.title("About Group")
|
||||
.child(body(&group, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let Some(entity) = group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_style(TextStyle::Callout).child(header(group, cx));
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
body = body.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(about.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
let flags = flags(metadata);
|
||||
|
||||
if !flags.is_empty() {
|
||||
body = body.child(section(
|
||||
"Flags",
|
||||
wrap(flags.into_iter().map(|f| chip(f, cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = metadata.parent.as_ref() {
|
||||
body = body.child(section(
|
||||
"Parent",
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !metadata.children.is_empty() {
|
||||
body = body.child(section(
|
||||
"Children",
|
||||
wrap(
|
||||
metadata
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| chip(child.as_str().to_owned(), cx)),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(kinds) = metadata.supported_kinds.as_ref() {
|
||||
let items: Vec<AnyElement> = kinds
|
||||
.iter()
|
||||
.map(|kind| chip(kind.to_string(), cx))
|
||||
.collect();
|
||||
|
||||
body = body.child(section(
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
wrap(items)
|
||||
},
|
||||
cx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let roles = group.roles().roles();
|
||||
if !roles.is_empty() {
|
||||
body = body.child(section(
|
||||
"Roles",
|
||||
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
let pins = group.pins().pins();
|
||||
if !pins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Pinned",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
body.into_any_element()
|
||||
}
|
||||
|
||||
fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
let key = group.key();
|
||||
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::from_source(group.display_image())
|
||||
.seed(key.cache_tag())
|
||||
.large(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Body)
|
||||
.font_semibold()
|
||||
.truncate()
|
||||
.child(group.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.truncate()
|
||||
.child(SharedString::from(format!(
|
||||
"{} · {}",
|
||||
key.id().as_str(),
|
||||
key.relay().as_str()
|
||||
))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_medium()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label),
|
||||
)
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(items)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.text_color(cx.theme().text)
|
||||
.text_style(TextStyle::Caption)
|
||||
.line_height(relative(1.))
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
if metadata.private {
|
||||
flags.push("Private");
|
||||
}
|
||||
if metadata.restricted {
|
||||
flags.push("Restricted");
|
||||
}
|
||||
if metadata.hidden {
|
||||
flags.push("Hidden");
|
||||
}
|
||||
if metadata.closed {
|
||||
flags.push("Closed");
|
||||
}
|
||||
if metadata.livekit {
|
||||
flags.push("LiveKit");
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use nip29::{Group, Invite};
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::util::{field, report};
|
||||
|
||||
const CODE_LEN: usize = 12;
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Invite code")
|
||||
.default_value(random_code())
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
.show_close(true)
|
||||
.title("Invites")
|
||||
.child(body(group.clone(), input.clone(), cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn random_code() -> String {
|
||||
SecretKey::generate()
|
||||
.to_secret_hex()
|
||||
.chars()
|
||||
.take(CODE_LEN)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
let invites = group
|
||||
.upgrade()
|
||||
.map(|entity| entity.read(cx).invites().to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Anyone with a code can ask to join. Revoking a code stops new joins, it doesn't remove anyone."),
|
||||
)
|
||||
.when(invites.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("No active invites"),
|
||||
)
|
||||
})
|
||||
.children(invites.iter().map(|invite| invite_row(group.clone(), invite, cx)))
|
||||
.child(create_row(group, input, cx))
|
||||
}
|
||||
|
||||
fn invite_row(group: WeakEntity<Group>, invite: &Invite, cx: &App) -> impl IntoElement {
|
||||
let created = div()
|
||||
.flex_shrink_0()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(invite.created_at.to_ago());
|
||||
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.child(SharedString::from(invite.code.clone())),
|
||||
)
|
||||
.child(created),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.child(copy_button(&group, &invite.code))
|
||||
.child(revoke_button(&group, invite.id, &invite.code)),
|
||||
)
|
||||
}
|
||||
|
||||
fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
|
||||
let group = group.clone();
|
||||
let code = code.to_owned();
|
||||
|
||||
Button::new(SharedString::from(format!("invite-copy-{code}")))
|
||||
.label("Copy")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
let Some(link) = group
|
||||
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
window.push_notification(
|
||||
Notification::error(
|
||||
"The relay doesn't publish group details, so no invite link can be made",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
|
||||
let group = group.clone();
|
||||
|
||||
Button::new(SharedString::from(format!("invite-revoke-{code}")))
|
||||
.label("Revoke")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.items_end()
|
||||
.gap_2()
|
||||
.child(field("New code", &input, cx).flex_1())
|
||||
.child(
|
||||
Button::new("invite-create")
|
||||
.label("Create & copy link")
|
||||
.on_click(move |_event, window, cx| {
|
||||
create(group.clone(), input.clone(), window, cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn invite_link(group: &Group, code: &str) -> Option<String> {
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.naddr(group.key().relay(), Some(code)).ok())
|
||||
}
|
||||
|
||||
fn create(group: WeakEntity<Group>, input: Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let code = input.read(cx).value().trim().to_owned();
|
||||
|
||||
if code.is_empty() {
|
||||
window.push_notification(Notification::error("The invite code is empty"), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(link) = group
|
||||
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
window.push_notification(
|
||||
Notification::error(
|
||||
"The relay doesn't publish group details, so no invite link can be made",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
||||
Ok(task) => {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cx.update(|window, cx| {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
use chat::Message;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, div, px,
|
||||
};
|
||||
use nip29::{Activity, Group, Reaction};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||
use ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
|
||||
use crate::GroupPanel;
|
||||
|
||||
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
||||
|
||||
pub(crate) struct Actions {
|
||||
pub group: WeakEntity<Group>,
|
||||
pub panel: WeakEntity<GroupPanel>,
|
||||
pub admin: bool,
|
||||
pub pinned: bool,
|
||||
pub persons: Entity<Directory>,
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
reactions: &[Reaction],
|
||||
actions: Actions,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
let persons = actions.persons.clone();
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
|
||||
let reaction_groups = group_reactions(
|
||||
reactions.iter().map(|reaction| {
|
||||
(
|
||||
SharedString::from(reaction.emoji.as_str()),
|
||||
reaction.image.clone(),
|
||||
reaction.author,
|
||||
)
|
||||
}),
|
||||
nostr.read(cx).current_user(),
|
||||
);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(!message.replies_to.is_empty(), |this| {
|
||||
this.children(replies(
|
||||
&message.replies_to,
|
||||
&persons,
|
||||
&actions.group,
|
||||
&actions.panel,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(MessageMedia::new(&message.media))
|
||||
})
|
||||
.when(!reaction_groups.is_empty(), |this| {
|
||||
this.child(ReactionBar::new(reaction_groups))
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
let id = message.id;
|
||||
let admin = actions.admin;
|
||||
let pinned = actions.pinned;
|
||||
let group = actions.group;
|
||||
let panel = actions.panel;
|
||||
|
||||
ContextMenu::new(
|
||||
format!("message-menu-{}", id.to_hex()),
|
||||
row,
|
||||
move |menu, window, cx| {
|
||||
let menu = menu
|
||||
.item({
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.start_reply(id, window, cx);
|
||||
});
|
||||
})
|
||||
})
|
||||
.submenu("React", window, cx, {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
|
||||
});
|
||||
|
||||
if !admin {
|
||||
return menu;
|
||||
}
|
||||
|
||||
menu.separator()
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||
move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_pin(group.clone(), id, window, cx);
|
||||
});
|
||||
},
|
||||
)
|
||||
})
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.delete_message(group.clone(), id, window, cx);
|
||||
});
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn react_menu(
|
||||
menu: PopupMenu,
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
id: EventId,
|
||||
cx: &App,
|
||||
) -> PopupMenu {
|
||||
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
let emojis = panel
|
||||
.upgrade()
|
||||
.and_then(|panel| {
|
||||
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
|
||||
Some(
|
||||
panel
|
||||
.read(cx)
|
||||
.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if emojis.is_empty() {
|
||||
return menu;
|
||||
}
|
||||
|
||||
let menu = menu.separator();
|
||||
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
|
||||
let emoji = format!(":{shortcode}:");
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_activity(
|
||||
ix: usize,
|
||||
activity: &Activity,
|
||||
persons: &Entity<Directory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let subject = persons.read(cx).person(&activity.subject(), cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap(px(theme::SPACE))
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(subject.avatar())
|
||||
.seed(subject.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_semibold()
|
||||
.child(subject.name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(SharedString::from(action(activity))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(activity.created_at.to_ago()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn badge(activity: &Activity, cx: &App) -> AnyElement {
|
||||
let (icon, color) = match activity.kind {
|
||||
Kind::GroupJoinRequest => (IconName::Login, cx.theme().accent),
|
||||
Kind::GroupPutUser => (IconName::Login, cx.theme().accent),
|
||||
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().danger),
|
||||
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().danger),
|
||||
_ => (IconName::Info, cx.theme().text_faint),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.size_8()
|
||||
.child(Icon::new(icon).small().text_color(color))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn action(activity: &Activity) -> String {
|
||||
match activity.kind {
|
||||
Kind::GroupJoinRequest => "joined the group".to_owned(),
|
||||
Kind::GroupLeaveRequest => "left the group".to_owned(),
|
||||
Kind::GroupRemoveUser => "was removed from the group".to_owned(),
|
||||
Kind::GroupPutUser => format!("is now {}", activity.roles.join(", ")),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn replies(
|
||||
targets: &[EventId],
|
||||
persons: &Entity<Directory>,
|
||||
group: &WeakEntity<Group>,
|
||||
panel: &WeakEntity<GroupPanel>,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
let Some(entity) = group.upgrade() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut items = Vec::with_capacity(targets.len());
|
||||
|
||||
for (ix, id) in targets.iter().enumerate() {
|
||||
let context = entity.read_with(cx, |group, cx| {
|
||||
let message = group.message(*id)?;
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
|
||||
Some((author.name(), message.preview()))
|
||||
});
|
||||
|
||||
let (author, preview) = context.unwrap_or_else(|| {
|
||||
(
|
||||
SharedString::from("Message"),
|
||||
SharedString::from("original not loaded"),
|
||||
)
|
||||
});
|
||||
|
||||
let id = *id;
|
||||
let panel = panel.clone();
|
||||
|
||||
items.push(
|
||||
ReplyPreview::new(ix, author, preview)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("nip29: scrolling to message failed: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use nip29::{Group, GroupId, GroupMetadata};
|
||||
use nostr_sdk::prelude::RelayUrl;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::switch::Switch;
|
||||
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::util::field;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Flags {
|
||||
private: bool,
|
||||
restricted: bool,
|
||||
hidden: bool,
|
||||
closed: bool,
|
||||
livekit: bool,
|
||||
}
|
||||
|
||||
impl Flags {
|
||||
fn of(metadata: &GroupMetadata) -> Self {
|
||||
Self {
|
||||
private: metadata.private,
|
||||
restricted: metadata.restricted,
|
||||
hidden: metadata.hidden,
|
||||
closed: metadata.closed,
|
||||
livekit: metadata.livekit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let base = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Group name")
|
||||
.default_value(base.name.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let about = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("About")
|
||||
.default_value(base.about.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let picture = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://example.com/picture.png")
|
||||
.default_value(base.picture.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let parent = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Parent group id, empty for a root group")
|
||||
.default_value(
|
||||
base.parent
|
||||
.as_ref()
|
||||
.map(|id| id.as_str().to_owned())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
|
||||
let children = cx.new(|_cx| base.children.clone());
|
||||
let flags = cx.new(|_cx| Flags::of(&base));
|
||||
let livekit = cx.new(|_cx| None);
|
||||
|
||||
if let Ok(relay) = group.read_with(cx, |group, _cx| group.key().relay().clone()) {
|
||||
let http = cx.http_client();
|
||||
let probe = cx.background_spawn(async move { probe_livekit(http, relay).await });
|
||||
let livekit = livekit.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let supported = probe.await;
|
||||
|
||||
livekit.update(cx, |livekit, cx| {
|
||||
*livekit = Some(supported);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let current = *flags.read(cx);
|
||||
let supported = *livekit.read(cx) != Some(false);
|
||||
|
||||
this.confirm()
|
||||
.title("Edit Group")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Save"),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("About", &about, cx))
|
||||
.child(field("Picture", &picture, cx))
|
||||
.child(field("Parent", &parent, cx))
|
||||
.child(children_section(&children, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.label("Private")
|
||||
.description("Only members can read group messages")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("restricted")
|
||||
.label("Restricted")
|
||||
.description("Only members can post messages")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("hidden")
|
||||
.label("Hidden")
|
||||
.description("Hidden from non-members, not discoverable")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("closed")
|
||||
.label("Closed")
|
||||
.description("Join requests are ignored (invite-only)")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("livekit")
|
||||
.label("LiveKit")
|
||||
.description(if supported {
|
||||
"Announce audio and video support."
|
||||
} else {
|
||||
"The relay doesn't announce LiveKit support."
|
||||
})
|
||||
.checked(current.livekit)
|
||||
.on_click({
|
||||
let flags = flags.clone();
|
||||
let livekit = livekit.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
if *next && *livekit.read(cx) == Some(false) {
|
||||
return;
|
||||
}
|
||||
flags.update(cx, |flags, cx| {
|
||||
flags.livekit = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.on_ok({
|
||||
let group = group.clone();
|
||||
let name = name.clone();
|
||||
let about = about.clone();
|
||||
let picture = picture.clone();
|
||||
let parent = parent.clone();
|
||||
let children = children.clone();
|
||||
let flags = flags.clone();
|
||||
|
||||
move |_event, window, cx| {
|
||||
let mut next = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
next.name = value(&name, cx);
|
||||
next.about = value(&about, cx);
|
||||
next.picture = value(&picture, cx);
|
||||
|
||||
match value(&parent, cx).map(GroupId::new) {
|
||||
Some(Ok(id)) => next.parent = Some(id),
|
||||
Some(Err(error)) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
None => next.parent = None,
|
||||
}
|
||||
|
||||
next.children = children.read(cx).clone();
|
||||
|
||||
// The relay must reject these anyway; catch them early.
|
||||
if let Some(id) = &next.parent {
|
||||
let key = group
|
||||
.read_with(cx, |group, _cx| group.key().id().as_str().to_owned())
|
||||
.ok();
|
||||
|
||||
if key.as_deref() == Some(id.as_str()) {
|
||||
window.push_notification(
|
||||
Notification::error("A group can't be its own parent"),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if next.children.iter().any(|child| child == id) {
|
||||
window.push_notification(
|
||||
Notification::error("The parent is one of this group's children"),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let current = flags.read(cx);
|
||||
next.private = current.private;
|
||||
next.restricted = current.restricted;
|
||||
next.hidden = current.hidden;
|
||||
next.closed = current.closed;
|
||||
next.livekit = current.livekit;
|
||||
|
||||
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
|
||||
Ok(task) => crate::util::report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// NIP-29: relays announce LiveKit support with a 204 at this well-known path.
|
||||
async fn probe_livekit(http: Arc<dyn HttpClient>, relay: RelayUrl) -> bool {
|
||||
let base = relay
|
||||
.as_str()
|
||||
.replacen("wss://", "https://", 1)
|
||||
.replacen("ws://", "http://", 1);
|
||||
let url = format!("{}/.well-known/nip29/livekit", base.trim_end_matches('/'));
|
||||
|
||||
match http.get(&url, AsyncBody::default(), true).await {
|
||||
Ok(response) => response.status() == 204,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
let list = children.read(cx);
|
||||
|
||||
let mut section = v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Children");
|
||||
|
||||
if list.is_empty() {
|
||||
return section
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("No subgroups"),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
for (ix, child) in list.iter().enumerate() {
|
||||
let order = h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.when(ix > 0, |this| {
|
||||
this.child(move_button(children.clone(), ix, ix - 1, "up"))
|
||||
})
|
||||
.when(ix + 1 < list.len(), |this| {
|
||||
this.child(move_button(children.clone(), ix, ix + 1, "down"))
|
||||
});
|
||||
|
||||
section = section.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(cx.theme().text)
|
||||
.child(SharedString::from(child.as_str().to_owned())),
|
||||
)
|
||||
.child(order),
|
||||
);
|
||||
}
|
||||
|
||||
section.into_any_element()
|
||||
}
|
||||
|
||||
fn move_button(
|
||||
children: Entity<Vec<GroupId>>,
|
||||
ix: usize,
|
||||
target: usize,
|
||||
direction: &'static str,
|
||||
) -> Button {
|
||||
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
|
||||
.label(if direction == "up" { "Up" } else { "Down" })
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, _window, cx| {
|
||||
children.update(cx, |children, cx| {
|
||||
if target < children.len() {
|
||||
children.swap(ix, target);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn toggle(
|
||||
flags: &Entity<Flags>,
|
||||
select: fn(&mut Flags) -> &mut bool,
|
||||
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
let flags = flags.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
flags.update(cx, |flags, cx| {
|
||||
*select(flags) = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn value(input: &Entity<InputState>, cx: &App) -> Option<String> {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use gpui::{App, Div, Entity, ParentElement, SharedString, Styled, Task, Window};
|
||||
use nip29::{Group, Pin, Row};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
pub(crate) fn display_name(group: &Group) -> SharedString {
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.map_or_else(
|
||||
|| SharedString::from(group.key().id().as_str()),
|
||||
SharedString::from,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn attachment_name(url: &Url) -> SharedString {
|
||||
url.path_segments()
|
||||
.and_then(|mut segments| segments.next_back())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map_or_else(
|
||||
|| SharedString::from(url.as_str()),
|
||||
|name| SharedString::from(name.to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||
let (Some(current), Some(previous)) = (
|
||||
rows.get(index),
|
||||
index.checked_sub(1).and_then(|index| rows.get(index)),
|
||||
) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
match (current, previous) {
|
||||
(Row::Message(current), Row::Message(previous)) => ui::message::opens_run(
|
||||
Some((previous.author, previous.created_at.as_secs())),
|
||||
(current.author, current.created_at.as_secs()),
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
}
|
||||
|
||||
pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString {
|
||||
match pin {
|
||||
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
|
||||
Pin::Event(id) => {
|
||||
let snippet = group
|
||||
.message(*id)
|
||||
.and_then(|message| message.content.lines().next())
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty());
|
||||
|
||||
match snippet {
|
||||
Some(snippet) => SharedString::from(snippet.chars().take(48).collect::<String>()),
|
||||
None => SharedString::from(id.to_hex().chars().take(8).collect::<String>()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a group write and surface any relay error as a notification.
|
||||
pub(crate) fn report(window: &mut Window, cx: &mut App, task: Task<Result<()>>) {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::{Person, PersonEvent, PersonRegistry};
|
||||
|
||||
/// Directory of persons tracked by a view
|
||||
#[derive(Debug)]
|
||||
pub struct Directory {
|
||||
persons: HashMap<PublicKey, Person>,
|
||||
/// Keys awaiting their first update, guarded for render-time access
|
||||
pending: RwLock<HashSet<PublicKey>>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
/// Create a directory following the registry's events
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription = cx.subscribe(®istry, |this: &mut Self, _registry, event, cx| {
|
||||
this.handle_event(event.clone(), cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
pending: RwLock::new(HashSet::new()),
|
||||
tasks: SmallVec::new(),
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Load known profiles from the database, request the rest
|
||||
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>) {
|
||||
/// Profiles and emoji lists loaded from the database
|
||||
type Loaded = (Vec<Person>, Vec<(PublicKey, Vec<(String, Url)>)>);
|
||||
|
||||
let missing: Vec<PublicKey> = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
!self.persons.contains_key(key)
|
||||
&& !self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept events that arrive while the database query is running
|
||||
self.with_pending(|pending| {
|
||||
pending.extend(missing.iter().copied());
|
||||
});
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let authors = missing.clone();
|
||||
|
||||
let task: Task<Result<Loaded, Error>> = cx.background_spawn(async move {
|
||||
let mut metadata_events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::Metadata).authors(authors.clone()))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Keep only the newest event per author
|
||||
metadata_events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let persons = metadata_events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut emoji_events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::Emojis).authors(authors))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Keep only the newest event per author
|
||||
emoji_events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let emojis = emoji_events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
|
||||
.collect();
|
||||
|
||||
Ok((persons, emojis))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let loaded = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let remaining = match loaded {
|
||||
Ok((persons, emojis)) => {
|
||||
let found: HashSet<PublicKey> =
|
||||
persons.iter().map(Person::public_key).collect();
|
||||
this.bulk_insert(persons, cx);
|
||||
|
||||
// Emojis apply on top of loaded or already received data
|
||||
let mut updated = false;
|
||||
|
||||
for (public_key, emojis) in emojis {
|
||||
if emojis.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let person = this
|
||||
.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||
person.set_emojis(emojis);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
missing
|
||||
.into_iter()
|
||||
.filter(|key| !found.contains(key))
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load persons from the database: {e}");
|
||||
missing
|
||||
}
|
||||
};
|
||||
|
||||
if !remaining.is_empty() {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
registry.read(cx).fetch(remaining);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// The person for a key, or a placeholder that queues a request
|
||||
pub fn person(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
if let Some(person) = self.persons.get(public_key) {
|
||||
return person.clone();
|
||||
}
|
||||
|
||||
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
registry.read(cx).fetch([*public_key]);
|
||||
}
|
||||
|
||||
Person::new(*public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Merge event data into the tracked person
|
||||
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
|
||||
let public_key = match &event {
|
||||
PersonEvent::Metadata(person) => person.public_key(),
|
||||
PersonEvent::Emojis { public_key, .. }
|
||||
| PersonEvent::Announcement { public_key, .. }
|
||||
| PersonEvent::MessagingRelays { public_key, .. } => *public_key,
|
||||
};
|
||||
|
||||
if !self.persons.contains_key(&public_key)
|
||||
&& !self.with_pending(|pending| pending.remove(&public_key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let person = self
|
||||
.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||
|
||||
match event {
|
||||
PersonEvent::Metadata(updated) => person.set_metadata(updated.metadata()),
|
||||
PersonEvent::Emojis { emojis, .. } => person.set_emojis(emojis),
|
||||
PersonEvent::Announcement { announcement, .. } => {
|
||||
person.set_announcement(announcement);
|
||||
}
|
||||
PersonEvent::MessagingRelays { relays, .. } => person.set_messaging_relays(relays),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Insert loaded persons, keeping data already received from relays
|
||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
let mut inserted = false;
|
||||
|
||||
for person in persons {
|
||||
let public_key = person.public_key();
|
||||
|
||||
if self.persons.contains_key(&public_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.persons.insert(public_key, person);
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
if inserted {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `then` with the pending set, tolerating poisoning: it is only a dedupe cache
|
||||
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
|
||||
let mut pending = match self.pending.write() {
|
||||
Ok(pending) => pending,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
@@ -2,67 +2,46 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
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};
|
||||
|
||||
mod directory;
|
||||
mod person;
|
||||
|
||||
pub use directory::*;
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
|
||||
impl Global for GlobalPersonRegistry {}
|
||||
|
||||
/// A change to a person's data
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PersonEvent {
|
||||
/// Profile metadata
|
||||
Metadata(Box<Person>),
|
||||
/// Custom emoji list (NIP-30)
|
||||
Emojis {
|
||||
public_key: PublicKey,
|
||||
emojis: Vec<(String, Url)>,
|
||||
},
|
||||
/// Profile encryption keys announcement (NIP-4e)
|
||||
Announcement {
|
||||
public_key: PublicKey,
|
||||
announcement: Announcement,
|
||||
},
|
||||
/// Messaging relay list (NIP-65)
|
||||
MessagingRelays {
|
||||
public_key: PublicKey,
|
||||
relays: Vec<RelayUrl>,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Person),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
Emojis(Event),
|
||||
}
|
||||
|
||||
/// Person Registry
|
||||
#[derive(Debug)]
|
||||
pub struct PersonRegistry {
|
||||
/// Public keys with a metadata request in flight
|
||||
in_flight: RwLock<HashSet<PublicKey>>,
|
||||
/// Collection of all persons (user profiles)
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seen: RwLock<HashSet<PublicKey>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
sender: flume::Sender<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl PersonRegistry {
|
||||
@@ -77,7 +56,7 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Create a new person registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
@@ -110,26 +89,22 @@ impl PersonRegistry {
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
Dispatch::Emojis(event) => {
|
||||
this.set_emojis(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
|
||||
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.get_emojis(cx);
|
||||
}
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
in_flight: RwLock::new(HashSet::new()),
|
||||
persons: HashMap::new(),
|
||||
seen: RwLock::new(HashSet::new()),
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,21 +129,25 @@ impl PersonRegistry {
|
||||
Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
|
||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||
log::warn!("Channel closed, dropping metadata event");
|
||||
log::warn!("PersonRegistry channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
if let Err(e) = get_metadata(client, public_keys).await {
|
||||
log::warn!("Failed to get metadata for contact list: {e}");
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
|
||||
}
|
||||
Kind::Emojis => {
|
||||
_ = tx.send_async(Dispatch::Emojis(event.into_owned())).await;
|
||||
tx.send_async(Dispatch::Relays(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
_ = tx
|
||||
.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await;
|
||||
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -205,101 +184,116 @@ impl PersonRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the signed-in user's emoji list
|
||||
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
||||
/// Load all user profiles from the database
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let filter = Filter::new().kind(Kind::Emojis).author(public_key).limit(1);
|
||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
let persons = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Err(e) = client.subscribe(vec![filter]).close_on(opts).await {
|
||||
log::warn!("Failed to subscribe to emoji list: {e}");
|
||||
Ok(persons)
|
||||
});
|
||||
|
||||
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);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
log::warn!("Ignoring announcement with missing or invalid key tag");
|
||||
return;
|
||||
};
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
let announcement = Announcement::from(event);
|
||||
|
||||
cx.emit(PersonEvent::Announcement {
|
||||
public_key: event.pubkey,
|
||||
announcement,
|
||||
});
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_announcement(announcement);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person =
|
||||
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set messaging relays for a person
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
cx.emit(PersonEvent::MessagingRelays {
|
||||
public_key: event.pubkey,
|
||||
relays: nip17::extract_relay_list(event).collect(),
|
||||
});
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_messaging_relays(urls);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set custom emoji for a person from their emoji list event
|
||||
fn set_emojis(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
|
||||
/// Insert batch of persons
|
||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons.into_iter() {
|
||||
let public_key = person.public_key();
|
||||
self.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| cx.new(|_| person));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set custom emoji for a person
|
||||
fn set_emojis_for(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
emojis: Vec<(String, Url)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.emit(PersonEvent::Emojis { public_key, emojis });
|
||||
}
|
||||
|
||||
/// Publish a person's profile metadata to every directory
|
||||
pub fn insert(&mut self, person: Person, cx: &mut Context<Self>) {
|
||||
/// Insert or update a person
|
||||
pub fn insert(&mut self, person: Person, cx: &mut App) {
|
||||
let public_key = person.public_key();
|
||||
self.release_in_flight(&public_key);
|
||||
|
||||
cx.emit(PersonEvent::Metadata(Box::new(person)));
|
||||
match self.persons.get(&public_key) {
|
||||
Some(this) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_metadata(person.metadata());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
None => {
|
||||
self.persons.insert(public_key, cx.new(|_| person));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a batched metadata request for keys without one in flight
|
||||
pub(crate) fn fetch<I>(&self, keys: I)
|
||||
where
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let mut queue = Vec::new();
|
||||
{
|
||||
let mut in_flight = match self.in_flight.write() {
|
||||
Ok(in_flight) => in_flight,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
/// Get single person by public key
|
||||
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
if let Some(person) = self.persons.get(public_key) {
|
||||
return person.read(cx).clone();
|
||||
}
|
||||
|
||||
for key in keys {
|
||||
if in_flight.insert(key) {
|
||||
queue.push(key);
|
||||
let public_key = *public_key;
|
||||
|
||||
if self.seen.write().unwrap().insert(public_key) {
|
||||
let sender = self.sender.clone();
|
||||
|
||||
// Spawn background task to request metadata
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = sender.send_async(public_key).await {
|
||||
log::warn!("Failed to send public key for metadata request: {e}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
for public_key in queue {
|
||||
if let Err(error) = self.sender.send(public_key) {
|
||||
log::warn!("Failed to queue metadata request: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow future fetches for a key once its metadata has arrived
|
||||
fn release_in_flight(&self, public_key: &PublicKey) {
|
||||
let mut in_flight = match self.in_flight.write() {
|
||||
Ok(in_flight) => in_flight,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
in_flight.remove(public_key);
|
||||
// Return a temporary profile with default metadata
|
||||
Person::new(public_key, Metadata::default())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,13 @@ use state::Announcement;
|
||||
pub struct Person {
|
||||
/// Public Key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Metadata (profile)
|
||||
metadata: Metadata,
|
||||
/// Custom emoji (NIP-30) from the person's emoji list
|
||||
emojis: Vec<(String, Url)>,
|
||||
|
||||
/// Dekey (NIP-4e) announcement
|
||||
announcement: Option<Announcement>,
|
||||
|
||||
/// Messaging relays
|
||||
messaging_relays: Vec<RelayUrl>,
|
||||
}
|
||||
@@ -46,17 +47,37 @@ impl Hash for Person {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PublicKey> for Person {
|
||||
fn from(public_key: PublicKey) -> Self {
|
||||
Self::new(public_key, Metadata::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
emojis: vec![],
|
||||
announcement: None,
|
||||
messaging_relays: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build profile encryption keys announcement
|
||||
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
self.announcement = Some(announcement);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build profile messaging relays
|
||||
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get profile public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
@@ -67,30 +88,6 @@ impl Person {
|
||||
self.metadata.clone()
|
||||
}
|
||||
|
||||
/// Custom emoji (NIP-30) as (shortcode, image url) pairs
|
||||
pub fn emojis(&self) -> &[(String, Url)] {
|
||||
&self.emojis
|
||||
}
|
||||
|
||||
/// Set custom emoji from the person's emoji list
|
||||
pub(crate) fn set_emojis(&mut self, emojis: Vec<(String, Url)>) {
|
||||
self.emojis = emojis;
|
||||
}
|
||||
|
||||
/// An `emoji` tag for every custom emoji referenced as `:shortcode:` in `content`
|
||||
pub fn emoji_tags(&self, content: &str) -> Vec<Tag> {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
for (shortcode, url) in &self.emojis {
|
||||
let pattern = format!(":{shortcode}:");
|
||||
if content.contains(&pattern) {
|
||||
tags.push(Tag::custom("emoji", [shortcode.clone(), url.to_string()]));
|
||||
}
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// Get profile encryption keys announcement
|
||||
pub fn announcement(&self) -> Option<Announcement> {
|
||||
self.announcement.clone()
|
||||
@@ -106,18 +103,14 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Get profile picture, if the profile has one
|
||||
pub fn avatar(&self) -> Option<SharedString> {
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
/// A stable seed for this profile's generated avatar
|
||||
pub fn avatar_seed(&self) -> String {
|
||||
self.public_key().to_hex()
|
||||
.map(|picture| picture.into())
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
/// Get profile name
|
||||
@@ -138,17 +131,17 @@ impl Person {
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub(crate) fn set_metadata(&mut self, metadata: Metadata) {
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub(crate) fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{AppExt as _, AppearanceMode};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
@@ -40,33 +38,14 @@ macro_rules! setting_accessors {
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub theme: Option<String>,
|
||||
pub theme_mode: ThemeMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub nip4e: bool,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
@@ -79,6 +58,14 @@ pub enum SignerKind {
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn auto(&self) -> bool {
|
||||
matches!(self, SignerKind::Auto)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> bool {
|
||||
matches!(self, SignerKind::User)
|
||||
}
|
||||
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
@@ -120,10 +107,14 @@ impl RoomConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default, alias = "theme_mode")]
|
||||
pub appearance_mode: AppearanceMode,
|
||||
/// Theme
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Theme mode
|
||||
pub theme_mode: ThemeMode,
|
||||
|
||||
/// Hide user avatars
|
||||
pub hide_avatar: bool,
|
||||
@@ -134,46 +125,33 @@ pub struct Settings {
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
|
||||
/// Show an image behind chat and group panels
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
|
||||
/// Custom image for the panel background; the built-in one when unset
|
||||
#[serde(default)]
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
|
||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||
pub trusted_relays: Vec<String>,
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Pinned chat rooms, by conversation ID
|
||||
#[serde(default)]
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
|
||||
/// Pinned groups, by cache tag
|
||||
#[serde(default)]
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
appearance_mode: AppearanceMode::default(),
|
||||
theme: None,
|
||||
theme_mode: ThemeMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
panel_background: true,
|
||||
panel_background_path: None,
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
pinned_rooms: vec![],
|
||||
pinned_groups: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Settings> for Settings {
|
||||
fn as_ref(&self) -> &Settings {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalAppSettings(Entity<AppSettings>);
|
||||
|
||||
impl Global for GlobalAppSettings {}
|
||||
@@ -182,6 +160,7 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
/// Settings
|
||||
inner: Entity<Settings>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -197,8 +176,7 @@ impl AppSettings {
|
||||
cx.set_global(GlobalAppSettings(state));
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let inner = cx.new(|_| Settings::default());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -210,8 +188,8 @@ impl AppSettings {
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -229,7 +207,7 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -241,7 +219,7 @@ impl AppSettings {
|
||||
Err(anyhow!("Not found"))
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut settings = task.await.unwrap_or(Settings::default());
|
||||
|
||||
// Move settings still pointed at the old default file server over to the new one
|
||||
@@ -250,10 +228,9 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
// Update settings
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
this.apply_theme(cx);
|
||||
cx.refresh_windows();
|
||||
this.apply_theme(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -273,10 +250,44 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance mode to the app
|
||||
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
|
||||
let mode = self.inner.read(cx).appearance_mode;
|
||||
cx.set_appearance_mode(mode);
|
||||
/// Set theme
|
||||
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
// Update settings
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = Some(theme.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Apply theme
|
||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
||||
let mode = self.inner.read(cx).theme_mode;
|
||||
|
||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if decoupling encryption key is enabled
|
||||
@@ -284,79 +295,27 @@ impl AppSettings {
|
||||
self.inner.read(cx).nip4e
|
||||
}
|
||||
|
||||
/// The image to show behind chat and group panels, when enabled
|
||||
pub fn panel_background_source(cx: &App) -> Option<ImageSource> {
|
||||
if !Self::get_panel_background(cx) {
|
||||
return None;
|
||||
}
|
||||
Some(match Self::get_panel_background_path(cx) {
|
||||
Some(path) => path.into(),
|
||||
None => DEFAULT_BACKGROUND.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the chat room is pinned
|
||||
pub fn pinned_room(&self, id: u64, cx: &App) -> bool {
|
||||
self.inner.read(cx).pinned_rooms.contains(&id)
|
||||
}
|
||||
|
||||
/// Pin or unpin the chat room
|
||||
pub fn toggle_pinned_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if let Some(index) = this.pinned_rooms.iter().position(|pinned| *pinned == id) {
|
||||
this.pinned_rooms.remove(index);
|
||||
} else {
|
||||
this.pinned_rooms.push(id);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if the group is pinned by its cache tag
|
||||
pub fn pinned_group(&self, tag: &str, cx: &App) -> bool {
|
||||
/// Check if the given relay is already authenticated
|
||||
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
|
||||
self.inner
|
||||
.read(cx)
|
||||
.pinned_groups
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|pinned| pinned == tag)
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
}
|
||||
|
||||
/// Pin or unpin the group by its cache tag
|
||||
pub fn toggle_pinned_group(&mut self, tag: &str, cx: &mut Context<Self>) {
|
||||
/// Add a relay to the trusted list
|
||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if let Some(index) = this.pinned_groups.iter().position(|pinned| pinned == tag) {
|
||||
this.pinned_groups.remove(index);
|
||||
} else {
|
||||
this.pinned_groups.push(tag.to_owned());
|
||||
if !this
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
{
|
||||
this.trusted_relays
|
||||
.push(url.as_str_without_trailing_slash().to_string());
|
||||
cx.notify();
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
|
||||
// Every field the persisted format has always required; `partial`
|
||||
// contributes the fields under test.
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
|
||||
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
|
||||
partial
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_theme_mode_key_keeps_its_pinned_choice() {
|
||||
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_appearance_mode_falls_back_to_dark() {
|
||||
let settings = json_with("").unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ gpui.workspace = true
|
||||
instant.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
/// COOP's public key
|
||||
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x";
|
||||
|
||||
/// App ID
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Keyring name
|
||||
pub const USER_KEYRING: &str = "Coop Safe Storage";
|
||||
pub const MASTER_KEYRING: &str = "Coop Master Key";
|
||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
||||
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 2;
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
|
||||
@@ -25,16 +26,25 @@ pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
/// Default subscription id for user gift wrap events
|
||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||
|
||||
/// Default search relays
|
||||
pub(crate) const INDEXER_RELAYS: [&str; 2] =
|
||||
["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||
|
||||
/// Default vertex relays
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
|
||||
/// Default search relays
|
||||
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// Default search relays
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://profiles.nostr1.com",
|
||||
];
|
||||
|
||||
@@ -15,9 +15,9 @@ use nostr::nips::nip94::Sha256Hash;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) const ALGORITHM: &str = "aes-gcm";
|
||||
pub const ALGORITHM: &str = "aes-gcm";
|
||||
|
||||
pub(crate) const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
@@ -30,7 +30,7 @@ const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EncryptedFile {
|
||||
pub struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
@@ -115,7 +115,7 @@ impl FileAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||
let cipher = AesGcm::<Aes256, U16>::new(&key);
|
||||
@@ -131,7 +131,7 @@ pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||
let key = decode(key, "decryption key")?;
|
||||
let nonce = decode(nonce, "decryption nonce")?;
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(crate) fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Er
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
@@ -227,7 +227,7 @@ pub async fn upload_encrypted(
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
pub(crate) async fn download_and_decrypt(
|
||||
pub async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, TaskExt};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -29,7 +29,10 @@ pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
|
||||
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
// 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`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
@@ -39,7 +42,7 @@ pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
|
||||
}
|
||||
|
||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
@@ -49,12 +52,12 @@ impl Global for GlobalNostrRegistry {}
|
||||
/// Signer event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum StateEvent {
|
||||
/// The state is busy
|
||||
Busy,
|
||||
/// User has no signer
|
||||
NoSigner,
|
||||
/// The signer has changed
|
||||
SignerChanged,
|
||||
/// A stored credential needs a passphrase to unlock
|
||||
PassphraseRequired,
|
||||
/// An error occurred
|
||||
Error(String),
|
||||
}
|
||||
@@ -64,7 +67,7 @@ impl StateEvent {
|
||||
matches!(self, StateEvent::SignerChanged)
|
||||
}
|
||||
|
||||
pub(crate) fn error<T>(error: T) -> Self
|
||||
pub fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
@@ -77,12 +80,13 @@ impl StateEvent {
|
||||
pub struct NostrRegistry {
|
||||
/// Nostr client
|
||||
client: Client,
|
||||
|
||||
/// Universal signer
|
||||
signer: UniversalSigner,
|
||||
|
||||
/// Current user's public key
|
||||
current_user: Option<PublicKey>,
|
||||
/// Whether the stored credential needs a passphrase to unlock
|
||||
passphrase_required: bool,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -101,8 +105,7 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
@@ -124,35 +127,30 @@ impl NostrRegistry {
|
||||
.gossip(NostrGossipMemory::unbounded())
|
||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.verify_subscriptions(true)
|
||||
.ban_relay_on_mismatch(true)
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(600),
|
||||
})
|
||||
.build();
|
||||
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
// 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 restore_session
|
||||
let keys = Keys::new(secret);
|
||||
this.set_signer(keys, cx);
|
||||
} else {
|
||||
this.restore_session(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);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
passphrase_required: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -173,7 +171,7 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Update the signer
|
||||
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
||||
@@ -186,8 +184,6 @@ impl NostrRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
this.signer.swap_inner(new_signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.passphrase_required = false;
|
||||
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -238,249 +234,89 @@ impl NostrRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Restore the signer from the stored credential
|
||||
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
/// Check the user's credential and set the signer if valid
|
||||
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
|
||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||
let master_keyring = self.get_master_key(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = async {
|
||||
match user_keyring.await {
|
||||
Ok(Some((_username, secret))) => {
|
||||
let content = String::from_utf8(secret)?;
|
||||
match user_keyring.await {
|
||||
Ok(Some((_username, secret))) => {
|
||||
let content = String::from_utf8(secret)?;
|
||||
|
||||
if content.starts_with("nsec1") {
|
||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
} else if content.starts_with("bunker://") {
|
||||
// The master key used for Nostr Connect is embedded in
|
||||
// the stored URI so reconnects reuse the same local key
|
||||
let (base, keys) = extract_master_key(&content);
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
if content.starts_with("nsec1") {
|
||||
let secret_key = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
} else if content.starts_with("bunker://") {
|
||||
let keys = master_keyring.await;
|
||||
let timeout = Duration::from_secs(30);
|
||||
let uri = NostrConnectUri::parse(content)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
})?;
|
||||
} else if content.starts_with("ncryptsec1") {
|
||||
this.update(cx, |this, cx| {
|
||||
this.passphrase_required = true;
|
||||
cx.emit(StateEvent::PassphraseRequired);
|
||||
})?;
|
||||
} else if content == "proxy" {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
this.update(cx, |this, cx| {
|
||||
this.connect_proxy(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
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);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::error(e.to_string()));
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Unlock the stored encrypted key with its passphrase
|
||||
pub fn restore_with_passphrase(
|
||||
&mut self,
|
||||
password: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let password = password.to_owned();
|
||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||
/// 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() });
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let content = user_keyring
|
||||
.await?
|
||||
.map(|(_username, secret)| String::from_utf8(secret))
|
||||
.transpose()?
|
||||
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
||||
let task = cx.read_credentials(MASTER_KEYRING);
|
||||
|
||||
if !content.starts_with("ncryptsec1") {
|
||||
return Err(anyhow!("stored credential is not passphrase-encrypted"));
|
||||
cx.spawn(async move |cx| {
|
||||
let (keys, new_key) = match task.await {
|
||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
||||
_ => (Keys::generate(), true),
|
||||
},
|
||||
_ => (Keys::generate(), true),
|
||||
};
|
||||
|
||||
if new_key {
|
||||
let keys_clone = keys.clone();
|
||||
let username = keys_clone.public_key().to_hex();
|
||||
let password = keys_clone.secret_key().to_secret_bytes();
|
||||
|
||||
cx.update(|cx| {
|
||||
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
||||
cx.background_spawn(async move { task.await.ok() }).detach();
|
||||
});
|
||||
}
|
||||
|
||||
let decrypt_task = cx.background_spawn(async move {
|
||||
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
||||
let secret = encrypted.decrypt(&password)?;
|
||||
Ok::<_, Error>(Keys::new(secret))
|
||||
});
|
||||
|
||||
let keys = decrypt_task.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
keys
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password`
|
||||
pub fn import_nsec(
|
||||
&mut self,
|
||||
nsec: &str,
|
||||
password: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let keys = match SecretKey::parse(nsec.trim()) {
|
||||
Ok(secret) => Keys::new(secret),
|
||||
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||
};
|
||||
|
||||
if password.is_empty() {
|
||||
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||
}
|
||||
|
||||
let password = password.to_owned();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let job = cx.background_spawn(async move {
|
||||
let encrypted =
|
||||
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
||||
let ncryptsec = encrypted.to_bech32()?;
|
||||
Ok::<_, Error>((keys, ncryptsec))
|
||||
});
|
||||
|
||||
let (keys, ncryptsec) = job.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let write = cx.update(|cx| {
|
||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
||||
});
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password`
|
||||
pub fn import_ncryptsec(
|
||||
&mut self,
|
||||
ncryptsec: &str,
|
||||
password: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let ncryptsec = ncryptsec.trim().to_owned();
|
||||
|
||||
if password.is_empty() {
|
||||
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||
}
|
||||
|
||||
let password = password.to_owned();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let stored = ncryptsec.clone();
|
||||
let decrypt_task = cx.background_spawn(async move {
|
||||
let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?;
|
||||
let secret = encrypted.decrypt(&password)?;
|
||||
Ok::<_, Error>(Keys::new(secret))
|
||||
});
|
||||
|
||||
let keys = decrypt_task.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let write = cx.update(|cx| {
|
||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
||||
});
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a `bunker://...` URI (NIP-46), connecting to the remote signer
|
||||
pub fn import_bunker(
|
||||
&mut self,
|
||||
uri: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let uri_string = uri.trim().to_owned();
|
||||
|
||||
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||
Ok(uri) => uri,
|
||||
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||
};
|
||||
|
||||
let keys = Keys::generate();
|
||||
let credential = with_master_key(&uri_string, &keys);
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Verify the bunker responds before persisting the credential
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sign out and remove the stored credential
|
||||
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
|
||||
self.current_user = None;
|
||||
self.passphrase_required = false;
|
||||
|
||||
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Start the browser proxy
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -660,24 +496,68 @@ impl NostrRegistry {
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the base URI and the master key from a bunker credential with `master=<nsec>`
|
||||
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||
match credential.split_once("master=") {
|
||||
Some((base, nsec)) => {
|
||||
let keys = SecretKey::parse(nsec)
|
||||
.map(Keys::new)
|
||||
.unwrap_or_else(|_| Keys::generate());
|
||||
(base.trim_end_matches(['?', '&']), keys)
|
||||
}
|
||||
None => (credential, Keys::generate()),
|
||||
/// Perform a WoT (via Vertex) search for a given query.
|
||||
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||
let client = self.client();
|
||||
let query = query.to_string();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct a vertex request event
|
||||
let event = EventBuilder::new(Kind::Custom(5315), "")
|
||||
.tags(vec![
|
||||
Tag::custom("param", vec!["search", &query]),
|
||||
Tag::custom("param", vec!["limit", "10"]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to vertex relays
|
||||
let output = client.send_event(&event).to(WOT_RELAYS).await?;
|
||||
|
||||
// Construct a filter to get the response or error from vertex
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Custom(6315), Kind::Custom(7000)])
|
||||
.event(output.id().to_owned());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = WOT_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
// Stream events from the wot relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
match event.kind {
|
||||
Kind::Custom(6315) => {
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||
let pubkeys: Vec<PublicKey> = content
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| item.as_object())
|
||||
.filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str()))
|
||||
.filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok())
|
||||
.collect();
|
||||
|
||||
return Ok(pubkeys);
|
||||
}
|
||||
Kind::Custom(7000) => {
|
||||
return Err(anyhow!("Search error"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No results for query: {query}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||
format!("{uri}{separator}master={nsec}")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Announcement
|
||||
@@ -5,22 +6,50 @@ use nostr_sdk::prelude::*;
|
||||
pub struct Announcement {
|
||||
/// The public key of the device that created this announcement.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// The name of the device that created this announcement.
|
||||
client_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
let public_key = event
|
||||
impl From<&Event> for Announcement {
|
||||
fn from(val: &Event) -> Self {
|
||||
let public_key = val
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||
.and_then(|c| PublicKey::parse(c).ok())
|
||||
.unwrap_or(val.pubkey);
|
||||
|
||||
Some(Self { public_key })
|
||||
let client_name = val
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "client")
|
||||
.and_then(|tag| tag.content())
|
||||
.map(|c| c.to_string());
|
||||
|
||||
Self::new(public_key, client_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
client_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the public key of the device that created this announcement.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
/// Returns the client name of the device that created this announcement.
|
||||
pub fn client_name(&self) -> SharedString {
|
||||
self.client_name
|
||||
.as_ref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("Unknown"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,32 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nostr_connect::client::AuthUrlHandler;
|
||||
use nostr_sdk::error::Error as SignerError;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||
|
||||
impl fmt::Display for UniversalSignerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for UniversalSignerError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSignerError {
|
||||
pub fn new<E>(err: E) -> Self
|
||||
where
|
||||
E: Error + Send + Sync + 'static,
|
||||
{
|
||||
UniversalSignerError(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||
@@ -36,46 +59,26 @@ impl UniversalSigner {
|
||||
{
|
||||
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||
}
|
||||
|
||||
/// Encrypt a payload to the given key with NIP-44.
|
||||
pub async fn nip44_encrypt(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
content: &str,
|
||||
) -> Result<String, SignerError> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
inner.nip44_encrypt_async(public_key, content).await
|
||||
}
|
||||
|
||||
/// Decrypt a NIP-44 payload from the given key.
|
||||
pub async fn nip44_decrypt(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
payload: &str,
|
||||
) -> Result<String, SignerError> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
inner.nip44_decrypt_async(public_key, payload).await
|
||||
}
|
||||
}
|
||||
|
||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -90,22 +93,22 @@ where
|
||||
{
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||
.await
|
||||
.map_err(SignerError::other)
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||
.await
|
||||
.map_err(SignerError::other)
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,11 +116,11 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||
.await
|
||||
.map_err(SignerError::other)
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,17 +128,25 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||
.await
|
||||
.map_err(SignerError::other)
|
||||
.map_err(UniversalSignerError::new)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSigner {
|
||||
#[allow(dead_code)]
|
||||
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
|
||||
let guard = self.inner.read().expect("RwLock poisoned");
|
||||
f(&**guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = SignerError;
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
@@ -146,7 +157,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for UniversalSigner {
|
||||
type Error = SignerError;
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
@@ -158,7 +169,7 @@ impl AsyncSignEvent for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncNip44 for UniversalSigner {
|
||||
type Error = SignerError;
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
@@ -188,7 +199,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
auth_url: Url,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||
webbrowser::open(auth_url.as_str()).unwrap();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
schemars.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../LICENSE
|
||||
@@ -0,0 +1 @@
|
||||
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Light/dark switching, adapted from bezel
|
||||
//! (`crates/theme/src/appearance.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors): what the user asked for, what the OS reports, and the
|
||||
//! plumbing that turns a change in either into a repaint.
|
||||
|
||||
use gpui::{App, Global, Subscription, Window};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Theme, ThemeMode};
|
||||
|
||||
/// The user's appearance preference. Serde-serializable so callers can persist
|
||||
/// it wherever their settings live; this crate never touches disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the OS — matches every other native app on the machine,
|
||||
/// including when the OS is set to switch at sunset.
|
||||
System,
|
||||
Light,
|
||||
/// Paint dark regardless of the OS.
|
||||
#[default]
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl AppearanceMode {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
|
||||
|
||||
/// Menu/label text.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state behind the current theme: what the user chose, and what the OS
|
||||
/// last said. Kept separate from [`Theme`] itself so that flipping the OS
|
||||
/// appearance while the user has pinned Light still records the new system
|
||||
/// value (and takes effect the moment they switch back to `System`).
|
||||
pub struct AppearanceState {
|
||||
pub mode: AppearanceMode,
|
||||
pub system: ThemeMode,
|
||||
}
|
||||
|
||||
impl Global for AppearanceState {}
|
||||
|
||||
/// Combine the user's choice with the OS state.
|
||||
pub fn resolve(mode: AppearanceMode, system: ThemeMode) -> ThemeMode {
|
||||
match mode {
|
||||
AppearanceMode::System => system,
|
||||
AppearanceMode::Light => ThemeMode::Light,
|
||||
AppearanceMode::Dark => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the appearance globals and the matching theme. Call once at boot,
|
||||
/// before any window opens, so the first frame is already the right palette
|
||||
/// (installing later produces a visible dark-to-light flash).
|
||||
pub fn init(mode: AppearanceMode, cx: &mut App) {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.set_global(AppearanceState { mode, system });
|
||||
Theme::change(resolve(mode, system), None, cx);
|
||||
}
|
||||
|
||||
/// The mode currently in effect (defaults to `Dark` before [`init`]).
|
||||
pub fn mode(cx: &App) -> AppearanceMode {
|
||||
cx.try_global::<AppearanceState>()
|
||||
.map(|state| state.mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the user's preference and repaint if that changed the palette.
|
||||
/// Persisting the choice is the caller's job.
|
||||
pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode == mode {
|
||||
return;
|
||||
}
|
||||
state.mode = mode;
|
||||
// Coming back to `System`, re-read the OS: a pinned mode holds an
|
||||
// appearance override, so `system` may be stale from before the pin.
|
||||
if mode == AppearanceMode::System {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.global_mut::<AppearanceState>().system = system;
|
||||
}
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
|
||||
/// must outlive the window — callers typically `.detach()` it.
|
||||
///
|
||||
/// The notification is *per window*, but the appearance it reports is a system
|
||||
/// setting, so any one window is enough to learn about the change; re-applying
|
||||
/// is idempotent when several fire.
|
||||
pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
|
||||
// Reconcile against the *window's* appearance before subscribing: `init`
|
||||
// runs before any window exists and can only ask the app, which on some
|
||||
// platforms is not reliably populated that early in launch.
|
||||
record_system(window.appearance().into(), cx);
|
||||
window.observe_window_appearance(move |window, cx| {
|
||||
record_system(window.appearance().into(), cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. `window`, when given,
|
||||
/// is the more authoritative source for what the OS currently reports.
|
||||
pub fn record_system_appearance(window: Option<&Window>, cx: &mut App) {
|
||||
let appearance = match window {
|
||||
Some(window) => window.appearance(),
|
||||
None => cx.window_appearance(),
|
||||
};
|
||||
record_system(ThemeMode::from_window(appearance), cx);
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. Only recorded while the
|
||||
/// user's mode is `System` — a pinned mode ignores the OS anyway, and
|
||||
/// recording an override read back would overwrite what the OS said with what
|
||||
/// we asked for.
|
||||
fn record_system(system: ThemeMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode != AppearanceMode::System || state.system == system {
|
||||
return;
|
||||
}
|
||||
state.system = system;
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Re-resolve the palette and, if it moved, swap the theme and force a full
|
||||
/// repaint. A no-op when the resolved appearance is unchanged — the OS fires
|
||||
/// the appearance notification for other changes too, and repainting every
|
||||
/// window for those would be a visible hitch for nothing.
|
||||
pub fn apply(cx: &mut App) {
|
||||
let Some(state) = cx.try_global::<AppearanceState>() else {
|
||||
return;
|
||||
};
|
||||
let wanted = resolve(state.mode, state.system);
|
||||
let changed = !cx
|
||||
.try_global::<Theme>()
|
||||
.is_some_and(|theme| theme.mode == wanted);
|
||||
if changed {
|
||||
Theme::change(wanted, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//! Brand, ported from bezel (`crates/theme/src/brand.rs`, MIT License,
|
||||
//! Copyright (c) 2026 bezel contributors): one hue for the greys, one for the
|
||||
//! accent, one radius, and the text ink.
|
||||
//!
|
||||
//! A brand moves hue alone: every lightness in `palette.rs` stays as the
|
||||
//! palette set it.
|
||||
|
||||
use gpui::{App, Global, Hsla};
|
||||
|
||||
use crate::{ThemeColors, ThemeMode, color, layout};
|
||||
|
||||
/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped
|
||||
/// neutral, so [`Brand::default`] reproduces the built-in palette exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Tint {
|
||||
/// oklch hue, in degrees.
|
||||
pub hue: f32,
|
||||
/// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more.
|
||||
pub chroma: f32,
|
||||
}
|
||||
|
||||
impl Tint {
|
||||
pub const NONE: Self = Self {
|
||||
hue: 0.0,
|
||||
chroma: 0.0,
|
||||
};
|
||||
|
||||
pub const fn new(hue: f32, chroma: f32) -> Self {
|
||||
Self { hue, chroma }
|
||||
}
|
||||
}
|
||||
|
||||
/// The greys a UI is built on, as oklch hue and chroma.
|
||||
///
|
||||
/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors)
|
||||
/// — the same list shadcn offers as its base colour, and the reason these are
|
||||
/// quoted rather than invented: a neutral that carries hue is a judgement
|
||||
/// someone else has already made five times.
|
||||
pub const BASE_COLORS: [(&str, Tint); 5] = [
|
||||
("Neutral", Tint::NONE),
|
||||
("Stone", Tint::new(58.071, 0.013)),
|
||||
("Zinc", Tint::new(285.938, 0.016)),
|
||||
("Gray", Tint::new(264.364, 0.027)),
|
||||
("Slate", Tint::new(257.417, 0.046)),
|
||||
];
|
||||
|
||||
/// The alpha of each text role over the appearance's ink — white in dark,
|
||||
/// black in light.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TextInk {
|
||||
/// [`ThemeColors::text`].
|
||||
pub text: f32,
|
||||
/// [`ThemeColors::text_muted`].
|
||||
pub muted: f32,
|
||||
/// [`ThemeColors::text_faint`].
|
||||
pub faint: f32,
|
||||
}
|
||||
|
||||
impl TextInk {
|
||||
/// The role at `alpha` over `appearance`'s ink.
|
||||
pub fn color(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha),
|
||||
ThemeMode::Light => gpui::hsla(0.0, 0.0, 0.0, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes [`ThemeColors::text`], [`ThemeColors::text_muted`] and
|
||||
/// [`ThemeColors::text_faint`] for the appearance.
|
||||
pub fn paint(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
|
||||
colors.text = Self::color(appearance, self.text);
|
||||
colors.text_muted = Self::color(appearance, self.muted);
|
||||
colors.text_faint = Self::color(appearance, self.faint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text ink for both appearances.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Ink {
|
||||
pub dark: TextInk,
|
||||
pub light: TextInk,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
/// Text and muted at 4.5:1 or more and faint at 3:1 or more, on
|
||||
/// [`ThemeColors::bg`] and [`ThemeColors::surface`] in both appearances.
|
||||
pub const ACCESSIBLE: Self = Self {
|
||||
dark: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.52,
|
||||
faint: 0.40,
|
||||
},
|
||||
light: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.56,
|
||||
faint: 0.43,
|
||||
},
|
||||
};
|
||||
/// AppKit's label, secondary and tertiary label colours. Muted is under
|
||||
/// 4.5:1 on white, and faint is under 3:1 in both appearances.
|
||||
pub const APPKIT: Self = Self {
|
||||
dark: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.55,
|
||||
faint: 0.25,
|
||||
},
|
||||
light: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.50,
|
||||
faint: 0.26,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn for_appearance(&self, appearance: ThemeMode) -> TextInk {
|
||||
match appearance {
|
||||
ThemeMode::Dark => self.dark,
|
||||
ThemeMode::Light => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an app changes about the shipped palette without redesigning it.
|
||||
///
|
||||
/// Installed as a gpui [`Global`] via [`crate::AppExt::set_brand`]; every
|
||||
/// palette install applies it, so it survives a light/dark switch and composes
|
||||
/// with [`crate::AppExt::set_palette`] rather than competing with it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Brand {
|
||||
/// The hue every grey in the palette carries.
|
||||
pub tint: Tint,
|
||||
/// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
|
||||
/// any other grey — which is what the shipped palette already is.
|
||||
pub accent: Tint,
|
||||
/// The base corner radius; every other corner is a ratio of it. See
|
||||
/// [`crate::BASE_RADIUS`].
|
||||
pub radius: f32,
|
||||
/// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship.
|
||||
pub ink: Ink,
|
||||
}
|
||||
|
||||
impl Global for Brand {}
|
||||
|
||||
impl Default for Brand {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tint: Tint::NONE,
|
||||
accent: Tint::NONE,
|
||||
radius: layout::BASE_RADIUS,
|
||||
ink: Ink::APPKIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brand {
|
||||
/// Whether applying this brand would change nothing — the shipped palette
|
||||
/// already carries these values, so a JSON theme's own text colors must be
|
||||
/// left alone.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The shipped palette for an appearance, rotated onto this brand.
|
||||
pub fn branded(&self, appearance: ThemeMode) -> ThemeColors {
|
||||
let mut colors = ThemeColors::for_appearance(appearance);
|
||||
// apply() re-asserts this brand's ink, so the default brand — whose
|
||||
// ink is the shipped one — must skip it to stay a strict no-op.
|
||||
if !self.is_default() {
|
||||
self.apply(appearance, &mut colors);
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
/// Rotate a palette onto this brand's hues.
|
||||
pub fn apply(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
|
||||
self.ink
|
||||
.for_appearance(appearance)
|
||||
.paint(appearance, colors);
|
||||
// Every colour token, with the rule doing the choosing: a token that is
|
||||
// already grey takes the tint, and one that already carries a hue —
|
||||
// danger, warning, success — is semantic and keeps it. Translucent ink
|
||||
// is skipped because it paints over whatever is beneath it, which is
|
||||
// tinted already.
|
||||
let tokens: [&mut Hsla; 41] = [
|
||||
&mut colors.bg,
|
||||
&mut colors.surface,
|
||||
&mut colors.surface_raised,
|
||||
&mut colors.surface_card,
|
||||
&mut colors.surface_dialog,
|
||||
&mut colors.surface_overlay,
|
||||
&mut colors.element_hover,
|
||||
&mut colors.element_active,
|
||||
&mut colors.border_faint,
|
||||
&mut colors.border,
|
||||
&mut colors.border_strong,
|
||||
&mut colors.text,
|
||||
&mut colors.text_muted,
|
||||
&mut colors.text_faint,
|
||||
&mut colors.text_dim,
|
||||
&mut colors.solid,
|
||||
&mut colors.on_solid,
|
||||
&mut colors.accent,
|
||||
&mut colors.accent_strong,
|
||||
&mut colors.on_accent,
|
||||
&mut colors.danger,
|
||||
&mut colors.danger_muted,
|
||||
&mut colors.warning,
|
||||
&mut colors.warning_muted,
|
||||
&mut colors.success,
|
||||
&mut colors.busy,
|
||||
&mut colors.success_muted,
|
||||
&mut colors.surface_raised_hover,
|
||||
&mut colors.band,
|
||||
&mut colors.input_bg,
|
||||
&mut colors.selection,
|
||||
&mut colors.cursor,
|
||||
&mut colors.caret,
|
||||
&mut colors.ring,
|
||||
&mut colors.drop_target,
|
||||
&mut colors.danger_strong,
|
||||
&mut colors.code_text,
|
||||
&mut colors.code_wash,
|
||||
&mut colors.diff_add,
|
||||
&mut colors.diff_del,
|
||||
&mut colors.diff_hunk_bg,
|
||||
];
|
||||
let syntax: [&mut Hsla; 24] = [
|
||||
&mut colors.syntax.comment,
|
||||
&mut colors.syntax.keyword,
|
||||
&mut colors.syntax.string,
|
||||
&mut colors.syntax.string_special,
|
||||
&mut colors.syntax.escape,
|
||||
&mut colors.syntax.number,
|
||||
&mut colors.syntax.boolean,
|
||||
&mut colors.syntax.type_name,
|
||||
&mut colors.syntax.type_builtin,
|
||||
&mut colors.syntax.constructor,
|
||||
&mut colors.syntax.function,
|
||||
&mut colors.syntax.function_builtin,
|
||||
&mut colors.syntax.macro_name,
|
||||
&mut colors.syntax.property,
|
||||
&mut colors.syntax.constant,
|
||||
&mut colors.syntax.variable,
|
||||
&mut colors.syntax.variable_special,
|
||||
&mut colors.syntax.parameter,
|
||||
&mut colors.syntax.operator,
|
||||
&mut colors.syntax.punctuation,
|
||||
&mut colors.syntax.tag,
|
||||
&mut colors.syntax.attribute,
|
||||
&mut colors.syntax.label,
|
||||
&mut colors.syntax.invalid,
|
||||
];
|
||||
for slot in tokens.into_iter().chain(syntax) {
|
||||
if slot.a == 1.0 && slot.s <= f32::EPSILON {
|
||||
*slot = color::tint(*slot, self.tint.hue, self.tint.chroma);
|
||||
}
|
||||
}
|
||||
|
||||
if self.accent.chroma > 0.0 {
|
||||
let (accent_l, plate_l) = if appearance.is_light() {
|
||||
(ACCENT_L.1, PLATE_L.1)
|
||||
} else {
|
||||
(ACCENT_L.0, PLATE_L.0)
|
||||
};
|
||||
colors.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
|
||||
colors.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
|
||||
// Whichever label the plate can actually hold. The shipped accent is
|
||||
// the maximum-contrast neutral, where the answer is always the
|
||||
// inverse; a chromatic plate at a yellow hue is bright enough that
|
||||
// the inverse would be the unreadable one.
|
||||
colors.on_accent = label_on(colors.accent_strong, colors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
|
||||
/// the two steps `palette.rs` picked so an accent clears WCAG AA on its own
|
||||
/// background rather than glowing on one and vanishing on the other.
|
||||
const ACCENT_L: (f32, f32) = (0.673, 0.511);
|
||||
|
||||
/// The lightness of a *plate* carrying [`ThemeColors::on_accent`], taken from
|
||||
/// `danger_strong` — the palette's existing chromatic plate, already tuned to
|
||||
/// hold a label in both appearances.
|
||||
const PLATE_L: (f32, f32) = (0.58, 0.51);
|
||||
|
||||
/// Whichever of the palette's two extremes the plate can actually hold.
|
||||
fn label_on(plate: Hsla, colors: &ThemeColors) -> Hsla {
|
||||
let (a, b) = (colors.solid, colors.on_solid);
|
||||
if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the installed brand (the default before one is set).
|
||||
pub fn installed(cx: &App) -> Brand {
|
||||
cx.try_global::<Brand>().copied().unwrap_or_default()
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
//! Color math ported from bezel (`crates/theme/src/color.rs`, MIT License,
|
||||
//! Copyright (c) 2026 bezel contributors): the oklch → sRGB → HSL converters
|
||||
//! behind the token constructors, WCAG contrast for the palette tests, and
|
||||
//! small paint helpers.
|
||||
|
||||
use gpui::{Hsla, hsla};
|
||||
|
||||
/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
|
||||
/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
|
||||
/// float-noise saturation).
|
||||
pub fn neutral(lightness: f32) -> Hsla {
|
||||
let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
|
||||
hsla(0.0, 0.0, v, 1.0)
|
||||
}
|
||||
|
||||
/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
|
||||
/// — for surfaces matched against reference samples.
|
||||
pub fn grey(value: u8) -> Hsla {
|
||||
hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
|
||||
}
|
||||
|
||||
/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
|
||||
pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
|
||||
let [r, g, b] = oklch_to_srgb(l, c, h_deg);
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
hsla(h, s, l, 1.0)
|
||||
}
|
||||
|
||||
/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
|
||||
/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
|
||||
pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
||||
let [r, g, b] = oklch_to_linear(l, c, h_deg);
|
||||
[gamma_encode(r), gamma_encode(g), gamma_encode(b)]
|
||||
}
|
||||
|
||||
/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the
|
||||
/// display cannot make, which is what [`fit_chroma`] tests for.
|
||||
fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
||||
let h = h_deg.to_radians();
|
||||
let a = c * h.cos();
|
||||
let b = c * h.sin();
|
||||
|
||||
// OKLab → LMS (cube roots undone)
|
||||
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
|
||||
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||
|
||||
// LMS → linear sRGB
|
||||
let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
|
||||
let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
|
||||
let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
|
||||
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
fn gamma_encode(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x <= 0.003_130_8 {
|
||||
12.92 * x
|
||||
} else {
|
||||
1.055 * x.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
fn gamma_decode(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x <= 0.040_449_936 {
|
||||
x / 12.92
|
||||
} else {
|
||||
((x + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// The oklch lightness of an achromatic tone: [`neutral`] inverted.
|
||||
///
|
||||
/// Only greys round-trip through this. A grey's three channels are equal, and
|
||||
/// the three LMS matrix rows each sum to 1, so the whole OKLab transform
|
||||
/// collapses to one cube root — no inverse matrix needed.
|
||||
pub fn lightness(color: Hsla) -> f32 {
|
||||
gamma_decode(color.l).cbrt()
|
||||
}
|
||||
|
||||
/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`.
|
||||
///
|
||||
/// Near black and near white the gamut is a needle: asking for a mid-ramp
|
||||
/// chroma there produces an out-of-range component, and clamping it per channel
|
||||
/// shifts the hue instead of dropping the saturation.
|
||||
fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 {
|
||||
let fits = |c: f32| {
|
||||
oklch_to_linear(l, c, hue)
|
||||
.iter()
|
||||
.all(|x| (-1e-4..=1.0 + 1e-4).contains(x))
|
||||
};
|
||||
if fits(chroma) {
|
||||
return chroma;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, chroma);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if fits(mid) { lo = mid } else { hi = mid }
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its
|
||||
/// lightness can hold. Alpha rides through untouched.
|
||||
pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla {
|
||||
if chroma <= 0.0 {
|
||||
return color;
|
||||
}
|
||||
let l = lightness(color);
|
||||
let mut out = oklch(l, fit_chroma(l, chroma, hue), hue);
|
||||
out.a = color.a;
|
||||
out
|
||||
}
|
||||
|
||||
/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
|
||||
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let delta = max - min;
|
||||
if delta < f32::EPSILON {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let s = if l > 0.5 {
|
||||
delta / (2.0 - max - min)
|
||||
} else {
|
||||
delta / (max + min)
|
||||
};
|
||||
let h = if (max - r).abs() < f32::EPSILON {
|
||||
((g - b) / delta).rem_euclid(6.0)
|
||||
} else if (max - g).abs() < f32::EPSILON {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
} / 6.0;
|
||||
(h, s, l)
|
||||
}
|
||||
|
||||
/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
|
||||
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||||
if s <= f32::EPSILON {
|
||||
return [l, l, l];
|
||||
}
|
||||
let q = if l < 0.5 {
|
||||
l * (1.0 + s)
|
||||
} else {
|
||||
l + s - l * s
|
||||
};
|
||||
let p = 2.0 * l - q;
|
||||
let hue = |mut t: f32| {
|
||||
t = t.rem_euclid(1.0);
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
|
||||
}
|
||||
|
||||
/// WCAG 2.1 relative luminance of an opaque color.
|
||||
pub fn relative_luminance(color: Hsla) -> f32 {
|
||||
let lin = |c: f32| {
|
||||
if c <= 0.040_45 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
|
||||
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
}
|
||||
|
||||
/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
|
||||
///
|
||||
/// Used by the palette tests to prove each light token reproduces the contrast
|
||||
/// its dark counterpart had, rather than merely looking plausible.
|
||||
pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
|
||||
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
|
||||
/// opaque result — the color the eye actually receives.
|
||||
pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
|
||||
let a = fg.a.clamp(0.0, 1.0);
|
||||
let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
|
||||
let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
|
||||
let (h, s, l) = rgb_to_hsl(
|
||||
fr * a + br * (1.0 - a),
|
||||
fg_ * a + bg_ * (1.0 - a),
|
||||
fb * a + bb * (1.0 - a),
|
||||
);
|
||||
hsla(h, s, l, 1.0)
|
||||
}
|
||||
|
||||
/// Linear per-component mix of two colors.
|
||||
pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let lerp = |x: f32, y: f32| x + (y - x) * t;
|
||||
hsla(
|
||||
lerp(a.h, b.h),
|
||||
lerp(a.s, b.s),
|
||||
lerp(a.l, b.l),
|
||||
lerp(a.a, b.a),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,57 @@
|
||||
use std::fmt::Debug;
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
|
||||
use gpui::Axis;
|
||||
use gpui::{AbsoluteLength, Axis, Length, Pixels};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A enum for defining the placement of the element.
|
||||
///
|
||||
/// See also: [`Side`] if you need to define the left, right side.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Placement {
|
||||
#[serde(rename = "top")]
|
||||
Top,
|
||||
#[serde(rename = "bottom")]
|
||||
Bottom,
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Placement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Placement::Top => write!(f, "Top"),
|
||||
Placement::Bottom => write!(f, "Bottom"),
|
||||
Placement::Left => write!(f, "Left"),
|
||||
Placement::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
#[inline]
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, Placement::Left | Placement::Right)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Placement::Top | Placement::Bottom)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A enum for defining the side of the element.
|
||||
///
|
||||
/// See also: [`Placement`] if you need to define the 4 edges.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Side {
|
||||
#[serde(rename = "left")]
|
||||
@@ -46,6 +94,39 @@ impl AxisExt for Axis {
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for converting [`Pixels`] to `f32` and `f64`.
|
||||
pub trait PixelsExt {
|
||||
fn as_f32(&self) -> f32;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn as_f64(self) -> f64;
|
||||
}
|
||||
impl PixelsExt for Pixels {
|
||||
fn as_f32(&self) -> f32 {
|
||||
f32::from(self)
|
||||
}
|
||||
|
||||
fn as_f64(self) -> f64 {
|
||||
f64::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Length`] enum with utility methods.
|
||||
pub trait LengthExt {
|
||||
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
|
||||
///
|
||||
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
|
||||
}
|
||||
|
||||
impl LengthExt for Length {
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
||||
match self {
|
||||
Length::Auto => None,
|
||||
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct for defining the edges of an element.
|
||||
///
|
||||
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
|
||||
@@ -61,3 +142,18 @@ pub struct Edges<T: Clone + Debug + Default + PartialEq> {
|
||||
/// The size of the left edge.
|
||||
pub left: T,
|
||||
}
|
||||
|
||||
impl<T> Edges<T>
|
||||
where
|
||||
T: Clone + Debug + Default + PartialEq,
|
||||
{
|
||||
/// Creates a new `Edges` instance with all edges set to the same value.
|
||||
pub fn all(value: T) -> Self {
|
||||
Self {
|
||||
top: value.clone(),
|
||||
right: value.clone(),
|
||||
bottom: value.clone(),
|
||||
left: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
//! Layout constants, ported from bezel
|
||||
//! (`crates/theme/src/theme/layout.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors). Numbers drive layout, colors are paint: these live as plain
|
||||
//! numbers and never depend on which color is painted.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// The branded base radius behind every radius accessor, as raw `f32` bits.
|
||||
static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits());
|
||||
|
||||
/// Point the radius accessors at a base. Called when a palette is installed.
|
||||
pub(crate) fn set_base_radius(radius: f32) {
|
||||
BASE.store(radius.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`,
|
||||
/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)`
|
||||
/// all report 8.
|
||||
pub const SPACE: f32 = 8.0;
|
||||
|
||||
/// The margin from content to its container's edge. Same measurement,
|
||||
/// visual format's `|-`.
|
||||
pub const CONTENT_MARGIN: f32 = 20.0;
|
||||
|
||||
/// From a column's edge to the glyph of the control at the end of a row,
|
||||
/// measured to the glyph rather than the button. Mirrored at the trailing end.
|
||||
pub const EDGE: f32 = 12.0;
|
||||
|
||||
/// Button, text field and select-trigger height. Measured:
|
||||
/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
|
||||
/// `.regular` — the body line box with 4 above and below.
|
||||
pub const BUTTON_HEIGHT: f32 = 24.0;
|
||||
|
||||
/// The same controls at `.small`.
|
||||
pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
|
||||
|
||||
/// Every radius below is a ratio of this, so a brand radius moves the whole
|
||||
/// set together and keeps the concentric relationships intact.
|
||||
pub const BASE_RADIUS: f32 = 8.0;
|
||||
|
||||
/// Message bubble corner radius (2x).
|
||||
pub fn bubble_radius() -> f32 {
|
||||
radius(2.0)
|
||||
}
|
||||
|
||||
/// Floating-surface corner radius — popovers, menus, dialogs (1.5x).
|
||||
pub fn surface_radius() -> f32 {
|
||||
radius(1.5)
|
||||
}
|
||||
|
||||
/// Panel / card corner radius (1.25x).
|
||||
pub fn panel_radius() -> f32 {
|
||||
radius(1.25)
|
||||
}
|
||||
|
||||
/// Button, text field and select-trigger radius (1x).
|
||||
pub fn button_radius() -> f32 {
|
||||
radius(1.0)
|
||||
}
|
||||
|
||||
/// Small control radius (chips, tags, steppers) — a size down from
|
||||
/// [`button_radius`], for things that sit inside a control rather than being one
|
||||
/// (0.75x).
|
||||
pub fn control_radius() -> f32 {
|
||||
radius(0.75)
|
||||
}
|
||||
|
||||
/// A corner as a multiple of the branded base radius.
|
||||
///
|
||||
/// Read from a process-wide mirror rather than the theme global for the reason
|
||||
/// [`crate::paint::current_appearance`] is: the element builders that round a
|
||||
/// corner are free functions with no `cx` in scope, and a radius is one number
|
||||
/// for the whole app.
|
||||
fn radius(ratio: f32) -> f32 {
|
||||
f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
|
||||
}
|
||||
|
||||
/// The concentric child of a surface: a row inset by `inset` inside a
|
||||
/// container of radius `outer` keeps its corners parallel to the container's,
|
||||
/// rather than looking pasted onto it.
|
||||
///
|
||||
/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
|
||||
/// has no container shape to inherit at paint time, so the relationship is
|
||||
/// stated where the child is *defined* instead of resolved at runtime.
|
||||
pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
|
||||
if outer > inset { outer - inset } else { 0.0 }
|
||||
}
|
||||
@@ -1,51 +1,25 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||
|
||||
mod brand;
|
||||
mod color;
|
||||
mod colors;
|
||||
mod geometry;
|
||||
mod layout;
|
||||
mod notification;
|
||||
mod paint;
|
||||
mod palette;
|
||||
mod platform_kind;
|
||||
mod registry;
|
||||
mod scale;
|
||||
mod scrollbar_mode;
|
||||
mod syntax;
|
||||
mod theme;
|
||||
mod typography;
|
||||
|
||||
pub mod appearance;
|
||||
|
||||
pub use appearance::AppearanceMode;
|
||||
pub use brand::{BASE_COLORS, Brand, Ink, TextInk, Tint};
|
||||
pub use color::{
|
||||
contrast_ratio, flatten, grey, hsl_to_rgb, lightness, mix, neutral, oklch, oklch_to_srgb,
|
||||
relative_luminance, rgb_to_hsl, tint,
|
||||
};
|
||||
pub use colors::*;
|
||||
pub use geometry::*;
|
||||
pub use layout::{
|
||||
BASE_RADIUS, BUTTON_HEIGHT, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SPACE, bubble_radius,
|
||||
button_radius, control_radius, inset_radius, panel_radius, surface_radius,
|
||||
};
|
||||
pub use notification::*;
|
||||
pub use paint::{
|
||||
INK_FILL_SCALE, INK_HAIRLINE_SCALE, SCRIM_ALPHA_DARK, band, current_appearance, hairline, ink,
|
||||
plate_active, plate_hover, scrim, set_current_appearance, surface_shadows, wash,
|
||||
};
|
||||
pub use platform_kind::PlatformKind;
|
||||
pub use registry::*;
|
||||
pub use scale::*;
|
||||
pub use scrollbar_mode::*;
|
||||
pub use syntax::{HighlightKind, SyntaxPalette};
|
||||
pub use theme::*;
|
||||
pub use typography::{
|
||||
DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size, set_base_text_size,
|
||||
};
|
||||
|
||||
/// Which appearance the app is painting. Bezel names this enum `Appearance`;
|
||||
/// coop's `ThemeMode` predates the port and carries the same two variants.
|
||||
pub type Appearance = ThemeMode;
|
||||
|
||||
/// Defines window border radius for platforms that use client side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
|
||||
@@ -57,110 +31,21 @@ pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
|
||||
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
||||
|
||||
/// Defines window titlebar height
|
||||
pub(crate) const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Defines workspace tabbar height
|
||||
pub const TABBAR_HEIGHT: Pixels = px(36.0);
|
||||
pub const TABBAR_HEIGHT: Pixels = px(44.0);
|
||||
|
||||
/// Defines default sidebar width
|
||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
appearance::init(AppearanceMode::default(), cx);
|
||||
registry::init(cx);
|
||||
|
||||
Theme::sync_system_appearance(None, cx);
|
||||
Theme::sync_scrollbar_appearance(cx);
|
||||
}
|
||||
|
||||
/// An alternative palette source.
|
||||
pub type PaletteBuilder = fn(ThemeMode) -> ThemeColors;
|
||||
|
||||
struct GlobalPaletteBuilder(Option<PaletteBuilder>);
|
||||
|
||||
impl Global for GlobalPaletteBuilder {}
|
||||
|
||||
/// Install a brand or an alternative palette on the app.
|
||||
pub trait AppExt {
|
||||
fn set_brand(&mut self, brand: Brand);
|
||||
fn set_palette(&mut self, build: PaletteBuilder);
|
||||
fn set_appearance_mode(&mut self, mode: AppearanceMode);
|
||||
fn set_base_text_size(&mut self, points: f32);
|
||||
}
|
||||
|
||||
impl AppExt for App {
|
||||
fn set_brand(&mut self, brand: Brand) {
|
||||
self.set_global(brand);
|
||||
reinstall(self);
|
||||
}
|
||||
|
||||
fn set_palette(&mut self, build: PaletteBuilder) {
|
||||
self.set_global(GlobalPaletteBuilder(Some(build)));
|
||||
reinstall(self);
|
||||
}
|
||||
|
||||
fn set_appearance_mode(&mut self, mode: AppearanceMode) {
|
||||
appearance::set_mode(mode, self);
|
||||
}
|
||||
|
||||
fn set_base_text_size(&mut self, points: f32) {
|
||||
typography::set_base_text_size(points);
|
||||
self.refresh_windows();
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-install the palette for the current appearance.
|
||||
fn reinstall(cx: &mut App) {
|
||||
let mode = cx
|
||||
.try_global::<Theme>()
|
||||
.map(|theme| theme.mode)
|
||||
.unwrap_or_default();
|
||||
Theme::change(mode, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
|
||||
/// Mirror the active coop theme into the `gpui-base` global theme.
|
||||
pub fn sync_base(cx: &mut App) {
|
||||
let Some(theme) = cx.try_global::<Theme>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let appearance = if theme.mode.is_dark() {
|
||||
gpui_base::ThemeAppearance::Dark
|
||||
} else {
|
||||
gpui_base::ThemeAppearance::Light
|
||||
};
|
||||
let scrollbar_mode = match theme.scrollbar_mode {
|
||||
ScrollbarMode::Scrolling => gpui_base::ScrollbarMode::Scrolling,
|
||||
ScrollbarMode::Hover => gpui_base::ScrollbarMode::Hover,
|
||||
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
|
||||
};
|
||||
let colors = theme.colors;
|
||||
let font_sans = theme.font_sans.clone();
|
||||
|
||||
let base = gpui_base::Theme::global_mut(cx);
|
||||
base.appearance = appearance;
|
||||
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
|
||||
base.tokens.typography.sans = font_sans;
|
||||
|
||||
let tokens = &mut base.tokens.colors;
|
||||
tokens.background = colors.bg;
|
||||
tokens.foreground = colors.text;
|
||||
tokens.surface = colors.surface;
|
||||
tokens.surface_foreground = colors.text;
|
||||
tokens.primary = colors.solid;
|
||||
tokens.primary_foreground = colors.on_solid;
|
||||
tokens.secondary = colors.surface_raised;
|
||||
tokens.secondary_foreground = colors.text;
|
||||
tokens.muted = colors.surface_card;
|
||||
tokens.muted_foreground = colors.text_muted;
|
||||
tokens.accent = colors.element_hover;
|
||||
tokens.accent_foreground = colors.text;
|
||||
tokens.destructive = colors.danger_strong;
|
||||
tokens.destructive_foreground = gpui::white();
|
||||
tokens.border = colors.border;
|
||||
tokens.input = colors.border;
|
||||
tokens.ring = colors.ring;
|
||||
tokens.selection = colors.selection;
|
||||
}
|
||||
|
||||
pub trait ActiveTheme {
|
||||
fn theme(&self) -> &Theme;
|
||||
}
|
||||
@@ -174,13 +59,38 @@ impl ActiveTheme for App {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
/// Theme colors
|
||||
pub colors: ThemeColors,
|
||||
|
||||
/// Theme family
|
||||
pub theme: Rc<ThemeFamily>,
|
||||
|
||||
/// The appearance of the theme (light or dark).
|
||||
pub mode: ThemeMode,
|
||||
pub font_sans: SharedString,
|
||||
pub font_mono: SharedString,
|
||||
|
||||
/// The font family for the application.
|
||||
pub font_family: SharedString,
|
||||
|
||||
/// The root font size for the application, default is 15px.
|
||||
pub font_size: Pixels,
|
||||
|
||||
/// Radius for the general elements.
|
||||
pub radius: Pixels,
|
||||
|
||||
/// Radius for the large elements, e.g.: modal, notification.
|
||||
pub radius_lg: Pixels,
|
||||
|
||||
/// Enable shadow for the general elements. default is true
|
||||
pub shadow: bool,
|
||||
|
||||
/// Show the scrollbar mode, default: scrolling
|
||||
pub scrollbar_mode: ScrollbarMode,
|
||||
|
||||
/// Notification settings
|
||||
pub notification: NotificationSettings,
|
||||
|
||||
/// Platform kind
|
||||
pub platform: PlatformKind,
|
||||
}
|
||||
|
||||
impl Deref for Theme {
|
||||
@@ -206,7 +116,7 @@ impl Theme {
|
||||
}
|
||||
|
||||
/// Returns the global theme mutable reference
|
||||
pub(crate) fn global_mut(cx: &mut App) -> &mut Theme {
|
||||
pub fn global_mut(cx: &mut App) -> &mut Theme {
|
||||
cx.global_mut::<Theme>()
|
||||
}
|
||||
|
||||
@@ -217,7 +127,12 @@ impl Theme {
|
||||
|
||||
/// Sync the theme with the system appearance
|
||||
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
|
||||
appearance::record_system_appearance(window.as_deref(), cx);
|
||||
let appearance = window
|
||||
.as_ref()
|
||||
.map(|window| window.appearance())
|
||||
.unwrap_or_else(|| cx.window_appearance());
|
||||
|
||||
Self::change(appearance, window, cx);
|
||||
}
|
||||
|
||||
/// Sync the Scrollbar showing behavior with the system
|
||||
@@ -229,84 +144,77 @@ impl Theme {
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply a new theme to the application.
|
||||
pub fn apply_theme(new_theme: Rc<ThemeFamily>, window: Option<&mut Window>, cx: &mut App) {
|
||||
let theme = cx.global_mut::<Theme>();
|
||||
let mode = theme.mode;
|
||||
// Update the theme
|
||||
theme.theme = new_theme;
|
||||
// Emit a theme change event
|
||||
Self::change(mode, window, cx);
|
||||
}
|
||||
|
||||
/// Change the app's appearance
|
||||
pub fn change<M>(mode: M, window: Option<&mut Window>, cx: &mut App)
|
||||
where
|
||||
M: Into<ThemeMode>,
|
||||
{
|
||||
if !cx.has_global::<Theme>() {
|
||||
cx.set_global(Theme::default());
|
||||
let default_theme = ThemeFamily::default();
|
||||
let theme = Theme::from(default_theme);
|
||||
|
||||
cx.set_global(theme);
|
||||
}
|
||||
|
||||
let mode = mode.into();
|
||||
let builder = cx
|
||||
.try_global::<GlobalPaletteBuilder>()
|
||||
.and_then(|builder| builder.0);
|
||||
let mut colors = match builder {
|
||||
Some(build) => build(mode),
|
||||
None => ThemeColors::for_appearance(mode),
|
||||
};
|
||||
|
||||
// A default brand changes nothing; anything else rotates the tokens.
|
||||
let installed_brand = brand::installed(cx);
|
||||
if !installed_brand.is_default() {
|
||||
installed_brand.apply(mode, &mut colors);
|
||||
}
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
let theme = cx.global_mut::<Theme>();
|
||||
|
||||
// Set the theme mode
|
||||
theme.mode = mode;
|
||||
|
||||
// Set the theme colors
|
||||
theme.colors = colors;
|
||||
if mode.is_dark() {
|
||||
theme.colors = *theme.theme.dark();
|
||||
} else {
|
||||
theme.colors = *theme.theme.light();
|
||||
}
|
||||
|
||||
// Refresh the window if available
|
||||
if let Some(window) = window {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Keep the context-free paint helpers and the radius accessors in step
|
||||
paint::set_current_appearance(mode);
|
||||
layout::set_base_radius(installed_brand.radius);
|
||||
|
||||
// Keep the base-layer projection in step with the coop palette
|
||||
sync_base(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
impl From<ThemeFamily> for Theme {
|
||||
fn from(family: ThemeFamily) -> Self {
|
||||
let platform = PlatformKind::platform();
|
||||
let mode = ThemeMode::default();
|
||||
|
||||
// The system UI font, by platform.
|
||||
let font_sans = match platform {
|
||||
// Define the font family based on the platform.
|
||||
let font_family = match platform {
|
||||
PlatformKind::Linux => "Inter",
|
||||
_ => ".SystemUIFont",
|
||||
};
|
||||
|
||||
Self {
|
||||
font_sans: font_sans.into(),
|
||||
font_mono: system_mono().into(),
|
||||
// Define the theme colors based on the appearance
|
||||
let colors = match mode {
|
||||
ThemeMode::Light => family.light(),
|
||||
ThemeMode::Dark => family.dark(),
|
||||
};
|
||||
|
||||
Theme {
|
||||
font_size: px(15.),
|
||||
font_family: font_family.into(),
|
||||
radius: px(6.),
|
||||
radius_lg: px(10.),
|
||||
shadow: true,
|
||||
scrollbar_mode: ScrollbarMode::default(),
|
||||
notification: NotificationSettings::default(),
|
||||
mode,
|
||||
colors: ThemeColors::for_appearance(mode),
|
||||
colors: *colors,
|
||||
theme: Rc::new(family),
|
||||
platform,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mono face has no alias of its own, so each backend is named here.
|
||||
fn system_mono() -> &'static str {
|
||||
if cfg!(target_family = "wasm") {
|
||||
".ZedMono"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
".AppleSystemUIFontMonospaced"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Cascadia Mono"
|
||||
} else {
|
||||
"DejaVu Sans Mono"
|
||||
}
|
||||
}
|
||||
|
||||