chore: use gpui image cache (#47)
Reviewed-on: #47
This commit was merged in pull request #47.
This commit is contained in:
Generated
+334
-282
File diff suppressed because it is too large
Load Diff
@@ -1,452 +0,0 @@
|
|||||||
# NIP-17 Encrypted File Messages (kind 15) — Backend Plan
|
|
||||||
|
|
||||||
Implementation plan for sending and receiving NIP-17 **file messages** in coop.
|
|
||||||
|
|
||||||
**Scope: backend only.** This document covers crypto, blob upload/download, rumor
|
|
||||||
construction, rumor parsing and local caching. No UI work (composer button, file
|
|
||||||
rendering, image cache, decryption-on-render state) — that is a separate follow-up
|
|
||||||
once these APIs exist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What the protocol requires
|
|
||||||
|
|
||||||
NIP-17 file messages are **not** a new transport. They reuse everything coop already
|
|
||||||
has (NIP-44, NIP-59 seal + gift wrap, kind 10050 inbox relays) and only add:
|
|
||||||
|
|
||||||
1. A new inner **rumor kind: `15`** (`Kind::Custom(15)`), whose `.content` is the URL
|
|
||||||
of an **encrypted** blob and whose tags carry the MIME type and the decryption
|
|
||||||
material.
|
|
||||||
2. **AES-256-GCM** encryption of the file bytes before upload.
|
|
||||||
|
|
||||||
Kind 15 tags (per NIP-17):
|
|
||||||
|
|
||||||
| Tag | Required | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `p` | yes | receivers (as for kind 14) |
|
|
||||||
| `e` | if reply | parent message id |
|
|
||||||
| `subject` | optional | conversation title |
|
|
||||||
| `file-type` | yes | MIME type of the **plaintext** file |
|
|
||||||
| `encryption-algorithm` | yes | `aes-gcm` (only supported value) |
|
|
||||||
| `decryption-key` | yes | key for the recipient |
|
|
||||||
| `decryption-nonce` | yes | nonce for the recipient |
|
|
||||||
| `x` | yes | SHA-256 hex of the **encrypted** file |
|
|
||||||
| `ox` | expected | SHA-256 hex of the file **before** encryption |
|
|
||||||
| `size` | optional | size of the **encrypted** file in bytes |
|
|
||||||
| `dim` | optional | `<width>x<height>` in pixels |
|
|
||||||
| `thumbhash` / `blurhash` | optional | placeholder previews |
|
|
||||||
| `thumb` | optional | thumbnail URL (same key/nonce) |
|
|
||||||
| `fallback` | optional | extra file sources (same key/nonce) |
|
|
||||||
|
|
||||||
Key material travels **inside** the gift wrap, so the public blob URL is useless
|
|
||||||
without it. That property falls out of the existing seal/gift-wrap code for free.
|
|
||||||
|
|
||||||
`thumbhash`, `blurhash`, `thumb`, `fallback` are out of scope for v1 (all optional).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. What already exists in coop (verified against the pinned deps)
|
|
||||||
|
|
||||||
| Requirement | Location | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| NIP-59 seal + gift wrap | `crates/chat/src/room.rs::send_gift_wrap` (`nip59::GiftWrapBuilder`) | exists |
|
|
||||||
| Per-recipient publish + self backup | `crates/chat/src/room.rs::send` | exists |
|
|
||||||
| Inbox relays (kind 10050) | `crates/chat/src/lib.rs::handle_notifications` | exists |
|
|
||||||
| Rumor unwrap + local cache | `crates/chat/src/lib.rs::{extract_rumor,try_unwrap_with,set_rumor,get_rumor}` | exists |
|
|
||||||
| Kind 14 rumor construction | `crates/chat/src/room.rs::rumor` | exists |
|
|
||||||
| Blossom upload | `crates/state/src/blossom.rs::upload` (plaintext, random signing key) | partial |
|
|
||||||
| **Kind 15 rumor + parse** | — | **to add** |
|
|
||||||
| **AES-256-GCM encrypt/decrypt** | — | **to add** |
|
|
||||||
| **Encrypted blob upload/download** | — | **to add** |
|
|
||||||
|
|
||||||
Verified facts about the pinned SDK (`rust-nostr@b230cec`, `nostr 0.45.4`) and
|
|
||||||
`gpui@69af529`:
|
|
||||||
|
|
||||||
- rust-nostr has **no** kind-15 helper and **no** AES-GCM/AES-GCM-tag support anywhere
|
|
||||||
(`FileMetadata` in NIP-94 is kind 1063, unrelated). `nip17.rs` only covers kind 14
|
|
||||||
and 10050.
|
|
||||||
- `nip59::GiftWrapBuilder::new(receiver, rumor: UnsignedEvent)` accepts **any**
|
|
||||||
`UnsignedEvent`, so kind 15 flows through the existing wrap/send path unchanged.
|
|
||||||
- `Kind` has no named variant for 15; `Kind::Custom(15)` is required, and
|
|
||||||
`Kind: Display` prints `as_u16()`, so `rumor.kind.to_string()` yields `"15"`.
|
|
||||||
- `nostr::nips::nip94::Sha256Hash` is public (`from_byte_array`, `to_hex`, `Display`,
|
|
||||||
`from_hex`) even though it lives in the nip94 module — usable for `x`/`ox` hex
|
|
||||||
formatting without a new hashing crate.
|
|
||||||
- `gpui::App::http_client()` returns `Arc<dyn HttpClient>` and
|
|
||||||
`gpui_web/src/http_client.rs` implements it, so HTTP download is cross-platform.
|
|
||||||
`AsyncApp` exposes `update(|app| ...)`, which is how backend async code reaches it.
|
|
||||||
- `nostr-blossom` exposes `upload_blob`, `get_blob`, `has_blob`, `list_blobs`,
|
|
||||||
`delete_blob`.
|
|
||||||
- The `k` tag is currently hardcoded to `"14"` in two places
|
|
||||||
(`room.rs::send_gift_wrap`, `lib.rs::set_rumor`) and used as a room-list filter
|
|
||||||
(`lib.rs::get_rooms_task`, `custom_tag(LOWERCASE_K, "14")`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Dependencies
|
|
||||||
|
|
||||||
Add to `[workspace.dependencies]` in `Cargo.toml`, then reference from the crates below.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
aes-gcm = "0.10" # NEW - RustCrypto: Aes256Gcm, aead::{Aead, KeyInit, OsRng}
|
|
||||||
sha2 = "0.10" # NEW (already in Cargo.lock, cached) - SHA-256 for x / ox
|
|
||||||
base64 = "0.22" # NEW as a direct dep (already in the tree transitively)
|
|
||||||
```
|
|
||||||
|
|
||||||
- No new RNG dependency: `aes_gcm::aead::OsRng` (the wasm getrandom backends are
|
|
||||||
already configured in `web/Cargo.toml`).
|
|
||||||
- No new hashing/hex dependency: `sha2` output → `nostr::nips::nip94::Sha256Hash::from_byte_array(...).to_hex()`.
|
|
||||||
- `aes-gcm` is the only crate that needs a crates.io fetch (`aes 0.8` / `aead 0.5` are
|
|
||||||
already in the lock file), so it is a small addition to the build graph.
|
|
||||||
- Hand-rolling AES-GCM is explicitly **not** an option.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Layering and type ownership
|
|
||||||
|
|
||||||
`chat` depends on `state` (see `crates/chat/Cargo.toml`), never the reverse, so the
|
|
||||||
shared types and tag names must live in `state`.
|
|
||||||
|
|
||||||
- `crates/state/src/file.rs` (new) owns:
|
|
||||||
- `EncryptedFile`, `FileAttachment`, the tag-name constants, `ALGORITHM = "aes-gcm"`
|
|
||||||
- `encrypt` / `decrypt` / `sha256_hex`
|
|
||||||
- `FileAttachment::from_tags` / `FileAttachment::tags` (single source of truth for
|
|
||||||
tag names, so build and parse can't drift)
|
|
||||||
- `upload_encrypted`, `download_and_decrypt`
|
|
||||||
- re-exported from `crates/state/src/lib.rs`: `mod file; pub use file::*;`
|
|
||||||
- `crates/chat` consumes it: `message.rs` (parse into `Message`), `room.rs`
|
|
||||||
(build kind-15 rumor), `lib.rs` (cache tag + room list query).
|
|
||||||
- `crates/chat` should re-export the type for the future UI layer:
|
|
||||||
`pub use state::FileAttachment;` in `crates/chat/src/lib.rs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Data flow (backend)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[caller: path + blossom server] --> B[read bytes + guess mime]
|
|
||||||
B --> C[encrypt: random 32B key, 12B nonce, AES-256-GCM]
|
|
||||||
C --> D[sha256 ciphertext = x, sha256 plaintext = ox]
|
|
||||||
D --> E[Blossom upload ciphertext]
|
|
||||||
E --> F[FileAttachment]
|
|
||||||
F --> G[Room::file_rumor -> kind 15 rumor]
|
|
||||||
G --> H[Room::send -> existing NIP-59 wrap per member + self backup]
|
|
||||||
H --> I[existing extract_rumor on receiver]
|
|
||||||
I --> J[Message.file]
|
|
||||||
J --> K[download_and_decrypt: GET url, verify x, decrypt]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Implementation steps
|
|
||||||
|
|
||||||
### Step 1 — Dependencies
|
|
||||||
|
|
||||||
Add the three lines from section 3 and wire them into `crates/state/Cargo.toml`
|
|
||||||
(`aes-gcm`, `sha2`, `base64`). Run `cargo check -p state` to confirm the fetch works.
|
|
||||||
|
|
||||||
### Step 2 — `crates/state/src/file.rs` (new, ~180 LOC)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
|
||||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
|
|
||||||
use nostr::nips::nip94::Sha256Hash;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
pub const ALGORITHM: &str = "aes-gcm";
|
|
||||||
/// Ciphertext hash tag (NIP-17).
|
|
||||||
const TAG_SHA256: &str = "x";
|
|
||||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
|
||||||
const TAG_FILE_TYPE: &str = "file-type";
|
|
||||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
|
||||||
const TAG_KEY: &str = "decryption-key";
|
|
||||||
const TAG_NONCE: &str = "decryption-nonce";
|
|
||||||
const TAG_SIZE: &str = "size";
|
|
||||||
const TAG_DIM: &str = "dim";
|
|
||||||
const TAG_ALT: &str = "alt";
|
|
||||||
|
|
||||||
/// Maximum blob size accepted when downloading (bytes). See edge cases.
|
|
||||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Result of encrypting a file: ciphertext to upload plus NIP-17 key material.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct EncryptedFile {
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
/// base64-encoded 32-byte key
|
|
||||||
pub key: String,
|
|
||||||
/// base64-encoded 12-byte nonce
|
|
||||||
pub nonce: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NIP-17 kind 15 attachment metadata (tags + `.content` URL).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct FileAttachment {
|
|
||||||
pub url: Url,
|
|
||||||
pub mime: String,
|
|
||||||
pub key: String,
|
|
||||||
pub nonce: String,
|
|
||||||
pub sha256: Option<String>,
|
|
||||||
pub original_sha256: Option<String>,
|
|
||||||
pub size: Option<u64>,
|
|
||||||
pub dim: Option<(u32, u32)>,
|
|
||||||
/// Non-standard display name (see "Open decisions" #2).
|
|
||||||
pub name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileAttachment {
|
|
||||||
/// Build the NIP-17 tags for a kind 15 rumor.
|
|
||||||
pub fn tags(&self) -> Vec<Tag>;
|
|
||||||
/// Parse a kind 15 rumor's tags. Returns `None` if key material is missing
|
|
||||||
/// or `encryption-algorithm` is not `aes-gcm`.
|
|
||||||
pub fn from_tags(tags: &Tags) -> Option<Self>;
|
|
||||||
pub fn is_image(&self) -> bool;
|
|
||||||
pub fn display_name(&self) -> SharedString; // falls back to mime/size
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AES-256-GCM encrypt with a fresh random key and nonce.
|
|
||||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile>;
|
|
||||||
/// AES-256-GCM decrypt using the values from a kind 15 rumor.
|
|
||||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>>;
|
|
||||||
|
|
||||||
/// Lowercase hex SHA-256, matching the NIP-94 `x`/`ox` convention.
|
|
||||||
pub fn sha256_hex(data: &[u8]) -> String;
|
|
||||||
|
|
||||||
/// Read a file, encrypt it, upload the ciphertext to Blossom, return the attachment.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub async fn upload_encrypted(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<FileAttachment>;
|
|
||||||
|
|
||||||
/// Fetch the blob, verify its SHA-256 against `expected_sha256`, then decrypt.
|
|
||||||
pub async fn download_and_decrypt(
|
|
||||||
url: &Url,
|
|
||||||
key: &str,
|
|
||||||
nonce: &str,
|
|
||||||
expected_sha256: Option<&str>,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<Vec<u8>>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Implementation notes, in priority order:
|
|
||||||
|
|
||||||
1. **Compute `x` and `ox` locally with `sha256_hex`.** Do **not** derive `x` from
|
|
||||||
`BlobDescriptor::sha256` — that field is a `bitcoin_hashes::sha256::Hash` whose
|
|
||||||
`Display` byte order could not be confirmed from the vendored sources, and a
|
|
||||||
reversed digest would silently break integrity checks in other clients. Use the
|
|
||||||
Blossom response only for `blob.url`. (`sha2` → `Sha256Hash::from_byte_array(...).to_hex()`
|
|
||||||
gives the conventional digest order and is unambiguous.)
|
|
||||||
2. **Key/nonce decoding must be tolerant.** NIP-17 does not specify an encoding:
|
|
||||||
accept base64 standard (padded and unpadded), base64url, and hex on read; emit
|
|
||||||
base64 standard on write. See "Open decisions" #1.
|
|
||||||
3. Validate sizes after decoding: key must be exactly 32 bytes, nonce exactly 12.
|
|
||||||
Return a descriptive error otherwise.
|
|
||||||
4. `decrypt` must fail closed on a bad tag (GCM authentication failure) — never
|
|
||||||
return partial plaintext.
|
|
||||||
5. `upload_encrypted` mirrors the existing `state::blossom::upload` shape:
|
|
||||||
`smol::fs::read` + `mime_guess::from_path`, `BlossomClient::new(server)`,
|
|
||||||
`upload_blob(ciphertext, Some("application/octet-stream"), None, Some(&keys))`
|
|
||||||
wrapped in `Tokio::spawn(cx, ...)`. Note the content type describes the
|
|
||||||
**ciphertext**; the plaintext MIME goes in the `file-type` tag.
|
|
||||||
On `wasm32` return `Err(anyhow!("File upload not supported on web"))`, matching
|
|
||||||
the existing stub in `blossom.rs`.
|
|
||||||
6. `download_and_decrypt` uses gpui's HTTP client
|
|
||||||
(`cx.update(|app| app.http_client())`, then
|
|
||||||
`client.get(url.as_str(), AsyncBody::default(), true)`), reads the body with
|
|
||||||
`futures::AsyncReadExt`, caps the read at `MAX_FILE_SIZE + 1` and rejects anything
|
|
||||||
larger, verifies `sha256_hex(&ciphertext)` against `expected_sha256`
|
|
||||||
(case-insensitive) when present, then decrypts. This path works on desktop and web.
|
|
||||||
7. `size` in `FileAttachment` is the **ciphertext** size (per the NIP wording), i.e.
|
|
||||||
`encrypted.data.len()`. `dim` is optional; if wanted, decode dimensions with the
|
|
||||||
`image` crate (`image::ImageReader`) — mark as a nice-to-have, not a blocker.
|
|
||||||
8. `encrypt` should take `&[u8]` (not a path) so it stays pure and testable without I/O.
|
|
||||||
|
|
||||||
### Step 3 — `crates/chat/src/message.rs` (~80 LOC)
|
|
||||||
|
|
||||||
- Add the kind constant next to `Message`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// NIP-17 file message (rust-nostr has no named `Kind` variant for 15).
|
|
||||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add the field:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Message {
|
|
||||||
// ... existing fields ...
|
|
||||||
/// Encrypted file attachment (NIP-17 kind 15)
|
|
||||||
pub file: Option<FileAttachment>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- In all three `From` impls (`&Event`, `&UnsignedEvent`, `&NewMessage`), parse from
|
|
||||||
tags when `kind == KIND_FILE_MESSAGE`, and when a file is present **do not** run
|
|
||||||
`extract_and_remove_media_urls` or `extract_mentions` on `.content` — for kind 15
|
|
||||||
`.content` is a ciphertext URL that must never be rendered as text or as media:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let file = (val.kind == KIND_FILE_MESSAGE)
|
|
||||||
.then(|| FileAttachment::from_tags(&val.tags))
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let (media, content) = if file.is_some() {
|
|
||||||
(vec![], String::new())
|
|
||||||
} else {
|
|
||||||
extract_and_remove_media_urls(&val.content)
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add a helper for non-UI consumers that display a message one-line
|
|
||||||
(reply previews, notifications, copy):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Single-line representation: text for kind 14, `[File] <name>` for kind 15.
|
|
||||||
pub fn preview(&self) -> SharedString;
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Ord`/`Eq`/`Hash` are keyed on `id`/`created_at`, so adding the field does not
|
|
||||||
change `Message` ordering semantics.
|
|
||||||
|
|
||||||
### Step 4 — `crates/chat/src/room.rs` (~70 LOC)
|
|
||||||
|
|
||||||
- Extract the shared tag logic from `rumor()` into a private helper so kind 14 and
|
|
||||||
kind 15 cannot drift:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// `subject` + reply `e` tags + receiver `p` tags (excluding `sender`).
|
|
||||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag>;
|
|
||||||
```
|
|
||||||
|
|
||||||
`rumor()` then becomes `EventBuilder::new(kind, content).tags(self.conversation_tags(...))`,
|
|
||||||
and gains no behavior change.
|
|
||||||
|
|
||||||
- Add:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Build a NIP-17 kind 15 (file message) rumor.
|
|
||||||
///
|
|
||||||
/// WARNING: never sign and send this event to relays; it is only ever wrapped.
|
|
||||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = EventId>,
|
|
||||||
{
|
|
||||||
// tags = conversation_tags(...) ++ file.tags()
|
|
||||||
// content = file.url.to_string()
|
|
||||||
// EventBuilder::new(KIND_FILE_MESSAGE, content).tags(tags).finalize_unsigned(sender)
|
|
||||||
// then event.ensure_id()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Fix the gift wrap's `k` tag so relays see the real inner kind
|
|
||||||
(`send_gift_wrap`, currently hardcoded):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Room::send` needs **no change**: it already accepts any `UnsignedEvent`, and the
|
|
||||||
self-backup branch publishes the same rumor wrapped for the sender, so the sender's
|
|
||||||
other devices receive the file message with the same key material.
|
|
||||||
|
|
||||||
### Step 5 — `crates/chat/src/lib.rs` (~5 LOC)
|
|
||||||
|
|
||||||
- `set_rumor`: store the real kind in the local cache tag:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Tag::custom("k", [rumor.kind.to_string()]),
|
|
||||||
```
|
|
||||||
|
|
||||||
- `get_rooms_task` currently filters `custom_tag(SingleLetterTag::LOWERCASE_K, "14")`,
|
|
||||||
which would hide rooms whose only messages are file messages. Simplest fix: drop
|
|
||||||
the `k` filter and keep `.kind(Kind::ApplicationSpecificData)`; the existing
|
|
||||||
in-loop JSON parse plus `tags.public_keys().next().is_some()` check already
|
|
||||||
discards junk, and kind 30078 rows in the local DB exist only for this rumor cache.
|
|
||||||
Fallback if the extra rows are a concern: run two queries (`k = "14"` and
|
|
||||||
`k = "15"`) and merge the results.
|
|
||||||
|
|
||||||
- No changes to `handle_notifications`, `extract_rumor`, `try_unwrap_with`, `new_message`,
|
|
||||||
`Room::get_messages` — all are already kind-agnostic. (`Room::get_messages` filters
|
|
||||||
only on the `r` room tag, so file messages load with the rest of the history.)
|
|
||||||
|
|
||||||
### Step 6 — (out of scope) UI wiring
|
|
||||||
|
|
||||||
Composer entry point, decryption cache, image/file rendering live in
|
|
||||||
`crates/chat_ui`. Not part of this plan; the APIs above are what that layer needs:
|
|
||||||
`state::upload_encrypted`, `FileAttachment::tags/from_tags`, `Room::file_rumor`,
|
|
||||||
`Room::send`, `state::download_and_decrypt`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Open decisions
|
|
||||||
|
|
||||||
1. **Key/nonce encoding (interop risk — decide/verify first).** NIP-17 does not specify
|
|
||||||
how `decryption-key` / `decryption-nonce` are encoded, and no reference
|
|
||||||
implementation could be reached from this environment to confirm. Plan: emit
|
|
||||||
**base64 standard**, accept base64 std/unpadded/url-safe/hex on read. Before
|
|
||||||
release, verify against another NIP-17 client (0xchat, Amethyst) and tighten if
|
|
||||||
needed. Everything else in the plan is encoding-agnostic.
|
|
||||||
2. **Display name.** The NIP defines no filename tag and Blossom URLs end in a content
|
|
||||||
hash, so a name has to come from a non-standard tag. Proposal: emit
|
|
||||||
`["alt", <file name>]` (NIP-94 tag; NIP-17 allows "rest of tags", and `alt` is a
|
|
||||||
benign a11y field), tolerate its absence, fall back to mime + size. Alternative:
|
|
||||||
ship strictly spec-shaped and show mime + size only.
|
|
||||||
3. **`thumbhash`/`blurhash`/`thumb`/`fallback`/`dim`**: all optional; recommend skipping
|
|
||||||
in v1 (and letting the UI show a placeholder until the blob is decrypted).
|
|
||||||
4. **Web (wasm)**: encrypted upload stays unsupported on web, matching today's
|
|
||||||
`state::blossom::upload` stub. Download/decrypt is cross-platform via gpui's client.
|
|
||||||
5. **Ciphertext caching**: recommend *not* persisting decrypted plaintext in the local
|
|
||||||
database in v1; decryption happens on demand from the blob URL.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Edge cases and failure handling
|
|
||||||
|
|
||||||
| Case | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| `encryption-algorithm` != `aes-gcm` | do not decrypt; report "unsupported encryption" (forward compatibility) |
|
|
||||||
| missing `decryption-key` / `decryption-nonce` | `FileAttachment::from_tags` returns `None` → surfaced as a failed message, not a panic |
|
|
||||||
| key != 32 bytes or nonce != 12 bytes | descriptive error |
|
|
||||||
| GCM authentication failure | error; never emit partial plaintext |
|
|
||||||
| `sha256_hex(ciphertext) != x` | error (tampered or corrupted blob), checked **before** decrypting |
|
|
||||||
| blob larger than `MAX_FILE_SIZE` | rejected while reading the body |
|
|
||||||
| HTTP error / non-200 | error message including status |
|
|
||||||
| `content` not a valid URL | error; message still cached so it renders as a failed attachment |
|
|
||||||
| sender's own copy | works automatically via the existing self-backup gift wrap |
|
|
||||||
| multiple receivers | one blob, one key, key re-sent inside each recipient's gift wrap |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Verification
|
|
||||||
|
|
||||||
The feature cannot be exercised end-to-end without the UI step (out of scope), so
|
|
||||||
backend verification is:
|
|
||||||
|
|
||||||
1. `cargo check -p state -p chat` after each step.
|
|
||||||
2. `cargo check -p coop_web --target wasm32-unknown-unknown` to confirm the
|
|
||||||
`cfg(target_arch = "wasm32")` stubs and the new module compile for web.
|
|
||||||
3. Cheap unit checks (no network, ~15 LOC in `state/src/file.rs`), despite "no tests
|
|
||||||
required" for the feature, because these are pure functions and catch the two
|
|
||||||
riskiest bugs — encoding and tag ordering:
|
|
||||||
- `encrypt` → `decrypt` round trip returns the original bytes.
|
|
||||||
- flipping one ciphertext bit makes `decrypt` fail.
|
|
||||||
- `sha256_hex(b"abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"`
|
|
||||||
(confirms the digest order used for `x`/`ox`).
|
|
||||||
- `FileAttachment` tags → `from_tags` round trip preserves key/nonce/mime/url.
|
|
||||||
4. End-to-end (requires the UI step or a temporary call site): send to self with
|
|
||||||
`RoomConfig::backup` enabled and confirm the rumor unwraps, downloads, verifies and
|
|
||||||
decrypts; then a second account; then a cross-client check for decision #1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Estimated size
|
|
||||||
|
|
||||||
| File | Change | ~LOC |
|
|
||||||
|---|---|---|
|
|
||||||
| `Cargo.toml`, `crates/state/Cargo.toml` | deps | 6 |
|
|
||||||
| `crates/state/src/file.rs` | new | 180 |
|
|
||||||
| `crates/state/src/lib.rs` | module + re-export | 2 |
|
|
||||||
| `crates/chat/src/message.rs` | parse + preview | 80 |
|
|
||||||
| `crates/chat/src/room.rs` | `file_rumor` + tag refactor + `k` tag | 70 |
|
|
||||||
| `crates/chat/src/lib.rs` | cache tag + room list query | 5 |
|
|
||||||
|
|
||||||
Order of work: 1 → 2 → 3 → 4 → 5, each step compiling on its own. Steps 2–4 are
|
|
||||||
independent of any UI change, so the branch stays green without touching `chat_ui`.
|
|
||||||
@@ -5,7 +5,7 @@ use std::sync::{Arc, LazyLock, RwLock};
|
|||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||||
use common::{TimestampExt, coop_cache};
|
use common::TimestampExt;
|
||||||
use futures::lock::Mutex;
|
use futures::lock::Mutex;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -13,8 +13,8 @@ use gpui::{
|
|||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||||
white,
|
retain_all, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -2024,7 +2024,7 @@ impl Render for ChatPanel {
|
|||||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache(self.id.clone(), 100))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::mem::take;
|
|
||||||
|
|
||||||
use futures::FutureExt;
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
|
||||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
|
||||||
CoopImageCacheProvider {
|
|
||||||
id: id.into(),
|
|
||||||
max_items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCacheProvider {
|
|
||||||
id: ElementId,
|
|
||||||
max_items: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
|
||||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
|
||||||
window
|
|
||||||
.with_global_id(self.id.clone(), |id, window| {
|
|
||||||
window.with_element_state(id, |cache, _| {
|
|
||||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
|
||||||
(cache.clone(), cache)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCache {
|
|
||||||
max_items: usize,
|
|
||||||
usage_list: VecDeque<u64>,
|
|
||||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CoopImageCache {
|
|
||||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| {
|
|
||||||
log::info!("Creating CoopImageCache");
|
|
||||||
cx.on_release(|this: &mut Self, cx| {
|
|
||||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
|
||||||
if let Some(Ok(image)) = image.get() {
|
|
||||||
log::info!("Dropping image {ix}");
|
|
||||||
cx.drop_image(image, None);
|
|
||||||
}
|
|
||||||
ImageSource::Resource(resource).remove_asset(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
CoopImageCache {
|
|
||||||
max_items,
|
|
||||||
usage_list: VecDeque::with_capacity(max_items),
|
|
||||||
cache: HashMap::with_capacity(max_items),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCache for CoopImageCache {
|
|
||||||
fn load(
|
|
||||||
&mut self,
|
|
||||||
resource: &Resource,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::App,
|
|
||||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
|
||||||
let hash = hash(resource);
|
|
||||||
|
|
||||||
if let Some(item) = self.cache.get_mut(&hash) {
|
|
||||||
let current_idx = self
|
|
||||||
.usage_list
|
|
||||||
.iter()
|
|
||||||
.position(|item| *item == hash)
|
|
||||||
.expect("cache has an item usage_list doesn't");
|
|
||||||
|
|
||||||
self.usage_list.remove(current_idx);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
return item.0.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
|
||||||
let task = cx.background_executor().spawn(load_future).shared();
|
|
||||||
|
|
||||||
if self.usage_list.len() >= self.max_items {
|
|
||||||
log::info!("Image cache is full, evicting oldest item");
|
|
||||||
|
|
||||||
if let Some(oldest) = self.usage_list.pop_back() {
|
|
||||||
let mut image = self
|
|
||||||
.cache
|
|
||||||
.remove(&oldest)
|
|
||||||
.expect("usage_list has an item cache doesn't");
|
|
||||||
|
|
||||||
if let Some(Ok(image)) = image.0.get() {
|
|
||||||
log::info!("requesting image to be dropped");
|
|
||||||
cx.drop_image(image, Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageSource::Resource(image.1).remove_asset(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache.insert(
|
|
||||||
hash,
|
|
||||||
(
|
|
||||||
gpui::ImageCacheItem::Loading(task.clone()),
|
|
||||||
resource.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
let entity = window.current_view();
|
|
||||||
|
|
||||||
window
|
|
||||||
.spawn(cx, async move |cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
log::error!("error loading image into cache: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.on_next_frame(move |_, cx| {
|
|
||||||
cx.notify(entity);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub use caching::*;
|
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
@@ -7,7 +6,6 @@ pub use parser::*;
|
|||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
pub use range::*;
|
pub use range::*;
|
||||||
|
|
||||||
mod caching;
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
|||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
/// Default image cache size
|
|
||||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
|
|||||||
+18
-28
@@ -4,18 +4,18 @@ use ::settings::AppSettings;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::{CoopImageCache, download_dir};
|
use common::download_dir;
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
use person::{PersonRegistry, shorten_pubkey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -64,9 +64,6 @@ pub struct Workspace {
|
|||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// App's Image Cache
|
|
||||||
image_cache: Entity<CoopImageCache>,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
@@ -82,7 +79,6 @@ impl Workspace {
|
|||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -233,7 +229,6 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
sidebar,
|
sidebar,
|
||||||
dock,
|
dock,
|
||||||
image_cache,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -778,31 +773,26 @@ impl Render for Workspace {
|
|||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
image_cache(self.image_cache.clone())
|
v_flex()
|
||||||
.relative()
|
|
||||||
.size_full()
|
.size_full()
|
||||||
|
// Title Bar
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
TitleBar::new()
|
||||||
|
.child(self.titlebar_left(cx))
|
||||||
|
.child(self.titlebar_right(cx)),
|
||||||
|
)
|
||||||
|
// Main
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
// Title Bar
|
|
||||||
.child(
|
.child(
|
||||||
TitleBar::new()
|
div()
|
||||||
.child(self.titlebar_left(cx))
|
.flex_shrink_0()
|
||||||
.child(self.titlebar_right(cx)),
|
.h_full()
|
||||||
|
.w(SIDEBAR_WIDTH)
|
||||||
|
.child(self.sidebar.clone()),
|
||||||
)
|
)
|
||||||
// Main
|
.child(self.dock.clone()),
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.h_full()
|
|
||||||
.w(SIDEBAR_WIDTH)
|
|
||||||
.child(self.sidebar.clone()),
|
|
||||||
)
|
|
||||||
.child(self.dock.clone()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems,
|
Task, TextAlign, Window, div, rems, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -297,6 +297,7 @@ impl Focusable for ContactListPanel {
|
|||||||
impl Render for ContactListPanel {
|
impl Render for ContactListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("contact-list-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anyhow::Error;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Window, div,
|
Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -321,6 +321,7 @@ impl Render for ProfilePanel {
|
|||||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("profile-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
use common::{DebouncedDelay, TimestampExt};
|
||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||||
Window, div, uniform_list,
|
Window, div, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
@@ -521,7 +521,7 @@ impl Render for Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
Reference in New Issue
Block a user