Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9147c09c | ||
|
|
2a50804583 | ||
|
|
610bf816b0 | ||
|
|
c61be4a139 | ||
|
|
273ddabde5 | ||
|
|
7ba289440b | ||
|
|
6510e45e4e | ||
|
|
1032ac3751 | ||
|
|
6ff1eeddbb | ||
|
|
0c1c23fe9c | ||
|
|
874f103752 | ||
|
|
fdabfcf026 | ||
|
|
5500082426 | ||
|
|
7a34d66c7c | ||
|
|
57c85f5b99 | ||
|
|
bf7080654d | ||
|
|
42983383b0 | ||
|
|
e2b10c173e | ||
|
|
9e33da717b | ||
|
|
584ab34df6 | ||
|
|
0514c1d982 | ||
|
|
2f834a0bcc | ||
|
|
49cd5cb9a0 | ||
|
|
d082f4fad9 | ||
|
|
8c59eabba3 | ||
|
|
bc6bdb3c35 | ||
|
|
dbfee32d55 | ||
|
|
4f52fc52df | ||
|
|
fbf06f2c81 | ||
|
|
b0d1521c49 | ||
|
|
9dbac5308d | ||
|
|
c8cabb7cd2 | ||
|
|
6d9284b37a |
@@ -152,11 +152,23 @@ jobs:
|
||||
echo "Artifacts structure:"
|
||||
find artifacts -type f -exec ls -la {} \;
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||
# this to verify a download before installing it, so it must be
|
||||
# published alongside every release. Written outside the directory
|
||||
# being hashed so the checksums file never includes itself.
|
||||
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||
cat artifacts/SHA256SUMS
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
server_url: "https://git.reya.su/"
|
||||
server_url: "https://git.reya.info/"
|
||||
repository: "reya/coop"
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
draft: true
|
||||
|
||||
@@ -19,8 +19,8 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
.cargo/
|
||||
vendor/
|
||||
wasm/
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Rust coding guidelines
|
||||
|
||||
* 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.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* 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:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Avoid creative additions unless explicitly requested
|
||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||
Example:
|
||||
```rust
|
||||
executor.spawn({
|
||||
let task_ran = task_ran.clone();
|
||||
async move {
|
||||
*task_ran.borrow_mut() = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
# Timers in tests
|
||||
|
||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||
|
||||
# GPUI
|
||||
|
||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||
|
||||
## Context
|
||||
|
||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||
|
||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||
|
||||
## `Window`
|
||||
|
||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||
|
||||
## Entities
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||
|
||||
* `thing.entity_id()` returns `EntityId`
|
||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||
* `thing.read(cx: &App)` returns `&T`.
|
||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||
|
||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||
|
||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||
|
||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All use of entities and UI rendering occurs on a single foreground thread.
|
||||
|
||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||
|
||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||
|
||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||
|
||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||
|
||||
* Awaiting the task in some other async context.
|
||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
## 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".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
@@ -4,19 +4,26 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta5"
|
||||
version = "1.0.2"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# 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" }
|
||||
# GPUI. The `gpui-pre` family is upstream zed's gpui republished unchanged, so these
|
||||
# aliases keep every `use gpui::..` site as it is while moving off the zed git pin.
|
||||
gpui = { package = "gpui-pre", version = "0.3.5" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.5" }
|
||||
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
|
||||
# Unstyled behavior, state, and infrastructure from GPUI Kit
|
||||
gpui-base = "0.6.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
@@ -24,13 +31,24 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
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" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
@@ -43,7 +61,10 @@ schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
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
|
||||
|
||||
@@ -1,125 +1,5 @@
|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
|
||||
</a>
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
|
||||
</p>
|
||||
|
||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<p float="left">
|
||||
<img src="/docs/mac_01.png" width="250" />
|
||||
<img src="/docs/mac_02.png" width="250" />
|
||||
<img src="/docs/mac_03.png" width="250" />
|
||||
<img src="/docs/mac_04.png" width="250" />
|
||||
<img src="/docs/mac_05.png" width="250" />
|
||||
<img src="/docs/mac_06.png" width="250" />
|
||||
<img src="/docs/mac_07.png" width="250" />
|
||||
<img src="/docs/mac_08.png" width="250" />
|
||||
<img src="/docs/mac_09.png" width="250" />
|
||||
<img src="/docs/linux_01.png" width="250" />
|
||||
<img src="/docs/linux_02.png" width="250" />
|
||||
<img src="/docs/linux_03.png" width="250" />
|
||||
<img src="/docs/linux_04.png" width="250" />
|
||||
<img src="/docs/linux_05.png" width="250" />
|
||||
</p>
|
||||
|
||||
### Installation
|
||||
|
||||
To install Coop, follow these steps:
|
||||
|
||||
1. **Download the Latest Release**:
|
||||
|
||||
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
|
||||
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
||||
|
||||
2. **Install**:
|
||||
|
||||
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
||||
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
|
||||
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
|
||||
|
||||
3. **Run Coop**:
|
||||
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
||||
|
||||
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
|
||||
|
||||
### Developing Coop
|
||||
|
||||
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
|
||||
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
|
||||
- **Git**: To clone the repository and manage version control.
|
||||
|
||||
#### Setting Up the Development Environment
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/reyakov/coop.git
|
||||
cd coop
|
||||
```
|
||||
|
||||
2.1 Install Linux dependencies:
|
||||
|
||||
```bash
|
||||
./script/linux
|
||||
```
|
||||
|
||||
2.2 Install FreeBSD dependencies:
|
||||
|
||||
```bash
|
||||
./script/freebsd
|
||||
```
|
||||
|
||||
3. Install Rust dependencies:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
4. Run the app:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
#### Building for Production
|
||||
|
||||
To build Coop for production, use the following command:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
This will generate an optimized binary in the `target/release` directory.
|
||||
|
||||
#### Contributing Code
|
||||
|
||||
If you'd like to contribute to Coop, please follow these steps:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a new branch for your feature or bugfix.
|
||||
3. Make your changes and ensure all tests pass.
|
||||
4. Submit a pull request with a detailed description of your changes.
|
||||
|
||||
For more information, see the [Contributing](#contributing) section.
|
||||
|
||||
#### Additional Resources
|
||||
|
||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||
- [GPUI](https://www.gpui.rs/)
|
||||
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
||||
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
|
||||
|
||||
### License
|
||||
|
||||
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After 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="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="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"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 826 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="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"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 931 B |
@@ -2,7 +2,7 @@
|
||||
"id": "aurora",
|
||||
"name": "Aurora",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"url": "https://coopchat.xyz",
|
||||
"light": {
|
||||
"background": "#fdfcfeff",
|
||||
"surface_background": "#f8f8ffff",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "forest",
|
||||
"name": "Forest",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"url": "https://coopchat.xyz",
|
||||
"light": {
|
||||
"background": "#fbfefcff",
|
||||
"surface_background": "#f4fbf6ff",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "ocean",
|
||||
"name": "Ocean",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"url": "https://coopchat.xyz",
|
||||
"light": {
|
||||
"background": "#fafefeff",
|
||||
"surface_background": "#f2fbfaff",
|
||||
|
||||
@@ -5,17 +5,11 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
instant.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ureq.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||
|
||||
@@ -1,563 +1,324 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
Window,
|
||||
};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::fs::File;
|
||||
use smol::io::AsyncReadExt;
|
||||
use smol::process::Command;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
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";
|
||||
|
||||
/// Delay before the automatic check that runs on startup.
|
||||
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
||||
/// How long a failure stays visible before the status reverts to "Up to date".
|
||||
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||
|
||||
fn get_github_repo_owner() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||
fn uses_managed_updates() -> bool {
|
||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||
std::env::var("FLATPAK_ID").is_ok()
|
||||
// Allow opting out of in-app updates via an explicit environment variable.
|
||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value | value == "snap")
|
||||
}
|
||||
|
||||
fn get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
// Check if app is installed via Flatpak
|
||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
}
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
// Skip auto-update initialization if installed via Flatpak
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
/// Initialize the auto-update system.
|
||||
pub fn init(cx: &mut App) {
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
||||
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||
|
||||
let Some(filter) = asset_filter_for(os, arch) else {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||
log::error!(
|
||||
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
struct InstallerDir(tempfile::TempDir);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
impl InstallerDir {
|
||||
async fn new() -> Result<Self, Error> {
|
||||
Ok(Self(
|
||||
tempfile::Builder::new()
|
||||
.prefix("coop-auto-update")
|
||||
.tempdir()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct InstallerDir(PathBuf);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl InstallerDir {
|
||||
async fn new() -> Result<Self, Error> {
|
||||
let installer_dir = std::env::current_exe()?
|
||||
.parent()
|
||||
.context("No parent dir for Coop.exe")?
|
||||
.join("updates");
|
||||
|
||||
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
||||
smol::fs::remove_dir_all(&installer_dir).await?;
|
||||
}
|
||||
|
||||
smol::fs::create_dir(&installer_dir).await?;
|
||||
|
||||
Ok(Self(installer_dir))
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.as_path()
|
||||
}
|
||||
}
|
||||
|
||||
struct MacOsUnmounter<'a> {
|
||||
mount_path: PathBuf,
|
||||
background_executor: &'a BackgroundExecutor,
|
||||
}
|
||||
|
||||
impl Drop for MacOsUnmounter<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mount_path = std::mem::take(&mut self.mount_path);
|
||||
|
||||
self.background_executor
|
||||
.spawn(async move {
|
||||
let unmount_output = Command::new("hdiutil")
|
||||
.args(["detach", "-force"])
|
||||
.arg(&mount_path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match unmount_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("Successfully unmounted the disk image");
|
||||
}
|
||||
Ok(output) => {
|
||||
log::error!(
|
||||
"Failed to unmount disk image: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Checked { download_url: String },
|
||||
Installing,
|
||||
Updated,
|
||||
Errored { msg: Box<String> },
|
||||
}
|
||||
|
||||
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
||||
fn as_ref(&self) -> &AutoUpdateStatus {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoUpdateStatus {
|
||||
pub fn is_updating(&self) -> bool {
|
||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||
}
|
||||
|
||||
pub fn is_updated(&self) -> bool {
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(download_url: String) -> Self {
|
||||
Self::Checked { download_url }
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
Self::Errored { msg: Box::new(e) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitHubRelease {
|
||||
pub tag_name: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitHubAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
/// Current status of the auto updater
|
||||
pub status: AutoUpdateStatus,
|
||||
|
||||
/// Current version of the application
|
||||
/// The blocking engine, driven on the background executor.
|
||||
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||
status: UpdateStatus,
|
||||
/// The newer release found by the last successful check, if any.
|
||||
available: Option<Release>,
|
||||
/// Currently running app version.
|
||||
pub version: Version,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// The in-flight check or download, if any.
|
||||
task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the global auto updater instance
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Set the global auto updater instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let mut subscriptions = smallvec![];
|
||||
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
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));
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the status
|
||||
cx.observe_self(|this, cx| {
|
||||
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
||||
this.download_and_install(&download_url, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.check(cx);
|
||||
// Schedule an auto-check after a 2-minute delay
|
||||
cx.defer(move |cx| {
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||
entity.update(cx, |this, cx| this.check(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
engine,
|
||||
status: UpdateStatus::Idle,
|
||||
available: None,
|
||||
version,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
/// Whether nothing is happening, so the UI can hide the status line.
|
||||
pub fn idle(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Idle)
|
||||
}
|
||||
|
||||
fn check(&mut self, cx: &mut Context<Self>) {
|
||||
let version = self.version.clone();
|
||||
let duration = Duration::from_secs(120);
|
||||
let task = self.check_for_updates(version, cx);
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// Check for updates after 2 minutes
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
pub fn staged(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Staged(_))
|
||||
}
|
||||
|
||||
// Update the status to checking
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||
})?;
|
||||
|
||||
match task.await {
|
||||
Ok(download_url) => {
|
||||
// Update the status to checked with download URL
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(download_url), cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to check for updates: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
})?;
|
||||
/// A short, human-readable description of the current status.
|
||||
pub fn status(&self) -> SharedString {
|
||||
match &self.status {
|
||||
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||
UpdateStatus::Downloading { downloaded, total } => {
|
||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
|
||||
match total_mb {
|
||||
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
|
||||
None => format!("Downloading {downloaded_mb:.1} MB").into(),
|
||||
}
|
||||
}
|
||||
UpdateStatus::Installing => "Installing update…".into(),
|
||||
UpdateStatus::Staged(version) => {
|
||||
format!("Version {version} ready — restart to apply").into()
|
||||
}
|
||||
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// Check the release host for a newer version, then download and install it.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
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();
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
|
||||
let http_client = cx.http_client();
|
||||
let repo_owner = get_github_repo_owner();
|
||||
let repo_name = get_github_repo_name();
|
||||
/// 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;
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/releases/latest",
|
||||
GITHUB_API_URL, repo_owner, repo_name
|
||||
);
|
||||
let engine = self.engine.clone();
|
||||
self.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
let async_body = AsyncBody::default();
|
||||
let mut body = Vec::new();
|
||||
let mut response = http_client.get(&url, async_body, false).await?;
|
||||
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));
|
||||
|
||||
// Read the response body into a vector
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
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
|
||||
})
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("GitHub API returned error: {}", response.status()));
|
||||
}
|
||||
|
||||
// Parse the response body as JSON
|
||||
let release: GitHubRelease = serde_json::from_slice(&body)?;
|
||||
|
||||
// Parse version from tag (remove 'v' prefix if present)
|
||||
let tag_version = release.tag_name.trim_start_matches('v');
|
||||
let new_version = Version::parse(tag_version).context(format!(
|
||||
"Failed to parse version from tag: {}",
|
||||
release.tag_name
|
||||
))?;
|
||||
|
||||
if new_version > version {
|
||||
// Find the appropriate asset for the current platform
|
||||
let current_os = std::env::consts::OS;
|
||||
let asset_name = match current_os {
|
||||
"macos" => "Coop.dmg",
|
||||
"linux" => "coop.tar.gz",
|
||||
"windows" => "Coop.exe",
|
||||
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
|
||||
};
|
||||
|
||||
let download_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == asset_name)
|
||||
.map(|asset| asset.browser_download_url.clone())
|
||||
.context(format!(
|
||||
"No {} asset found in release {}",
|
||||
asset_name, release.tag_name
|
||||
))?;
|
||||
|
||||
Ok(download_url)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"No update available. Current: {}, Latest: {}",
|
||||
version,
|
||||
new_version
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
|
||||
let http_client = cx.http_client();
|
||||
let download_url = download_url.to_string();
|
||||
|
||||
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||
let installer_dir = InstallerDir::new().await?;
|
||||
let target_path = Self::target_path(&installer_dir).await?;
|
||||
|
||||
// Download the release
|
||||
download(&download_url, &target_path, http_client).await?;
|
||||
|
||||
Ok((installer_dir, target_path))
|
||||
});
|
||||
|
||||
self.tasks.push(
|
||||
// Install the new release
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
let got = downloaded.load(Ordering::Relaxed);
|
||||
let total = total.load(Ordering::Relaxed);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Installing, 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;
|
||||
}
|
||||
|
||||
match task.await {
|
||||
Ok((installer_dir, target_path)) => {
|
||||
if Self::install(installer_dir, target_path, cx).await.is_ok() {
|
||||
// Update the status to updated
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||
})?;
|
||||
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(e) => {
|
||||
// Update the status to error including the error message
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||
})?;
|
||||
Err(error) => {
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}),
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||
let filename = match std::env::consts::OS {
|
||||
"macos" => anyhow::Ok("Coop.dmg"),
|
||||
"linux" => Ok("coop.tar.gz"),
|
||||
"windows" => Ok("Coop.exe"),
|
||||
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||
}?;
|
||||
|
||||
Ok(installer_dir.path().join(filename))
|
||||
}
|
||||
|
||||
async fn install(
|
||||
installer_dir: InstallerDir,
|
||||
target_path: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
match std::env::consts::OS {
|
||||
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
||||
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
|
||||
"windows" => install_release_windows(target_path).await,
|
||||
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
||||
/// Relaunch into the staged update.
|
||||
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.staged() {
|
||||
log::warn!("Ignoring restart request: no update is staged");
|
||||
return;
|
||||
}
|
||||
cx.restart();
|
||||
}
|
||||
}
|
||||
|
||||
async fn download(
|
||||
url: &str,
|
||||
target_path: &std::path::Path,
|
||||
client: Arc<dyn HttpClient>,
|
||||
) -> Result<(), Error> {
|
||||
let body = AsyncBody::default();
|
||||
let mut target_file = File::create(&target_path).await?;
|
||||
let mut response = client.get(url, body, true).await?;
|
||||
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||
self.status = status;
|
||||
|
||||
// Copy the response body to the target file
|
||||
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_macos(
|
||||
temp_dir: &InstallerDir,
|
||||
downloaded_dmg: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||
let running_app_filename = running_app_path
|
||||
.file_name()
|
||||
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
||||
|
||||
let mount_path = temp_dir.path().join("Coop");
|
||||
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||
|
||||
mounted_app_path.push("/");
|
||||
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["attach", "-nobrowse"])
|
||||
.arg(&downloaded_dmg)
|
||||
.arg("-mountroot")
|
||||
.arg(temp_dir.path())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to mount: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||
let _unmounter = MacOsUnmounter {
|
||||
mount_path: mount_path.clone(),
|
||||
background_executor: cx.background_executor(),
|
||||
};
|
||||
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&mounted_app_path)
|
||||
.arg(&running_app_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_linux(
|
||||
temp_dir: &InstallerDir,
|
||||
downloaded_tar_gz: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||
|
||||
// Extract the tar.gz file
|
||||
let extracted = temp_dir.path().join("coop");
|
||||
smol::fs::create_dir_all(&extracted)
|
||||
.await
|
||||
.context("failed to create directory to extract update")?;
|
||||
|
||||
let output = Command::new("tar")
|
||||
.arg("-xzf")
|
||||
.arg(&downloaded_tar_gz)
|
||||
.arg("-C")
|
||||
.arg(&extracted)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to extract {:?} to {:?}: {:?}",
|
||||
downloaded_tar_gz,
|
||||
extracted,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Find the extracted app directory
|
||||
let mut entries = smol::fs::read_dir(&extracted).await?;
|
||||
let mut app_dir = None;
|
||||
|
||||
use smol::stream::StreamExt;
|
||||
|
||||
while let Some(entry) = entries.next().await {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
app_dir = Some(path);
|
||||
break;
|
||||
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();
|
||||
}
|
||||
|
||||
let from = app_dir.context("No app directory found in archive")?;
|
||||
|
||||
// Copy to the current installation directory
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&from)
|
||||
.arg(
|
||||
running_app_path
|
||||
.parent()
|
||||
.context("No parent directory for app")?,
|
||||
)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app from {:?} to {:?}: {:?}",
|
||||
from,
|
||||
running_app_path.parent(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let system_root = std::env::var("SYSTEMROOT");
|
||||
let powershell_path = system_root.as_ref().map_or_else(
|
||||
|_| "powershell.exe".to_string(),
|
||||
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
);
|
||||
|
||||
let mut installer_path = std::ffi::OsString::new();
|
||||
installer_path.push("\"");
|
||||
installer_path.push(&downloaded_installer);
|
||||
installer_path.push("\"");
|
||||
|
||||
let output = Command::new(powershell_path)
|
||||
//.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||
.args(["Start-Process"])
|
||||
.arg(installer_path)
|
||||
.arg("-ArgumentList")
|
||||
.args(["/P", "/R"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to start installer: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||
const RELEASE_PAGE_SIZE: usize = 20;
|
||||
|
||||
/// Which published artifact belongs to a target platform.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AssetFilter {
|
||||
extension: &'static str,
|
||||
arch: &'static str,
|
||||
}
|
||||
|
||||
impl AssetFilter {
|
||||
/// Whether `name` is the installable artifact for this target.
|
||||
fn matches(&self, name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.ends_with(self.extension) && name.contains(self.arch)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||
let extension = match os {
|
||||
"macos" => ".dmg",
|
||||
"linux" => ".tar.gz",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let arch = match (os, arch) {
|
||||
// cargo-packager names the disk images `aarch64`/`x64`.
|
||||
("macos", "aarch64") => "aarch64",
|
||||
("macos", "x86_64") => "x64",
|
||||
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||
("linux", "aarch64") => "aarch64",
|
||||
("linux", "x86_64") => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(AssetFilter { extension, arch })
|
||||
}
|
||||
|
||||
/// Reads releases from a Gitea repository's Releases.
|
||||
pub struct GiteaSource {
|
||||
api_base: String,
|
||||
owner: String,
|
||||
repo: String,
|
||||
filter: AssetFilter,
|
||||
}
|
||||
|
||||
impl GiteaSource {
|
||||
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
|
||||
/// (e.g. `https://git.reya.info/api/v1`).
|
||||
pub fn new(
|
||||
api_base: impl Into<String>,
|
||||
owner: impl Into<String>,
|
||||
repo: impl Into<String>,
|
||||
filter: AssetFilter,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
filter,
|
||||
}
|
||||
}
|
||||
|
||||
fn releases_url(&self) -> String {
|
||||
format!(
|
||||
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
||||
self.api_base, self.owner, self.repo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateSource for GiteaSource {
|
||||
fn fetch_latest(&self) -> Result<Release> {
|
||||
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()))?;
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| self.filter.matches(&asset.name))
|
||||
.ok_or(Error::NoMatchingAsset {
|
||||
target_os: std::env::consts::OS,
|
||||
target_arch: std::env::consts::ARCH,
|
||||
})?;
|
||||
|
||||
// Resolve the published checksum so the engine can reject a truncated or substituted download.
|
||||
let sha256 = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||
.map(|sums| http::get_string(&sums.browser_download_url))
|
||||
.transpose()?
|
||||
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|release| !release.draft && !release.prerelease)
|
||||
.filter_map(|release| {
|
||||
parse_tag(&release.tag_name)
|
||||
.ok()
|
||||
.map(|version| (version, release))
|
||||
})
|
||||
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||
.map(|(_, release)| release)
|
||||
}
|
||||
|
||||
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
|
||||
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
||||
sums.lines().find_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let (hash, path) = (parts.next()?, parts.next()?);
|
||||
let path = path.strip_prefix('*').unwrap_or(path);
|
||||
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
(base == asset_name).then(|| hash.to_ascii_lowercase())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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_core::Version;
|
||||
|
||||
use super::*;
|
||||
|
||||
const PUBLISHED: &[&str] = &[
|
||||
"Coop_1.0.1_aarch64.dmg",
|
||||
"Coop_1.0.1_x64.dmg",
|
||||
"coop-linux-aarch64.tar.gz",
|
||||
"coop-linux-x86_64.tar.gz",
|
||||
"coop_1.0.1_aarch64.snap",
|
||||
"coop_1.0.1_arm64-setup.exe",
|
||||
"coop_1.0.1_x64-setup.exe",
|
||||
"coop_1.0.1_x86_64.snap",
|
||||
"su.reya.coop_aarch64.flatpak",
|
||||
"su.reya.coop_x86_64.flatpak",
|
||||
];
|
||||
|
||||
fn selected(os: &str, arch: &str) -> Option<&'static str> {
|
||||
let filter = asset_filter_for(os, arch)?;
|
||||
PUBLISHED.iter().copied().find(|name| filter.matches(name))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_artifact_matching_os_and_architecture() {
|
||||
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
||||
assert_eq!(
|
||||
selected("linux", "aarch64"),
|
||||
Some("coop-linux-aarch64.tar.gz")
|
||||
);
|
||||
assert_eq!(
|
||||
selected("linux", "x86_64"),
|
||||
Some("coop-linux-x86_64.tar.gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
|
||||
let macos = asset_filter_for("macos", "aarch64").unwrap();
|
||||
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
|
||||
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
|
||||
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
|
||||
|
||||
let linux = asset_filter_for("linux", "x86_64").unwrap();
|
||||
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
|
||||
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_checksums_by_basename_ignoring_directory_prefix() {
|
||||
let sums = "\
|
||||
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
||||
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
|
||||
789abc SHA256SUMS
|
||||
";
|
||||
assert_eq!(
|
||||
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
|
||||
Some("abcdef")
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
|
||||
Some("123456")
|
||||
);
|
||||
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
|
||||
let releases: Vec<GiteaRelease> = serde_json::from_str(
|
||||
r#"[
|
||||
{
|
||||
"tag_name": "v1.0.2",
|
||||
"draft": true,
|
||||
"assets": []
|
||||
},
|
||||
{
|
||||
"tag_name": "v2.0.0-rc.1",
|
||||
"prerelease": true,
|
||||
"assets": []
|
||||
},
|
||||
{
|
||||
"tag_name": "nightly",
|
||||
"assets": []
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.0.0",
|
||||
"assets": [
|
||||
{
|
||||
"name": "coop-linux-x86_64.tar.gz",
|
||||
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
|
||||
"size": 26160329
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.0.1",
|
||||
"name": "v1.0.1",
|
||||
"body": "Fixed app panic on flatpak installations",
|
||||
"assets": [
|
||||
{
|
||||
"name": "coop-linux-x86_64.tar.gz",
|
||||
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
|
||||
"size": 26160329
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let newest = newest_published(&releases).unwrap();
|
||||
assert_eq!(newest.tag_name, "v1.0.1");
|
||||
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
|
||||
assert_eq!(newest.assets.len(), 1);
|
||||
assert_eq!(newest.assets[0].size, 26160329);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires network access to the release host"]
|
||||
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);
|
||||
|
||||
let release = source
|
||||
.fetch_latest()
|
||||
.expect("release lookup should succeed");
|
||||
|
||||
assert!(
|
||||
release.version >= Version::new(1, 0, 0),
|
||||
"unexpected version {}",
|
||||
release.version
|
||||
);
|
||||
assert!(
|
||||
source.filter.matches(&release.asset.name),
|
||||
"unexpected artifact {}",
|
||||
release.asset.name
|
||||
);
|
||||
assert!(
|
||||
release.asset.url.starts_with("https://"),
|
||||
"{} ",
|
||||
release.asset.url
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "browser-signer-proxy"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/nostrdevkit/nostr"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
atomic-destructor = "0.2"
|
||||
event-listener = "5"
|
||||
nostr.workspace = true
|
||||
opaquerr = { version = "0.1", features = ["alloc"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
tracing = { version = "0.1", features = ["std"] }
|
||||
uuid = { version = "1.23", features = ["serde", "v4"] }
|
||||
@@ -0,0 +1,55 @@
|
||||
# browser-signer-proxy
|
||||
|
||||
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
|
||||
|
||||
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
|
||||
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
|
||||
|
||||
## Description
|
||||
|
||||
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
|
||||
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
|
||||
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
|
||||
the browser extension.
|
||||
|
||||
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
|
||||
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use browser_signer_proxy::prelude::*;
|
||||
|
||||
async fn example() -> Result<(), Error> {
|
||||
// Create the proxy with default options (localhost:7400)
|
||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||
|
||||
// Open the proxy URL in a browser
|
||||
webbrowser::open(&proxy.url())?;
|
||||
|
||||
// Start the proxy server
|
||||
proxy.start().await?;
|
||||
|
||||
// Use it as an async Nostr signer
|
||||
let public_key = proxy.get_public_key_async().await?;
|
||||
println!("Connected with public key: {public_key}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Differences from the tokio-based version
|
||||
|
||||
| Feature | tokio (original) | smol (this crate) |
|
||||
|---|---|---|
|
||||
| Async runtime | `tokio` | `smol` |
|
||||
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
|
||||
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
|
||||
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
|
||||
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
|
||||
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
|
||||
| Task spawning | `tokio::spawn` | `smol::spawn` |
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the MIT software license.
|
||||
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coop — Web Signer Proxy</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #F8FF37;
|
||||
--ink: #111111;
|
||||
--ink-soft: #333333;
|
||||
--muted: #666666;
|
||||
--paper: #FFFFFF;
|
||||
--edge: rgba(17, 17, 17, 0.14);
|
||||
--radius-sm: 1rem;
|
||||
--radius-md: 1.5rem;
|
||||
--radius-lg: 2.5rem;
|
||||
--green: #2E8B57;
|
||||
--red: #D32F2F;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--brand);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--paper);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.logo__mark {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: var(--ink);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.2rem;
|
||||
color: var(--brand);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo__text {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 1.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: background 300ms ease, color 300ms ease;
|
||||
}
|
||||
|
||||
.status--checking {
|
||||
background: rgba(17, 17, 17, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status--connected {
|
||||
background: rgba(46, 139, 87, 0.1);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status--error {
|
||||
background: rgba(211, 47, 47, 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status__dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status--checking .status__dot {
|
||||
background: var(--muted);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status--connected .status__dot {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.status--error .status__dot {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--edge);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint strong {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 class="heading">Web Signer</h1>
|
||||
|
||||
<p class="subtitle">
|
||||
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
|
||||
</p>
|
||||
|
||||
<div id="nip07-status" class="status status--checking">
|
||||
<div class="status__dot"></div>
|
||||
<span id="nip07-status-text">Checking extension…</span>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="proxy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
let isPolling = false;
|
||||
|
||||
async function pollForRequests() {
|
||||
if (isPolling) return;
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pending');
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Polled for requests, got:', data);
|
||||
|
||||
// Process any new requests
|
||||
if (data.requests && data.requests.length > 0) {
|
||||
console.log(`Processing ${data.requests.length} requests`);
|
||||
for (const request of data.requests) {
|
||||
await handleNip07Request(request);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
updateStatus('Error: ' + error.message, 'error');
|
||||
}
|
||||
|
||||
isPolling = false;
|
||||
}
|
||||
|
||||
async function handleNip07Request(request) {
|
||||
console.log('Handling request:', request);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('NIP-07 extension not available');
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'get_public_key':
|
||||
console.log('Calling nostr.getPublicKey()');
|
||||
result = await window.nostr.getPublicKey();
|
||||
console.log('Got public key:', result);
|
||||
break;
|
||||
|
||||
case 'sign_event':
|
||||
console.log('Calling nostr.signEvent() with:', request.params);
|
||||
result = await window.nostr.signEvent(request.params);
|
||||
console.log('Got signed event:', result);
|
||||
break;
|
||||
|
||||
case 'nip04_encrypt':
|
||||
console.log('Calling nostr.nip04.encrypt()');
|
||||
result = await window.nostr.nip04.encrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip04_decrypt':
|
||||
console.log('Calling nostr.nip04.decrypt()');
|
||||
result = await window.nostr.nip04.decrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip44_encrypt':
|
||||
console.log('Calling nostr.nip44.encrypt()');
|
||||
result = await window.nostr.nip44.encrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip44_decrypt':
|
||||
console.log('Calling nostr.nip44.decrypt()');
|
||||
result = await window.nostr.nip44.decrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown method: ${request.method}`);
|
||||
}
|
||||
|
||||
// Send response back to server
|
||||
const responsePayload = {
|
||||
id: request.id,
|
||||
result: result,
|
||||
error: null
|
||||
};
|
||||
|
||||
console.log('Sending response:', responsePayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(responsePayload)
|
||||
});
|
||||
|
||||
console.log('Response sent successfully');
|
||||
updateStatus('Request processed successfully', 'connected');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
|
||||
// Send error response back to server
|
||||
const errorPayload = {
|
||||
id: request.id,
|
||||
result: null,
|
||||
error: error.message
|
||||
};
|
||||
|
||||
console.log('Sending error response:', errorPayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(errorPayload)
|
||||
});
|
||||
|
||||
updateStatus('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(message, state) {
|
||||
const container = document.getElementById('nip07-status');
|
||||
const textEl = document.getElementById('nip07-status-text');
|
||||
if (container && textEl) {
|
||||
container.className = 'status status--' + state;
|
||||
textEl.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling when page loads
|
||||
window.addEventListener('load', () => {
|
||||
console.log('NIP-07 Proxy loaded');
|
||||
|
||||
// Check if NIP-07 extension is available
|
||||
if (window.nostr) {
|
||||
console.log('NIP-07 extension detected');
|
||||
updateStatus('Connected — ready', 'connected');
|
||||
} else {
|
||||
console.log('NIP-07 extension not found');
|
||||
updateStatus('No NIP-07 extension found', 'error');
|
||||
}
|
||||
|
||||
// Start polling every 500 ms
|
||||
setInterval(pollForRequests, 500);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||
// Distributed under the MIT software license
|
||||
|
||||
//! Error types for the browser signer proxy.
|
||||
|
||||
opaquerr::define_kind! {
|
||||
/// Nostr browser signer proxy error kind.
|
||||
pub ErrorKind {
|
||||
/// Nostr protocol error.
|
||||
Protocol => "nostr protocol error",
|
||||
/// I/O error.
|
||||
IO => "I/O error",
|
||||
/// JSON error.
|
||||
Json => "JSON error",
|
||||
/// The operation timed out.
|
||||
Timeout => "timeout",
|
||||
/// The operation cannot be completed in the current state.
|
||||
State => "invalid state",
|
||||
/// Anything not covered by the stable categories above.
|
||||
Other => "other error",
|
||||
}
|
||||
}
|
||||
|
||||
opaquerr::define_error! {
|
||||
/// Nostr browser signer proxy error.
|
||||
pub Error(ErrorKind)
|
||||
|
||||
from {
|
||||
nostr::error::Error => ErrorKind::Protocol,
|
||||
std::io::Error => ErrorKind::IO,
|
||||
serde_json::Error => ErrorKind::Json,
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn generic<S>(message: S) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
Self::new(ErrorKind::Other, message.into())
|
||||
}
|
||||
|
||||
pub(crate) fn timeout() -> Self {
|
||||
Self::simple(ErrorKind::Timeout)
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown() -> Self {
|
||||
Self::with_static_message(ErrorKind::State, "server is shutdown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
||||
use event_listener::Event as ShutdownEvent;
|
||||
use nostr::prelude::*;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
use smol::channel;
|
||||
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use smol::lock::Mutex;
|
||||
use smol::net::{TcpListener, TcpStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod error;
|
||||
pub mod prelude;
|
||||
|
||||
pub use self::error::Error;
|
||||
|
||||
const DEFAULT_HTML: &str = include_str!("../index.html");
|
||||
const JS: &str = include_str!("../proxy.js");
|
||||
|
||||
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Message {
|
||||
id: Uuid,
|
||||
error: Option<String>,
|
||||
result: Option<Value>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
fn into_result(self) -> Result<Value, String> {
|
||||
if let Some(error) = self.error {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(self.result.unwrap_or(Value::Null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum RequestMethod {
|
||||
GetPublicKey,
|
||||
SignEvent,
|
||||
Nip04Encrypt,
|
||||
Nip04Decrypt,
|
||||
Nip44Encrypt,
|
||||
Nip44Decrypt,
|
||||
}
|
||||
|
||||
impl RequestMethod {
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GetPublicKey => "get_public_key",
|
||||
Self::SignEvent => "sign_event",
|
||||
Self::Nip04Encrypt => "nip04_encrypt",
|
||||
Self::Nip04Decrypt => "nip04_decrypt",
|
||||
Self::Nip44Encrypt => "nip44_encrypt",
|
||||
Self::Nip44Decrypt => "nip44_decrypt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for RequestMethod {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RequestData {
|
||||
id: Uuid,
|
||||
method: RequestMethod,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
impl RequestData {
|
||||
#[inline]
|
||||
fn new(method: RequestMethod, params: Value) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
method,
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Requests<'a> {
|
||||
requests: &'a [RequestData],
|
||||
}
|
||||
|
||||
impl<'a> Requests<'a> {
|
||||
#[inline]
|
||||
fn new(requests: &'a [RequestData]) -> Self {
|
||||
Self { requests }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.requests.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Params for NIP-04 and NIP-44 encryption/decryption
|
||||
#[derive(Serialize)]
|
||||
struct CryptoParams<'a> {
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> CryptoParams<'a> {
|
||||
#[inline]
|
||||
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProxyState {
|
||||
/// Requests waiting to be picked up by browser
|
||||
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
||||
/// Map of request ID to response sender
|
||||
pub pending_responses: Mutex<PendingResponseMap>,
|
||||
/// Last time the client asked for the pending requests
|
||||
pub last_pending_request: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Configuration options for [`BrowserSignerProxy`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserSignerProxyOptions {
|
||||
/// Request timeout for the signer extension. Default is 30 seconds.
|
||||
pub timeout: Duration,
|
||||
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
||||
pub addr: SocketAddr,
|
||||
/// Custom HTML page.
|
||||
// NOTE: not `Option` to move it between threads without reference counter
|
||||
pub custom_html: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct InnerBrowserSignerProxy {
|
||||
/// Configuration options for the proxy
|
||||
options: BrowserSignerProxyOptions,
|
||||
/// Internal state of the proxy including request queues
|
||||
state: Arc<ProxyState>,
|
||||
/// Notification trigger for graceful shutdown
|
||||
shutdown: Arc<ShutdownEvent>,
|
||||
/// Flag to indicate if the server is shutdown
|
||||
is_shutdown: Arc<AtomicBool>,
|
||||
/// Flag indicating if the server is started
|
||||
is_started: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
||||
fn on_destroy(&self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl InnerBrowserSignerProxy {
|
||||
#[inline]
|
||||
fn is_shutdown(&self) -> bool {
|
||||
self.is_shutdown.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
// Mark the server as shutdown
|
||||
self.is_shutdown.store(true, Ordering::SeqCst);
|
||||
|
||||
// Notify all waiters that the proxy is shutting down
|
||||
self.shutdown.notify(usize::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr Browser Signer Proxy
|
||||
///
|
||||
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrowserSignerProxy {
|
||||
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
||||
}
|
||||
|
||||
impl Default for BrowserSignerProxyOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_secs(30),
|
||||
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
||||
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
||||
custom_html: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserSignerProxyOptions {
|
||||
/// Sets the timeout duration.
|
||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IP address.
|
||||
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
||||
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the port number.
|
||||
pub const fn port(mut self, new_port: u16) -> Self {
|
||||
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a custom html page.
|
||||
///
|
||||
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
||||
/// which will handle communication with the server and update the element
|
||||
/// with id `nip07-proxy-status` with the status.
|
||||
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
||||
self.custom_html = custom_html;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserSignerProxy {
|
||||
/// Construct a new browser signer proxy
|
||||
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
||||
let state = ProxyState {
|
||||
outgoing_requests: Mutex::new(Vec::new()),
|
||||
pending_responses: Mutex::new(HashMap::new()),
|
||||
last_pending_request: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
|
||||
Self {
|
||||
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
||||
options,
|
||||
state: Arc::new(state),
|
||||
shutdown: Arc::new(ShutdownEvent::new()),
|
||||
is_shutdown: Arc::new(AtomicBool::new(false)),
|
||||
is_started: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates whether the server is currently running.
|
||||
#[inline]
|
||||
pub fn is_started(&self) -> bool {
|
||||
self.inner.is_started.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Checks if there is an open browser tab ready to respond to requests by
|
||||
/// verifying the time since the last pending request.
|
||||
#[inline]
|
||||
pub fn is_session_active(&self) -> bool {
|
||||
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
||||
}
|
||||
|
||||
/// Get the signer proxy webpage URL
|
||||
#[inline]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://{}", self.inner.options.addr)
|
||||
}
|
||||
|
||||
/// Start the proxy server.
|
||||
///
|
||||
/// If this is not called explicitly, the server will be automatically
|
||||
/// started on the first interaction with the signer.
|
||||
pub async fn start(&self) -> Result<(), Error> {
|
||||
// Ensure is not shutdown
|
||||
if self.inner.is_shutdown() {
|
||||
return Err(Error::shutdown());
|
||||
}
|
||||
|
||||
// Mark the proxy as started and check if was already started
|
||||
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
||||
|
||||
// Immediately return if already started
|
||||
if is_started {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
// Undo the started flag if binding fails
|
||||
self.inner.is_started.store(false, Ordering::SeqCst);
|
||||
return Err(Error::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
let addr: SocketAddr = self.inner.options.addr;
|
||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||
let custom_html: &'static str = self.inner.options.custom_html;
|
||||
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
||||
|
||||
smol::spawn(async move {
|
||||
tracing::info!("Starting proxy server on {addr}");
|
||||
|
||||
loop {
|
||||
// Race between accepting a new connection and shutdown signal
|
||||
let shutdown_listener = shutdown.listen();
|
||||
|
||||
enum AcceptEvent {
|
||||
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
let event = smol::future::or(
|
||||
async { AcceptEvent::Connection(listener.accept().await) },
|
||||
async {
|
||||
shutdown_listener.await;
|
||||
AcceptEvent::Shutdown
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match event {
|
||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||
let state: Arc<ProxyState> = state.clone();
|
||||
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
||||
|
||||
smol::spawn(async move {
|
||||
let shutdown_listener = shutdown.listen();
|
||||
|
||||
smol::future::or(
|
||||
async {
|
||||
handle_connection(stream, state, custom_html).await;
|
||||
},
|
||||
async {
|
||||
shutdown_listener.await;
|
||||
tracing::debug!(
|
||||
"Closing connection, proxy server is shutting down."
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
AcceptEvent::Connection(Err(e)) => {
|
||||
tracing::error!("Failed to accept connection: {e}");
|
||||
}
|
||||
AcceptEvent::Shutdown => break,
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Proxy server shut down.");
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
|
||||
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
||||
pending_responses.insert(id, tx);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn store_outgoing_request(&self, request: RequestData) {
|
||||
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
||||
outgoing_requests.push(request);
|
||||
}
|
||||
|
||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
// Start the proxy if not already started
|
||||
self.start().await?;
|
||||
|
||||
// Construct the request
|
||||
let request: RequestData = RequestData::new(method, params);
|
||||
|
||||
// Create a bounded channel of size 1 as a oneshot replacement
|
||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||
|
||||
// Store the response sender
|
||||
self.store_pending_response(request.id, tx).await;
|
||||
|
||||
// Add to outgoing requests queue
|
||||
self.store_outgoing_request(request).await;
|
||||
|
||||
// Wait for response with timeout
|
||||
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
|
||||
|
||||
match response {
|
||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||
Ok(Err(error)) => Err(Error::generic(error)),
|
||||
Err(TimeoutError) => Err(Error::timeout()),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
||||
self.request(RequestMethod::GetPublicKey, json!({})).await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||
let event: Event = self
|
||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
||||
.await?;
|
||||
event.verify()?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
#[inline]
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||
Box::pin(async move { self._get_public_key().await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
#[inline]
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||
Box::pin(async move { self._sign_event(unsigned).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip04 for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
fn nip04_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip04_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
encrypted_content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip44 for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
||||
|
||||
/// Handle a single HTTP connection.
|
||||
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
// Read the request line
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let request_line = request_line.trim_end().to_string();
|
||||
|
||||
// Parse method, path, and HTTP version from request line
|
||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
let method = parts[0].to_uppercase();
|
||||
let path = parts[1].to_string();
|
||||
|
||||
// Read headers until empty line
|
||||
let mut headers = Vec::new();
|
||||
let mut content_length: usize = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let line = line.trim_end().to_string();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("content-length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
headers.push(line);
|
||||
}
|
||||
|
||||
match (method.as_str(), path.as_str()) {
|
||||
// Serve the HTML proxy page
|
||||
("GET", "/") => {
|
||||
let html = if custom_html.is_empty() {
|
||||
DEFAULT_HTML
|
||||
} else {
|
||||
custom_html
|
||||
};
|
||||
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
||||
}
|
||||
// Serve the JS proxy script
|
||||
("GET", "/proxy.js") => {
|
||||
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
|
||||
}
|
||||
// Browser polls this endpoint to get pending requests
|
||||
("GET", "/api/pending") => {
|
||||
state
|
||||
.last_pending_request
|
||||
.store(current_time(), Ordering::SeqCst);
|
||||
|
||||
let mut outgoing = state.outgoing_requests.lock().await;
|
||||
|
||||
let requests = Requests::new(&outgoing);
|
||||
let json = match serde_json::to_string(&requests) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize pending requests: {e}");
|
||||
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Sending {} pending requests to browser", requests.len());
|
||||
|
||||
// Clear the outgoing requests after sending them
|
||||
outgoing.clear();
|
||||
|
||||
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
||||
}
|
||||
// Receive response from browser extension
|
||||
("POST", "/api/response") => {
|
||||
let mut body_bytes = vec![0u8; content_length];
|
||||
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let message: Message = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse response body: {e}");
|
||||
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Received response from browser: {message:?}");
|
||||
|
||||
let id: Uuid = message.id;
|
||||
let mut pending = state.pending_responses.lock().await;
|
||||
|
||||
match pending.remove(&id) {
|
||||
Some(sender) => {
|
||||
// Use try_send since we already hold the lock
|
||||
let _ = sender.try_send(message.into_result());
|
||||
tracing::info!("Forwarded response for request {id}");
|
||||
}
|
||||
None => tracing::warn!("No pending request found for {id}"),
|
||||
}
|
||||
|
||||
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||
}
|
||||
// CORS preflight
|
||||
("OPTIONS", _) => {
|
||||
let response = "HTTP/1.1 200 OK\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
||||
let _ = reader.get_mut().flush().await;
|
||||
}
|
||||
// 404 - not found
|
||||
_ => {
|
||||
send_response(&mut reader, 404, "Not Found", "", "").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an HTTP response to the stream.
|
||||
async fn send_response(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
|
||||
if !content_type.is_empty() {
|
||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||
}
|
||||
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers and JSON content type.
|
||||
async fn send_response_cors_json(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
response.push_str("Content-Type: application/json\r\n");
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers.
|
||||
async fn send_response_cors(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
|
||||
if !content_type.is_empty() {
|
||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||
}
|
||||
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
||||
|
||||
/// An error indicating that an operation timed out.
|
||||
#[derive(Debug)]
|
||||
struct TimeoutError;
|
||||
|
||||
/// Races a channel receive against a duration.
|
||||
///
|
||||
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
||||
/// elapses first or the channel is closed.
|
||||
async fn race_timeout<T>(
|
||||
duration: Duration,
|
||||
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
||||
) -> Result<T, TimeoutError> {
|
||||
enum Event<T> {
|
||||
Value(T),
|
||||
ChannelClosed,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
let event = smol::future::or(
|
||||
async {
|
||||
match recv.await {
|
||||
Ok(value) => Event::Value(value),
|
||||
Err(_) => Event::ChannelClosed,
|
||||
}
|
||||
},
|
||||
async {
|
||||
smol::Timer::after(duration).await;
|
||||
Event::Timeout
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match event {
|
||||
Event::Value(value) => Ok(value),
|
||||
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
||||
/// time is before the epoch, returns 0.
|
||||
#[inline]
|
||||
fn current_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||
// Distributed under the MIT software license
|
||||
|
||||
//! Prelude
|
||||
|
||||
#![allow(unknown_lints)]
|
||||
#![allow(ambiguous_glob_reexports)]
|
||||
#![doc(hidden)]
|
||||
|
||||
pub use nostr::prelude::*;
|
||||
|
||||
pub use crate::error::{Error, ErrorKind};
|
||||
pub use crate::*;
|
||||
@@ -13,6 +13,7 @@ settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
@@ -20,6 +21,7 @@ smallvec.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
fuzzy-matcher = "0.3.7"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::RwLock;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
WeakEntity,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use smol::lock::RwLock;
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||
|
||||
mod message;
|
||||
@@ -26,9 +21,13 @@ mod room;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
pub use state::FileAttachment;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
/// 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);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
@@ -38,15 +37,8 @@ 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),
|
||||
}
|
||||
|
||||
@@ -82,16 +74,19 @@ 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
|
||||
trashes: Entity<BTreeSet<FailedMessage>>,
|
||||
trash: Entity<BTreeSet<FailedMessage>>,
|
||||
|
||||
/// Tracking events seen on which relays in the current session
|
||||
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
|
||||
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>>>,
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
/// True while the initial event backlog is still loading
|
||||
tracking: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
@@ -103,10 +98,37 @@ pub struct ChatRegistry {
|
||||
/// 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]>,
|
||||
}
|
||||
|
||||
/// Wrapper to provide Debug for SkimMatcherV2
|
||||
struct CachedMatcher(SkimMatcherV2);
|
||||
|
||||
impl std::fmt::Debug for CachedMatcher {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("CachedMatcher { .. }")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for CachedMatcher {
|
||||
type Target = SkimMatcherV2;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ChatEvent> for ChatRegistry {}
|
||||
|
||||
impl ChatRegistry {
|
||||
@@ -121,7 +143,8 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -138,37 +161,45 @@ impl ChatRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.tracking(cx);
|
||||
this.get_rooms(cx);
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
trashes: cx.new(|_| BTreeSet::default()),
|
||||
seens: Arc::new(RwLock::new(HashMap::default())),
|
||||
room_index: HashMap::new(),
|
||||
trash: cx.new(|_| BTreeSet::default()),
|
||||
seen: Arc::new(RwLock::new(HashMap::default())),
|
||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||
tracking: Arc::new(AtomicBool::new(false)),
|
||||
tracking: Arc::new(AtomicBool::new(true)),
|
||||
matcher: CachedMatcher(SkimMatcherV2::default()),
|
||||
signal_rx: rx,
|
||||
signal_tx: tx,
|
||||
tasks: smallvec![],
|
||||
notification_listener: None,
|
||||
signal_consumer: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
// Cancel previous notification tasks before spawning new ones
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let tracking = self.tracking.clone();
|
||||
let seens = self.seens.clone();
|
||||
let seen = self.seen.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trashes = self.trashes.downgrade();
|
||||
let trash = self.trash.downgrade();
|
||||
|
||||
let initialized_at = Timestamp::now();
|
||||
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
||||
|
||||
@@ -176,19 +207,36 @@ impl ChatRegistry {
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
const MAX_PROCESSED: usize = 10_000;
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, relay_url } = notification else {
|
||||
// Skip non-message notifications
|
||||
continue;
|
||||
};
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
// De-duplicate events by their ID
|
||||
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;
|
||||
}
|
||||
|
||||
// Prune the dedup set before it grows unbounded
|
||||
if processed_events.len() >= MAX_PROCESSED {
|
||||
processed_events.clear();
|
||||
}
|
||||
if !processed_events.insert(event.id) {
|
||||
continue;
|
||||
}
|
||||
@@ -196,7 +244,6 @@ impl ChatRegistry {
|
||||
// Handle msg relays event to determine when the app is ready to subscribe
|
||||
if event.kind == Kind::InboxRelays {
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
// Emit the inbox ready signal
|
||||
if event.pubkey == current_user {
|
||||
tx.send_async(Signal::InboxReady).await?;
|
||||
}
|
||||
@@ -209,33 +256,30 @@ impl ChatRegistry {
|
||||
|
||||
// Keep track of which relays have seen this event
|
||||
{
|
||||
let mut seens = seens.write().await;
|
||||
seens.entry(event.id).or_default().insert(relay_url);
|
||||
let mut seen = seen.write().unwrap();
|
||||
seen.entry(event.id).or_default().insert(relay_url);
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
Ok(rumor) => {
|
||||
// Map the rumor id to the gift wrap event id for later lookup
|
||||
let Some(rumor_id) = rumor.id else {
|
||||
log::error!("Rumor missing id after ensure_id");
|
||||
continue;
|
||||
};
|
||||
{
|
||||
let mut event_map = event_map.write().await;
|
||||
event_map.insert(rumor.id.unwrap(), event.id);
|
||||
let mut event_map = event_map.write().unwrap();
|
||||
event_map.insert(rumor_id, event.id);
|
||||
}
|
||||
|
||||
// Check if the rumor has a recipient
|
||||
if rumor.tags.is_empty() {
|
||||
let signal = Signal::error(&event, "Recipient is missing");
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
|
||||
// Check if the rumor was created after the chat was initialized (for detecting new messages)
|
||||
if rumor.created_at >= initialized_at {
|
||||
let signal = Signal::message(event.id, rumor);
|
||||
tx.send_async(signal).await?;
|
||||
} else {
|
||||
// Mark the chat still processing new messages
|
||||
tracking.store(true, Ordering::Release);
|
||||
}
|
||||
// Emit message for both new and backlog events
|
||||
let signal = Signal::message(event.id, rumor);
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = format!("Failed to extract rumor: {e}");
|
||||
@@ -256,7 +300,7 @@ impl ChatRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
Signal::Message(message) => {
|
||||
@@ -270,13 +314,17 @@ impl ChatRegistry {
|
||||
})?;
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.update(cx, |this, _cx| {
|
||||
this.tracking.store(false, Ordering::Release);
|
||||
})?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Error(trash) => {
|
||||
trashes.update(cx, |this, cx| {
|
||||
this.insert(trash);
|
||||
Signal::Error(failed) => {
|
||||
trash.update(cx, |this, cx| {
|
||||
this.insert(failed);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
@@ -287,25 +335,6 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.tracking.clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_, cx| {
|
||||
let loop_duration = Duration::from_secs(15);
|
||||
loop {
|
||||
if status.load(Ordering::Acquire) {
|
||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
} else {
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
}
|
||||
cx.background_executor().timer(loop_duration).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get all necessary metadata from relays for current user
|
||||
pub fn get_metadata(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -315,55 +344,44 @@ impl ChatRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Subscribe to metadata from relays
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Construct filter for msg relays
|
||||
let msg_relays = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct filter for contact list
|
||||
let contact_list = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe
|
||||
client
|
||||
_ = client
|
||||
.subscribe(vec![msg_relays, contact_list])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Spawn a task to verify user inbox relays after 5 seconds
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Give relays time to respond
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
if !cx
|
||||
.background_spawn(async move {
|
||||
// Construct inbox relays filter
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
// Verify inbox relays were received
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Check the latest inbox relays event in database
|
||||
client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.first_owned()
|
||||
.is_some()
|
||||
})
|
||||
let found = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
{
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.is_some();
|
||||
|
||||
if !found {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::InboxRelayNotFound);
|
||||
})?;
|
||||
@@ -379,58 +397,53 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Construct inbox relays filter
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Get the latest inbox relays event in database
|
||||
let event = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.first_owned()
|
||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||
|
||||
// Extract relay list from event
|
||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||
|
||||
// Ensure relay connections
|
||||
for url in relays.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
// Construct gift wrap event filter
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<RelayUrl, Filter> = relays
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
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?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let event = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||
|
||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||
for url in relays.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||
|
||||
let target: HashMap<RelayUrl, Filter> = relays
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Refresh the chat registry, fetching messages and contact list from relays.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
/// Reload the chat registry, fetching messages and contact list from relays.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
self.get_metadata(cx);
|
||||
self.get_rooms(cx);
|
||||
@@ -441,12 +454,9 @@ impl ChatRegistry {
|
||||
self.tracking.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Get a weak reference to a room by its ID.
|
||||
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|this| &this.read(cx).id == id)
|
||||
.map(|this| this.downgrade())
|
||||
/// Get a weak reference to a room by its ID
|
||||
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.room_index.get(id).map(|room| room.downgrade())
|
||||
}
|
||||
|
||||
/// Get all rooms based on the filter.
|
||||
@@ -468,35 +478,38 @@ impl ChatRegistry {
|
||||
|
||||
/// Count the number of messages seen by a given relay.
|
||||
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
self.seen
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|seen| seen.contains(relay_url))
|
||||
.filter(|s| s.contains(relay_url))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of trash messages.
|
||||
pub fn count_trash_messages(&self, cx: &App) -> usize {
|
||||
self.trashes.read(cx).len()
|
||||
self.trash.read(cx).len()
|
||||
}
|
||||
|
||||
/// Get the trash messages entity.
|
||||
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
|
||||
self.trashes.clone()
|
||||
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
|
||||
.read_blocking()
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|id| self.seen_on(id))
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given gift wrap id.
|
||||
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
self.seen
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
@@ -505,60 +518,49 @@ impl ChatRegistry {
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room> + 'static,
|
||||
I: Into<Room>,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
let room_id = room.id;
|
||||
let entity = cx.new(|_| room);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.rooms.insert(0, cx.new(|_| room));
|
||||
cx.emit(ChatEvent::Ping);
|
||||
cx.notify();
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.detach();
|
||||
self.room_index.insert(room_id, entity.clone());
|
||||
self.rooms.insert(0, entity);
|
||||
|
||||
cx.emit(ChatEvent::Ping);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emit an open room event.
|
||||
///
|
||||
/// If the room is new, add it to the registry.
|
||||
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||
// Get the room's ID.
|
||||
/// 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>) {
|
||||
let id = room.read(cx).id;
|
||||
|
||||
// If the room is new, add it to the registry.
|
||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
self.rooms.insert(0, room.to_owned());
|
||||
}
|
||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||
let entity = room.to_owned();
|
||||
e.insert(entity.clone());
|
||||
|
||||
// Emit the open room event.
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
}
|
||||
|
||||
/// Close a room.
|
||||
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
cx.emit(ChatEvent::CloseRoom(id));
|
||||
self.rooms.insert(0, entity);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort rooms by their created at.
|
||||
/// Sort rooms by their created at. Only notifies if order changed.
|
||||
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));
|
||||
cx.notify();
|
||||
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
||||
if before != after {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Finding rooms based on a query.
|
||||
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
|
||||
if let Ok(public_key) = PublicKey::parse(query) {
|
||||
self.rooms
|
||||
.iter()
|
||||
@@ -569,7 +571,7 @@ impl ChatRegistry {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| {
|
||||
matcher
|
||||
self.matcher
|
||||
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||
.is_some()
|
||||
})
|
||||
@@ -581,7 +583,8 @@ impl ChatRegistry {
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.clear();
|
||||
self.trashes.update(cx, |this, cx| {
|
||||
self.room_index.clear();
|
||||
self.trash.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
@@ -608,7 +611,9 @@ impl ChatRegistry {
|
||||
});
|
||||
} else {
|
||||
let new_room_id = new_room.id;
|
||||
self.rooms.push(cx.new(|_| new_room));
|
||||
let entity = cx.new(|_| new_room);
|
||||
self.room_index.insert(new_room_id, entity.clone());
|
||||
self.rooms.push(entity);
|
||||
|
||||
let new_index = self.rooms.len();
|
||||
room_map.insert(new_room_id, new_index);
|
||||
@@ -618,13 +623,7 @@ impl ChatRegistry {
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task = self.get_rooms_from_database(public_key, cx);
|
||||
let task = self.query_chat_rooms(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
@@ -635,7 +634,9 @@ impl ChatRegistry {
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load rooms: {}", e);
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -643,78 +644,65 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn get_rooms_from_database(
|
||||
&self,
|
||||
public_key: PublicKey,
|
||||
cx: &App,
|
||||
) -> Task<Result<HashSet<Room>, Error>> {
|
||||
/// Query the chat rooms from the database
|
||||
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Get contacts
|
||||
let contacts = client
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.contacts_public_keys(public_key)
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Construct authored filter
|
||||
let authored_filter = Filter::new()
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||
|
||||
// Get all authored events
|
||||
let authored = client.database().query(authored_filter).await?;
|
||||
|
||||
// Construct addressed filter
|
||||
let addressed_filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
||||
|
||||
// Get all addressed events
|
||||
let addressed = client.database().query(addressed_filter).await?;
|
||||
|
||||
// Merge authored and addressed events
|
||||
let events = authored.merge(addressed);
|
||||
|
||||
// Collect results
|
||||
let mut rooms: HashSet<Room> = HashSet::new();
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
|
||||
// Process each event and group by room hash
|
||||
for raw in events.into_iter() {
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||
&& rumor.tags.public_keys().peekable().peek().is_some()
|
||||
&& rumor.tags.public_keys().next().is_some()
|
||||
{
|
||||
if rumor.pubkey != public_key
|
||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
for (_id, mut messages) in grouped.into_iter() {
|
||||
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||
let mut rooms = HashSet::with_capacity(grouped.len());
|
||||
|
||||
// Always use the latest message
|
||||
let Some(latest) = messages.first() else {
|
||||
continue;
|
||||
};
|
||||
for (_id, messages) in grouped.into_iter() {
|
||||
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
|
||||
let room = Room::from(latest).organize(&public_key);
|
||||
|
||||
// Construct the room from the latest message.
|
||||
//
|
||||
// Call `.organize` to ensure the current user is at the end of the list.
|
||||
let mut room = Room::from(latest).organize(&public_key);
|
||||
|
||||
// Check if the user has responded to the room
|
||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||
|
||||
// Check if public keys are from the user's contacts
|
||||
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
||||
|
||||
// Set the room's kind based on status
|
||||
if user_sent || is_contact {
|
||||
room = room.kind(RoomKind::Ongoing);
|
||||
}
|
||||
let room = if user_sent || is_contact {
|
||||
room.kind(RoomKind::Ongoing)
|
||||
} else {
|
||||
room
|
||||
};
|
||||
|
||||
rooms.insert(room);
|
||||
}
|
||||
@@ -725,8 +713,8 @@ impl ChatRegistry {
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -734,7 +722,7 @@ impl ChatRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||
match self.room_index.get(&message.room).cloned() {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
||||
@@ -775,9 +763,14 @@ async fn extract_rumor(
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = try_unwrap(signer, gift_wrap).await?;
|
||||
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
|
||||
let mut rumor = unwrapped.rumor;
|
||||
|
||||
// Verify rumor author matches the seal sender (as per mobile implementation)
|
||||
if rumor.pubkey != unwrapped.sender {
|
||||
return Err(anyhow!("Rumor author does not match seal sender"));
|
||||
}
|
||||
|
||||
// Generate event id for the rumor if it doesn't have one
|
||||
rumor.ensure_id();
|
||||
|
||||
@@ -789,25 +782,6 @@ async fn extract_rumor(
|
||||
Ok(rumor)
|
||||
}
|
||||
|
||||
/// Helper method to try unwrapping with different signers
|
||||
async fn try_unwrap(signer: &UniversalSigner, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
|
||||
/*
|
||||
* // Try with the device signer first
|
||||
if let Some(signer) = signer.get_encryption_signer().await {
|
||||
log::info!("trying with encryption key");
|
||||
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
|
||||
return Ok(unwrapped);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the user's signer
|
||||
let user_signer = signer.get().await;
|
||||
*/
|
||||
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with(
|
||||
signer: &UniversalSigner,
|
||||
@@ -820,7 +794,7 @@ async fn try_unwrap_with(
|
||||
|
||||
// Verify the sealed event
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify_with_ctx(&SECP256K1)?;
|
||||
seal.verify()?;
|
||||
|
||||
// Get the rumor event
|
||||
let rumor = signer
|
||||
@@ -837,39 +811,20 @@ async fn try_unwrap_with(
|
||||
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
||||
let author = rumor.pubkey;
|
||||
let conversation = conversation_id(rumor);
|
||||
let room_id = rumor.uniq_id().to_string();
|
||||
|
||||
let mut tags = rumor.tags.clone().to_vec();
|
||||
let tags = vec![
|
||||
Tag::identifier(id),
|
||||
Tag::public_key(rumor.pubkey),
|
||||
Tag::custom("r", [room_id]),
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
];
|
||||
|
||||
// Add a unique identifier
|
||||
tags.push(Tag::identifier(id));
|
||||
|
||||
// Add a reference to the rumor's author
|
||||
tags.push(Tag::custom("a", [author]));
|
||||
|
||||
// Add a conversation id
|
||||
tags.push(Tag::custom("c", [conversation.to_string()]));
|
||||
|
||||
// Add a reference to the rumor's id
|
||||
tags.push(Tag::event(rumor_id));
|
||||
|
||||
// Add references to the rumor's participants
|
||||
for receiver in rumor.tags.public_keys() {
|
||||
tags.push(Tag::custom("P", [receiver]));
|
||||
}
|
||||
|
||||
// Convert rumor to json
|
||||
let content = rumor.as_json();
|
||||
|
||||
// Construct the event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&Keys::generate())
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -877,26 +832,11 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
|
||||
/// Retrieves a previously unwrapped event from local database
|
||||
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(gift_wrap)
|
||||
.limit(1);
|
||||
let filter = Filter::new().identifier(gift_wrap).limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||
} else {
|
||||
Err(anyhow!("Event is not cached yet."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the conversation ID for a given rumor (message).
|
||||
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().collect();
|
||||
pubkeys.push(rumor.pubkey);
|
||||
pubkeys.sort();
|
||||
pubkeys.dedup();
|
||||
pubkeys.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ use std::ops::Range;
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -21,61 +24,90 @@ pub struct Message {
|
||||
pub mentions: Vec<Mention>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
/// Encrypted file attachment
|
||||
pub file: Option<FileAttachment>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Message {
|
||||
fn from(val: &Event) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
from_parts(
|
||||
val.id,
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
from_parts(
|
||||
// Event ID must be known
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
val.id.unwrap(),
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
let mentions = extract_mentions(&val.rumor.content);
|
||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
||||
|
||||
Self {
|
||||
from_parts(
|
||||
// Event ID must be known
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
val.rumor.id.unwrap(),
|
||||
val.rumor.pubkey,
|
||||
val.rumor.created_at,
|
||||
val.rumor.kind,
|
||||
&val.rumor.content,
|
||||
&val.rumor.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
id: EventId,
|
||||
author: PublicKey,
|
||||
created_at: Timestamp,
|
||||
kind: Kind,
|
||||
content: &str,
|
||||
tags: &Tags,
|
||||
) -> Message {
|
||||
let file = if kind == KIND_FILE_MESSAGE {
|
||||
FileAttachment::from_tags(content, tags)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_file = file.is_some();
|
||||
|
||||
let replies_to = extract_reply_ids(tags);
|
||||
|
||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||
let mentions = if has_file {
|
||||
Vec::new()
|
||||
} else {
|
||||
extract_mentions(content)
|
||||
};
|
||||
|
||||
let (media, content) = if has_file {
|
||||
(Vec::new(), String::new())
|
||||
} else {
|
||||
extract_and_remove_media_urls(content)
|
||||
};
|
||||
|
||||
Message {
|
||||
id,
|
||||
author,
|
||||
content,
|
||||
media,
|
||||
created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +137,17 @@ impl Hash for Message {
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Single-line representation for reply previews, notifications and copy.
|
||||
pub fn preview(&self) -> SharedString {
|
||||
if let Some(file) = &self.file {
|
||||
return format!("[File] {}", file.display_name()).into();
|
||||
}
|
||||
|
||||
self.content.clone().into()
|
||||
}
|
||||
}
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use device::DeviceRegistry;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use instant::Duration;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::NewMessage;
|
||||
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 or it's not available.";
|
||||
@@ -271,8 +271,8 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Returns the members of the room
|
||||
pub fn members(&self) -> Vec<PublicKey> {
|
||||
self.members.clone()
|
||||
pub fn members(&self) -> &[PublicKey] {
|
||||
&self.members
|
||||
}
|
||||
|
||||
/// Checks if the room has more than two members (group)
|
||||
@@ -289,12 +289,21 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar()
|
||||
/// 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 {
|
||||
SharedString::from("brand/group.png")
|
||||
self.display_member(cx).avatar()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable seed for the room's generated avatar
|
||||
pub fn display_image_seed(&self, cx: &App) -> SharedString {
|
||||
if self.is_group() {
|
||||
SharedString::from(self.id.to_string())
|
||||
} else {
|
||||
self.display_member(cx).avatar_seed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,29 +365,38 @@ impl Room {
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let members = self.members();
|
||||
let members = self.members().to_vec();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
for public_key in members.into_iter() {
|
||||
let inbox = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
let tasks: Vec<_> = members
|
||||
.into_iter()
|
||||
.map(|public_key| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let inbox = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
|
||||
let announcement = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Custom(10044))
|
||||
.limit(1);
|
||||
let announcement = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Custom(10044))
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to the target
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for result in futures::future::join_all(tasks).await {
|
||||
result?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -389,12 +407,12 @@ impl Room {
|
||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let conversation_id = self.id.to_string();
|
||||
let room_id = self.id.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
@@ -402,10 +420,6 @@ impl Room {
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||
.filter(|event| {
|
||||
// Only process private direct messages and file messages
|
||||
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
|
||||
})
|
||||
.sorted_by_key(|message| message.created_at)
|
||||
.collect();
|
||||
|
||||
@@ -414,28 +428,71 @@ impl Room {
|
||||
}
|
||||
|
||||
// Construct a rumor event for direct message
|
||||
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
pub fn rumor<S, I>(
|
||||
&self,
|
||||
content: S,
|
||||
replies: I,
|
||||
reaction: bool,
|
||||
cx: &App,
|
||||
) -> Option<UnsignedEvent>
|
||||
where
|
||||
S: Into<String>,
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let kind = Kind::PrivateDirectMessage;
|
||||
let kind = if reaction {
|
||||
Kind::Reaction
|
||||
} else {
|
||||
Kind::PrivateDirectMessage
|
||||
};
|
||||
|
||||
let content: String = content.into();
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.collect();
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(self.conversation_tags(&replies, sender, cx))
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
|
||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(file.tags());
|
||||
|
||||
// Construct a file message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
@@ -446,31 +503,44 @@ impl Room {
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
for id in replies.into_iter() {
|
||||
tags.push(Tag::event(id))
|
||||
for id in replies {
|
||||
tags.push(Tag::event(*id))
|
||||
}
|
||||
|
||||
// Add all receiver tags
|
||||
for member in members.into_iter() {
|
||||
tags.push(
|
||||
Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
// Add all receiver tags (no intermediate allocation)
|
||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||
let member = persons.read(cx).get(public_key, cx);
|
||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
fn select_signer(
|
||||
signer_kind: &SignerKind,
|
||||
has_announcement: bool,
|
||||
encryption_signer: &Option<UniversalSigner>,
|
||||
user_signer: &UniversalSigner,
|
||||
) -> UniversalSigner {
|
||||
match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if has_announcement {
|
||||
encryption_signer
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_signer.clone())
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
.to_tag(),
|
||||
);
|
||||
}
|
||||
SignerKind::Encryption => encryption_signer
|
||||
.clone()
|
||||
.expect("encryption signer must be set"),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
}
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
@@ -525,23 +595,12 @@ impl Room {
|
||||
}
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if announcement.is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
let signer = Self::select_signer(
|
||||
signer_kind,
|
||||
announcement.is_some(),
|
||||
&encryption_signer,
|
||||
&user_signer,
|
||||
);
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||
@@ -561,23 +620,12 @@ impl Room {
|
||||
let public_key = sender.public_key();
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if sender.announcement().is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
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),
|
||||
@@ -601,7 +649,7 @@ async fn send_gift_wrap(
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", vec!["14"]);
|
||||
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
|
||||
|
||||
@@ -19,11 +19,11 @@ anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chat::FileAttachment;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A file attachment that has been uploaded, but not sent yet.
|
||||
///
|
||||
/// The local `path` is kept around so the composer can preview
|
||||
/// the file without downloading and decrypting it again.
|
||||
pub(crate) struct PendingFile {
|
||||
pub file: FileAttachment,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// State of the encrypted file attachment of a message
|
||||
pub(crate) enum DecryptedFile {
|
||||
Loading,
|
||||
Ready(PathBuf),
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// Result of an upload, either plain or encrypted
|
||||
pub(crate) enum Uploaded {
|
||||
Url(Url),
|
||||
File(FileAttachment, PathBuf),
|
||||
}
|
||||
|
||||
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
||||
pub(crate) fn file_url(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use chat::Mention;
|
||||
use common::RangeExt;
|
||||
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;
|
||||
|
||||
/// 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());
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
@@ -39,25 +41,61 @@ impl RenderedText {
|
||||
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_plain_text_mut(
|
||||
render_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
persons,
|
||||
cx,
|
||||
markdown,
|
||||
resolve_mention,
|
||||
);
|
||||
|
||||
text.truncate(text.trim_end().len());
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
text.truncate(trimmed_len);
|
||||
}
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
@@ -70,55 +108,71 @@ impl RenderedText {
|
||||
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(),
|
||||
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()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
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 url.starts_with("http") {
|
||||
if WEB_URL.is_match(url) {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
@@ -128,15 +182,15 @@ impl RenderedText {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_plain_text_mut(
|
||||
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>,
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
@@ -145,34 +199,58 @@ fn render_plain_text_mut(
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
let mut code_block = false;
|
||||
|
||||
let mut options = Options::all();
|
||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||
// 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 Parser::new_ext(block, options).into_offset_iter() {
|
||||
for (event, source_range) in events {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
// Process text with mention replacements
|
||||
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 !source_range.contains_inclusive(&mention.range) {
|
||||
if mention.range.start >= source_range.end {
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate positions within the current text
|
||||
let mention_start_in_text = mention.range.start - source_range.start;
|
||||
let mention_end_in_text = mention.range.end - source_range.start;
|
||||
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,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
@@ -185,9 +263,7 @@ fn render_plain_text_mut(
|
||||
}
|
||||
|
||||
// Process the mention replacement
|
||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||
let replacement_text = format!("@{}", profile.name());
|
||||
|
||||
let replacement_text = resolve_mention(mention);
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
@@ -195,7 +271,6 @@ fn render_plain_text_mut(
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
mentions = &mentions[1..];
|
||||
}
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
@@ -203,7 +278,6 @@ fn render_plain_text_mut(
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
@@ -234,11 +308,14 @@ fn render_plain_text_mut(
|
||||
}
|
||||
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 = Some(dest_url.to_string()),
|
||||
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));
|
||||
}
|
||||
@@ -264,6 +341,7 @@ fn render_plain_text_mut(
|
||||
_ => {}
|
||||
},
|
||||
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,
|
||||
@@ -272,6 +350,11 @@ fn render_plain_text_mut(
|
||||
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'),
|
||||
_ => {}
|
||||
@@ -282,7 +365,6 @@ fn render_plain_text_mut(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
segment_start: usize,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
@@ -307,7 +389,8 @@ fn process_text_segment(
|
||||
});
|
||||
}
|
||||
|
||||
// Add the text
|
||||
// Ranges always refer to the rendered text, including replaced mentions.
|
||||
let segment_start = text.len();
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
@@ -330,7 +413,10 @@ fn process_text_segment(
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder.links(segment) {
|
||||
for link in finder
|
||||
.links(segment)
|
||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||
{
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
@@ -375,6 +461,7 @@ fn process_text_segment(
|
||||
|
||||
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;
|
||||
@@ -390,9 +477,11 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
itertools.workspace = true
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub use caching::*;
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
@@ -7,7 +6,6 @@ pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod caching;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "community"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
concord = { path = "../concord" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde_json.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
@@ -0,0 +1,331 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use concord::cord01::OpenedStream;
|
||||
use concord::state::{CommunityState, STATE_PREFIX, state_identifier};
|
||||
use concord::{ChannelId, CommunityId, cord03};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(|| {
|
||||
Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key"))
|
||||
});
|
||||
|
||||
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: SingleLetterTag = SingleLetterTag::LOWERCASE_K;
|
||||
|
||||
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
|
||||
pub async fn cache_rumor(
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
opened: &OpenedStream,
|
||||
) -> Result<bool> {
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
|
||||
if cord03::expiration_of(&opened.rumor)?
|
||||
.is_some_and(|expiration| expiration <= Timestamp::now())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let tags = vec![
|
||||
Tag::identifier(opened.rumor_id),
|
||||
Tag::custom(KIND_TAG.as_str(), [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 event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
let mut expired = Vec::new();
|
||||
|
||||
for event in client.database().query(filter).await? {
|
||||
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if expiration <= now {
|
||||
expired.push(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
let purged = expired.len();
|
||||
|
||||
if purged > 0 {
|
||||
client.database().delete(Filter::new().ids(expired)).await?;
|
||||
}
|
||||
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Observed {
|
||||
pub author: PublicKey,
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
/// The cached rumors of `channel`, keyed by the wrap they were opened from.
|
||||
pub async fn wrapper_index(
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
) -> Result<BTreeMap<EventId, Observed>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
let mut index = BTreeMap::new();
|
||||
|
||||
for event in client.database().query(filter).await? {
|
||||
let (Some(wrapper_id), Some(author)) = (
|
||||
event.tags.event_ids().next(),
|
||||
event.tags.public_keys().next(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
index.insert(
|
||||
wrapper_id,
|
||||
Observed {
|
||||
author,
|
||||
at_ms: event.created_at.as_secs().saturating_mul(1000),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Cached rumors for `channel`, newest first, deduplicated by rumor id.
|
||||
pub async fn query_rumors(
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
kinds: Option<&[u16]>,
|
||||
) -> 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(kinds) = kinds {
|
||||
filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string));
|
||||
}
|
||||
|
||||
if let Some(until) = until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
||||
for event in client.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)
|
||||
}
|
||||
|
||||
pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest state document per community carried in the local database.
|
||||
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
|
||||
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
|
||||
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
|
||||
|
||||
for event in client.database().query(filter).await? {
|
||||
let Some(id) = state_document_of(&event) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some(existing) if existing.created_at >= event.created_at => {}
|
||||
_ => {
|
||||
newest.insert(id, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut states = Vec::with_capacity(newest.len());
|
||||
|
||||
for event in newest.into_values() {
|
||||
match serde_json::from_str::<CommunityState>(&event.content) {
|
||||
Ok(state) => states.push(state),
|
||||
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
fn state_document_of(event: &Event) -> Option<CommunityId> {
|
||||
let identifier = event.tags.identifier()?;
|
||||
let hex = identifier.strip_prefix(STATE_PREFIX)?;
|
||||
hex.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use concord::Epoch;
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn client() -> Client {
|
||||
ClientBuilder::default()
|
||||
.database(MemoryDatabase::unbounded())
|
||||
.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
|
||||
let state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: Some("Anime and Manga".to_owned()),
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::new(),
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms: 7,
|
||||
};
|
||||
|
||||
save_state(&client, &state).await.expect("saves");
|
||||
|
||||
// A cached rumor is also an application-specific document, but not a
|
||||
// state document, so the prefix keeps it out of the state scan.
|
||||
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
|
||||
.tags([Tag::identifier("deadbeef")])
|
||||
.finalize(&*LOCAL_KEYS)
|
||||
.expect("builds");
|
||||
client.database().save_event(&other).await.expect("saves");
|
||||
|
||||
let loaded = load_states(&client).await.expect("loads");
|
||||
|
||||
assert_eq!(loaded, vec![state]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caching_the_same_rumor_twice_leaves_one_row() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||
let keys = Keys::generate();
|
||||
let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0))
|
||||
.expect("a group key");
|
||||
|
||||
let rumor = concord::cord03::build_message(
|
||||
keys.public_key(),
|
||||
&channel,
|
||||
Epoch(0),
|
||||
"twice",
|
||||
None,
|
||||
1_700_000_000_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false)
|
||||
.await
|
||||
.expect("seals");
|
||||
let (opened, _) =
|
||||
concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens");
|
||||
|
||||
assert!(
|
||||
cache_rumor(&client, &channel, &opened)
|
||||
.await
|
||||
.expect("caches")
|
||||
);
|
||||
assert!(
|
||||
cache_rumor(&client, &channel, &opened)
|
||||
.await
|
||||
.expect("caches")
|
||||
);
|
||||
|
||||
let cached = query_rumors(&client, &channel, None, 10, None)
|
||||
.await
|
||||
.expect("reads");
|
||||
assert_eq!(cached.len(), 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use concord::cord03::{self, ChatRumor};
|
||||
use concord::derive::channel_group_key;
|
||||
use concord::state::{ChannelCursor, HeldKey};
|
||||
use concord::{ChannelId, GroupKey};
|
||||
use futures::future::{Either, select};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cache::cache_rumor;
|
||||
use crate::sync::connect_relays;
|
||||
|
||||
/// How long one relay is given to answer one page of history.
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How far below a cursor a warm window reaches back.
|
||||
pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The region of history to read.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub until: Option<Timestamp>,
|
||||
pub since: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// The newest wraps, with no older bound.
|
||||
pub fn newest() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The wraps strictly older than `oldest`.
|
||||
pub fn older_than(oldest: Timestamp) -> Self {
|
||||
Self {
|
||||
until: Some(oldest - 1u64),
|
||||
since: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The region between `since` and `oldest`, both inclusive.
|
||||
pub fn between(since: Timestamp, oldest: Timestamp) -> Self {
|
||||
Self {
|
||||
until: Some(oldest - 1u64),
|
||||
since: Some(since),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a channel is opened with.
|
||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||
match cursor.newest {
|
||||
Some(newest) => Self {
|
||||
since: Some(newest - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a relay said about one page subscription.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Settled {
|
||||
Replayed,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// One relay's verdict on one page subscription.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PageReport {
|
||||
pub relay: RelayUrl,
|
||||
pub outcome: Settled,
|
||||
}
|
||||
|
||||
/// The page subscriptions in flight, by subscription id.
|
||||
pub struct PageRegistry {
|
||||
pages: Arc<Mutex<HashMap<SubscriptionId, flume::Sender<PageReport>>>>,
|
||||
}
|
||||
|
||||
impl Default for PageRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pages: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for PageRegistry {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
pages: Arc::clone(&self.pages),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRegistry {
|
||||
pub fn register(&self, id: SubscriptionId, sender: flume::Sender<PageReport>) {
|
||||
self.lock().insert(id, sender);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, id: &SubscriptionId) {
|
||||
self.lock().remove(id);
|
||||
}
|
||||
|
||||
/// Forget every page still in flight, because its round is gone.
|
||||
pub fn clear(&self) {
|
||||
self.lock().clear();
|
||||
}
|
||||
|
||||
/// Hand a relay's verdict to the page that owns `id`, when it is still waiting.
|
||||
pub fn deliver(&self, id: &SubscriptionId, relay: RelayUrl, outcome: Settled) {
|
||||
let sender = self.lock().get(id).cloned();
|
||||
|
||||
let Some(sender) = sender else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = sender.try_send(PageReport { relay, outcome }) {
|
||||
log::debug!("community: a page report was not delivered: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, HashMap<SubscriptionId, flume::Sender<PageReport>>> {
|
||||
match self.pages.lock() {
|
||||
Ok(pages) => pages,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one paged fetch saw.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WrapPage {
|
||||
pub opened: Vec<ChatRumor>,
|
||||
pub raw: usize,
|
||||
/// Wraps that reached us under a held plane but that no held key could open.
|
||||
pub unreadable: usize,
|
||||
pub newest: Option<Timestamp>,
|
||||
pub oldest: Option<Timestamp>,
|
||||
pub exhausted: bool,
|
||||
pub failed: bool,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
/// Walks a channel's history back over the community's own relays.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn page(
|
||||
client: &Client,
|
||||
pages: &PageRegistry,
|
||||
channel: &ChannelId,
|
||||
held: &[HeldKey],
|
||||
relays: &[RelayUrl],
|
||||
window: Window,
|
||||
max_pages: usize,
|
||||
limit: usize,
|
||||
) -> Result<WrapPage> {
|
||||
let planes: Vec<(HeldKey, GroupKey)> = held
|
||||
.iter()
|
||||
.map(|key| Ok((*key, channel_group_key(&key.key, channel, key.epoch)?)))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||
|
||||
if authors.is_empty() || relays.is_empty() || limit == 0 {
|
||||
return Ok(WrapPage {
|
||||
failed: true,
|
||||
..WrapPage::default()
|
||||
});
|
||||
}
|
||||
|
||||
// A REQ can only target a relay the pool already knows about.
|
||||
connect_relays(client, relays).await;
|
||||
|
||||
let mut walk = Walk::new(relays, window);
|
||||
let mut opened = Vec::new();
|
||||
|
||||
for _ in 0..max_pages {
|
||||
if walk.is_done() {
|
||||
break;
|
||||
}
|
||||
|
||||
let filter = wrap_filter(&authors, walk.region(), limit);
|
||||
let asked: Vec<(usize, RelayUrl)> = walk
|
||||
.live()
|
||||
.map(|index| (index, walk.url(index).clone()))
|
||||
.collect();
|
||||
|
||||
let answers = ask_page(client, pages, &asked, &filter).await;
|
||||
|
||||
for (index, url) in &asked {
|
||||
match answers.get(url) {
|
||||
Some(Settled::Replayed) => {}
|
||||
Some(Settled::Refused(reason)) => {
|
||||
log::warn!("community: relay {url} refused a history page: {reason}");
|
||||
walk.reject(*index);
|
||||
}
|
||||
None => {
|
||||
log::warn!("community: relay {url} did not finish a history page");
|
||||
walk.reject(*index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let answered = client.database().query(filter).await?;
|
||||
|
||||
for wrap in walk.accept(answered, limit) {
|
||||
let Some((held, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else {
|
||||
walk.unreadable += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if cache_rumor(client, channel, &stream).await? {
|
||||
opened.push(rumor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(walk.finish(opened))
|
||||
}
|
||||
|
||||
/// Opens one wrap under a held key.
|
||||
fn read_under(
|
||||
wrap: &Event,
|
||||
held: &HeldKey,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
) -> Result<(OpenedStream, ChatRumor)> {
|
||||
if held
|
||||
.retired_at
|
||||
.is_some_and(|retired| wrap.created_at > retired)
|
||||
{
|
||||
bail!("sealed after the key that reads it was retired");
|
||||
}
|
||||
|
||||
Ok(cord03::open(wrap, group, channel, held.epoch)?)
|
||||
}
|
||||
|
||||
/// The one filter a page is asked for.
|
||||
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
||||
let mut filter = Filter::new()
|
||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||
.authors(authors.iter().copied())
|
||||
.limit(limit);
|
||||
|
||||
if let Some(until) = window.until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
if let Some(since) = window.since {
|
||||
filter = filter.since(since);
|
||||
}
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// One page's verdicts, by relay.
|
||||
type PageAnswers = BTreeMap<RelayUrl, Settled>;
|
||||
|
||||
async fn ask_page(
|
||||
client: &Client,
|
||||
pages: &PageRegistry,
|
||||
asked: &[(usize, RelayUrl)],
|
||||
filter: &Filter,
|
||||
) -> PageAnswers {
|
||||
let mut answers = PageAnswers::new();
|
||||
let distinct: BTreeSet<RelayUrl> = asked.iter().map(|(_, url)| url.clone()).collect();
|
||||
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(distinct.len());
|
||||
|
||||
for url in &distinct {
|
||||
match client.relay(url).await {
|
||||
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
|
||||
Ok(None) => {
|
||||
log::warn!("community: relay {url} is not in the pool for a history page");
|
||||
answers.insert(
|
||||
url.clone(),
|
||||
Settled::Refused("not in the relay pool".to_owned()),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("community: relay {url} could not be looked up: {error}");
|
||||
answers.insert(url.clone(), Settled::Refused(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
return answers;
|
||||
}
|
||||
|
||||
let id = history_subscription();
|
||||
let (sender, receiver) = flume::bounded(distinct.len());
|
||||
pages.register(id.clone(), sender);
|
||||
|
||||
let options = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
match client
|
||||
.subscribe(ReqTarget::manual(targets))
|
||||
.with_id(id.clone())
|
||||
.close_on(options)
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
for (url, reason) in output.failed {
|
||||
answers.insert(url, Settled::Refused(reason));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + PAGE_TIMEOUT;
|
||||
|
||||
while answers.len() < distinct.len() {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
|
||||
let Some(report) = within(remaining, receiver.recv_async()).await else {
|
||||
break;
|
||||
};
|
||||
|
||||
match report {
|
||||
Ok(report) => {
|
||||
answers.insert(report.relay, report.outcome);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("community: a history page's reports were lost: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("community: a history page REQ was refused: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pages.unregister(&id);
|
||||
|
||||
answers
|
||||
}
|
||||
|
||||
pub(crate) fn auth_required(reason: &str) -> bool {
|
||||
matches!(
|
||||
MachineReadablePrefix::parse(reason),
|
||||
Some(MachineReadablePrefix::AuthRequired)
|
||||
)
|
||||
}
|
||||
|
||||
fn history_subscription() -> SubscriptionId {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
let future = std::pin::pin!(future);
|
||||
let deadline = std::pin::pin!(smol::Timer::after(limit));
|
||||
|
||||
match select(future, deadline).await {
|
||||
Either::Left((output, _)) => Some(output),
|
||||
Either::Right(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass over a channel's history, page by page.
|
||||
#[derive(Debug)]
|
||||
struct Walk {
|
||||
relays: Vec<Walker>,
|
||||
since: Option<Timestamp>,
|
||||
/// The inclusive upper bound of the next page.
|
||||
cursor: Option<Timestamp>,
|
||||
seen: BTreeSet<EventId>,
|
||||
newest: Option<Timestamp>,
|
||||
oldest: Option<Timestamp>,
|
||||
raw: usize,
|
||||
errors: usize,
|
||||
/// Wraps the caller could not read under any held key.
|
||||
unreadable: usize,
|
||||
/// A short page ended the walk.
|
||||
bottom: bool,
|
||||
}
|
||||
|
||||
/// One relay's standing in a walk.
|
||||
#[derive(Debug)]
|
||||
struct Walker {
|
||||
url: RelayUrl,
|
||||
dead: bool,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn new(relays: &[RelayUrl], window: Window) -> Self {
|
||||
Self {
|
||||
relays: relays
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|url| Walker { url, dead: false })
|
||||
.collect(),
|
||||
since: window.since,
|
||||
cursor: window.until,
|
||||
seen: BTreeSet::new(),
|
||||
newest: None,
|
||||
oldest: None,
|
||||
raw: 0,
|
||||
errors: 0,
|
||||
unreadable: 0,
|
||||
bottom: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.bottom || self.relays.iter().all(|walker| walker.dead)
|
||||
}
|
||||
|
||||
/// The region the next page asks for.
|
||||
fn region(&self) -> Window {
|
||||
Window {
|
||||
until: self.cursor,
|
||||
since: self.since,
|
||||
}
|
||||
}
|
||||
|
||||
fn live(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
(0..self.relays.len()).filter(|&index| !self.relays[index].dead)
|
||||
}
|
||||
|
||||
fn url(&self, index: usize) -> &RelayUrl {
|
||||
&self.relays[index].url
|
||||
}
|
||||
|
||||
fn reject(&mut self, index: usize) {
|
||||
self.relays[index].dead = true;
|
||||
self.errors += 1;
|
||||
}
|
||||
|
||||
fn accept(&mut self, page: BTreeSet<Event>, limit: usize) -> Vec<Event> {
|
||||
if page.len() < limit {
|
||||
self.bottom = true;
|
||||
}
|
||||
|
||||
let mut oldest: Option<Timestamp> = None;
|
||||
let mut events = Vec::with_capacity(page.len());
|
||||
|
||||
for event in page {
|
||||
let at = event.created_at;
|
||||
self.newest = Some(self.newest.map_or(at, |newest| newest.max(at)));
|
||||
oldest = Some(oldest.map_or(at, |oldest| oldest.min(at)));
|
||||
|
||||
if self.seen.insert(event.id) {
|
||||
self.raw += 1;
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// The walk's floor, which the round resumes the older pass from.
|
||||
if let Some(oldest) = oldest {
|
||||
self.oldest = Some(self.oldest.map_or(oldest, |held| held.min(oldest)));
|
||||
}
|
||||
|
||||
match oldest {
|
||||
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
|
||||
Some(_) => self.bottom = true,
|
||||
None => {}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn finish(self, opened: Vec<ChatRumor>) -> WrapPage {
|
||||
let swept = self.bottom && self.errors == 0;
|
||||
|
||||
WrapPage {
|
||||
opened,
|
||||
raw: self.raw,
|
||||
unreadable: self.unreadable,
|
||||
newest: self.newest,
|
||||
oldest: self.oldest,
|
||||
exhausted: swept && self.raw > 0,
|
||||
failed: self.errors > 0 || (self.bottom && self.raw == 0),
|
||||
errors: self.errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use concord::Epoch;
|
||||
use concord::cord03::{build_message, seal_rumor};
|
||||
use concord::derive::channel_group_key;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||
|
||||
fn serve_page(database: &BTreeSet<Event>, window: Window, limit: usize) -> BTreeSet<Event> {
|
||||
let mut events: Vec<Event> = database
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
window.until.is_none_or(|until| event.created_at <= until)
|
||||
&& window.since.is_none_or(|since| event.created_at >= since)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
events.into_iter().collect()
|
||||
}
|
||||
|
||||
fn relay_url(host: &str) -> RelayUrl {
|
||||
RelayUrl::parse(&format!("wss://{host}.example.com")).expect("parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_pages_back_across_a_rekey() {
|
||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||
let author = Keys::generate();
|
||||
let held = [
|
||||
HeldKey {
|
||||
epoch: Epoch(0),
|
||||
key: SECRET,
|
||||
retired_at: None,
|
||||
},
|
||||
HeldKey {
|
||||
epoch: Epoch(1),
|
||||
key: NEXT_SECRET,
|
||||
retired_at: None,
|
||||
},
|
||||
];
|
||||
let planes: Vec<(HeldKey, GroupKey)> = held
|
||||
.iter()
|
||||
.map(|key| {
|
||||
(
|
||||
*key,
|
||||
channel_group_key(&key.key, &channel, key.epoch).expect("derives"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Three messages a second apart: a page boundary falls between each.
|
||||
let base = 1_700_000_000_000;
|
||||
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
||||
|
||||
for (content, secret, epoch, at_ms) in [
|
||||
("before the rekey", &SECRET, Epoch(0), base),
|
||||
("still before", &SECRET, Epoch(0), base + 1_000),
|
||||
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
||||
] {
|
||||
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
&channel,
|
||||
epoch,
|
||||
content,
|
||||
None,
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
relay.insert(
|
||||
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
||||
.expect("seals")
|
||||
.0,
|
||||
);
|
||||
}
|
||||
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
let mut found = Vec::new();
|
||||
let mut pages = 0;
|
||||
|
||||
while !walk.is_done() && pages < 10 {
|
||||
pages += 1;
|
||||
let page = serve_page(&relay, walk.region(), 2);
|
||||
|
||||
for wrap in walk.accept(page, 2) {
|
||||
let Some((held, group)) =
|
||||
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let (_, rumor) = cord03::open(&wrap, group, &channel, held.epoch).expect("opens");
|
||||
found.push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||
|
||||
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
||||
assert_eq!(
|
||||
contents,
|
||||
["after the rekey", "still before", "before the rekey"]
|
||||
);
|
||||
|
||||
let page = walk.finish(Vec::new());
|
||||
assert!(page.exhausted);
|
||||
assert!(!page.failed);
|
||||
assert_eq!(page.raw, 3);
|
||||
assert_eq!(page.newest, Some(Timestamp::from_secs(1_700_000_002)));
|
||||
assert_eq!(
|
||||
page.oldest,
|
||||
Some(Timestamp::from_secs(1_700_000_000)),
|
||||
"the walk reports its floor, which the older pass resumes below"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wrap that reaches us and still will not open is history we cannot read,
|
||||
/// not history that does not exist.
|
||||
#[test]
|
||||
fn a_wrap_no_held_key_can_open_reads_as_unreadable() {
|
||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||
let other = ChannelId::from_bytes([0x9du8; 32]);
|
||||
let author = Keys::generate();
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
let held = HeldKey {
|
||||
epoch: Epoch(0),
|
||||
key: SECRET,
|
||||
retired_at: Some(Timestamp::from_secs(1_000)),
|
||||
};
|
||||
|
||||
let wrap_at = |channel: &ChannelId, at_ms: u64| {
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
channel,
|
||||
Epoch(0),
|
||||
"sealed",
|
||||
None,
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
||||
.expect("seals")
|
||||
.0
|
||||
};
|
||||
|
||||
// Sealed before the rotation superseded this key, so it still reads.
|
||||
let before = wrap_at(&channel, 999_000);
|
||||
assert!(read_under(&before, &held, &group, &channel).is_ok());
|
||||
|
||||
// Sealed after the cutoff the rotation set on that key.
|
||||
let after = wrap_at(&channel, 1_001_000);
|
||||
assert!(read_under(&after, &held, &group, &channel).is_err());
|
||||
|
||||
// Sealed to this plane but bound to another channel.
|
||||
let misbound = wrap_at(&other, 999_000);
|
||||
assert!(read_under(&misbound, &held, &group, &channel).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_answer_never_seals_the_channel() {
|
||||
let database: BTreeSet<Event> = BTreeSet::new();
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
|
||||
let page = serve_page(&database, walk.region(), 50);
|
||||
assert!(walk.accept(page, 50).is_empty());
|
||||
|
||||
let page = walk.finish(Vec::new());
|
||||
assert!(page.failed);
|
||||
assert!(!page.exhausted);
|
||||
assert_eq!(page.raw, 0);
|
||||
assert_eq!(page.oldest, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_relay_blocks_the_bottom() {
|
||||
let database: BTreeSet<Event> = BTreeSet::new();
|
||||
let mut walk = Walk::new(
|
||||
&[relay_url("history"), relay_url("archive")],
|
||||
Window::newest(),
|
||||
);
|
||||
|
||||
// One relay answered the empty page; the other never answered at all, so
|
||||
// its share of the region was never read and the walk must not seal.
|
||||
walk.reject(1);
|
||||
let page = serve_page(&database, walk.region(), 50);
|
||||
assert!(walk.accept(page, 50).is_empty());
|
||||
|
||||
let page = walk.finish(Vec::new());
|
||||
assert!(page.failed);
|
||||
assert!(!page.exhausted);
|
||||
assert_eq!(page.errors, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_boundary_is_exclusive() {
|
||||
let database = BTreeSet::from([
|
||||
event_at(Timestamp::from_secs(1_700_000_000)),
|
||||
event_at(Timestamp::from_secs(1_700_000_001)),
|
||||
]);
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
|
||||
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||
let second = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert_ne!(first[0].id, second[0].id);
|
||||
|
||||
let oldest = second
|
||||
.iter()
|
||||
.map(|event| event.created_at)
|
||||
.min()
|
||||
.expect("one wrap");
|
||||
assert_eq!(oldest, Timestamp::from_secs(1_700_000_000));
|
||||
}
|
||||
|
||||
fn event_at(at: Timestamp) -> Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::TextNote, "page")
|
||||
.custom_created_at(at)
|
||||
.finalize(&keys)
|
||||
.expect("signs")
|
||||
}
|
||||
|
||||
/// A cold channel asks wide; a warm one resumes at the overlap above its
|
||||
/// cursor, and `older_than` never includes the boundary event itself.
|
||||
#[test]
|
||||
fn a_cold_window_is_open_and_a_warm_one_resumes_at_the_overlap() {
|
||||
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
|
||||
|
||||
let warm = Window::opening(ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(2_000_000)),
|
||||
oldest: Some(Timestamp::from_secs(1_000)),
|
||||
exhausted: false,
|
||||
});
|
||||
assert_eq!(
|
||||
warm,
|
||||
Window {
|
||||
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Window::older_than(Timestamp::from_secs(1_000)),
|
||||
Window {
|
||||
since: None,
|
||||
until: Some(Timestamp::from_secs(999)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A page whose round has moved on is simply a report nobody reads.
|
||||
#[test]
|
||||
fn a_page_that_moved_on_receives_nothing() {
|
||||
let pages = PageRegistry::default();
|
||||
let id = SubscriptionId::new("concord-history-9");
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
|
||||
pages.register(id.clone(), sender);
|
||||
pages.unregister(&id);
|
||||
pages.deliver(&id, relay_url("history"), Settled::Replayed);
|
||||
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
pub use concord::cord02::CommunityMetadata;
|
||||
pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||
use concord::state::CommunityState;
|
||||
pub use concord::{ChannelId, CommunityId, Epoch};
|
||||
use futures::future::{Either, select};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::history::{PageRegistry, Settled, auth_required};
|
||||
use crate::rekey::WatchRegistry;
|
||||
|
||||
pub mod cache;
|
||||
mod community;
|
||||
pub mod history;
|
||||
mod rekey;
|
||||
mod sync;
|
||||
|
||||
pub use community::*;
|
||||
pub use sync::*;
|
||||
|
||||
/// How long a burst of relay notifications is collected before it is folded.
|
||||
const PUMP_WINDOW: Duration = Duration::from_millis(200);
|
||||
/// How long a community's relays may deliver nothing before
|
||||
/// its standing subscription is torn down and re-issued.
|
||||
const LIVE_ROTATE: Duration = Duration::from_secs(90);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalCommunityRegistry(Entity<CommunityRegistry>);
|
||||
|
||||
impl Global for GlobalCommunityRegistry {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Signal {
|
||||
Event(CommunityId),
|
||||
List,
|
||||
Rekey(CommunityId),
|
||||
}
|
||||
|
||||
/// Which standing subscription an id belongs to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Route {
|
||||
List,
|
||||
Community(CommunityId),
|
||||
}
|
||||
|
||||
fn route_of(id: &SubscriptionId) -> Option<Route> {
|
||||
if sync::is_list_subscription(id) {
|
||||
return Some(Route::List);
|
||||
}
|
||||
|
||||
sync::community_of(id).map(Route::Community)
|
||||
}
|
||||
|
||||
/// What a window of relay notifications saw, waiting to be folded once.
|
||||
#[derive(Default)]
|
||||
struct Batch {
|
||||
list: bool,
|
||||
communities: BTreeSet<CommunityId>,
|
||||
rekeys: BTreeSet<CommunityId>,
|
||||
}
|
||||
|
||||
/// Whether the pump should keep listening.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Flow {
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Fold one notification into the window, or settle a page it belongs to.
|
||||
fn route(
|
||||
notification: ClientNotification,
|
||||
pages: &PageRegistry,
|
||||
watches: &WatchRegistry,
|
||||
batch: &mut Batch,
|
||||
) -> Flow {
|
||||
match notification {
|
||||
ClientNotification::Event {
|
||||
subscription_id, ..
|
||||
} => match route_of(&subscription_id) {
|
||||
Some(Route::List) => batch.list = true,
|
||||
Some(Route::Community(id)) => {
|
||||
batch.communities.insert(id);
|
||||
}
|
||||
None => {
|
||||
if let Some(id) = watches.community_of(&subscription_id) {
|
||||
batch.rekeys.insert(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
ClientNotification::Message { relay_url, message } => match *message {
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
pages.deliver(&id, relay_url, Settled::Replayed);
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} if !auth_required(&message) => {
|
||||
pages.deliver(
|
||||
&subscription_id,
|
||||
relay_url,
|
||||
Settled::Refused(message.into_owned()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ClientNotification::Shutdown => return Flow::Stop,
|
||||
}
|
||||
|
||||
Flow::Continue
|
||||
}
|
||||
|
||||
/// Hand the window's signals to the foreground consumer, one per community.
|
||||
async fn flush(tx: &flume::Sender<Signal>, batch: &mut Batch) -> Result<()> {
|
||||
for id in std::mem::take(&mut batch.communities) {
|
||||
tx.send_async(Signal::Event(id)).await?;
|
||||
}
|
||||
|
||||
for id in std::mem::take(&mut batch.rekeys) {
|
||||
tx.send_async(Signal::Rekey(id)).await?;
|
||||
}
|
||||
|
||||
if std::mem::take(&mut batch.list) {
|
||||
tx.send_async(Signal::List).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl EventEmitter<CommunityEvent> for CommunityRegistry {}
|
||||
|
||||
pub struct CommunityRegistry {
|
||||
communities: Vec<Entity<Community>>,
|
||||
index: HashMap<CommunityId, Entity<Community>>,
|
||||
/// The plane set each community was last subscribed with
|
||||
synced: HashMap<CommunityId, SubscriptionKey>,
|
||||
/// When a relay last delivered something for a community,
|
||||
/// which is the only evidence the standing subscription is alive.
|
||||
last_event: HashMap<CommunityId, Instant>,
|
||||
/// One observer per tracked community, dropped on reset
|
||||
observers: HashMap<CommunityId, Subscription>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
/// The page subscriptions in flight, shared with the notification pump.
|
||||
pages: PageRegistry,
|
||||
/// The rekey watch subscriptions, resolved to their community.
|
||||
watches: WatchRegistry,
|
||||
tasks: SmallVec<[Task<Result<()>>; 2]>,
|
||||
/// Notification listener task (cancelled on signer change)
|
||||
notification_listener: Option<Task<Result<()>>>,
|
||||
/// Signal consumer task (cancelled on signer change)
|
||||
signal_consumer: Option<Task<Result<()>>>,
|
||||
/// The round scheduler (cancelled on signer change)
|
||||
scheduler: Option<Task<Result<()>>>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl CommunityRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalCommunityRegistry>().0.clone()
|
||||
}
|
||||
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalCommunityRegistry(state));
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.subscribe_list(cx);
|
||||
this.load(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.subscribe_list(cx);
|
||||
this.load(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
communities: Vec::new(),
|
||||
index: HashMap::new(),
|
||||
synced: HashMap::new(),
|
||||
last_event: HashMap::new(),
|
||||
observers: HashMap::new(),
|
||||
signal_tx: tx,
|
||||
signal_rx: rx,
|
||||
pages: PageRegistry::default(),
|
||||
watches: WatchRegistry::default(),
|
||||
tasks: smallvec![],
|
||||
notification_listener: None,
|
||||
signal_consumer: None,
|
||||
scheduler: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn communities(&self) -> &[Entity<Community>] {
|
||||
&self.communities
|
||||
}
|
||||
|
||||
pub fn community(&self, id: &CommunityId) -> Option<Entity<Community>> {
|
||||
self.index.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Create a community owned by the current account and begin tracking it.
|
||||
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
if current_user.is_none() {
|
||||
cx.emit(CommunityEvent::Error(
|
||||
"cannot create a community without an account".to_owned(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task =
|
||||
cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_state) => this.update(cx, |this, cx| this.load(cx))?,
|
||||
Err(error) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Forget the current account and cancel everything in flight.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
self.scheduler = None;
|
||||
self.tasks.clear();
|
||||
self.observers.clear();
|
||||
self.pages.clear();
|
||||
self.watches.clear();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
|
||||
for id in ids {
|
||||
let client = client.clone();
|
||||
let subscription = sync::subscription_id(&id);
|
||||
let rekey = rekey::subscription_id(&id);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.unsubscribe(&subscription).await?;
|
||||
client.unsubscribe(&rekey).await?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
self.communities.clear();
|
||||
self.index.clear();
|
||||
self.synced.clear();
|
||||
self.last_event.clear();
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Subscribe to the account's community list.
|
||||
fn subscribe_list(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let self_pk = signer.get_public_key_async().await?;
|
||||
|
||||
if let Err(error) = sync::subscribe_list(&client, self_pk).await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Discover the account's communities in the local database.
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let self_pk = signer.get_public_key_async().await?;
|
||||
sync::load(&client, &signer, self_pk).await
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(states) => {
|
||||
this.update(cx, |this, cx| this.track(states, cx))?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Replace the tracked communities with a freshly loaded set.
|
||||
///
|
||||
/// A community that survives the reload keeps its entity, so an open panel
|
||||
/// and a browsing sidebar stay pointed at a live community.
|
||||
fn track(&mut self, states: Vec<CommunityState>, cx: &mut Context<Self>) {
|
||||
let mut communities = Vec::with_capacity(states.len());
|
||||
|
||||
for state in states {
|
||||
let id = state.id;
|
||||
|
||||
let community = match self.index.remove(&id) {
|
||||
Some(community) => {
|
||||
// The list can carry plane material the store does not.
|
||||
if community.read(cx).state() != &state {
|
||||
community.update(cx, |community, _cx| community.adopt(state));
|
||||
}
|
||||
|
||||
community
|
||||
}
|
||||
None => {
|
||||
let community = cx.new(|_| Community::new(state, self.pages.clone()));
|
||||
|
||||
self.observers.insert(
|
||||
id,
|
||||
cx.observe(&community, |this, _community, cx| {
|
||||
this.sync_subscriptions(cx);
|
||||
cx.notify();
|
||||
}),
|
||||
);
|
||||
|
||||
community
|
||||
}
|
||||
};
|
||||
|
||||
communities.push((id, community));
|
||||
}
|
||||
|
||||
// Whatever the index still holds is no longer in the list.
|
||||
let dropped: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
|
||||
for id in dropped {
|
||||
self.observers.remove(&id);
|
||||
self.synced.remove(&id);
|
||||
self.last_event.remove(&id);
|
||||
}
|
||||
|
||||
self.communities = communities
|
||||
.iter()
|
||||
.map(|(_, community)| community.clone())
|
||||
.collect();
|
||||
self.index = communities.into_iter().collect();
|
||||
|
||||
self.sync_subscriptions(cx);
|
||||
|
||||
// A backlog already in the database produces no notification, so fold it once.
|
||||
for community in self.communities.clone() {
|
||||
community.update(cx, |community, cx| community.refresh(cx));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn refresh(&mut self, id: CommunityId, cx: &mut Context<Self>) {
|
||||
let Some(community) = self.index.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
community.update(cx, |community, cx| community.refresh(cx));
|
||||
}
|
||||
|
||||
/// Adopt whatever a community's rekey watch has delivered.
|
||||
fn rekey(&mut self, id: CommunityId, cx: &mut Context<Self>) {
|
||||
let Some(community) = self.index.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
community.update(cx, |community, cx| community.rekey(cx));
|
||||
}
|
||||
|
||||
/// One scheduler pass: every community repairs itself if it has gone stale,
|
||||
/// and any whose relays have gone quiet is re-subscribed.
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
for community in self.communities.clone() {
|
||||
community.update(cx, |community, cx| community.tick(cx));
|
||||
}
|
||||
self.rotate_quiet(cx);
|
||||
}
|
||||
|
||||
/// Re-issue the standing subscription of every community that has been quiet for `LIVE_ROTATE`
|
||||
fn rotate_quiet(&mut self, cx: &mut Context<Self>) {
|
||||
let now = Instant::now();
|
||||
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
let mut rotated = false;
|
||||
|
||||
for id in ids {
|
||||
let quiet = self
|
||||
.last_event
|
||||
.get(&id)
|
||||
.is_none_or(|at| now.duration_since(*at) >= LIVE_ROTATE);
|
||||
|
||||
if !quiet {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.synced.remove(&id);
|
||||
rotated = true;
|
||||
}
|
||||
|
||||
if rotated {
|
||||
self.sync_subscriptions(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-subscribe every community whose held planes moved.
|
||||
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
for community in self.communities.clone() {
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let (id, key, state) = {
|
||||
let community = community.read(cx);
|
||||
(
|
||||
community.id(),
|
||||
community.subscription_key(),
|
||||
community.state().clone(),
|
||||
)
|
||||
};
|
||||
|
||||
if self.synced.get(&id) == Some(&key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let planes = match sync::planes(&state) {
|
||||
Ok(planes) => planes,
|
||||
Err(error) => {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let subscription = sync::subscription_id(&id);
|
||||
let filter = sync::live_filter(&planes, sync::live_window(&state, Timestamp::now()));
|
||||
let relays = key.relays().to_vec();
|
||||
|
||||
// A fresh REQ counts as evidence of life for this community.
|
||||
self.last_event.insert(id, Instant::now());
|
||||
|
||||
// The rekey watch is a second standing REQ over the same relays.
|
||||
let watch = match rekey::watches(&state) {
|
||||
Ok(watches) => rekey::watch_filter(&watches),
|
||||
Err(error) => {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let rekey_subscription = rekey::subscription_id(&id);
|
||||
self.watches.register(rekey_subscription.clone(), id);
|
||||
|
||||
self.synced.insert(id, key);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = subscribe(&client, &subscription, &relays, filter).await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Err(error) = subscribe(&client, &rekey_subscription, &relays, watch).await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
self.scheduler = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
let pages = self.pages.clone();
|
||||
let watches = self.watches.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut batch = Batch::default();
|
||||
|
||||
'outer: loop {
|
||||
match notifications.next().await {
|
||||
Some(notification) => {
|
||||
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
|
||||
flush(&tx, &mut batch).await?;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
None => break 'outer,
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + PUMP_WINDOW;
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = executor.timer(deadline - now);
|
||||
let next = notifications.next();
|
||||
futures::pin_mut!(timer);
|
||||
futures::pin_mut!(next);
|
||||
|
||||
match select(next, timer).await {
|
||||
Either::Left((Some(notification), _)) => {
|
||||
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
|
||||
flush(&tx, &mut batch).await?;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
Either::Left((None, _)) => break 'outer,
|
||||
Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
flush(&tx, &mut batch).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
match signal {
|
||||
Signal::Event(id) => this.update(cx, |this, cx| {
|
||||
// The only proof a community's subscription is still
|
||||
// delivering anything.
|
||||
this.last_event.insert(id, Instant::now());
|
||||
this.refresh(id, cx);
|
||||
})?,
|
||||
Signal::Rekey(id) => this.update(cx, |this, cx| this.rekey(id, cx))?,
|
||||
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.scheduler = Some(cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(MIN_ROUND_INTERVAL).await;
|
||||
|
||||
if let Some(registry) = this.upgrade() {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.tick(cx);
|
||||
});
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
client: &Client,
|
||||
id: &SubscriptionId,
|
||||
relays: &[RelayUrl],
|
||||
filter: Filter,
|
||||
) -> Result<()> {
|
||||
client.unsubscribe(id).await?;
|
||||
|
||||
if relays.is_empty() {
|
||||
log::warn!("community {id}: no relay to subscribe to");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(relays.len());
|
||||
|
||||
for url in relays {
|
||||
if let Err(error) = client.add_relay(url).and_connect().await {
|
||||
log::warn!("community {id}: failed to add relay {url}: {error}");
|
||||
}
|
||||
|
||||
match client.relay(url).await {
|
||||
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
|
||||
Ok(None) => log::warn!("community {id}: relay {url} is not in the pool"),
|
||||
Err(error) => log::warn!("community {id}: relay {url} could not be looked up: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
log::warn!("community {id}: no relay accepted the standing subscription");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = client
|
||||
.subscribe(ReqTarget::manual(targets))
|
||||
.with_id(id.clone())
|
||||
.await?;
|
||||
|
||||
if !output.failed.is_empty() {
|
||||
log::warn!(
|
||||
"community {id}: {} relay(s) rejected the subscription",
|
||||
output.failed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn relay() -> RelayUrl {
|
||||
RelayUrl::parse("wss://relay.example").expect("a url")
|
||||
}
|
||||
|
||||
fn event(subscription_id: SubscriptionId) -> ClientNotification {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "hi")
|
||||
.finalize(&keys)
|
||||
.expect("signs");
|
||||
|
||||
ClientNotification::Event {
|
||||
relay_url: relay(),
|
||||
subscription_id,
|
||||
event: Box::new(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn message(message: RelayMessage<'static>) -> ClientNotification {
|
||||
ClientNotification::Message {
|
||||
relay_url: relay(),
|
||||
message: Box::new(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// A burst within one window folds each community once, and the list once,
|
||||
/// however many events arrived.
|
||||
#[test]
|
||||
fn a_burst_collapses_to_one_signal_per_community() {
|
||||
let pages = PageRegistry::default();
|
||||
let watches = WatchRegistry::default();
|
||||
let community = CommunityId::from_bytes([0x42; 32]);
|
||||
let mut batch = Batch::default();
|
||||
|
||||
let plane = event(sync::subscription_id(&community));
|
||||
for _ in 0..50 {
|
||||
assert_eq!(
|
||||
route(plane.clone(), &pages, &watches, &mut batch),
|
||||
Flow::Continue
|
||||
);
|
||||
}
|
||||
|
||||
route(
|
||||
event(sync::list_subscription_id()),
|
||||
&pages,
|
||||
&watches,
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
// A page's event is folded from the database later, not routed here.
|
||||
route(
|
||||
event(SubscriptionId::new("concord-history-7")),
|
||||
&pages,
|
||||
&watches,
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
assert!(batch.list);
|
||||
assert_eq!(batch.communities, BTreeSet::from([community]));
|
||||
assert!(batch.rekeys.is_empty());
|
||||
}
|
||||
|
||||
/// A rekey watch's wraps put themselves in the database; the pump's only job
|
||||
/// is to wake the adoption pass, and it resolves the community by id.
|
||||
#[test]
|
||||
fn a_rekey_watch_event_wakes_its_community() {
|
||||
let pages = PageRegistry::default();
|
||||
let watches = WatchRegistry::default();
|
||||
let community = CommunityId::from_bytes([0x42; 32]);
|
||||
let id = rekey::subscription_id(&community);
|
||||
watches.register(id.clone(), community);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
route(event(id), &pages, &watches, &mut batch);
|
||||
|
||||
assert_eq!(batch.rekeys, BTreeSet::from([community]));
|
||||
assert!(batch.communities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eose_settles_only_the_page_that_owns_the_id() {
|
||||
let pages = PageRegistry::default();
|
||||
let mine = SubscriptionId::new("concord-history-1");
|
||||
let other = SubscriptionId::new("concord-history-2");
|
||||
let (mine_tx, mine_rx) = flume::bounded(1);
|
||||
let (other_tx, other_rx) = flume::bounded(1);
|
||||
pages.register(mine.clone(), mine_tx);
|
||||
pages.register(other, other_tx);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
let flow = route(
|
||||
message(RelayMessage::eose(mine)),
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
assert_eq!(flow, Flow::Continue);
|
||||
assert!(matches!(
|
||||
mine_rx.try_recv().expect("a report").outcome,
|
||||
Settled::Replayed
|
||||
));
|
||||
assert!(other_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_page_settles_its_relay_as_refused() {
|
||||
let pages = PageRegistry::default();
|
||||
let id = SubscriptionId::new("concord-history-1");
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
pages.register(id.clone(), sender);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
route(
|
||||
message(RelayMessage::closed(id, "blocked: not allowed")),
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
match receiver.try_recv().expect("a report").outcome {
|
||||
Settled::Refused(reason) => assert!(reason.contains("blocked")),
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDK re-issues an `auth-required` REQ under the same id after AUTH, so
|
||||
/// the page keeps waiting rather than writing the relay off.
|
||||
#[test]
|
||||
fn an_auth_required_close_settles_nothing() {
|
||||
let pages = PageRegistry::default();
|
||||
let id = SubscriptionId::new("concord-history-1");
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
pages.register(id.clone(), sender);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
route(
|
||||
message(RelayMessage::closed(
|
||||
id,
|
||||
"auth-required: please authenticate",
|
||||
)),
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shutdown_stops_the_pump() {
|
||||
let pages = PageRegistry::default();
|
||||
let mut batch = Batch::default();
|
||||
|
||||
assert_eq!(
|
||||
route(
|
||||
ClientNotification::Shutdown,
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch
|
||||
),
|
||||
Flow::Stop
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "community_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
community = { path = "../community" }
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,820 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use community::{
|
||||
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
|
||||
TIMELINE_PAGE, Timeline,
|
||||
};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
|
||||
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||
};
|
||||
use nostr_sdk::prelude::EventId;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||
use ui::message::WelcomeMessage;
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
mod message;
|
||||
|
||||
/// How near the top row a scroll has to come before the panel splices older history in.
|
||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||
/// A repeat message within this window keeps its run, so it carries no avatar or name.
|
||||
const RUN_WINDOW_MS: u64 = 300_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Notice {
|
||||
Stranded,
|
||||
Removed(Epoch),
|
||||
ChannelRemoved(Epoch),
|
||||
MissingKey(Epoch),
|
||||
Unreachable,
|
||||
Unreadable(usize),
|
||||
}
|
||||
|
||||
impl fmt::Display for Notice {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Notice::Stranded => formatter.write_str(
|
||||
"This invite is stale, the community has rotated past the epoch it names",
|
||||
),
|
||||
Notice::Removed(epoch) => write!(
|
||||
formatter,
|
||||
"You were removed from this community at epoch {}. Its history stays readable",
|
||||
epoch.0
|
||||
),
|
||||
Notice::ChannelRemoved(epoch) => write!(
|
||||
formatter,
|
||||
"A rotation removed you from this channel at epoch {}",
|
||||
epoch.0
|
||||
),
|
||||
Notice::MissingKey(epoch) => write!(
|
||||
formatter,
|
||||
"Messages here can't be read yet, this channel's key for epoch {} is missing",
|
||||
epoch.0
|
||||
),
|
||||
Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"),
|
||||
Notice::Unreadable(1) => {
|
||||
formatter.write_str("1 message here can't be read yet, no key we hold opens it")
|
||||
}
|
||||
Notice::Unreadable(count) => write!(
|
||||
formatter,
|
||||
"{count} messages here can't be read yet, no key we hold opens them"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
fn writable(self) -> bool {
|
||||
matches!(self, Notice::Unreachable | Notice::Unreadable(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(
|
||||
community: Entity<Community>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<CommunityPanel> {
|
||||
cx.new(|cx| CommunityPanel::new(community, window, cx))
|
||||
}
|
||||
|
||||
/// Community Panel
|
||||
pub struct CommunityPanel {
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
/// Community
|
||||
community: WeakEntity<Community>,
|
||||
/// The selected channel
|
||||
channel: Option<ChannelId>,
|
||||
/// The selected channel's timeline (oldest first)
|
||||
rows: Vec<ChatMessage>,
|
||||
/// Whether the store holds rows older than `rows`
|
||||
has_more: bool,
|
||||
/// A round or a page read is in flight
|
||||
loading: bool,
|
||||
/// Message list state
|
||||
list_state: ListState,
|
||||
/// Message input state
|
||||
input: Entity<TextareaState>,
|
||||
/// Spawned reads and publishes, cancelled when the panel closes
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl CommunityPanel {
|
||||
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let (id, name, channel) = {
|
||||
let community = community.read(cx);
|
||||
|
||||
(
|
||||
SharedString::from(format!("community-{}", community.id().to_hex())),
|
||||
community.name(),
|
||||
community.active_channel(),
|
||||
)
|
||||
};
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.placeholder(format!("Message {name}"))
|
||||
.auto_grow(1, 20)
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.send(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&community,
|
||||
window,
|
||||
|_this, _community, event, window, cx| {
|
||||
match event {
|
||||
CommunityEvent::Channel(..) => {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
}
|
||||
CommunityEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
_ => {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.reload(window, cx);
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
));
|
||||
|
||||
let panel = Self {
|
||||
id,
|
||||
focus_handle: cx.focus_handle(),
|
||||
community: community.downgrade(),
|
||||
channel,
|
||||
rows: Vec::new(),
|
||||
has_more: false,
|
||||
loading: false,
|
||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||
input,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
panel.list_state.set_follow_mode(FollowMode::Tail);
|
||||
panel.list_state.set_scroll_handler(cx.listener(
|
||||
|this, event: &ListScrollEvent, window, cx| {
|
||||
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||
this.load_older(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
|
||||
panel
|
||||
}
|
||||
|
||||
/// The channel to show, following the community's selection.
|
||||
fn resolve_channel(&mut self, cx: &App) -> Option<ChannelId> {
|
||||
let channel = self
|
||||
.community
|
||||
.read_with(cx, |community, _cx| community.active_channel())
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if channel != self.channel {
|
||||
self.channel = channel;
|
||||
self.rows.clear();
|
||||
self.has_more = false;
|
||||
self.loading = false;
|
||||
self.list_state.reset(2);
|
||||
}
|
||||
|
||||
channel
|
||||
}
|
||||
|
||||
/// The list's item count: the welcome row, the load-older row, then every message row.
|
||||
fn item_count(&self) -> usize {
|
||||
self.rows.len() + 2
|
||||
}
|
||||
|
||||
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
|
||||
fn read(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
before_ms: Option<u64>,
|
||||
cx: &App,
|
||||
) -> Option<Task<Result<Timeline>>> {
|
||||
self.community
|
||||
.read_with(cx, |community, cx| {
|
||||
community.timeline(&channel, before_ms, TIMELINE_PAGE, cx)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A channel round, or `None` once the community is gone.
|
||||
fn sync(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
intent: Intent,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<Result<community::Progress>>> {
|
||||
self.community
|
||||
.update(cx, |community, cx| {
|
||||
community.sync_channel(&channel, intent, cx)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Paint the selected channel's cache, then catch it up from the relays.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.resolve_channel(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.reload(window, cx);
|
||||
|
||||
// Opening a channel twice in a breath asks the relays once.
|
||||
if self.due(channel, cx) {
|
||||
self.round(channel, Intent::CatchUp, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the community would actually round `channel`, or just serve it.
|
||||
fn due(&self, channel: ChannelId, cx: &App) -> bool {
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| community.due(&channel))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Why the room is not showing messages, when it is not simply empty.
|
||||
fn notice(&self, cx: &App) -> Option<Notice> {
|
||||
let channel = self.channel?;
|
||||
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
if community.stranded() {
|
||||
return Some(Notice::Stranded);
|
||||
}
|
||||
|
||||
if let Some(epoch) = community.removed_at() {
|
||||
return Some(Notice::Removed(epoch));
|
||||
}
|
||||
|
||||
if let Some(epoch) = community.channel_removed_at(&channel) {
|
||||
return Some(Notice::ChannelRemoved(epoch));
|
||||
}
|
||||
|
||||
if let Some(epoch) = community.missing_key(&channel) {
|
||||
return Some(Notice::MissingKey(epoch));
|
||||
}
|
||||
|
||||
if community
|
||||
.progress(&channel)
|
||||
.is_some_and(|progress| progress.failed && progress.errors > 0)
|
||||
{
|
||||
return Some(Notice::Unreachable);
|
||||
}
|
||||
|
||||
let unreadable = community.unreadable(&channel);
|
||||
|
||||
(unreadable > 0).then_some(Notice::Unreadable(unreadable))
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Run a round for `channel`, its completion re-reads the timeline.
|
||||
fn round(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
intent: Intent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(round) = self.sync(channel, intent, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading = true;
|
||||
cx.notify();
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
let result = round.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading = false;
|
||||
|
||||
if let Err(error) = result {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Run the newest round again after a relay failure.
|
||||
fn retry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.round(channel, Intent::CatchUp, window, cx);
|
||||
}
|
||||
|
||||
/// Read the newest page and fold it into what is on screen.
|
||||
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.resolve_channel(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(timeline) = self.read(channel, None, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
match timeline.await {
|
||||
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
|
||||
Err(error) => {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Splice the page of history above the oldest row on screen.
|
||||
fn load_older(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.loading || !self.has_more {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(before_ms) = self
|
||||
.rows
|
||||
.first()
|
||||
.map(|message| message.at_ms.saturating_sub(1))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(page) = self.read(channel, Some(before_ms), cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading = true;
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
let timeline = match page.await {
|
||||
Ok(timeline) => timeline,
|
||||
Err(error) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading = false;
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let swept = this.update(cx, |this, cx| {
|
||||
this.prepend(channel, timeline, cx);
|
||||
!this.has_more
|
||||
})?;
|
||||
|
||||
if !swept {
|
||||
this.update(cx, |this, _cx| this.loading = false)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let round = this.update(cx, |this, cx| {
|
||||
this.sync(
|
||||
channel,
|
||||
Intent::Older {
|
||||
pages: LOAD_OLDER_PAGES,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
let Some(round) = round else {
|
||||
this.update(cx, |this, _cx| this.loading = false)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match round.await {
|
||||
Ok(_) => {
|
||||
let page =
|
||||
this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?;
|
||||
|
||||
if let Some(page) = page {
|
||||
match page.await {
|
||||
Ok(timeline) => {
|
||||
this.update(cx, |this, cx| this.prepend(channel, timeline, cx))?
|
||||
}
|
||||
Err(error) => this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?,
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |this, _cx| this.loading = false)?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading = false;
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Fold a freshly read window into the rows on screen.
|
||||
fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
|
||||
if self.channel != Some(channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Timeline { messages, has_more } = timeline;
|
||||
|
||||
let connected = self
|
||||
.rows
|
||||
.last()
|
||||
.is_some_and(|last| messages.iter().any(|message| message.id == last.id));
|
||||
|
||||
if !connected {
|
||||
self.rows = messages;
|
||||
self.has_more = has_more;
|
||||
self.list_state.reset(self.item_count());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
self.merge(messages);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Splice older rows in above what is on screen.
|
||||
fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
|
||||
if self.channel != Some(channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.has_more = timeline.has_more;
|
||||
self.merge(timeline.messages);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Fold read rows into what is on screen, keeping the list in time order.
|
||||
fn merge(&mut self, messages: Vec<ChatMessage>) {
|
||||
let mut shown: HashMap<EventId, usize> = self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, message)| (message.id, ix))
|
||||
.collect();
|
||||
|
||||
let mut fresh = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
match shown.get(&message.id).copied() {
|
||||
Some(ix) => self.rows[ix] = message,
|
||||
None => fresh.push(message),
|
||||
}
|
||||
}
|
||||
|
||||
for message in fresh {
|
||||
if shown.contains_key(&message.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = self
|
||||
.rows
|
||||
.partition_point(|row| (row.at_ms, row.id) <= (message.at_ms, message.id));
|
||||
|
||||
shown.insert(message.id, at);
|
||||
self.rows.insert(at, message);
|
||||
// The welcome and load-older rows sit above the messages.
|
||||
self.list_state.splice(at + 2..at + 2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.input.read(cx).value().trim().to_owned();
|
||||
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(channel) = self.resolve_channel(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) {
|
||||
window.push_notification(Notification::error(notice.to_string()).autohide(false), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(send) = self.community.read_with(cx, |community, cx| {
|
||||
community.send(&channel, &content, None, cx)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(send) = send else {
|
||||
window.push_notification(Notification::error("Failed to send the message"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
match send.await {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// The honest reason this room has nothing to show, with a way out of it.
|
||||
fn render_notice(&self, notice: Notice, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(notice.to_string())
|
||||
.when(notice == Notice::Unreachable, |this| {
|
||||
this.child(
|
||||
Button::new("retry-round")
|
||||
.label("Retry")
|
||||
.ghost()
|
||||
.small()
|
||||
.loading(self.loading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The row at index 0: the welcome message for the channel.
|
||||
fn render_welcome(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let (name, avatar) = self
|
||||
.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
let seed = community.id().to_hex();
|
||||
let avatar = match community.icon() {
|
||||
Some(path) => Avatar::from_source(path).seed(seed).large(),
|
||||
None => Avatar::new(None).seed(seed).large(),
|
||||
};
|
||||
|
||||
(community.name(), avatar)
|
||||
})
|
||||
.unwrap_or_else(|_| (SharedString::from("this community"), Avatar::new(None)));
|
||||
|
||||
WelcomeMessage::new("welcome")
|
||||
.icon(avatar)
|
||||
.title(format!("Welcome to {}", name))
|
||||
.message(format!("This is the start of the {} channel.", name))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The row at index 1: the affordance that pages older history in.
|
||||
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if !self.has_more {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(
|
||||
Button::new("load-older")
|
||||
.label(if self.loading {
|
||||
"Loading earlier messages…"
|
||||
} else {
|
||||
"Load earlier messages"
|
||||
})
|
||||
.ghost()
|
||||
.small()
|
||||
.loading(self.loading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Whether the row at `index` opens a run from one author.
|
||||
fn opens_run(&self, index: usize) -> bool {
|
||||
let Some(current) = self.rows.get(index) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
current.author != previous.author
|
||||
|| current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if ix == 0 {
|
||||
return self.render_welcome(cx);
|
||||
}
|
||||
|
||||
if ix == 1 {
|
||||
return self.render_older(cx);
|
||||
}
|
||||
|
||||
// The welcome and load-older rows sit above the messages.
|
||||
let Some(message) = self.rows.get(ix - 2) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let show_author = self.opens_run(ix - 2);
|
||||
|
||||
message::render(ix, message, show_author, cx)
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let writable = self.notice(cx).is_none_or(|notice| notice.writable());
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.items_end()
|
||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.tooltip("Send")
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(!writable)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for CommunityPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
let seed = community.id().to_hex();
|
||||
let avatar = match community.icon() {
|
||||
Some(path) => Avatar::from_source(path).seed(seed).xsmall(),
|
||||
None => Avatar::new(None).seed(seed).xsmall(),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(avatar)
|
||||
.child(community.name())
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or_else(|_| div().text_xs().child("Unknown").into_any_element())
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for CommunityPanel {}
|
||||
|
||||
impl Focusable for CommunityPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommunityPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.map(|this| {
|
||||
let notice = self.notice(cx);
|
||||
|
||||
if self.rows.is_empty() {
|
||||
this.child(
|
||||
v_flex().size_full().justify_center().child(match notice {
|
||||
Some(notice) => self.render_notice(notice, cx),
|
||||
None => h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("No messages yet")
|
||||
.into_any_element(),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.when_some(notice, |this, notice| {
|
||||
this.child(self.render_notice(notice, cx))
|
||||
})
|
||||
.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
cx.processor(move |this, ix, window, cx| {
|
||||
this.render_message(ix, window, cx)
|
||||
}),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
.child(self.render_composer(cx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use common::TimestampExt;
|
||||
use community::ChatMessage;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, div};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::h_flex;
|
||||
use ui::message::MessageRow;
|
||||
|
||||
pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let author = persons.read(cx).get(&message.author, cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
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(Timestamp::from_secs(message.at_ms / 1000).to_human_time())
|
||||
.when(message.edited_at.is_some(), |this| {
|
||||
this.header_extra(div().child("(edited)"))
|
||||
})
|
||||
.child(content(message, cx))
|
||||
.when(!message.reactions.is_empty(), |this| {
|
||||
this.child(reactions(message, cx))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||
if message.deleted {
|
||||
return div()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child("Message deleted")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.child(SharedString::from(&message.content))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn reactions(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||
let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
|
||||
for emoji in message.reactions.values() {
|
||||
*grouped.entry(emoji.as_str()).or_default() += 1;
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.mt_1()
|
||||
.gap_1()
|
||||
.children(grouped.into_iter().map(|(emoji, count)| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.text_xs()
|
||||
.child(SharedString::from(emoji))
|
||||
.child(SharedString::from(count.to_string()))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[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
|
||||
chacha20.workspace = true
|
||||
hmac.workspace = true
|
||||
data-encoding.workspace = true
|
||||
base64.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
smol.workspace = true
|
||||
@@ -0,0 +1,723 @@
|
||||
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::{
|
||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
|
||||
FinalizeEventAsync, 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";
|
||||
pub(crate) const TAG_CHANNEL: &str = "channel";
|
||||
pub(crate) 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: seconds in `created_at`, the remainder as `["ms", 0..=999]`.
|
||||
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 => seal_bytes(group.conversation(), json.as_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
|
||||
check_plaintext_cap(plaintext.len())?;
|
||||
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
|
||||
}
|
||||
|
||||
pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u8>, StreamError> {
|
||||
let payload = BASE64
|
||||
.decode(content.as_bytes())
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||
|
||||
decrypt_to_bytes(conversation, &payload)
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
||||
}
|
||||
|
||||
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
|
||||
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
check_plaintext_cap(plaintext.len())?;
|
||||
|
||||
let address = signer
|
||||
.get_public_key_async()
|
||||
.await
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||
|
||||
signer
|
||||
.nip44_encrypt_async(&address, plaintext)
|
||||
.await
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let address = signer
|
||||
.get_public_key_async()
|
||||
.await
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||
|
||||
signer
|
||||
.nip44_decrypt_async(&address, content)
|
||||
.await
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn build_seal<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
form: SealForm,
|
||||
group: &GroupKey,
|
||||
author: &S,
|
||||
) -> Result<Event, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = seal_content(rumor, form, group)?;
|
||||
EventBuilder::new(Kind::Custom(form.kind()), content)
|
||||
.custom_created_at(rumor.created_at)
|
||||
.finalize_async(author)
|
||||
.await
|
||||
.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,
|
||||
read: &GroupKey,
|
||||
signer: &GroupKey,
|
||||
at: Timestamp,
|
||||
) -> Result<(Event, Keys), StreamError> {
|
||||
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
|
||||
return Err(StreamError::NotRewrappable);
|
||||
}
|
||||
|
||||
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
|
||||
}
|
||||
|
||||
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
|
||||
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
|
||||
}
|
||||
|
||||
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 plaintext = open_bytes(conversation, content)?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) 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 {
|
||||
smol::block_on(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), &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),
|
||||
&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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
|
||||
};
|
||||
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
|
||||
pub use crate::cords::rumor::RumorError as GuestbookError;
|
||||
use crate::cords::rumor::{optional_citation, pubkey, required, value};
|
||||
use crate::{GroupKey, decode_hex_32};
|
||||
|
||||
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||
pub const KIND_KICK: u16 = 3309;
|
||||
pub const KIND_SNAPSHOT: u16 = 3312;
|
||||
|
||||
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
|
||||
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
|
||||
|
||||
const TAG_INVITE: &str = "invite";
|
||||
const TAG_TARGET: &str = "p";
|
||||
const TAG_SNAP: &str = "snap";
|
||||
const TAG_CONTENT: &str = "content";
|
||||
const CONTENT_JOIN: &str = "join";
|
||||
const CONTENT_LEAVE: &str = "leave";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GuestbookEntry {
|
||||
Join {
|
||||
member: PublicKey,
|
||||
at_ms: u64,
|
||||
/// The `(creator, label)` an invite attributed the join to.
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GuestbookRumor {
|
||||
pub id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub kind: Kind,
|
||||
pub at_ms: u64,
|
||||
pub entry: GuestbookEntry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MemberState {
|
||||
Joined {
|
||||
at_ms: u64,
|
||||
invited_by: Option<(String, String)>,
|
||||
},
|
||||
Left {
|
||||
at_ms: u64,
|
||||
},
|
||||
Kicked {
|
||||
at_ms: u64,
|
||||
actor: PublicKey,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn build_join(
|
||||
member: PublicKey,
|
||||
invited_by: Option<(&str, &str)>,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
if let Some((creator, label)) = invited_by {
|
||||
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
|
||||
}
|
||||
|
||||
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
|
||||
}
|
||||
|
||||
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
|
||||
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
|
||||
}
|
||||
|
||||
pub fn build_kick(
|
||||
actor: PublicKey,
|
||||
target: &PublicKey,
|
||||
citation: Option<&AuthorityCitation>,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
|
||||
|
||||
if let Some(citation) = citation {
|
||||
tags.push(citation_tag(citation));
|
||||
}
|
||||
|
||||
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
|
||||
}
|
||||
|
||||
pub fn build_snapshot_chunks(
|
||||
refounder: PublicKey,
|
||||
members: &[PublicKey],
|
||||
snapshot_id: [u8; 32],
|
||||
at_ms: u64,
|
||||
) -> Vec<UnsignedEvent> {
|
||||
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
|
||||
let total = chunks.len() as u32;
|
||||
|
||||
chunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, chunk)| {
|
||||
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
|
||||
let content = format!(
|
||||
"[{}]",
|
||||
hex.iter()
|
||||
.map(|member| format!("\"{member}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
let tags = vec![Tag::custom(
|
||||
TAG_SNAP,
|
||||
[
|
||||
HEXLOWER.encode(&snapshot_id),
|
||||
(index as u32 + 1).to_string(),
|
||||
total.to_string(),
|
||||
],
|
||||
)];
|
||||
|
||||
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn seal_rumor<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
group: &GroupKey,
|
||||
author: &S,
|
||||
) -> Result<(Event, Keys), GuestbookError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if !is_guestbook_kind(kind) {
|
||||
return Err(GuestbookError::UnknownKind(kind));
|
||||
}
|
||||
|
||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
|
||||
|
||||
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
wrap: &Event,
|
||||
group: &GroupKey,
|
||||
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
|
||||
let opened = open_wrap(wrap, group)?;
|
||||
|
||||
if opened.seal_form != SealForm::Encrypted {
|
||||
return Err(GuestbookError::NotEncryptedSealed);
|
||||
}
|
||||
|
||||
let entry = entry_of(&opened)?;
|
||||
let rumor = GuestbookRumor {
|
||||
id: opened.rumor_id,
|
||||
author: opened.author,
|
||||
kind: opened.rumor.kind,
|
||||
at_ms: opened.at_ms,
|
||||
entry,
|
||||
};
|
||||
|
||||
Ok((opened, rumor))
|
||||
}
|
||||
|
||||
/// Coalesce the guestbook flat: one final state per npub, the latest entry
|
||||
/// winning by millisecond time, ties broken by the lower rumor id.
|
||||
///
|
||||
/// `snapshot_authorities` are the npubs whose refounding is known to have minted an epoch this client reads.
|
||||
/// A snapshot chunk is honored only from one of them, and an empty set honors no snapshot at all.
|
||||
pub fn coalesce(
|
||||
rumors: &[GuestbookRumor],
|
||||
now_ms: u64,
|
||||
snapshot_authorities: &BTreeSet<PublicKey>,
|
||||
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
|
||||
) -> BTreeMap<PublicKey, MemberState> {
|
||||
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
|
||||
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
|
||||
|
||||
for rumor in rumors {
|
||||
if rumor.at_ms > horizon {
|
||||
continue;
|
||||
}
|
||||
|
||||
match &rumor.entry {
|
||||
GuestbookEntry::Join {
|
||||
member,
|
||||
at_ms,
|
||||
invited_by,
|
||||
} => offer(
|
||||
&mut states,
|
||||
*member,
|
||||
*at_ms,
|
||||
rumor.id,
|
||||
MemberState::Joined {
|
||||
at_ms: *at_ms,
|
||||
invited_by: invited_by.clone(),
|
||||
},
|
||||
),
|
||||
GuestbookEntry::Leave { member, at_ms } => offer(
|
||||
&mut states,
|
||||
*member,
|
||||
*at_ms,
|
||||
rumor.id,
|
||||
MemberState::Left { at_ms: *at_ms },
|
||||
),
|
||||
GuestbookEntry::Kick {
|
||||
actor,
|
||||
target,
|
||||
at_ms,
|
||||
citation,
|
||||
} => {
|
||||
if !can_kick(actor, target, citation.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
offer(
|
||||
&mut states,
|
||||
*target,
|
||||
*at_ms,
|
||||
rumor.id,
|
||||
MemberState::Kicked {
|
||||
at_ms: *at_ms,
|
||||
actor: *actor,
|
||||
},
|
||||
);
|
||||
}
|
||||
GuestbookEntry::Snapshot {
|
||||
refounder,
|
||||
members,
|
||||
at_ms,
|
||||
..
|
||||
} => {
|
||||
if !snapshot_authorities.contains(refounder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for member in members {
|
||||
offer(
|
||||
&mut states,
|
||||
*member,
|
||||
*at_ms,
|
||||
rumor.id,
|
||||
MemberState::Joined {
|
||||
at_ms: *at_ms,
|
||||
invited_by: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
states
|
||||
.into_iter()
|
||||
.map(|(member, (_, _, state))| (member, state))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn complete_memberlist(
|
||||
coalesced: &BTreeMap<PublicKey, MemberState>,
|
||||
observed: &BTreeMap<PublicKey, u64>,
|
||||
granted: &BTreeSet<PublicKey>,
|
||||
banned: &BTreeSet<PublicKey>,
|
||||
banned_at: &BTreeMap<PublicKey, u64>,
|
||||
) -> BTreeSet<PublicKey> {
|
||||
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
|
||||
candidates.extend(observed.keys());
|
||||
candidates.extend(granted.iter());
|
||||
|
||||
let mut members = BTreeSet::new();
|
||||
|
||||
for member in candidates {
|
||||
let mut inclusion = observed.get(member).copied();
|
||||
|
||||
if let Some(state) = coalesced.get(member) {
|
||||
match state {
|
||||
MemberState::Joined { at_ms, .. } => {
|
||||
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
|
||||
}
|
||||
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
if inclusion.is_none() && granted.contains(member) {
|
||||
inclusion = Some(0);
|
||||
}
|
||||
|
||||
let mut exclusion = match coalesced.get(member) {
|
||||
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
|
||||
Some(*at_ms)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if banned.contains(member) {
|
||||
exclusion = Some(match banned_at.get(member) {
|
||||
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
|
||||
None => u64::MAX,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(inclusion) = inclusion
|
||||
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
|
||||
{
|
||||
members.insert(*member);
|
||||
}
|
||||
}
|
||||
|
||||
members
|
||||
}
|
||||
|
||||
fn offer(
|
||||
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
|
||||
member: PublicKey,
|
||||
at_ms: u64,
|
||||
id: EventId,
|
||||
state: MemberState,
|
||||
) {
|
||||
let candidate = (at_ms, Reverse(id));
|
||||
|
||||
if let Some(existing) = states.get(&member)
|
||||
&& (existing.0, existing.1) >= candidate
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
states.insert(member, (at_ms, Reverse(id), state));
|
||||
}
|
||||
|
||||
fn is_guestbook_kind(kind: u16) -> bool {
|
||||
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
|
||||
}
|
||||
|
||||
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
|
||||
let rumor = &opened.rumor;
|
||||
let author = opened.author;
|
||||
let at_ms = opened.at_ms;
|
||||
|
||||
match rumor.kind.as_u16() {
|
||||
KIND_JOIN_LEAVE => match rumor.content.as_str() {
|
||||
CONTENT_JOIN => Ok(GuestbookEntry::Join {
|
||||
member: author,
|
||||
at_ms,
|
||||
invited_by: invite_of(rumor),
|
||||
}),
|
||||
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
|
||||
member: author,
|
||||
at_ms,
|
||||
}),
|
||||
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
|
||||
},
|
||||
KIND_KICK => Ok(GuestbookEntry::Kick {
|
||||
actor: author,
|
||||
target: tagged_pubkey(rumor, TAG_TARGET)?,
|
||||
at_ms,
|
||||
citation: optional_citation(rumor)?,
|
||||
}),
|
||||
KIND_SNAPSHOT => {
|
||||
let (snapshot_id, chunk) = snapshot_of(rumor)?;
|
||||
let members = members_of(&rumor.content)?;
|
||||
|
||||
Ok(GuestbookEntry::Snapshot {
|
||||
refounder: author,
|
||||
members,
|
||||
snapshot_id,
|
||||
chunk,
|
||||
at_ms,
|
||||
})
|
||||
}
|
||||
other => Err(GuestbookError::UnknownKind(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
|
||||
rumor.tags.iter().find_map(|candidate| {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
(fields.len() >= 3 && fields[0] == TAG_INVITE)
|
||||
.then(|| (fields[1].clone(), fields[2].clone()))
|
||||
})
|
||||
}
|
||||
|
||||
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
|
||||
let entries: Vec<String> =
|
||||
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
|
||||
|
||||
if entries.len() > MAX_SNAPSHOT_CHUNK {
|
||||
return Err(GuestbookError::BadTag(TAG_SNAP));
|
||||
}
|
||||
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| pubkey(entry, TAG_CONTENT))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
|
||||
let fields = required(rumor, TAG_SNAP)?;
|
||||
|
||||
if fields.len() != 4 {
|
||||
return Err(GuestbookError::BadTag(TAG_SNAP));
|
||||
}
|
||||
|
||||
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
|
||||
let index = decimal(&fields[2])?;
|
||||
let total = decimal(&fields[3])?;
|
||||
|
||||
if index == 0 || index > total {
|
||||
return Err(GuestbookError::BadTag(TAG_SNAP));
|
||||
}
|
||||
|
||||
Ok((snapshot_id, (index, total)))
|
||||
}
|
||||
|
||||
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
|
||||
canonical_decimal(raw)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or(GuestbookError::BadTag(TAG_SNAP))
|
||||
}
|
||||
|
||||
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||
pubkey(value(required(rumor, name)?, name)?, name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cord01::{StreamError, build_rumor_secs};
|
||||
use crate::cord04::TAG_CITATION;
|
||||
use crate::derive::guestbook_group_key;
|
||||
use crate::{CommunityId, Epoch};
|
||||
|
||||
const ROOT: [u8; 32] = [0x5au8; 32];
|
||||
const AT: u64 = 1_700_000_000_000;
|
||||
|
||||
fn community() -> CommunityId {
|
||||
CommunityId::from_bytes([0x11u8; 32])
|
||||
}
|
||||
|
||||
/// The refounders a fold is told about: a snapshot seeds members on theirs alone.
|
||||
fn refounders(keys: &[&Keys]) -> BTreeSet<PublicKey> {
|
||||
keys.iter().map(|keys| keys.public_key()).collect()
|
||||
}
|
||||
|
||||
fn group() -> GroupKey {
|
||||
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
|
||||
}
|
||||
|
||||
fn citation() -> AuthorityCitation {
|
||||
AuthorityCitation {
|
||||
entity: [0x33u8; 32],
|
||||
version: 1,
|
||||
hash: [0x44u8; 32],
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
|
||||
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
|
||||
.expect("seals")
|
||||
.0;
|
||||
|
||||
open(&wrap, &group()).expect("opens").1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let carol = Keys::generate();
|
||||
let dave = Keys::generate();
|
||||
let frank = Keys::generate();
|
||||
let grace = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
|
||||
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
|
||||
|
||||
let mut rumors = vec![
|
||||
publish(
|
||||
&build_join(
|
||||
alice.public_key(),
|
||||
Some((&"ab".repeat(32), "Reddit")),
|
||||
AT + 1_000,
|
||||
),
|
||||
&alice,
|
||||
),
|
||||
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
|
||||
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
|
||||
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
|
||||
publish(
|
||||
&build_kick(
|
||||
carol.public_key(),
|
||||
&dave.public_key(),
|
||||
Some(&citation()),
|
||||
AT + 5_000,
|
||||
),
|
||||
&carol,
|
||||
),
|
||||
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
|
||||
];
|
||||
|
||||
let snapshot_id = "77".repeat(32);
|
||||
let chunks =
|
||||
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
|
||||
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
|
||||
for (index, chunk) in chunks.iter().enumerate() {
|
||||
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
|
||||
== [
|
||||
TAG_SNAP,
|
||||
snapshot_id.as_str(),
|
||||
&(index + 1).to_string(),
|
||||
"2"
|
||||
]));
|
||||
rumors.push(publish(chunk, &carol));
|
||||
}
|
||||
|
||||
let can_kick =
|
||||
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
|
||||
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
|
||||
};
|
||||
|
||||
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
|
||||
|
||||
assert_eq!(
|
||||
states.get(&alice.public_key()),
|
||||
Some(&MemberState::Joined {
|
||||
at_ms: AT + 1_000,
|
||||
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
states.get(&bob.public_key()),
|
||||
Some(&MemberState::Left { at_ms: AT + 3_000 })
|
||||
);
|
||||
assert_eq!(
|
||||
states.get(&dave.public_key()),
|
||||
Some(&MemberState::Kicked {
|
||||
at_ms: AT + 5_000,
|
||||
actor: carol.public_key(),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
survivors
|
||||
.iter()
|
||||
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
|
||||
"every chunk seeds its own members"
|
||||
);
|
||||
|
||||
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
|
||||
assert_eq!(
|
||||
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
|
||||
states,
|
||||
"arrival order cannot change the fold"
|
||||
);
|
||||
|
||||
let observed = BTreeMap::from([
|
||||
(bob.public_key(), AT + 9_000),
|
||||
(carol.public_key(), AT + 5_000),
|
||||
]);
|
||||
let granted = BTreeSet::from([grace.public_key()]);
|
||||
let banned = BTreeSet::from([frank.public_key()]);
|
||||
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
|
||||
|
||||
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
|
||||
|
||||
let mut expected = BTreeSet::from([
|
||||
alice.public_key(),
|
||||
bob.public_key(),
|
||||
carol.public_key(),
|
||||
grace.public_key(),
|
||||
]);
|
||||
expected.extend(survivors.iter().copied());
|
||||
|
||||
assert_eq!(members, expected);
|
||||
assert!(
|
||||
!members.contains(&dave.public_key()),
|
||||
"a kicked member is out"
|
||||
);
|
||||
assert!(
|
||||
!members.contains(&frank.public_key()),
|
||||
"a ban wins over a later join"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kick_or_snapshot_without_authority_is_dropped() {
|
||||
let moderator = Keys::generate();
|
||||
let outsider = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let kicked = Keys::generate();
|
||||
let uncited = Keys::generate();
|
||||
let unranked = Keys::generate();
|
||||
let refounder = Keys::generate();
|
||||
let impostor = Keys::generate();
|
||||
let seeded = Keys::generate();
|
||||
let smuggled = Keys::generate();
|
||||
|
||||
let can_kick = |actor: &PublicKey,
|
||||
target: &PublicKey,
|
||||
citation: Option<&AuthorityCitation>| {
|
||||
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
|
||||
};
|
||||
|
||||
let rumors = vec![
|
||||
publish(
|
||||
&build_kick(
|
||||
moderator.public_key(),
|
||||
&kicked.public_key(),
|
||||
Some(&citation()),
|
||||
AT,
|
||||
),
|
||||
&moderator,
|
||||
),
|
||||
publish(
|
||||
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
|
||||
&moderator,
|
||||
),
|
||||
publish(
|
||||
&build_kick(
|
||||
outsider.public_key(),
|
||||
&unranked.public_key(),
|
||||
Some(&citation()),
|
||||
AT,
|
||||
),
|
||||
&outsider,
|
||||
),
|
||||
publish(
|
||||
&build_kick(
|
||||
moderator.public_key(),
|
||||
&owner.public_key(),
|
||||
Some(&citation()),
|
||||
AT,
|
||||
),
|
||||
&moderator,
|
||||
),
|
||||
];
|
||||
|
||||
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
|
||||
|
||||
assert_eq!(
|
||||
states.get(&kicked.public_key()),
|
||||
Some(&MemberState::Kicked {
|
||||
at_ms: AT,
|
||||
actor: moderator.public_key(),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!states.contains_key(&uncited.public_key()),
|
||||
"a kick cites the Grant it acts under"
|
||||
);
|
||||
assert!(
|
||||
!states.contains_key(&unranked.public_key()),
|
||||
"a kick needs KICK"
|
||||
);
|
||||
assert!(
|
||||
!states.contains_key(&owner.public_key()),
|
||||
"nobody kicks the owner"
|
||||
);
|
||||
|
||||
let by_refounder = build_snapshot_chunks(
|
||||
refounder.public_key(),
|
||||
&[seeded.public_key()],
|
||||
[0x77u8; 32],
|
||||
AT,
|
||||
)
|
||||
.remove(0);
|
||||
let by_impostor = build_snapshot_chunks(
|
||||
impostor.public_key(),
|
||||
&[smuggled.public_key()],
|
||||
[0x88u8; 32],
|
||||
AT,
|
||||
)
|
||||
.remove(0);
|
||||
|
||||
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
|
||||
let states = coalesce(
|
||||
&[
|
||||
publish(&by_refounder, &refounder),
|
||||
publish(&by_impostor, &impostor),
|
||||
],
|
||||
AT + 1_000,
|
||||
&authority,
|
||||
|_, _, _| true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
states.contains_key(&seeded.public_key()),
|
||||
!authority.is_empty(),
|
||||
"only a known refounder seeds, and there is no owner fallback"
|
||||
);
|
||||
assert!(
|
||||
!states.contains_key(&smuggled.public_key()),
|
||||
"a foreign snapshot never seeds"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
|
||||
let member = Keys::generate();
|
||||
let moderator = Keys::generate();
|
||||
let target = Keys::generate();
|
||||
|
||||
let future = publish(
|
||||
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
|
||||
&member,
|
||||
);
|
||||
let horizon = publish(
|
||||
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
|
||||
&member,
|
||||
);
|
||||
assert!(
|
||||
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
|
||||
"an entry more than an hour ahead is dropped"
|
||||
);
|
||||
assert_eq!(
|
||||
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
|
||||
1,
|
||||
"the horizon itself is skew, not forgery"
|
||||
);
|
||||
|
||||
let bad_ms = build_rumor_secs(
|
||||
KIND_JOIN_LEAVE,
|
||||
member.public_key(),
|
||||
CONTENT_JOIN,
|
||||
vec![Tag::custom("ms", ["1000"])],
|
||||
AT / 1000,
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::Stream(StreamError::BadMs))
|
||||
));
|
||||
|
||||
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::BadTag(TAG_CONTENT))
|
||||
));
|
||||
|
||||
let ambiguous = build_rumor_ms(
|
||||
KIND_KICK,
|
||||
moderator.public_key(),
|
||||
"",
|
||||
vec![
|
||||
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
|
||||
citation_tag(&citation()),
|
||||
citation_tag(&citation()),
|
||||
],
|
||||
AT,
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::DuplicateTag(TAG_CITATION))
|
||||
));
|
||||
|
||||
for fields in [
|
||||
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
|
||||
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
|
||||
vec![snapshot_id(), "1".to_owned()],
|
||||
] {
|
||||
let rumor = build_rumor_ms(
|
||||
KIND_SNAPSHOT,
|
||||
moderator.public_key(),
|
||||
"[]",
|
||||
vec![Tag::custom(TAG_SNAP, fields)],
|
||||
AT,
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::BadTag(TAG_SNAP))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_id() -> String {
|
||||
"ab".repeat(32)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
pub mod pins;
|
||||
pub mod roles;
|
||||
|
||||
use std::cmp::Reverse;
|
||||
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::cord01::build_rumor_secs;
|
||||
use crate::decode_hex_32;
|
||||
|
||||
pub const KIND_CONTROL: u16 = 3308;
|
||||
|
||||
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
|
||||
|
||||
/// Entity types an edition can address.
|
||||
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";
|
||||
}
|
||||
|
||||
pub const TAG_SUBKIND: &str = "vsk";
|
||||
pub const TAG_CITATION: &str = "vac";
|
||||
|
||||
const TAG_ENTITY: &str = "eid";
|
||||
const TAG_VERSION: &str = "ev";
|
||||
const TAG_PREV: &str = "ep";
|
||||
|
||||
#[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`: the Grant edition an actor claims rank under, pinned by coordinate, version and hash.
|
||||
#[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
|
||||
}
|
||||
|
||||
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 citation_tag(citation: &AuthorityCitation) -> Tag {
|
||||
Tag::custom(
|
||||
TAG_CITATION,
|
||||
[
|
||||
HEXLOWER.encode(&citation.entity),
|
||||
citation.version.to_string(),
|
||||
HEXLOWER.encode(&citation.hash),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
|
||||
if fields.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AuthorityCitation {
|
||||
entity: hex32(&fields[1], TAG_CITATION).ok()?,
|
||||
version: canonical_decimal(&fields[2])?,
|
||||
hash: hex32(&fields[3], TAG_CITATION).ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
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(citation_tag(&citation));
|
||||
}
|
||||
|
||||
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) => Some(citation_from(fields).ok_or(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)]
|
||||
struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// The highest version whose chain is intact, given a held floor.
|
||||
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.
|
||||
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, edition)| (Reverse(edition.version), edition.tiebreak_id))
|
||||
.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>;
|
||||
|
||||
pub(crate) 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, so pin the flag.
|
||||
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
||||
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
use std::fmt;
|
||||
|
||||
use chacha20::ChaCha20;
|
||||
use chacha20::cipher::{KeyIvInit, StreamCipher};
|
||||
use data_encoding::{BASE64, HEXLOWER};
|
||||
use hkdf::Hkdf;
|
||||
use hmac::{Hmac, Mac};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::cord01::{self, OpenedStream, SealForm, resolve_ms_strict};
|
||||
use crate::cord03::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
|
||||
use crate::cord04::canonical_decimal;
|
||||
use crate::{ChannelId, Epoch, Extra, GroupKey, decode_hex_lower};
|
||||
|
||||
pub const PIN_MAX_ENTRIES: usize = 25;
|
||||
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
|
||||
|
||||
/// The serialized disclosure: `chacha_key[32] || chacha_nonce[12] || hmac_key[32]`.
|
||||
pub const MESSAGE_KEYS_BYTES: usize = 76;
|
||||
|
||||
const TAG_CHANNEL: &str = "channel";
|
||||
const TAG_EPOCH: &str = "epoch";
|
||||
const TAG_TARGET: &str = "e";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PinError {
|
||||
NotEncryptedSeal,
|
||||
BadPayload,
|
||||
Unverifiable,
|
||||
Unreadable,
|
||||
TooManyEntries,
|
||||
Oversize(usize),
|
||||
Seal(String),
|
||||
Encode(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for PinError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PinError::NotEncryptedSeal => write!(f, "pin requires an encrypted seal"),
|
||||
PinError::BadPayload => write!(f, "the seal payload does not open"),
|
||||
PinError::Unverifiable => write!(f, "the entry would not verify"),
|
||||
PinError::Unreadable => {
|
||||
write!(f, "refusing to publish a pin list this client cannot read")
|
||||
}
|
||||
PinError::TooManyEntries => write!(f, "pin list exceeds {PIN_MAX_ENTRIES} entries"),
|
||||
PinError::Oversize(len) => {
|
||||
write!(
|
||||
f,
|
||||
"pin list content is {len} bytes (cap {PIN_MAX_CONTENT_BYTES})"
|
||||
)
|
||||
}
|
||||
PinError::Seal(error) => write!(f, "seal: {error}"),
|
||||
PinError::Encode(error) => write!(f, "encode: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PinError {}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MessageKeys {
|
||||
chacha_key: [u8; 32],
|
||||
chacha_nonce: [u8; 12],
|
||||
hmac_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl MessageKeys {
|
||||
pub fn to_hex(&self) -> String {
|
||||
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
|
||||
packed[0..32].copy_from_slice(&self.chacha_key);
|
||||
packed[32..44].copy_from_slice(&self.chacha_nonce);
|
||||
packed[44..76].copy_from_slice(&self.hmac_key);
|
||||
HEXLOWER.encode(&packed)
|
||||
}
|
||||
|
||||
pub fn from_hex(value: &str) -> Option<Self> {
|
||||
let bytes = decode_hex_lower::<MESSAGE_KEYS_BYTES>(value).ok()?;
|
||||
|
||||
Some(Self {
|
||||
chacha_key: bytes[0..32].try_into().ok()?,
|
||||
chacha_nonce: bytes[32..44].try_into().ok()?,
|
||||
hmac_key: bytes[44..76].try_into().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn derive(conversation_key: &[u8; 32], nonce: &[u8]) -> Option<Self> {
|
||||
let hkdf = Hkdf::<Sha256>::from_prk(conversation_key).ok()?;
|
||||
let mut key_material = [0u8; MESSAGE_KEYS_BYTES];
|
||||
hkdf.expand(nonce, &mut key_material).ok()?;
|
||||
|
||||
Some(Self {
|
||||
chacha_key: key_material[0..32].try_into().ok()?,
|
||||
chacha_nonce: key_material[32..44].try_into().ok()?,
|
||||
hmac_key: key_material[44..76].try_into().ok()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Payload {
|
||||
nonce: [u8; 32],
|
||||
ciphertext: Vec<u8>,
|
||||
mac: [u8; 32],
|
||||
}
|
||||
|
||||
fn decode_payload(payload: &str) -> Option<Payload> {
|
||||
let data = BASE64.decode(payload.as_bytes()).ok()?;
|
||||
|
||||
if data.len() < 99 || data[0] != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mac_at = data.len() - 32;
|
||||
|
||||
Some(Payload {
|
||||
nonce: data[1..33].try_into().ok()?,
|
||||
ciphertext: data[33..mac_at].to_vec(),
|
||||
mac: data[mac_at..].try_into().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn disclose_keys(payload: &str, conversation_key: &[u8; 32]) -> Option<MessageKeys> {
|
||||
let decoded = decode_payload(payload)?;
|
||||
MessageKeys::derive(conversation_key, &decoded.nonce)
|
||||
}
|
||||
|
||||
fn open_payload(payload: &str, keys: &MessageKeys) -> Option<String> {
|
||||
let decoded = decode_payload(payload)?;
|
||||
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&keys.hmac_key).ok()?;
|
||||
mac.update(&decoded.nonce);
|
||||
mac.update(&decoded.ciphertext);
|
||||
mac.verify_slice(&decoded.mac).ok()?;
|
||||
|
||||
let mut padded = decoded.ciphertext;
|
||||
let mut cipher = ChaCha20::new((&keys.chacha_key).into(), (&keys.chacha_nonce).into());
|
||||
cipher.apply_keystream(&mut padded);
|
||||
|
||||
unpad(&padded)
|
||||
}
|
||||
|
||||
fn unpad(padded: &[u8]) -> Option<String> {
|
||||
let (len, prefix) = plaintext_length(padded)?;
|
||||
let unpadded = padded.get(prefix..prefix.checked_add(len)?)?;
|
||||
|
||||
if len < 1 || padded.len() != prefix.checked_add(padded_len(len)?)? {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(unpadded.to_vec()).ok()
|
||||
}
|
||||
|
||||
fn plaintext_length(padded: &[u8]) -> Option<(usize, usize)> {
|
||||
let short = u16::from_be_bytes(padded.get(..2)?.try_into().ok()?);
|
||||
|
||||
if short != 0 {
|
||||
return Some((short as usize, 2));
|
||||
}
|
||||
|
||||
let long = u32::from_be_bytes(padded.get(2..6)?.try_into().ok()?);
|
||||
|
||||
if long < 65_536 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((long as usize, 6))
|
||||
}
|
||||
|
||||
fn padded_len(len: usize) -> Option<usize> {
|
||||
if len < 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if len <= 32 {
|
||||
return Some(32);
|
||||
}
|
||||
|
||||
let next_power = 1usize.checked_shl(usize::BITS - (len - 1).leading_zeros())?;
|
||||
let chunk = if next_power <= 256 {
|
||||
32
|
||||
} else {
|
||||
next_power / 8
|
||||
};
|
||||
|
||||
Some(chunk * ((len - 1) / chunk + 1))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PinEditBundle {
|
||||
pub seal: Event,
|
||||
pub keys: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PinEntry {
|
||||
pub seal: Event,
|
||||
pub keys: String,
|
||||
/// An unverifiable locator hint; a mismatch is expected and never fatal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wrap: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub edit: Option<PinEditBundle>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EditedContent {
|
||||
pub content: String,
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedPin {
|
||||
pub rumor_id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub kind: u16,
|
||||
pub content: String,
|
||||
pub tags: Tags,
|
||||
pub epoch: Epoch,
|
||||
pub at_ms: u64,
|
||||
pub created_at: u64,
|
||||
pub wrap: Option<String>,
|
||||
pub edited: Option<EditedContent>,
|
||||
pub entry: PinEntry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReadPinList {
|
||||
pub entries: Vec<PinEntry>,
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
pub fn build_entry(
|
||||
opened: &OpenedStream,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
) -> Result<PinEntry, PinError> {
|
||||
let keys = disclosed_keys(opened, group)?;
|
||||
|
||||
let entry = PinEntry {
|
||||
seal: opened.seal.clone(),
|
||||
keys: keys.to_hex(),
|
||||
wrap: Some(opened.wrapper_id.to_hex()),
|
||||
edit: None,
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
if verify_entry(&entry, channel).is_none() {
|
||||
return Err(PinError::Unverifiable);
|
||||
}
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
pub fn build_edit_bundle(
|
||||
edit: &OpenedStream,
|
||||
group: &GroupKey,
|
||||
original: &VerifiedPin,
|
||||
channel: &ChannelId,
|
||||
) -> Result<PinEditBundle, PinError> {
|
||||
let bundle = PinEditBundle {
|
||||
seal: edit.seal.clone(),
|
||||
keys: disclosed_keys(edit, group)?.to_hex(),
|
||||
};
|
||||
|
||||
if verify_edit_bundle(&bundle, &original.author, &original.rumor_id, channel).is_none() {
|
||||
return Err(PinError::Unverifiable);
|
||||
}
|
||||
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
pub fn with_proven_edit(
|
||||
entry: &PinEntry,
|
||||
edit: &OpenedStream,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
) -> PinEntry {
|
||||
let Some(original) = verify_entry(entry, channel) else {
|
||||
return entry.clone();
|
||||
};
|
||||
|
||||
let Ok(bundle) = build_edit_bundle(edit, group, &original, channel) else {
|
||||
return entry.clone();
|
||||
};
|
||||
|
||||
let mut refreshed = entry.clone();
|
||||
refreshed.edit = Some(bundle);
|
||||
refreshed
|
||||
}
|
||||
|
||||
fn disclosed_keys(opened: &OpenedStream, group: &GroupKey) -> Result<MessageKeys, PinError> {
|
||||
if opened.seal_form != SealForm::Encrypted {
|
||||
return Err(PinError::NotEncryptedSeal);
|
||||
}
|
||||
|
||||
let conversation: [u8; 32] = group
|
||||
.conversation()
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| PinError::BadPayload)?;
|
||||
|
||||
let keys = disclose_keys(&opened.seal.content, &conversation).ok_or(PinError::BadPayload)?;
|
||||
|
||||
if open_payload(&opened.seal.content, &keys).is_none() {
|
||||
return Err(PinError::BadPayload);
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin> {
|
||||
let seal = &entry.seal;
|
||||
|
||||
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let keys = MessageKeys::from_hex(&entry.keys)?;
|
||||
let plaintext = open_payload(&seal.content, &keys)?;
|
||||
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
|
||||
|
||||
// NIP-59's impersonation check: the renderer shows the rumor's fields.
|
||||
if rumor.pubkey != seal.pubkey {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if kind != KIND_MESSAGE && kind != KIND_COMMENT {
|
||||
return None;
|
||||
}
|
||||
|
||||
// CORD-01's binding, restated: a keyholder must not pin a message into another Channel's list.
|
||||
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
|
||||
|
||||
// Recomputed from the decrypted bytes; a claimed `id` is never trusted.
|
||||
rumor.verify_id().ok()?;
|
||||
let rumor_id = rumor.compute_id();
|
||||
|
||||
let edited = entry
|
||||
.edit
|
||||
.as_ref()
|
||||
.and_then(|bundle| verify_edit_bundle(bundle, &rumor.pubkey, &rumor_id, channel));
|
||||
|
||||
Some(VerifiedPin {
|
||||
author: rumor.pubkey,
|
||||
content: edited
|
||||
.as_ref()
|
||||
.map_or_else(|| rumor.content.clone(), |edited| edited.content.clone()),
|
||||
epoch,
|
||||
at_ms: resolve_ms_strict(&rumor).ok()?,
|
||||
created_at: rumor.created_at.as_secs(),
|
||||
tags: rumor.tags.clone(),
|
||||
wrap: entry.wrap.clone(),
|
||||
edited,
|
||||
entry: entry.clone(),
|
||||
kind,
|
||||
rumor_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_edit_bundle(
|
||||
bundle: &PinEditBundle,
|
||||
original_author: &PublicKey,
|
||||
original_id: &EventId,
|
||||
channel: &ChannelId,
|
||||
) -> Option<EditedContent> {
|
||||
let seal = &bundle.seal;
|
||||
|
||||
// Checkable before any crypto: nobody else may revise another member's words.
|
||||
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
|
||||
return None;
|
||||
}
|
||||
|
||||
if seal.verify().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let keys = MessageKeys::from_hex(&bundle.keys)?;
|
||||
let plaintext = open_payload(&seal.content, &keys)?;
|
||||
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
|
||||
|
||||
if rumor.pubkey != seal.pubkey || rumor.kind.as_u16() != KIND_EDIT {
|
||||
return None;
|
||||
}
|
||||
|
||||
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if tag_value(&rumor, TAG_TARGET)? != original_id.to_hex() {
|
||||
return None;
|
||||
}
|
||||
|
||||
rumor.verify_id().ok()?;
|
||||
|
||||
Some(EditedContent {
|
||||
content: rumor.content.clone(),
|
||||
at_ms: resolve_ms_strict(&rumor).ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn tag_value<'a>(rumor: &'a UnsignedEvent, name: &str) -> Option<&'a str> {
|
||||
rumor
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.as_slice().first().map(String::as_str) == Some(name))
|
||||
.and_then(|tag| tag.as_slice().get(1))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PlainForm {
|
||||
entries: Vec<PinEntry>,
|
||||
}
|
||||
|
||||
pub fn publishable(
|
||||
read: &ReadPinList,
|
||||
private: bool,
|
||||
group: &GroupKey,
|
||||
epoch: Epoch,
|
||||
) -> Result<String, PinError> {
|
||||
if read.sealed {
|
||||
return Err(PinError::Unreadable);
|
||||
}
|
||||
|
||||
if private {
|
||||
serialize_sealed(&read.entries, group, epoch)
|
||||
} else {
|
||||
serialize_public(&read.entries)
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_public(entries: &[PinEntry]) -> Result<String, PinError> {
|
||||
let content = encode_form(entries)?;
|
||||
check_caps(entries.len(), &content)?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn serialize_sealed(
|
||||
entries: &[PinEntry],
|
||||
group: &GroupKey,
|
||||
epoch: Epoch,
|
||||
) -> Result<String, PinError> {
|
||||
if entries.len() > PIN_MAX_ENTRIES {
|
||||
return Err(PinError::TooManyEntries);
|
||||
}
|
||||
|
||||
let inner = encode_form(entries)?;
|
||||
let sealed = cord01::seal_bytes(group.conversation(), inner.as_bytes())
|
||||
.map_err(|error| PinError::Seal(error.to_string()))?;
|
||||
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
|
||||
|
||||
check_caps(entries.len(), &content)?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn encode_form(entries: &[PinEntry]) -> Result<String, PinError> {
|
||||
serde_json::to_string(&PlainForm {
|
||||
entries: entries.to_vec(),
|
||||
})
|
||||
.map_err(|error| PinError::Encode(error.to_string()))
|
||||
}
|
||||
|
||||
fn check_caps(count: usize, content: &str) -> Result<(), PinError> {
|
||||
if count > PIN_MAX_ENTRIES {
|
||||
return Err(PinError::TooManyEntries);
|
||||
}
|
||||
|
||||
if content.len() > PIN_MAX_CONTENT_BYTES {
|
||||
return Err(PinError::Oversize(content.len()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> ReadPinList {
|
||||
const EMPTY: ReadPinList = ReadPinList {
|
||||
entries: Vec::new(),
|
||||
sealed: false,
|
||||
};
|
||||
|
||||
if content.len() > PIN_MAX_CONTENT_BYTES {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
if value.get("entries").is_some() {
|
||||
return match serde_json::from_value::<PlainForm>(value) {
|
||||
Ok(form) if form.entries.len() <= PIN_MAX_ENTRIES => ReadPinList {
|
||||
entries: form.entries,
|
||||
sealed: false,
|
||||
},
|
||||
_ => EMPTY,
|
||||
};
|
||||
}
|
||||
|
||||
let (Some(epoch), Some(sealed)) = (
|
||||
value.get("epoch").and_then(serde_json::Value::as_str),
|
||||
value.get("sealed").and_then(serde_json::Value::as_str),
|
||||
) else {
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
let Some(epoch) = canonical_decimal(epoch) else {
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
let Some(group) = unseal(Epoch(epoch)) else {
|
||||
return ReadPinList {
|
||||
sealed: true,
|
||||
..EMPTY
|
||||
};
|
||||
};
|
||||
|
||||
let Ok(inner) = cord01::open_bytes(group.conversation(), sealed) else {
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
if form.entries.len() > PIN_MAX_ENTRIES {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
ReadPinList {
|
||||
entries: form.entries,
|
||||
sealed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn killed_by(pin: &VerifiedPin, delete: &ChatRumor) -> bool {
|
||||
delete.author == pin.author
|
||||
&& matches!(&delete.action, ChatAction::Delete { target, .. } if *target == pin.rumor_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr::nips::nip44::v2::{self, ConversationKey};
|
||||
|
||||
use super::*;
|
||||
use crate::cord03::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
|
||||
use crate::derive::channel_group_key;
|
||||
|
||||
const AT_MS: u64 = 1_700_000_000_000;
|
||||
const SECRET: [u8; 32] = [0x21u8; 32];
|
||||
|
||||
fn channel() -> ChannelId {
|
||||
ChannelId::from_bytes([0xabu8; 32])
|
||||
}
|
||||
|
||||
fn group() -> GroupKey {
|
||||
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
|
||||
}
|
||||
|
||||
fn conversation() -> ConversationKey {
|
||||
*group().conversation()
|
||||
}
|
||||
|
||||
/// A real message through the production seal/open pipeline, as a pinner sees it.
|
||||
fn sealed_message(author: &Keys, text: &str, at_ms: u64) -> (OpenedStream, ChatRumor) {
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
text,
|
||||
None,
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
|
||||
|
||||
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
|
||||
}
|
||||
|
||||
fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
|
||||
let (opened, _) = sealed_message(author, text, AT_MS);
|
||||
let entry = build_entry(&opened, &group(), &channel()).expect("builds");
|
||||
(entry, opened)
|
||||
}
|
||||
|
||||
fn some(entries: Vec<PinEntry>) -> ReadPinList {
|
||||
ReadPinList {
|
||||
entries,
|
||||
sealed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The load-bearing primitive: the reproduction must open what nostr's own
|
||||
/// encryption produced, through the disclosure alone.
|
||||
#[test]
|
||||
fn a_disclosure_opens_its_message_and_nothing_else() {
|
||||
let nonce = [0x5au8; 32];
|
||||
let disclosure =
|
||||
MessageKeys::derive(conversation().as_bytes().try_into().expect("32"), &nonce)
|
||||
.expect("derives");
|
||||
|
||||
for text in ["a", "hello world", &"padding boundary ".repeat(40)] {
|
||||
let raw = v2::encrypt_to_bytes_with_nonce(&conversation(), text.as_bytes(), nonce)
|
||||
.expect("encrypts");
|
||||
let payload = BASE64.encode(&raw);
|
||||
|
||||
assert_eq!(open_payload(&payload, &disclosure).as_deref(), Some(text));
|
||||
}
|
||||
|
||||
// Another nonce discloses different keys, which open nothing else.
|
||||
let other = v2::encrypt_to_bytes_with_nonce(&conversation(), b"second", [0x99u8; 32])
|
||||
.expect("encrypts");
|
||||
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
|
||||
|
||||
let hex = disclosure.to_hex();
|
||||
assert_eq!(
|
||||
MessageKeys::from_hex(&hex).map(|keys| keys.to_hex()),
|
||||
Some(hex.clone())
|
||||
);
|
||||
assert!(MessageKeys::from_hex(&hex.to_uppercase()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_built_entry_proves_its_author_and_cannot_cross_channels() {
|
||||
let author = Keys::generate();
|
||||
let (entry, opened) = entry_for(&author, "pin me");
|
||||
let verified = verify_entry(&entry, &channel()).expect("verifies");
|
||||
|
||||
assert_eq!(verified.author, author.public_key());
|
||||
assert_eq!(verified.content, "pin me");
|
||||
assert_eq!(verified.rumor_id, opened.rumor_id);
|
||||
assert_eq!(verified.at_ms, AT_MS);
|
||||
assert_eq!(verified.epoch, Epoch(0));
|
||||
|
||||
// A keyholder must not be able to pin channel X's message into Y's list.
|
||||
let foreign = ChannelId::from_bytes([0xcdu8; 32]);
|
||||
assert!(verify_entry(&entry, &foreign).is_none());
|
||||
|
||||
// Tampered keys and a re-signed seal both fail.
|
||||
let mut bad_keys = entry.clone();
|
||||
bad_keys.keys = format!("00{}", &entry.keys[2..]);
|
||||
assert!(verify_entry(&bad_keys, &channel()).is_none());
|
||||
|
||||
let mut forged = entry.clone();
|
||||
forged.seal.pubkey = Keys::generate().public_key();
|
||||
assert!(verify_entry(&forged, &channel()).is_none());
|
||||
|
||||
// A rumor carrying a claimed id that is not its own is refused.
|
||||
let plaintext = cord01::open_bytes(&conversation(), &opened.seal.content).expect("opens");
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&plaintext).expect("json");
|
||||
value["id"] = serde_json::Value::String("00".repeat(32));
|
||||
|
||||
let raw = v2::encrypt_to_bytes_with_nonce(
|
||||
&conversation(),
|
||||
value.to_string().as_bytes(),
|
||||
[0x11u8; 32],
|
||||
)
|
||||
.expect("encrypts");
|
||||
let content = BASE64.encode(&raw);
|
||||
let seal = EventBuilder::new(Kind::Custom(cord01::KIND_SEAL_ENCRYPTED), &content)
|
||||
.custom_created_at(opened.seal.created_at)
|
||||
.finalize(&author)
|
||||
.expect("signs");
|
||||
|
||||
let lying = PinEntry {
|
||||
keys: disclose_keys(&content, conversation().as_bytes().try_into().expect("32"))
|
||||
.expect("discloses")
|
||||
.to_hex(),
|
||||
seal,
|
||||
wrap: None,
|
||||
edit: None,
|
||||
extra: Extra::default(),
|
||||
};
|
||||
assert!(verify_entry(&lying, &channel()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proven_edit_replaces_the_words_and_a_stranger_cannot_revise() {
|
||||
let author = Keys::generate();
|
||||
let (entry, original) = entry_for(&author, "teh typo");
|
||||
|
||||
let edit = build_edit(
|
||||
author.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
original.rumor_id,
|
||||
"the typo, fixed",
|
||||
AT_MS + 5_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
|
||||
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||
|
||||
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
|
||||
let verified = verify_entry(&refreshed, &channel()).expect("verifies");
|
||||
assert_eq!(verified.content, "the typo, fixed");
|
||||
assert_eq!(verified.edited.expect("edited").at_ms, AT_MS + 5_000);
|
||||
|
||||
// A stranger's edit of the same message never attaches.
|
||||
let stranger = Keys::generate();
|
||||
let hijack = build_edit(
|
||||
stranger.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
original.rumor_id,
|
||||
"hijacked",
|
||||
AT_MS + 6_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) =
|
||||
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
|
||||
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||
|
||||
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
|
||||
assert!(unchanged.edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_list_forms_round_trip_and_obey_their_caps() {
|
||||
let author = Keys::generate();
|
||||
let (entry, _) = entry_for(&author, "hello");
|
||||
|
||||
let public =
|
||||
publishable(&some(vec![entry.clone()]), false, &group(), Epoch(0)).expect("publishes");
|
||||
let read = read_list(&public, |_| None);
|
||||
assert!(!read.sealed);
|
||||
assert_eq!(read.entries.len(), 1);
|
||||
assert!(verify_entry(&read.entries[0], &channel()).is_some());
|
||||
|
||||
// A sealed list stays dark without its key, lights with it, and a wrong
|
||||
// key reads empty rather than panicking.
|
||||
let at_epoch_4 = channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives");
|
||||
let sealed = publishable(&some(vec![entry.clone()]), true, &at_epoch_4, Epoch(4))
|
||||
.expect("publishes");
|
||||
|
||||
let dark = read_list(&sealed, |_| None);
|
||||
assert!(dark.sealed && dark.entries.is_empty());
|
||||
|
||||
let lit = read_list(&sealed, |epoch| {
|
||||
(epoch == Epoch(4))
|
||||
.then(|| channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives"))
|
||||
});
|
||||
assert!(!lit.sealed);
|
||||
assert!(verify_entry(&lit.entries[0], &channel()).is_some());
|
||||
|
||||
assert!(read_list(&sealed, |_| Some(group())).entries.is_empty());
|
||||
|
||||
// 26 entries: the writer refuses, and a hand-built violating edition
|
||||
// reads as empty rather than forking the chain.
|
||||
let many = vec![entry; PIN_MAX_ENTRIES + 1];
|
||||
assert_eq!(
|
||||
publishable(&some(many.clone()), false, &group(), Epoch(0)),
|
||||
Err(PinError::TooManyEntries)
|
||||
);
|
||||
let violating = serde_json::json!({ "entries": many }).to_string();
|
||||
assert!(read_list(&violating, |_| None).entries.is_empty());
|
||||
|
||||
// Garbage never panics and never reads as a list.
|
||||
for bad in [
|
||||
"",
|
||||
"not json",
|
||||
"[]",
|
||||
"42",
|
||||
r#"{"entries": 7}"#,
|
||||
r#"{"epoch":"04","sealed":"y"}"#,
|
||||
] {
|
||||
let read = read_list(bad, |_| None);
|
||||
assert!(read.entries.is_empty() && !read.sealed, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dark_list_is_never_reformed_and_only_the_author_kills_a_pin() {
|
||||
let author = Keys::generate();
|
||||
let (entry, _) = entry_for(&author, "delete me later");
|
||||
|
||||
let dark = ReadPinList {
|
||||
entries: vec![entry.clone()],
|
||||
sealed: true,
|
||||
};
|
||||
assert_eq!(
|
||||
publishable(&dark, false, &group(), Epoch(0)),
|
||||
Err(PinError::Unreadable)
|
||||
);
|
||||
|
||||
let verified = verify_entry(&entry, &channel()).expect("verifies");
|
||||
|
||||
for author_keys in [&author, &Keys::generate()] {
|
||||
let delete = build_delete(
|
||||
author_keys.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
verified.rumor_id,
|
||||
Some(KIND_MESSAGE),
|
||||
None,
|
||||
AT_MS + 1_000,
|
||||
);
|
||||
let (wrap, _) =
|
||||
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
|
||||
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||
|
||||
assert_eq!(
|
||||
killed_by(&verified, &rumor),
|
||||
author_keys.public_key() == author.public_key()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::BASE64URL_NOPAD;
|
||||
use nostr::nips::nip01::Coordinate;
|
||||
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
|
||||
use crate::cord02::{ImageRef, MAX_RELAYS};
|
||||
use crate::cord04::{TAG_SUBKIND, vsk};
|
||||
use crate::derive::{TOKEN_LEN, verify_community_id};
|
||||
use crate::utils::{canonical, union};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||
|
||||
pub const KIND_BUNDLE: u16 = 33301;
|
||||
pub const KIND_INVITE_LIST: u16 = 13303;
|
||||
pub const KIND_DIRECT_INVITE: u16 = 3313;
|
||||
pub const FRAGMENT_VERSION: u8 = 4;
|
||||
pub const MAX_BUNDLE_CHANNELS: usize = 256;
|
||||
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
|
||||
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
|
||||
pub const MAX_INVITE_ENTRIES: usize = 64;
|
||||
|
||||
const FLAG_STOCK_SET: u8 = 0x01;
|
||||
const INVITE_PATH: &str = "/invite/";
|
||||
const TAG_IDENTIFIER: &str = "d";
|
||||
const TAG_EXPIRATION: &str = "expiration";
|
||||
|
||||
const RELAY_DICT: [&str; 4] = [
|
||||
"wss://jskitty.com/nostr",
|
||||
"wss://asia.vectorapp.io/nostr",
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://relay.dreamith.to",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InviteError {
|
||||
Stream(StreamError),
|
||||
Json(String),
|
||||
BadHex(&'static str),
|
||||
TooManyChannels(usize),
|
||||
TooManyInvites(usize),
|
||||
Oversize(usize),
|
||||
Kind(u16),
|
||||
EpochTooLarge(u64),
|
||||
OwnerMismatch,
|
||||
BadFragment(&'static str),
|
||||
BadVersion(u8),
|
||||
BadLink(&'static str),
|
||||
BadEvent(&'static str),
|
||||
Crypto(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for InviteError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
InviteError::Stream(error) => write!(f, "stream: {error}"),
|
||||
InviteError::Json(error) => write!(f, "json: {error}"),
|
||||
InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"),
|
||||
InviteError::TooManyChannels(count) => {
|
||||
write!(
|
||||
f,
|
||||
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
|
||||
)
|
||||
}
|
||||
InviteError::TooManyInvites(count) => {
|
||||
write!(
|
||||
f,
|
||||
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
|
||||
)
|
||||
}
|
||||
InviteError::Oversize(len) => {
|
||||
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
|
||||
}
|
||||
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
|
||||
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
|
||||
InviteError::OwnerMismatch => {
|
||||
write!(f, "bundle owner does not reproduce its community_id")
|
||||
}
|
||||
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
|
||||
InviteError::BadVersion(version) => {
|
||||
write!(f, "unsupported invite fragment version {version}")
|
||||
}
|
||||
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
|
||||
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
|
||||
InviteError::Crypto(error) => write!(f, "crypto: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InviteError {}
|
||||
|
||||
impl From<StreamError> for InviteError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
InviteError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChannelGrant {
|
||||
pub id: ChannelId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
pub epoch: Epoch,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityInvite {
|
||||
pub community_id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: String,
|
||||
pub community_root: String,
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_pk: Option<PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelGrant>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub relays: Vec<String>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<ImageRef>,
|
||||
/// Unix **ms**: past it the preview still renders, joining refuses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creator_npub: Option<PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
impl CommunityInvite {
|
||||
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
|
||||
let mut invite: Self =
|
||||
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
|
||||
|
||||
if invite.channels.len() > MAX_BUNDLE_CHANNELS {
|
||||
return Err(InviteError::TooManyChannels(invite.channels.len()));
|
||||
}
|
||||
|
||||
invite.relays.truncate(MAX_RELAYS);
|
||||
invite.validate()?;
|
||||
|
||||
Ok(invite)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), InviteError> {
|
||||
if self.channels.len() > MAX_BUNDLE_CHANNELS {
|
||||
return Err(InviteError::TooManyChannels(self.channels.len()));
|
||||
}
|
||||
|
||||
for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) {
|
||||
if epoch.0 > MAX_BUNDLE_EPOCH {
|
||||
return Err(InviteError::EpochTooLarge(epoch.0));
|
||||
}
|
||||
}
|
||||
|
||||
let owner_salt = hex32(&self.owner_salt, "owner_salt")?;
|
||||
hex32(&self.community_root, "community_root")?;
|
||||
|
||||
for channel in &self.channels {
|
||||
if let Some(key) = &channel.key {
|
||||
hex32(key, "channel key")?;
|
||||
}
|
||||
}
|
||||
|
||||
if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) {
|
||||
return Err(InviteError::OwnerMismatch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn expired(&self, now_ms: u64) -> bool {
|
||||
self.expires_at.is_some_and(|expires| now_ms > expires)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BundleState {
|
||||
Live(Box<CommunityInvite>),
|
||||
Revoked,
|
||||
}
|
||||
|
||||
pub fn build_bundle_event(
|
||||
link_signer: &Keys,
|
||||
invite: &CommunityInvite,
|
||||
bundle_key: &[u8; 32],
|
||||
) -> Result<Event, InviteError> {
|
||||
invite.validate()?;
|
||||
|
||||
let json = serde_json::to_string(invite).map_err(json_error)?;
|
||||
let content = seal_bundle(bundle_key, &json)?;
|
||||
|
||||
EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
|
||||
.tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)])
|
||||
.finalize(link_signer)
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
|
||||
EventBuilder::new(Kind::Custom(KIND_BUNDLE), "")
|
||||
.tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)])
|
||||
.finalize(link_signer)
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
pub fn parse_bundle_event(
|
||||
event: &Event,
|
||||
expected_signer: &PublicKey,
|
||||
bundle_key: &[u8; 32],
|
||||
) -> Result<BundleState, InviteError> {
|
||||
if event.kind.as_u16() != KIND_BUNDLE {
|
||||
return Err(InviteError::BadEvent("wrong kind"));
|
||||
}
|
||||
|
||||
if event.pubkey != *expected_signer {
|
||||
return Err(InviteError::BadEvent("author is not the link signer"));
|
||||
}
|
||||
|
||||
if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) {
|
||||
return Err(InviteError::BadEvent(
|
||||
"bundle is not at the link's coordinate",
|
||||
));
|
||||
}
|
||||
|
||||
event
|
||||
.verify()
|
||||
.map_err(|_| InviteError::BadEvent("signature invalid"))?;
|
||||
|
||||
match first_tag(event, TAG_SUBKIND).as_deref() {
|
||||
Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked),
|
||||
Some(vsk::INVITE_LIVE) => {}
|
||||
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
|
||||
}
|
||||
|
||||
let json = open_bundle(bundle_key, &event.content)?;
|
||||
|
||||
Ok(BundleState::Live(Box::new(
|
||||
CommunityInvite::from_bundle_json(&json)?,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn stock_relays() -> Vec<String> {
|
||||
RELAY_DICT.iter().map(|relay| relay.to_string()).collect()
|
||||
}
|
||||
|
||||
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
|
||||
let stock = relays == RELAY_DICT;
|
||||
|
||||
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
|
||||
bytes.push(FRAGMENT_VERSION);
|
||||
|
||||
if stock {
|
||||
bytes.push(FLAG_STOCK_SET);
|
||||
} else {
|
||||
bytes.push(0x00);
|
||||
|
||||
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
|
||||
bytes.push(bounded.len() as u8);
|
||||
|
||||
for relay in bounded {
|
||||
match dict_id(relay) {
|
||||
Some(id) => bytes.push(id),
|
||||
None => {
|
||||
let (lead, literal) = match relay.strip_prefix("wss://") {
|
||||
Some(host) => (0x00, host),
|
||||
None => (0xff, relay.as_str()),
|
||||
};
|
||||
|
||||
if literal.len() > u8::MAX as usize {
|
||||
return Err(InviteError::BadFragment("relay too long"));
|
||||
}
|
||||
|
||||
bytes.extend_from_slice(&[lead, literal.len() as u8]);
|
||||
bytes.extend_from_slice(literal.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes.extend_from_slice(token);
|
||||
|
||||
Ok(BASE64URL_NOPAD.encode(&bytes))
|
||||
}
|
||||
|
||||
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
|
||||
let bytes = BASE64URL_NOPAD
|
||||
.decode(fragment.trim().as_bytes())
|
||||
.map_err(|_| InviteError::BadFragment("not base64url"))?;
|
||||
|
||||
let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?;
|
||||
|
||||
if version != FRAGMENT_VERSION {
|
||||
return Err(InviteError::BadVersion(version));
|
||||
}
|
||||
|
||||
let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?;
|
||||
|
||||
let mut offset = 2;
|
||||
let mut relays = Vec::new();
|
||||
|
||||
if flags & FLAG_STOCK_SET != 0 {
|
||||
relays = stock_relays();
|
||||
} else {
|
||||
let count = *bytes
|
||||
.get(offset)
|
||||
.ok_or(InviteError::BadFragment("truncated"))? as usize;
|
||||
offset += 1;
|
||||
|
||||
if count > MAX_BOOTSTRAP_RELAYS {
|
||||
return Err(InviteError::BadFragment("too many bootstrap relays"));
|
||||
}
|
||||
|
||||
for _ in 0..count {
|
||||
let lead = *bytes
|
||||
.get(offset)
|
||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||
offset += 1;
|
||||
|
||||
if (1..=254).contains(&lead) {
|
||||
if let Some(url) = dict_url(lead) {
|
||||
relays.push(url.to_string());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let len = *bytes
|
||||
.get(offset)
|
||||
.ok_or(InviteError::BadFragment("truncated"))? as usize;
|
||||
offset += 1;
|
||||
|
||||
let end = offset
|
||||
.checked_add(len)
|
||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||
|
||||
let raw = bytes
|
||||
.get(offset..end)
|
||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||
|
||||
let text = std::str::from_utf8(raw)
|
||||
.map_err(|_| InviteError::BadFragment("relay is not utf8"))?;
|
||||
|
||||
relays.push(match lead {
|
||||
0x00 => format!("wss://{text}"),
|
||||
0xff => text.to_string(),
|
||||
_ => return Err(InviteError::BadFragment("unknown relay lead byte")),
|
||||
});
|
||||
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
|
||||
let end = offset
|
||||
.checked_add(TOKEN_LEN)
|
||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||
|
||||
let raw = bytes
|
||||
.get(offset..end)
|
||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||
|
||||
if end != bytes.len() {
|
||||
return Err(InviteError::BadFragment("trailing bytes"));
|
||||
}
|
||||
|
||||
let mut token = [0u8; TOKEN_LEN];
|
||||
token.copy_from_slice(raw);
|
||||
|
||||
Ok((token, relays))
|
||||
}
|
||||
|
||||
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
|
||||
let coordinate = Coordinate {
|
||||
kind: Kind::Custom(KIND_BUNDLE),
|
||||
public_key: *link_signer,
|
||||
identifier: String::new(),
|
||||
};
|
||||
|
||||
Nip19::Coordinate(Nip19Coordinate {
|
||||
coordinate,
|
||||
relays: Vec::new(),
|
||||
})
|
||||
.to_bech32()
|
||||
.map_err(|_| InviteError::BadLink("invalid naddr"))
|
||||
}
|
||||
|
||||
pub fn build_invite_url(
|
||||
base: &str,
|
||||
link_signer: &PublicKey,
|
||||
token: &[u8; TOKEN_LEN],
|
||||
relays: &[String],
|
||||
) -> Result<String, InviteError> {
|
||||
let naddr = bundle_naddr(link_signer)?;
|
||||
let fragment = encode_fragment(token, relays)?;
|
||||
|
||||
Ok(format!(
|
||||
"{}{INVITE_PATH}{naddr}#{fragment}",
|
||||
base.trim_end_matches('/')
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedInviteLink {
|
||||
/// The bundle coordinate's author.
|
||||
pub link_signer: PublicKey,
|
||||
pub token: [u8; TOKEN_LEN],
|
||||
pub bootstrap_relays: Vec<String>,
|
||||
/// The bare naddr as it appeared in the link, for the fetch.
|
||||
pub naddr: String,
|
||||
}
|
||||
|
||||
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
|
||||
let (locator, fragment) = input
|
||||
.trim()
|
||||
.split_once('#')
|
||||
.ok_or(InviteError::BadLink("no fragment"))?;
|
||||
|
||||
if fragment.is_empty() {
|
||||
return Err(InviteError::BadLink("empty fragment"));
|
||||
}
|
||||
|
||||
let naddr = match locator.find(INVITE_PATH) {
|
||||
Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'),
|
||||
None => locator.trim_start_matches("nostr:"),
|
||||
};
|
||||
|
||||
let link_signer = signer_from_naddr(naddr)?;
|
||||
let (token, bootstrap_relays) = decode_fragment(fragment)?;
|
||||
|
||||
Ok(ParsedInviteLink {
|
||||
link_signer,
|
||||
token,
|
||||
bootstrap_relays,
|
||||
naddr: naddr.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_direct_invite<S>(
|
||||
inviter: &S,
|
||||
recipient: &PublicKey,
|
||||
invite: &CommunityInvite,
|
||||
) -> Result<Event, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
invite.validate()?;
|
||||
|
||||
let json = serde_json::to_string(invite).map_err(json_error)?;
|
||||
let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
|
||||
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
|
||||
|
||||
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
|
||||
|
||||
if let Some(expires_at) = invite.expires_at {
|
||||
tags.push(Tag::custom(
|
||||
TAG_EXPIRATION,
|
||||
[(expires_at / 1000).to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
GiftWrapBuilder::new(*recipient, rumor)
|
||||
.extra_tags(tags)
|
||||
.finalize_async(inviter)
|
||||
.await
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
|
||||
pub async fn unwrap_direct_invite<S>(
|
||||
wrap: &Event,
|
||||
recipient: &S,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError>
|
||||
where
|
||||
S: AsyncNip44,
|
||||
{
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
|
||||
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
|
||||
return Err(InviteError::BadEvent("rumor is not a direct invite"));
|
||||
}
|
||||
|
||||
Ok((
|
||||
unwrapped.sender,
|
||||
CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InviteEntry {
|
||||
/// The link's unlock secret, and its merge key.
|
||||
pub token: String,
|
||||
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
|
||||
pub signer_sk: String,
|
||||
pub community_id: CommunityId,
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
pub created_at: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InviteTombstone {
|
||||
pub token: String,
|
||||
pub community_id: CommunityId,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
/// A creator's own link bookkeeping.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InviteList {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub entries: Vec<InviteEntry>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tombstones: Vec<InviteTombstone>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
impl InviteList {
|
||||
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
|
||||
pub fn is_live(&self, token: &str) -> bool {
|
||||
self.entries.iter().any(|entry| entry.token == token)
|
||||
&& !self
|
||||
.tombstones
|
||||
.iter()
|
||||
.any(|tombstone| tombstone.token == token)
|
||||
}
|
||||
|
||||
pub fn fits(&self) -> Result<(), InviteError> {
|
||||
if self.entries.len() > MAX_INVITE_ENTRIES {
|
||||
return Err(InviteError::TooManyInvites(self.entries.len()));
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(self).map_err(json_error)?;
|
||||
|
||||
if json.len() > NIP44_MAX_PLAINTEXT {
|
||||
return Err(InviteError::Oversize(json.len()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
|
||||
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
|
||||
|
||||
for entry in held.entries.into_iter().chain(incoming.entries) {
|
||||
match entries.entry(entry.token.clone()) {
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert(entry);
|
||||
}
|
||||
Entry::Occupied(mut slot) => {
|
||||
let merged = merge_entry(slot.get(), &entry);
|
||||
*slot.get_mut() = merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
|
||||
|
||||
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
|
||||
match tombstones.entry(tombstone.token.clone()) {
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert(tombstone);
|
||||
}
|
||||
Entry::Occupied(mut slot) => {
|
||||
if canonical(&tombstone) < canonical(slot.get()) {
|
||||
*slot.get_mut() = tombstone;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut extra = held.extra;
|
||||
union(&mut extra, incoming.extra);
|
||||
|
||||
InviteList {
|
||||
entries: entries.into_values().collect(),
|
||||
tombstones: tombstones.into_values().collect(),
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
list.fits()?;
|
||||
|
||||
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||
let content = cord01::seal_to_self(keys, &json).await?;
|
||||
|
||||
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
||||
.finalize_async(keys)
|
||||
.await
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.kind.as_u16() != KIND_INVITE_LIST {
|
||||
return Err(InviteError::Kind(event.kind.as_u16()));
|
||||
}
|
||||
|
||||
let json = cord01::open_to_self(keys, &event.content).await?;
|
||||
|
||||
serde_json::from_str(&json).map_err(json_error)
|
||||
}
|
||||
|
||||
/// An entry is immutable once minted, so two copies should agree.
|
||||
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
|
||||
let (winner, loser) = if canonical(incoming) < canonical(held) {
|
||||
(incoming, held)
|
||||
} else {
|
||||
(held, incoming)
|
||||
};
|
||||
|
||||
let mut merged = winner.clone();
|
||||
union(&mut merged.extra, loser.extra.clone());
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
|
||||
Ok(cord01::seal_bytes(
|
||||
&ConversationKey::new(*bundle_key),
|
||||
json.as_bytes(),
|
||||
)?)
|
||||
}
|
||||
|
||||
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
|
||||
let plaintext = cord01::open_bytes(&ConversationKey::new(*bundle_key), content)?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
|
||||
}
|
||||
|
||||
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
|
||||
match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) {
|
||||
Ok(Nip19::Coordinate(coordinate))
|
||||
if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE
|
||||
&& coordinate.coordinate.identifier.is_empty() =>
|
||||
{
|
||||
Ok(coordinate.coordinate.public_key)
|
||||
}
|
||||
_ => Err(InviteError::BadLink(
|
||||
"naddr is not an invite-bundle coordinate",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
|
||||
decode_hex_32(value).map_err(|_| InviteError::BadHex(field))
|
||||
}
|
||||
|
||||
fn dict_id(relay: &str) -> Option<u8> {
|
||||
RELAY_DICT
|
||||
.iter()
|
||||
.position(|known| *known == relay)
|
||||
.map(|index| index as u8 + 1)
|
||||
}
|
||||
|
||||
fn dict_url(id: u8) -> Option<&'static str> {
|
||||
RELAY_DICT.get(id.checked_sub(1)? as usize).copied()
|
||||
}
|
||||
|
||||
fn empty_identifier() -> Tag {
|
||||
Tag::identifier("")
|
||||
}
|
||||
|
||||
fn subkind_tag(value: &str) -> Tag {
|
||||
Tag::custom(TAG_SUBKIND, [value])
|
||||
}
|
||||
|
||||
fn first_tag(event: &Event, name: &str) -> Option<String> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let fields = tag.as_slice();
|
||||
|
||||
(fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn json_error(error: serde_json::Error) -> InviteError {
|
||||
InviteError::Json(error.to_string())
|
||||
}
|
||||
|
||||
fn crypto_error(error: impl fmt::Display) -> InviteError {
|
||||
InviteError::Crypto(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use data_encoding::HEXLOWER;
|
||||
|
||||
use super::*;
|
||||
use crate::derive::{community_id_of, invite_bundle_key};
|
||||
|
||||
const SALT: [u8; 32] = [0x33u8; 32];
|
||||
|
||||
fn bundle() -> CommunityInvite {
|
||||
let owner = Keys::generate();
|
||||
|
||||
CommunityInvite {
|
||||
community_id: community_id_of(&owner.public_key().to_bytes(), &SALT),
|
||||
owner: owner.public_key(),
|
||||
owner_salt: HEXLOWER.encode(&SALT),
|
||||
community_root: "44".repeat(32),
|
||||
root_epoch: Epoch(0),
|
||||
control_pk: None,
|
||||
channels: vec![ChannelGrant {
|
||||
id: ChannelId::from_bytes([0x9cu8; 32]),
|
||||
key: Some("55".repeat(32)),
|
||||
epoch: Epoch(1),
|
||||
name: "lounge".to_owned(),
|
||||
extra: Extra::default(),
|
||||
}],
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
name: "Test community".to_owned(),
|
||||
icon: None,
|
||||
expires_at: None,
|
||||
creator_npub: None,
|
||||
label: None,
|
||||
extra: Extra::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn token16() -> [u8; TOKEN_LEN] {
|
||||
std::array::from_fn(|i| i as u8)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_goldens_pin_the_wire_layout() {
|
||||
let token = token16();
|
||||
|
||||
// [04 version][01 stock flag][token 00..0f]
|
||||
let stock = encode_fragment(&token, &stock_relays()).expect("encodes");
|
||||
assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P");
|
||||
assert_eq!(
|
||||
decode_fragment(&stock).expect("decodes"),
|
||||
(token, stock_relays())
|
||||
);
|
||||
|
||||
// [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f]
|
||||
let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()];
|
||||
let encoded = encode_fragment(&token, &mixed).expect("encodes");
|
||||
assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
|
||||
assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed));
|
||||
|
||||
// [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f]
|
||||
let verbatim = vec!["ws://h".to_owned()];
|
||||
let encoded = encode_fragment(&token, &verbatim).expect("encodes");
|
||||
assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
|
||||
assert_eq!(
|
||||
decode_fragment(&encoded).expect("decodes"),
|
||||
(token, verbatim)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fragment_is_strict_about_framing_and_counts() {
|
||||
let token = token16();
|
||||
|
||||
for version in [3u8, 5] {
|
||||
let mut bytes = vec![version, FLAG_STOCK_SET];
|
||||
bytes.extend_from_slice(&token);
|
||||
let encoded = BASE64URL_NOPAD.encode(&bytes);
|
||||
assert!(
|
||||
matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version),
|
||||
"a legacy and a future version are both refused"
|
||||
);
|
||||
}
|
||||
|
||||
let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
|
||||
trailing.extend_from_slice(&token);
|
||||
trailing.push(0xff);
|
||||
assert!(matches!(
|
||||
decode_fragment(&BASE64URL_NOPAD.encode(&trailing)),
|
||||
Err(InviteError::BadFragment(_))
|
||||
));
|
||||
|
||||
let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4];
|
||||
over.extend_from_slice(&token);
|
||||
assert!(matches!(
|
||||
decode_fragment(&BASE64URL_NOPAD.encode(&over)),
|
||||
Err(InviteError::BadFragment(_))
|
||||
));
|
||||
|
||||
// An unknown dictionary id is skipped, not fatal, so the dictionary can grow.
|
||||
let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
|
||||
unknown.extend_from_slice(&token);
|
||||
let (decoded, relays) =
|
||||
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
|
||||
assert_eq!(decoded, token);
|
||||
assert!(relays.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_round_trips_and_refuses_a_non_invite() {
|
||||
let link_signer = Keys::generate();
|
||||
let token = token16();
|
||||
let relays = vec!["wss://a.example".to_owned()];
|
||||
|
||||
let url = build_invite_url(
|
||||
"https://vectorapp.io/",
|
||||
&link_signer.public_key(),
|
||||
&token,
|
||||
&relays,
|
||||
)
|
||||
.expect("builds");
|
||||
|
||||
let parsed = parse_link(&url).expect("parses");
|
||||
assert_eq!(parsed.link_signer, link_signer.public_key());
|
||||
assert_eq!(parsed.token, token);
|
||||
assert_eq!(parsed.bootstrap_relays, relays);
|
||||
|
||||
let fragment = url.split('#').nth(1).expect("carries a fragment");
|
||||
let bare = format!("{}#{fragment}", parsed.naddr);
|
||||
let reparsed = parse_link(&bare).expect("parses the domain-agnostic form");
|
||||
assert_eq!(reparsed.link_signer, link_signer.public_key());
|
||||
assert_eq!(reparsed.token, token);
|
||||
|
||||
assert!(
|
||||
parse_link("https://x/invite/#frag").is_err(),
|
||||
"the naddr is not optional"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() {
|
||||
let invite = bundle();
|
||||
let link_signer = Keys::generate();
|
||||
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
|
||||
|
||||
let event = build_bundle_event(&link_signer, &invite, &key).expect("builds");
|
||||
assert_eq!(event.pubkey, link_signer.public_key());
|
||||
|
||||
match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") {
|
||||
BundleState::Live(opened) => {
|
||||
assert_eq!(opened.community_id, invite.community_id);
|
||||
assert_eq!(opened.channels.len(), 1);
|
||||
}
|
||||
BundleState::Revoked => panic!("expected a live bundle"),
|
||||
}
|
||||
|
||||
let revocation = build_revocation(&link_signer).expect("builds");
|
||||
assert!(matches!(
|
||||
parse_bundle_event(&revocation, &link_signer.public_key(), &key),
|
||||
Ok(BundleState::Revoked)
|
||||
));
|
||||
|
||||
// The token is the only way in, and a squatter is a different coordinate.
|
||||
assert!(
|
||||
parse_bundle_event(
|
||||
&event,
|
||||
&link_signer.public_key(),
|
||||
&invite_bundle_key(&[8u8; TOKEN_LEN])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let squatter = Keys::generate();
|
||||
assert!(matches!(
|
||||
parse_bundle_event(&event, &squatter.public_key(), &key),
|
||||
Err(InviteError::BadEvent(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() {
|
||||
let invite = bundle();
|
||||
let link_signer = Keys::generate();
|
||||
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
|
||||
let json = serde_json::to_string(&invite).expect("serializes");
|
||||
let content = seal_bundle(&key, &json).expect("seals");
|
||||
|
||||
// The fetch filters on the author, so the empty `d` is pinned here: a
|
||||
// signature-valid event of the same author at another `d` is not the bundle.
|
||||
let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
|
||||
.tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)])
|
||||
.finalize(&link_signer)
|
||||
.expect("signs");
|
||||
assert!(matches!(
|
||||
parse_bundle_event(&elsewhere, &link_signer.public_key(), &key),
|
||||
Err(InviteError::BadEvent(_))
|
||||
));
|
||||
|
||||
let mut forged = bundle();
|
||||
forged.owner = Keys::generate().public_key();
|
||||
assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch)));
|
||||
assert!(matches!(
|
||||
build_bundle_event(&link_signer, &forged, &key),
|
||||
Err(InviteError::OwnerMismatch)
|
||||
));
|
||||
|
||||
let mut malformed = bundle();
|
||||
malformed.community_root = "not hex".to_owned();
|
||||
assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_))));
|
||||
|
||||
let mut crowded = bundle();
|
||||
crowded.channels = (0..=MAX_BUNDLE_CHANNELS)
|
||||
.map(|_| ChannelGrant {
|
||||
id: ChannelId::from_bytes([0x01; 32]),
|
||||
key: None,
|
||||
epoch: Epoch(0),
|
||||
name: String::new(),
|
||||
extra: Extra::default(),
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
crowded.validate(),
|
||||
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() {
|
||||
let inviter = Keys::generate();
|
||||
let recipient = Keys::generate();
|
||||
let invite = bundle();
|
||||
|
||||
let wrap = smol::block_on(build_direct_invite(
|
||||
&inviter,
|
||||
&recipient.public_key(),
|
||||
&invite,
|
||||
))
|
||||
.expect("builds");
|
||||
assert_eq!(wrap.kind, Kind::GiftWrap);
|
||||
assert_ne!(
|
||||
wrap.pubkey,
|
||||
inviter.public_key(),
|
||||
"the wrap author is ephemeral"
|
||||
);
|
||||
assert!(
|
||||
wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]),
|
||||
"the k tag is what makes an invite indexable"
|
||||
);
|
||||
|
||||
let (sender, opened) =
|
||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps");
|
||||
assert_eq!(sender, inviter.public_key());
|
||||
assert_eq!(opened.community_id, invite.community_id);
|
||||
|
||||
// Somebody else's wrap is not ours to open...
|
||||
let stranger = Keys::generate();
|
||||
assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err());
|
||||
|
||||
// ...and a wrap that opens to some other kind is not an invite.
|
||||
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
|
||||
.finalize_unsigned(recipient.public_key());
|
||||
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
|
||||
.finalize(&recipient)
|
||||
.expect("wraps");
|
||||
assert!(matches!(
|
||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
|
||||
Err(InviteError::BadEvent(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod rumor;
|
||||
|
||||
pub mod cord01;
|
||||
pub mod cord02;
|
||||
pub mod cord03;
|
||||
pub mod cord04;
|
||||
pub mod cord05;
|
||||
pub mod cord06;
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::fmt;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::StreamError;
|
||||
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
|
||||
use crate::decode_hex_32;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RumorError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
/// Neither a delete nor a timer notice may be erased by the policy it carries.
|
||||
ExemptExpiration,
|
||||
}
|
||||
|
||||
impl fmt::Display for RumorError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RumorError::Stream(error) => write!(f, "stream: {error}"),
|
||||
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
|
||||
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
|
||||
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
|
||||
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
|
||||
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
|
||||
RumorError::ExemptExpiration => {
|
||||
write!(f, "a delete or timer notice must not carry an expiration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RumorError {}
|
||||
|
||||
impl From<StreamError> for RumorError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
RumorError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, RumorError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(RumorError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn required<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<&'a [String], RumorError> {
|
||||
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
|
||||
}
|
||||
|
||||
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(RumorError::BadTag(name))
|
||||
}
|
||||
|
||||
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
|
||||
}
|
||||
|
||||
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(RumorError::BadTag(TAG_CITATION))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod cords;
|
||||
mod types;
|
||||
mod utils;
|
||||
|
||||
pub mod state;
|
||||
|
||||
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
|
||||
pub(crate) use types::Extra;
|
||||
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
|
||||
pub use utils::decode_hex_32;
|
||||
pub use utils::derive::{self, GroupKey};
|
||||
pub(crate) use utils::{decode_hex_lower, fill_random, random_32};
|
||||
@@ -0,0 +1,627 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
|
||||
use crate::cord02::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::cord05::ChannelGrant;
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||
|
||||
/// The `concord/` namespace for locally-keyed documents.
|
||||
pub const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
/// A key epoch the client still holds, retained so history stays readable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HeldKey {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
/// The publish time of the rotation that superseded this key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
/// A community root epoch the client still holds, retained for the same reason.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HeldRoot {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
/// The epoch's Control Plane signer, when the rotation delivered one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_pk: Option<PublicKey>,
|
||||
/// The publish time of the rotation that superseded this root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
/// The channel's read secret when the member was granted it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<[u8; 32]>,
|
||||
/// Keys this one superseded, retained so a rotation never blanks history.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub priors: Vec<HeldKey>,
|
||||
}
|
||||
|
||||
impl ChannelKeyRef {
|
||||
/// The write coordinate: only the current epoch is ever published under.
|
||||
pub fn current(&self) -> Option<(Epoch, [u8; 32])> {
|
||||
self.key.map(|key| (self.epoch, key))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a channel's history sync has reached, in wrap times.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelCursor {
|
||||
/// The newest wrap ingested, so a live subscription knows where to resume.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub newest: Option<Timestamp>,
|
||||
/// The oldest wrap paged back to, so the next round resumes below it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oldest: Option<Timestamp>,
|
||||
/// History verifiably swept to the bottom.
|
||||
#[serde(default)]
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
impl ChannelCursor {
|
||||
pub fn merge(self, round: Self) -> Self {
|
||||
Self {
|
||||
newest: later(self.newest, round.newest),
|
||||
oldest: earlier(self.oldest, round.oldest),
|
||||
exhausted: self.exhausted || round.exhausted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||
match (held, round) {
|
||||
(Some(held), Some(round)) => Some(held.max(round)),
|
||||
(held, None) => held,
|
||||
(None, round) => round,
|
||||
}
|
||||
}
|
||||
|
||||
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||
match (held, round) {
|
||||
(Some(held), Some(round)) => Some(held.min(round)),
|
||||
(held, None) => held,
|
||||
(None, round) => round,
|
||||
}
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
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>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
/// Where each channel's history sync has reached.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "channel_cursors",
|
||||
skip_serializing_if = "BTreeMap::is_empty"
|
||||
)]
|
||||
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
|
||||
/// Root epochs the community has rotated past that this client still holds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub held_roots: Vec<HeldRoot>,
|
||||
/// The epoch a channel rotation removed us at.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
|
||||
/// The npubs whose rotation minted an epoch of this community we verified.
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub refounders: BTreeSet<PublicKey>,
|
||||
/// The base epoch we were excluded at.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub removed_at: Option<Epoch>,
|
||||
/// A complete rotation ahead of our epoch predates our join and carries no blob.
|
||||
#[serde(default)]
|
||||
pub stranded: bool,
|
||||
#[serde(default)]
|
||||
pub dissolved: bool,
|
||||
/// When this community joined the member's list, in milliseconds.
|
||||
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();
|
||||
let mut name = None;
|
||||
|
||||
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()),
|
||||
);
|
||||
name = label(&metadata.name);
|
||||
}
|
||||
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,
|
||||
key: None,
|
||||
priors: Vec::new(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
name,
|
||||
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,
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
|
||||
let control_pks = match material.control_pk {
|
||||
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
|
||||
let mut channels = Vec::with_capacity(material.channels.len());
|
||||
|
||||
for grant in &material.channels {
|
||||
let key = match &grant.key {
|
||||
Some(key) => Some(decode_hex_32(key)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
channels.push(ChannelKeyRef {
|
||||
id: grant.id,
|
||||
name: grant.name.clone(),
|
||||
private: key.is_some(),
|
||||
epoch: grant.epoch,
|
||||
key,
|
||||
priors: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: material.community_id,
|
||||
name: label(&material.name),
|
||||
owner: material.owner,
|
||||
owner_salt: decode_hex_32(&material.owner_salt)?,
|
||||
community_root: decode_hex_32(&material.community_root)?,
|
||||
root_epoch: material.root_epoch,
|
||||
control_root: match &material.control_root {
|
||||
Some(root) => Some(decode_hex_32(root)?),
|
||||
None => None,
|
||||
},
|
||||
control_pks,
|
||||
channels,
|
||||
relays: material
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
|
||||
/// Every root epoch we hold, the current one first.
|
||||
pub fn roots(&self) -> Vec<HeldRoot> {
|
||||
let mut roots = Vec::with_capacity(self.held_roots.len() + 1);
|
||||
roots.push(HeldRoot {
|
||||
epoch: self.root_epoch,
|
||||
key: self.community_root,
|
||||
control_pk: self.control_pks.get(&self.root_epoch.0).copied(),
|
||||
retired_at: None,
|
||||
});
|
||||
roots.extend(self.held_roots.iter().copied());
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every secret held for a channel, newest epoch first.
|
||||
pub fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
|
||||
let Some(held) = self.channels.iter().find(|held| held.id == *channel) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if held.private {
|
||||
let mut keys: Vec<HeldKey> = held
|
||||
.key
|
||||
.map(|key| HeldKey {
|
||||
epoch: held.epoch,
|
||||
key,
|
||||
retired_at: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
keys.extend(held.priors.iter().copied());
|
||||
return keys;
|
||||
}
|
||||
|
||||
self.roots()
|
||||
.into_iter()
|
||||
.map(|root| HeldKey {
|
||||
epoch: root.epoch,
|
||||
key: root.key,
|
||||
retired_at: root.retired_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a channel rotation removed us at or after `epoch`.
|
||||
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
|
||||
self.channel_cuts
|
||||
.get(channel)
|
||||
.is_some_and(|cut| epoch <= *cut)
|
||||
}
|
||||
|
||||
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();
|
||||
self.banned = fold.banned.clone();
|
||||
|
||||
if let Some(community) = &fold.community {
|
||||
self.relays = community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
|
||||
if let Some(name) = label(&community.name) {
|
||||
self.name = Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
key: None,
|
||||
priors: Vec::new(),
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
|
||||
let material = JoinMaterial {
|
||||
community_id: state.id,
|
||||
owner: state.owner,
|
||||
owner_salt: HEXLOWER.encode(&state.owner_salt),
|
||||
community_root: HEXLOWER.encode(&state.community_root),
|
||||
root_epoch: state.root_epoch,
|
||||
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
|
||||
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
|
||||
channels: state
|
||||
.channels
|
||||
.iter()
|
||||
.map(|channel| ChannelGrant {
|
||||
id: channel.id,
|
||||
key: channel.key.map(|key| HEXLOWER.encode(&key)),
|
||||
epoch: channel.epoch,
|
||||
name: channel.name.clone(),
|
||||
extra: Extra::default(),
|
||||
})
|
||||
.collect(),
|
||||
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
|
||||
name: name.to_owned(),
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
CommunityListEntry {
|
||||
community_id: state.id,
|
||||
seed: material.clone(),
|
||||
current: material,
|
||||
added_at: state.added_at_ms,
|
||||
extra: Extra::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
/// The local document key a community's state is stored under.
|
||||
pub fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cursor_merge_only_moves_forward_and_never_seals() {
|
||||
let held = ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_000)),
|
||||
oldest: Some(Timestamp::from_secs(5_000)),
|
||||
exhausted: false,
|
||||
};
|
||||
|
||||
// An incomplete round reports nothing and moves neither bound.
|
||||
assert_eq!(held.merge(ChannelCursor::default()), held);
|
||||
|
||||
let merged = held.merge(ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(2_000)),
|
||||
oldest: Some(Timestamp::from_secs(3_000)),
|
||||
exhausted: true,
|
||||
});
|
||||
assert_eq!(
|
||||
merged,
|
||||
ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(2_000)),
|
||||
oldest: Some(Timestamp::from_secs(3_000)),
|
||||
exhausted: true,
|
||||
}
|
||||
);
|
||||
|
||||
// A later round that learned less cannot walk either bound back.
|
||||
assert_eq!(
|
||||
merged.merge(ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_500)),
|
||||
oldest: Some(Timestamp::from_secs(4_000)),
|
||||
exhausted: false,
|
||||
}),
|
||||
merged
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor stored when the boundaries were milliseconds must not be read as
|
||||
/// seconds. The key it was stored under is gone, so the document's counters
|
||||
/// are ignored and the channel re-syncs rather than being sealed off by an
|
||||
/// `exhausted` that outlived the bounds it was earned against.
|
||||
#[test]
|
||||
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
|
||||
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||
let cursor = ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_700_000_000)),
|
||||
exhausted: true,
|
||||
..ChannelCursor::default()
|
||||
};
|
||||
let mut state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: None,
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::new(),
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms: 7,
|
||||
};
|
||||
|
||||
// The document a version that stored milliseconds wrote: its own key,
|
||||
// and boundaries padded by a thousand.
|
||||
let mut stored = serde_json::Map::new();
|
||||
stored.insert(
|
||||
channel.to_hex(),
|
||||
serde_json::json!({
|
||||
"newest_ms": 1_700_000_000_000u64,
|
||||
"oldest_ms": 1_699_999_000_000u64,
|
||||
"exhausted": true
|
||||
}),
|
||||
);
|
||||
|
||||
let mut legacy = serde_json::to_value(&state).expect("serializes");
|
||||
legacy
|
||||
.as_object_mut()
|
||||
.expect("a document")
|
||||
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
|
||||
|
||||
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
|
||||
|
||||
assert!(
|
||||
read.cursors.is_empty(),
|
||||
"a millisecond cursor is not a seconds cursor"
|
||||
);
|
||||
|
||||
// A typed cursor still round-trips under the key it is written with.
|
||||
state.cursors.insert(channel, cursor);
|
||||
|
||||
let document = serde_json::to_value(&state).expect("serializes");
|
||||
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
|
||||
|
||||
assert_eq!(read.cursors.get(&channel), Some(&cursor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let control_pk = Keys::generate().public_key();
|
||||
let staff = ChannelId::from_bytes([0x9c; 32]);
|
||||
let general = ChannelId::from_bytes([0x9d; 32]);
|
||||
|
||||
let material = JoinMaterial {
|
||||
community_id: CommunityId::from_bytes([0x42; 32]),
|
||||
owner,
|
||||
owner_salt: "01".repeat(32),
|
||||
community_root: "02".repeat(32),
|
||||
root_epoch: Epoch(3),
|
||||
control_pk: Some(control_pk),
|
||||
control_root: Some("03".repeat(32)),
|
||||
channels: vec![
|
||||
ChannelGrant {
|
||||
id: staff,
|
||||
key: Some("04".repeat(32)),
|
||||
epoch: Epoch(2),
|
||||
name: "staff".to_owned(),
|
||||
extra: Extra::default(),
|
||||
},
|
||||
ChannelGrant {
|
||||
id: general,
|
||||
key: None,
|
||||
epoch: Epoch(0),
|
||||
name: "general".to_owned(),
|
||||
extra: Extra::default(),
|
||||
},
|
||||
],
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
name: "Room".to_owned(),
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
|
||||
|
||||
assert_eq!(state.id, material.community_id);
|
||||
assert_eq!(state.owner, owner);
|
||||
assert_eq!(state.owner_salt, [0x01; 32]);
|
||||
assert_eq!(state.community_root, [0x02; 32]);
|
||||
assert_eq!(state.root_epoch, Epoch(3));
|
||||
assert_eq!(state.control_root, Some([0x03; 32]));
|
||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||
assert!(
|
||||
state.heads.is_empty(),
|
||||
"the first control fold fills the heads"
|
||||
);
|
||||
assert!(state.banned.is_empty());
|
||||
assert!(!state.dissolved);
|
||||
assert_eq!(state.relays.len(), 1);
|
||||
assert_eq!(state.added_at_ms, 7);
|
||||
|
||||
// A granted key lands on the channel and makes it private; a grant with
|
||||
// no key is a public channel.
|
||||
let granted = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == staff)
|
||||
.expect("staff");
|
||||
assert!(granted.private);
|
||||
assert_eq!(granted.key, Some([0x04; 32]));
|
||||
assert_eq!(granted.epoch, Epoch(2));
|
||||
assert_eq!(granted.name, "staff");
|
||||
|
||||
let public = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == general)
|
||||
.expect("general");
|
||||
assert!(!public.private);
|
||||
assert_eq!(public.key, None);
|
||||
|
||||
// A member who is not staff carries no control_root, but reading needs no
|
||||
// secret: the address rides in the material either way.
|
||||
let mut member = material.clone();
|
||||
member.control_root = None;
|
||||
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
|
||||
assert_eq!(state.control_root, None);
|
||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::decode_hex_32;
|
||||
|
||||
/// 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, 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 fmt::Display for Epoch {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
||||
use base64::{Engine as _, alphabet};
|
||||
|
||||
/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value
|
||||
/// encoding at any depth.
|
||||
///
|
||||
/// The reader tolerates non-zero trailing bits; the writer never emits them.
|
||||
/// The spec's own worked example (`examples.md` §6.2) contains five such
|
||||
/// values, and a reader cannot tell a mis-encoded named field from a correctly
|
||||
/// encoded one, so the boundary is the writer's alone.
|
||||
const BASE64URL: GeneralPurpose = GeneralPurpose::new(
|
||||
&alphabet::URL_SAFE,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_encode_padding(false)
|
||||
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
pub(crate) fn encode(bytes: &[u8]) -> String {
|
||||
BASE64URL.encode(bytes)
|
||||
}
|
||||
|
||||
/// Decodes one 32-byte value, the width every §8 field has.
|
||||
pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> {
|
||||
let bytes = BASE64URL
|
||||
.decode(value.trim())
|
||||
.map_err(|error| anyhow!("invalid base64url: {error}"))?;
|
||||
|
||||
bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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 secret_key = hkdf_to_secret_key(secret, &build_info(label, id32, epoch))?;
|
||||
let keys = Keys::new(secret_key);
|
||||
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
||||
|
||||
Ok(Self { keys, conversation })
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 read 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`, so any retained member recovers any epoch's rekey.
|
||||
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,77 @@
|
||||
pub mod base64url;
|
||||
pub mod derive;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Extra;
|
||||
|
||||
/// Decode a 64-character lowercase-hex string into 32 bytes.
|
||||
///
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
pub fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
decode_hex_lower::<32>(value)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.map_err(|error| anyhow!("invalid hex: {error}"))?;
|
||||
|
||||
let decoded: [u8; N] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("expected {N} 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)
|
||||
}
|
||||
|
||||
/// Hex to unpadded base64url for one 32-byte §8 value.
|
||||
pub(crate) fn hex32_to_base64(value: &str) -> Result<String> {
|
||||
Ok(base64url::encode(&decode_hex_32(value)?))
|
||||
}
|
||||
|
||||
/// Unpadded base64url to lowercase hex for one 32-byte §8 value.
|
||||
pub(crate) fn base64_to_hex32(value: &str) -> Result<String> {
|
||||
Ok(HEXLOWER.encode(&base64url::decode_32(value)?))
|
||||
}
|
||||
|
||||
/// Canonical JSON bytes: the total-order tie-break every content merge uses.
|
||||
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Unions an unknown-field map. Where both sides carry a key, the
|
||||
/// lexicographically lowest canonical bytes win, so two devices converge
|
||||
/// instead of flapping.
|
||||
pub(crate) fn union(into: &mut Extra, other: Extra) {
|
||||
for (key, value) in other {
|
||||
let replace = match into.get(&key) {
|
||||
Some(existing) => canonical(&value) < canonical(existing),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if replace {
|
||||
into.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,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
|
||||
|
||||
@@ -4,13 +4,13 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
@@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
@@ -89,10 +89,10 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
let nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
||||
|
||||
let signer = cx.new(|_| None);
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -109,14 +109,16 @@ impl DeviceRegistry {
|
||||
subscriptions.push(
|
||||
// Observe the user signer
|
||||
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
|
||||
if event.signer_changed() && nip4e_enabled {
|
||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||
this.get_announcement(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| this.handle_notifications(cx))
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -128,7 +130,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -169,18 +171,18 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
Kind::Custom(10044) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_encryption(&event, cx);
|
||||
})?;
|
||||
}
|
||||
// New request event from other device
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
this.update(cx, |this, cx| {
|
||||
this.ask_for_approval(event, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event from the master device
|
||||
@@ -226,6 +228,11 @@ impl DeviceRegistry {
|
||||
let keys = get_keys(&client, &signer).await?;
|
||||
let content = keys.secret_key().to_bech32()?;
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(anyhow!("Not supported"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
smol::fs::write(path, &content).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -258,28 +265,20 @@ impl DeviceRegistry {
|
||||
}));
|
||||
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if !cx
|
||||
.background_spawn(async move {
|
||||
// Wait for 5 seconds
|
||||
executor.timer(Duration::from_secs(5)).await;
|
||||
// Wait for 5 seconds
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
// Then check if the msg relays have been found
|
||||
if !announcement_existed.load(Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
})
|
||||
.await
|
||||
{
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
// Then check if the msg relays have been found
|
||||
if announcement_existed.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
@@ -404,11 +403,10 @@ impl DeviceRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let Ok(app_keys) = get_or_init_app_keys(cx) else {
|
||||
return;
|
||||
};
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let app_pubkey = app_keys.public_key();
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
@@ -419,7 +417,7 @@ impl DeviceRegistry {
|
||||
.pubkey(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.first_owned() {
|
||||
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
|
||||
@@ -489,11 +487,10 @@ 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 Ok(app_keys) = get_or_init_app_keys(cx) else {
|
||||
return;
|
||||
};
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -573,7 +570,7 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
@@ -591,12 +588,13 @@ impl DeviceRegistry {
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
// Ignore if there is already a pending request
|
||||
if self.pending_request {
|
||||
return;
|
||||
@@ -605,7 +603,18 @@ impl DeviceRegistry {
|
||||
|
||||
// Show notification
|
||||
let notification = self.notification(event, cx);
|
||||
window.push_notification(notification, cx);
|
||||
|
||||
// The registry is global and not bound to a window, so surface the
|
||||
// request in an open window.
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a notification for the encryption request.
|
||||
@@ -660,7 +669,11 @@ impl DeviceRegistry {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).xsmall())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
),
|
||||
),
|
||||
@@ -715,33 +728,34 @@ impl DeviceRegistry {
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// Get or create new app keys
|
||||
fn get_or_init_app_keys(cx: &App) -> Result<Keys, Error> {
|
||||
/// 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);
|
||||
let stored_keys: Option<Keys> = cx.foreground_executor().block_on(async move {
|
||||
if let Ok(Some((_, secret))) = read.await {
|
||||
SecretKey::from_slice(&secret).map(Keys::new).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(keys) = stored_keys {
|
||||
Ok(keys)
|
||||
} else {
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Some((_, secret))) = read.await
|
||||
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
||||
{
|
||||
return Ok(keys);
|
||||
}
|
||||
|
||||
// No stored keys found or invalid — generate new ones
|
||||
let keys = Keys::generate();
|
||||
let user = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||
|
||||
cx.foreground_executor().block_on(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Keyring not available or panic: {e}")
|
||||
}
|
||||
cx.update(|cx| {
|
||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Keyring not available or panic: {e}")
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[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"] }
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Vendored from zed's `crates/gpui_tokio` (Apache-2.0) because the `gpui-pre` family
|
||||
//! does not republish it, and `nostr-sdk`'s reqwest client needs a Tokio runtime.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use gpui::{App, AppContext, Global, ReadGlobal, Task};
|
||||
use gpui_util::defer;
|
||||
pub use tokio::task::JoinError;
|
||||
|
||||
/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads.
|
||||
///
|
||||
/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime
|
||||
/// yourself and pass a Handle to `init_from_handle`.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// Initializes the Tokio wrapper using a Tokio runtime handle.
|
||||
pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) {
|
||||
cx.set_global(GlobalTokio {
|
||||
owned_runtime: None,
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle(cx: &App) -> tokio::runtime::Handle {
|
||||
GlobalTokio::global(cx).handle.clone()
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||
@@ -14,8 +13,8 @@ mod person;
|
||||
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
@@ -24,9 +23,9 @@ impl Global for GlobalPersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Box<Person>),
|
||||
Announcement(Box<Event>),
|
||||
Relays(Box<Event>),
|
||||
Person(Person),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
}
|
||||
|
||||
/// Person Registry
|
||||
@@ -36,7 +35,7 @@ pub struct PersonRegistry {
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seens: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
seen: RwLock<HashSet<PublicKey>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
sender: flume::Sender<PublicKey>,
|
||||
@@ -57,69 +56,55 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Create a new person registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
|
||||
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Handle nostr notifications
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
let client2 = client.clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_notifications(&client2, &tx).await;
|
||||
}));
|
||||
|
||||
async move {
|
||||
Self::handle_notifications(&client, &tx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
let client3 = client.clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client3, &metadata_rx).await;
|
||||
}));
|
||||
|
||||
tasks.push(
|
||||
// Handle metadata requests
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
|
||||
async move {
|
||||
Self::handle_requests(&client, &mta_rx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Update GPUI state
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(*person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
seens: Rc::new(RefCell::new(HashSet::new())),
|
||||
sender: mta_tx,
|
||||
seen: RwLock::new(HashSet::new()),
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
@@ -145,24 +130,25 @@ impl PersonRegistry {
|
||||
Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
let val = Box::new(person);
|
||||
// Send
|
||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||
log::warn!("PersonRegistry channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
// Get metadata for all public keys
|
||||
get_metadata(client, public_keys).await.ok();
|
||||
if let Err(e) = get_metadata(client, public_keys).await {
|
||||
log::warn!("Failed to get metadata for contact list: {e}");
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
let val = Box::new(event.into_owned());
|
||||
// Send
|
||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||
tx.send_async(Dispatch::Relays(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
let val = Box::new(event.into_owned());
|
||||
// Send
|
||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -182,13 +168,17 @@ impl PersonRegistry {
|
||||
Ok(Some(public_key)) => {
|
||||
batch.insert(public_key);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20 {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !batch.is_empty() {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +207,7 @@ impl PersonRegistry {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(persons) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_inserts(persons, cx);
|
||||
this.bulk_insert(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -256,7 +246,7 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Insert batch of persons
|
||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
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
|
||||
@@ -290,15 +280,14 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
let mut seen = self.seens.borrow_mut();
|
||||
|
||||
if seen.insert(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);
|
||||
log::warn!("Failed to send public key for metadata request: {e}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
@@ -5,8 +5,6 @@ use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::Announcement;
|
||||
|
||||
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||
|
||||
/// Person
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Person {
|
||||
@@ -105,20 +103,18 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
/// Get profile picture, if the profile has one
|
||||
pub fn avatar(&self) -> Option<SharedString> {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
let encoded_picture = urlencoding::encode(picture);
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
);
|
||||
url.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
/// A stable seed for this profile's generated avatar
|
||||
pub fn avatar_seed(&self) -> SharedString {
|
||||
SharedString::from(self.public_key().to_hex())
|
||||
}
|
||||
|
||||
/// Get profile name
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
@@ -9,10 +8,13 @@ use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
@@ -42,27 +44,12 @@ setting_accessors! {
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub nip4e: bool,
|
||||
pub auth_mode: AuthMode,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
pub recent_communities: Vec<String>,
|
||||
}
|
||||
|
||||
/// Authentication mode
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AuthMode {
|
||||
#[default]
|
||||
Auto,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Display for AuthMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthMode::Auto => write!(f, "Auto"),
|
||||
AuthMode::Manual => write!(f, "Ask every time"),
|
||||
}
|
||||
}
|
||||
}
|
||||
const RECENT_COMMUNITIES_CAP: usize = 10;
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -141,14 +128,15 @@ pub struct Settings {
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
|
||||
/// Authentication mode
|
||||
pub auth_mode: AuthMode,
|
||||
|
||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||
pub trusted_relays: Vec<String>,
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Recently opened community ids, newest first
|
||||
#[serde(default)]
|
||||
pub recent_communities: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -159,9 +147,9 @@ impl Default for Settings {
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
auth_mode: AuthMode::default(),
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
recent_communities: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +168,6 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
/// Settings
|
||||
inner: Entity<Settings>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -191,12 +178,18 @@ impl AppSettings {
|
||||
cx.global::<GlobalAppSettings>().0.clone()
|
||||
}
|
||||
|
||||
/// The underlying settings entity, which notifies whenever any field changes.
|
||||
pub fn entity(&self) -> &Entity<Settings> {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// Set the global settings instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAppSettings(state));
|
||||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let inner = cx.new(|_| Settings::default());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -208,8 +201,8 @@ impl AppSettings {
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -227,7 +220,7 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -239,13 +232,19 @@ impl AppSettings {
|
||||
Err(anyhow!("Not found"))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let settings = task.await.unwrap_or(Settings::default());
|
||||
cx.spawn(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
|
||||
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
|
||||
}
|
||||
|
||||
// Update settings
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
this.apply_theme(window, cx);
|
||||
this.apply_theme(None, cx);
|
||||
cx.refresh_windows();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -277,7 +276,7 @@ impl AppSettings {
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
@@ -286,22 +285,22 @@ impl AppSettings {
|
||||
this.theme = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Apply theme
|
||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub fn apply_theme(&mut self, mut window: Option<&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);
|
||||
Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
|
||||
Theme::change(mode, window, cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,4 +332,14 @@ impl AppSettings {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Move a community to the front of the recently opened list
|
||||
pub fn record_recent_community(&mut self, id: String, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.recent_communities.retain(|existing| existing != &id);
|
||||
this.recent_communities.insert(0, id);
|
||||
this.recent_communities.truncate(RECENT_COMMUNITIES_CAP);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ nostr-blossom.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
@@ -23,10 +24,15 @@ serde_json.workspace = true
|
||||
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
aes-gcm.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||
nostr-lmdb.workspace = true
|
||||
smol.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
|
||||
@@ -4,30 +4,52 @@ use anyhow::{Error, anyhow};
|
||||
use gpui::AsyncApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(path).await?;
|
||||
let keys = Keys::generate();
|
||||
use crate::file::sha256_hex;
|
||||
|
||||
// Construct the blossom client
|
||||
let client = BlossomClient::new(server);
|
||||
/// Upload a blob to a blossom server and return its URL
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) async fn upload_blob(
|
||||
server: &Url,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
sha256: &str,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Url, Error> {
|
||||
let client = BlossomClient::new(server.clone());
|
||||
let keys = Keys::generate();
|
||||
let content_type = content_type.to_string();
|
||||
let base = server.clone();
|
||||
let hash = sha256.to_string();
|
||||
|
||||
Tokio::spawn(cx, async move {
|
||||
let blob = client
|
||||
match client
|
||||
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||
.await?;
|
||||
|
||||
Ok(blob.url)
|
||||
.await
|
||||
{
|
||||
Ok(blob) => Ok(blob.url),
|
||||
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
|
||||
Err(e) => Err(anyhow!(e.to_string())),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(&path).await?;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
|
||||
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 2;
|
||||
|
||||
/// Default image cache size
|
||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
|
||||
@@ -39,14 +36,15 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
|
||||
/// Default search relays
|
||||
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
|
||||
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://user.kindpag.es",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://profiles.nostr1.com",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::aead::consts::{U12, U16, U32};
|
||||
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||
use aes_gcm::aes::Aes256;
|
||||
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::http_client::AsyncBody;
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const ALGORITHM: &str = "aes-gcm";
|
||||
|
||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
const TAG_FILE_TYPE: &str = "file-type";
|
||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||
const TAG_KEY: &str = "decryption-key";
|
||||
const TAG_NONCE: &str = "decryption-nonce";
|
||||
const TAG_SIZE: &str = "size";
|
||||
const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileAttachment {
|
||||
pub url: Url,
|
||||
pub mime: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub sha256: Option<String>,
|
||||
pub original_sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub dim: Option<(u32, u32)>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FileAttachment {
|
||||
pub fn tags(&self) -> Vec<Tag> {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
||||
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
||||
Tag::custom(TAG_KEY, [self.key.clone()]),
|
||||
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
||||
];
|
||||
|
||||
if let Some(sha256) = &self.sha256 {
|
||||
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(original_sha256) = &self.original_sha256 {
|
||||
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(size) = self.size {
|
||||
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
||||
}
|
||||
|
||||
if let Some((width, height)) = self.dim {
|
||||
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
||||
}
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
||||
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
url: Url::parse(content).ok()?,
|
||||
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
||||
key: tag_value(tags, TAG_KEY)?.to_string(),
|
||||
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
||||
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
||||
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
||||
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
||||
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
||||
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.mime.starts_with("image/")
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
if let Some(name) = &self.name {
|
||||
return name.clone().into();
|
||||
}
|
||||
|
||||
match self.size {
|
||||
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
||||
None => self.mime.clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||
let cipher = AesGcm::<Aes256, U16>::new(&key);
|
||||
|
||||
let data = cipher
|
||||
.encrypt(&nonce, data)
|
||||
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
||||
|
||||
Ok(EncryptedFile {
|
||||
data,
|
||||
key: HEXLOWER.encode(key.as_slice()),
|
||||
nonce: HEXLOWER.encode(nonce.as_slice()),
|
||||
})
|
||||
}
|
||||
|
||||
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")?;
|
||||
|
||||
if key.len() != 32 {
|
||||
bail!(
|
||||
"Invalid decryption key length: expected 32 bytes, got {}",
|
||||
key.len()
|
||||
);
|
||||
}
|
||||
|
||||
match nonce.len() {
|
||||
12 => Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||
len => bail!("Unsupported decryption nonce length: {len} bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
let encrypted = encrypt(&data)?;
|
||||
let sha256 = sha256_hex(&encrypted.data);
|
||||
let original_sha256 = sha256_hex(&data);
|
||||
let size = encrypted.data.len() as u64;
|
||||
let base_url = server.to_string();
|
||||
|
||||
let url = crate::blossom::upload_blob(
|
||||
&server,
|
||||
encrypted.data,
|
||||
"application/octet-stream",
|
||||
&sha256,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
|
||||
if !message.contains("415") {
|
||||
return anyhow!(message);
|
||||
}
|
||||
|
||||
anyhow!(
|
||||
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
|
||||
opaque data, which this file server does not accept. Choose a different file
|
||||
server in the settings."
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FileAttachment {
|
||||
url,
|
||||
mime,
|
||||
key: encrypted.key,
|
||||
nonce: encrypted.nonce,
|
||||
sha256: Some(sha256),
|
||||
original_sha256: Some(original_sha256),
|
||||
size: Some(size),
|
||||
dim: None,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload_encrypted(
|
||||
_server: Url,
|
||||
_path: PathBuf,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
pub async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let client = cx.update(|app| app.http_client());
|
||||
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Failed to download file: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
response
|
||||
.into_body()
|
||||
.take(MAX_FILE_SIZE as u64 + 1)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
|
||||
if data.len() > MAX_FILE_SIZE {
|
||||
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_sha256
|
||||
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||
{
|
||||
bail!("File hash mismatch");
|
||||
}
|
||||
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment into a temporary file.
|
||||
///
|
||||
/// The same attachment always maps to the same path, so callers can render the
|
||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let name = file
|
||||
.sha256
|
||||
.clone()
|
||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||
|
||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{name}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(
|
||||
&file.url,
|
||||
&file.key,
|
||||
&file.nonce,
|
||||
file.sha256.as_deref(),
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
_file: &FileAttachment,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
}
|
||||
|
||||
/// The cache file a decrypted blob for `plaintext_sha256` is written to.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn blob_cache_path(plaintext_sha256: &str) -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
.join("coop-blobs")
|
||||
.join(plaintext_sha256)
|
||||
}
|
||||
|
||||
/// Download an encrypted blob whose pointer carries the *plaintext* hash
|
||||
/// and write the decrypted bytes to a content-addressed cache file,
|
||||
/// so later renders skip the network.
|
||||
///
|
||||
/// The cache file carries no extension: `img` sniffs the format from the bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_cache(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
plaintext_sha256: &str,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let path = blob_cache_path(plaintext_sha256);
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(url, key, nonce, None, cx).await?;
|
||||
|
||||
if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) {
|
||||
bail!("Blob hash mismatch");
|
||||
}
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid blob cache path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_cache(
|
||||
_url: &Url,
|
||||
_key: &str,
|
||||
_nonce: &str,
|
||||
_plaintext_sha256: &str,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("Blob download not supported on web"))
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
|
||||
HEXLOWER
|
||||
.decode(value.to_ascii_lowercase().as_bytes())
|
||||
.map_err(|_| anyhow!("Invalid {label} encoding"))
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_gossip_memory::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -14,17 +17,19 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod file;
|
||||
mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
pub fn init(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`.
|
||||
@@ -37,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
|
||||
}
|
||||
|
||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
@@ -58,16 +63,16 @@ pub enum StateEvent {
|
||||
}
|
||||
|
||||
impl StateEvent {
|
||||
pub fn signer_changed(&self) -> bool {
|
||||
matches!(self, StateEvent::SignerChanged)
|
||||
}
|
||||
|
||||
pub fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
Self::Error(error.into())
|
||||
}
|
||||
|
||||
pub fn signer_changed(&self) -> bool {
|
||||
matches!(self, StateEvent::SignerChanged)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr Registry
|
||||
@@ -82,6 +87,9 @@ pub struct NostrRegistry {
|
||||
/// Current user's public key
|
||||
current_user: Option<PublicKey>,
|
||||
|
||||
/// Whether the initial credential check has concluded
|
||||
ready: bool,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -100,7 +108,8 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
@@ -127,16 +136,31 @@ impl NostrRegistry {
|
||||
})
|
||||
.build();
|
||||
|
||||
// Connect to bootstrap relays after the window is ready
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
this.get_user_credential(cx);
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
this.mark_ready(cx);
|
||||
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);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
ready: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -156,6 +180,20 @@ impl NostrRegistry {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
/// Whether the initial credential check has concluded
|
||||
pub fn ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn mark_ready(&mut self, cx: &mut Context<Self>) {
|
||||
if self.ready {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ready = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Update the signer
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
@@ -164,12 +202,13 @@ impl NostrRegistry {
|
||||
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
match new_signer.get_public_key_async().await {
|
||||
Ok(public_key) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.signer.swap_inner(new_signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.mark_ready(cx);
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -181,9 +220,9 @@ impl NostrRegistry {
|
||||
}
|
||||
};
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Connect to the bootstrapping relays
|
||||
@@ -253,10 +292,19 @@ impl NostrRegistry {
|
||||
this.set_signer(signer, cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
} else if content == "proxy" {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
this.update(cx, |this, cx| {
|
||||
this.mark_ready(cx);
|
||||
this.connect_proxy(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| this.mark_ready(cx))?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.mark_ready(cx);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
@@ -268,6 +316,10 @@ impl NostrRegistry {
|
||||
|
||||
/// Get the master key that used for Nostr Connect
|
||||
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return cx.background_spawn(async move { Keys::generate() });
|
||||
}
|
||||
|
||||
let task = cx.read_credentials(MASTER_KEYRING);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
@@ -294,6 +346,81 @@ impl NostrRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the browser proxy
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||
let (tx, rx) = flume::bounded::<String>(1);
|
||||
|
||||
self.tasks.push(Tokio::spawn_result(cx, {
|
||||
let proxy = proxy.clone();
|
||||
async move {
|
||||
// Start the proxy and get the web url
|
||||
proxy.start().await?;
|
||||
// Notify GPUI
|
||||
let url = proxy.url();
|
||||
tx.send(url).ok();
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
|
||||
self.tasks.push(Tokio::spawn_result(cx, {
|
||||
let proxy = proxy.clone();
|
||||
async move {
|
||||
loop {
|
||||
if proxy.is_session_active() {
|
||||
break;
|
||||
}
|
||||
smol::Timer::after(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn({
|
||||
let proxy = proxy.clone();
|
||||
async move |this, cx| {
|
||||
while let Ok(url) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
|
||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
||||
cx.open_url(&url);
|
||||
this.set_signer(proxy.clone(), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
|
||||
// Monitor the session, if the browser disconnects, notify user to reconnect
|
||||
self.tasks.push(cx.spawn({
|
||||
let proxy = proxy.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
async move |this, cx| {
|
||||
// Wait for the signer to be confirmed (timeout is 30s)
|
||||
executor.timer(Duration::from_secs(30)).await;
|
||||
|
||||
loop {
|
||||
executor.timer(Duration::from_secs(5)).await;
|
||||
if !proxy.is_session_active() {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
// Only notify if this proxy is still the active signer
|
||||
if this.current_user.is_some() {
|
||||
this.signer.swap_inner(Keys::generate());
|
||||
this.current_user = None;
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the public key of a NIP-05 address
|
||||
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||
let client = self.client();
|
||||
|
||||
@@ -4,6 +4,7 @@ use anyhow::Error;
|
||||
use futures::io::AsyncReadExt;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait NostrAddress {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nostr_connect::client::AuthUrlHandler;
|
||||
@@ -60,21 +62,23 @@ impl UniversalSigner {
|
||||
}
|
||||
|
||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, UniversalSignerError>>;
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> BoxedFuture<'_, Result<Event, UniversalSignerError>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, UniversalSignerError>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, UniversalSignerError>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -87,7 +91,9 @@ where
|
||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, UniversalSignerError>> {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||
.await
|
||||
@@ -98,7 +104,7 @@ where
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> BoxedFuture<'_, Result<Event, UniversalSignerError>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||
.await
|
||||
@@ -110,7 +116,7 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, UniversalSignerError>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||
.await
|
||||
@@ -122,7 +128,7 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, UniversalSignerError>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||
.await
|
||||
@@ -142,7 +148,9 @@ impl UniversalSigner {
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.get_public_key_async().await })
|
||||
}
|
||||
@@ -154,7 +162,7 @@ impl AsyncSignEvent for UniversalSigner {
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> BoxedFuture<'_, Result<Event, Self::Error>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.sign_event_async(unsigned).await })
|
||||
}
|
||||
@@ -167,7 +175,7 @@ impl AsyncNip44 for UniversalSigner {
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, Self::Error>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
|
||||
}
|
||||
@@ -176,7 +184,7 @@ impl AsyncNip44 for UniversalSigner {
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, Self::Error>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
|
||||
}
|
||||
@@ -186,8 +194,10 @@ impl AsyncNip44 for UniversalSigner {
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<(), nostr_connect::error::Error>> {
|
||||
fn on_auth_url(
|
||||
&self,
|
||||
auth_url: Url,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
webbrowser::open(auth_url.as_str()).unwrap();
|
||||
Ok(())
|
||||
|
||||
@@ -6,6 +6,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -112,7 +112,7 @@ impl ThemeColors {
|
||||
elevated_surface_background: neutral().light().step_3(),
|
||||
panel_background: neutral().light().step_1(),
|
||||
overlay: neutral().light_alpha().step_3(),
|
||||
title_bar: neutral().light().step_3(),
|
||||
title_bar: neutral().light().step_2(),
|
||||
title_bar_inactive: neutral().light().step_1(),
|
||||
window_border: hsl(240.0, 5.9, 78.0),
|
||||
|
||||
@@ -198,7 +198,7 @@ impl ThemeColors {
|
||||
elevated_surface_background: neutral().dark().step_3(),
|
||||
panel_background: neutral().dark().step_1(),
|
||||
overlay: neutral().dark_alpha().step_3(),
|
||||
title_bar: neutral().dark().step_3(),
|
||||
title_bar: neutral().dark().step_2(),
|
||||
title_bar_inactive: neutral().dark().step_1(),
|
||||
window_border: hsl(240.0, 3.7, 28.0),
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Defines workspace tabbar height
|
||||
pub const TABBAR_HEIGHT: Pixels = px(28.0);
|
||||
pub const TABBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Defines default sidebar width
|
||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
@@ -46,6 +46,63 @@ pub fn init(cx: &mut App) {
|
||||
Theme::sync_scrollbar_appearance(cx);
|
||||
}
|
||||
|
||||
/// Mirror the active coop theme into the `gpui-base` global theme.
|
||||
///
|
||||
/// Base paints a few things from its own tokens -- the focus ring, the wash
|
||||
/// behind selected text, scrollbars, and overlay backdrops -- so the two
|
||||
/// globals have to agree or those details drift away from the palette.
|
||||
///
|
||||
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
|
||||
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
|
||||
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
|
||||
/// mapping would be invented rather than derived.
|
||||
///
|
||||
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
|
||||
/// authoritative hook that keeps the projection current.
|
||||
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_family = theme.font_family.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_family;
|
||||
|
||||
let tokens = &mut base.tokens.colors;
|
||||
tokens.background = colors.background;
|
||||
tokens.foreground = colors.text;
|
||||
tokens.surface = colors.surface_background;
|
||||
tokens.surface_foreground = colors.text;
|
||||
tokens.primary = colors.element_background;
|
||||
tokens.primary_foreground = colors.element_foreground;
|
||||
tokens.secondary = colors.secondary_background;
|
||||
tokens.secondary_foreground = colors.secondary_foreground;
|
||||
tokens.muted = colors.ghost_element_background_alt;
|
||||
tokens.muted_foreground = colors.text_muted;
|
||||
tokens.accent = colors.ghost_element_hover;
|
||||
tokens.accent_foreground = colors.text;
|
||||
tokens.destructive = colors.danger_background;
|
||||
tokens.destructive_foreground = colors.danger_foreground;
|
||||
tokens.border = colors.border;
|
||||
tokens.input = colors.border;
|
||||
tokens.ring = colors.ring;
|
||||
tokens.selection = colors.selection;
|
||||
}
|
||||
|
||||
pub trait ActiveTheme {
|
||||
fn theme(&self) -> &Theme;
|
||||
}
|
||||
@@ -183,6 +240,9 @@ impl Theme {
|
||||
if let Some(window) = window {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Keep the base-layer projection in step with the coop palette
|
||||
sync_base(cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ pub enum PlatformKind {
|
||||
Mac,
|
||||
Linux,
|
||||
Windows,
|
||||
Web,
|
||||
}
|
||||
|
||||
impl PlatformKind {
|
||||
@@ -11,22 +12,21 @@ impl PlatformKind {
|
||||
Self::Linux
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Self::Windows
|
||||
} else {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::Mac
|
||||
} else {
|
||||
Self::Web
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_linux(&self) -> bool {
|
||||
matches!(self, Self::Linux)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_windows(&self) -> bool {
|
||||
matches!(self, Self::Windows)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_mac(&self) -> bool {
|
||||
matches!(self, Self::Mac)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
|
||||
id: "coop".into(),
|
||||
name: "Coop Default Theme".into(),
|
||||
author: "Coop".into(),
|
||||
url: "https://github.com/lumehq/coop".into(),
|
||||
url: "https://github.com/reyakov/coop".into(),
|
||||
light: ThemeColors::light(),
|
||||
dark: ThemeColors::dark(),
|
||||
}
|
||||
@@ -186,7 +186,7 @@ mod tests {
|
||||
"id": "test-theme",
|
||||
"name": "Test Theme",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"url": "https://github.com/reyakov/coop",
|
||||
"light": {
|
||||
"background": "#ffffff",
|
||||
"surface_background": "#fafafa",
|
||||
|
||||
@@ -9,19 +9,15 @@ common = { path = "../common" }
|
||||
theme = { path = "../theme" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
instant.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
unicode-segmentation = "1.12.0"
|
||||
uuid = "1.10"
|
||||
regex = "1"
|
||||
lsp-types = "0.97.0"
|
||||
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
|
||||
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
||||
tree-sitter = "0.26"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use gpui::{actions, Action};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Define a custom confirm action
|
||||
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = list, no_json)]
|
||||
pub struct Confirm {
|
||||
/// Is confirm with secondary.
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);
|
||||
@@ -1,12 +1,25 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
|
||||
px,
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Selectable, Sizable, Size};
|
||||
use crate::{Selectable, Sizable, Size, StyledExt};
|
||||
|
||||
/// Number of rows and columns in the generated pixel grid.
|
||||
const PIXEL_GRID: usize = 8;
|
||||
/// Probability that a cell in the left half of the grid is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
|
||||
const MIN_FILLED: usize = 5;
|
||||
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
|
||||
const FALLBACK_SEED: &str = "coop";
|
||||
/// Number of segments used to approximate the avatar circle.
|
||||
const CIRCLE_SEGMENTS: usize = 32;
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size`].
|
||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
@@ -14,26 +27,355 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
Size::Large => px(64.).into(),
|
||||
Size::Medium => px(32.).into(),
|
||||
Size::Small => px(24.).into(),
|
||||
Size::XSmall => px(20.).into(),
|
||||
Size::XSmall => px(18.).into(),
|
||||
Size::Size(size) => size.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
||||
///
|
||||
/// Use it for entities that have no profile picture: the same seed always
|
||||
/// renders the same pattern, so identities stay recognizable without a
|
||||
/// network round trip. The pattern is painted as geometry and cropped to a
|
||||
/// circle, at the same sizes as [`Avatar`].
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::{Avatar};
|
||||
/// use ui::avatar::PixelAvatar;
|
||||
///
|
||||
/// Avatar::new("path/to/image.png")
|
||||
/// .grayscale(true)
|
||||
/// .border_color(gpui::red());
|
||||
/// PixelAvatar::new("alice");
|
||||
/// ```
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Creates a pixel avatar from `seed`.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: Size::Medium,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for PixelAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let side = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let seed = self.seed;
|
||||
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| seed,
|
||||
move |bounds, seed, window, cx| {
|
||||
let theme = cx.theme();
|
||||
let main = Hsla {
|
||||
h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1.,
|
||||
s: 0.6,
|
||||
l: if theme.is_dark() { 0.6 } else { 0.45 },
|
||||
a: 1.,
|
||||
};
|
||||
let shade = if theme.is_dark() {
|
||||
Hsla {
|
||||
l: (main.l * 1.6).min(0.95),
|
||||
..main
|
||||
}
|
||||
} else {
|
||||
Hsla {
|
||||
l: (main.l * 0.45).max(0.18),
|
||||
..main
|
||||
}
|
||||
};
|
||||
|
||||
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
|
||||
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
|
||||
|
||||
let pattern = pixel_pattern(seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
for (value, color) in [(1u8, main), (2u8, shade)] {
|
||||
cells.clear();
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID {
|
||||
if pattern[row * PIXEL_GRID + col] != value {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
|
||||
if cell.len() >= 3 {
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paint_polygons(window, cells.iter(), color);
|
||||
}
|
||||
},
|
||||
)
|
||||
.refine_style(&self.style)
|
||||
.size(side)
|
||||
.flex_shrink_0()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the mirrored fill pattern for `seed`.
|
||||
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
|
||||
let mut filled = 0usize;
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled < MIN_FILLED {
|
||||
let half = PIXEL_GRID * PIXEL_GRID / 2;
|
||||
let start = (rng.next() % half as u64) as usize;
|
||||
|
||||
for offset in 0..half {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
|
||||
let ix = (start + offset) % half;
|
||||
let row = ix / (PIXEL_GRID / 2);
|
||||
let col = ix % (PIXEL_GRID / 2);
|
||||
|
||||
if pattern[row * PIXEL_GRID + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Paints `polygons` as a single anti-aliased filled path in `color`.
|
||||
fn paint_polygons<'a>(
|
||||
window: &mut Window,
|
||||
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
|
||||
color: Hsla,
|
||||
) {
|
||||
let mut builder = PathBuilder::fill();
|
||||
let mut painted = false;
|
||||
|
||||
for polygon in polygons {
|
||||
if polygon.len() >= 3 {
|
||||
builder.add_polygon(polygon, true);
|
||||
painted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if painted && let Ok(path) = builder.build() {
|
||||
window.paint_path(path, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximates the circle of `radius` around `center` as a convex polygon,
|
||||
/// wound so that its interior is on the left of every directed edge.
|
||||
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
|
||||
let center_x = center.x.as_f32();
|
||||
let center_y = center.y.as_f32();
|
||||
|
||||
(0..CIRCLE_SEGMENTS)
|
||||
.map(|index| {
|
||||
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
|
||||
point(
|
||||
px(center_x + radius * angle.cos()),
|
||||
px(center_y + radius * angle.sin()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The four corners of cell `(row, col)` of the grid laid out in `bounds`.
|
||||
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
|
||||
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
|
||||
let left = bounds.origin.x.as_f32() + col as f32 * cell;
|
||||
let top = bounds.origin.y.as_f32() + row as f32 * cell;
|
||||
|
||||
[
|
||||
point(px(left), px(top)),
|
||||
point(px(left + cell), px(top)),
|
||||
point(px(left + cell), px(top + cell)),
|
||||
point(px(left), px(top + cell)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Clips `subject` to the convex `clip` polygon, keeping the part inside it.
|
||||
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
|
||||
let mut current = subject.to_vec();
|
||||
let mut next = Vec::with_capacity(subject.len() + 4);
|
||||
|
||||
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
|
||||
if current.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
next.clear();
|
||||
let mut previous = match current.last() {
|
||||
Some(&vertex) => vertex,
|
||||
None => break,
|
||||
};
|
||||
|
||||
for &vertex in current.iter() {
|
||||
let previous_inside = is_inside(start, end, previous);
|
||||
let vertex_inside = is_inside(start, end, vertex);
|
||||
|
||||
if vertex_inside {
|
||||
if !previous_inside
|
||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
||||
{
|
||||
next.push(crossing);
|
||||
}
|
||||
next.push(vertex);
|
||||
} else if previous_inside
|
||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
||||
{
|
||||
next.push(crossing);
|
||||
}
|
||||
|
||||
previous = vertex;
|
||||
}
|
||||
|
||||
std::mem::swap(&mut current, &mut next);
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
/// Whether `vertex` lies on the interior side of the directed edge `start -> end`.
|
||||
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
|
||||
let start_x = start.x.as_f32();
|
||||
let start_y = start.y.as_f32();
|
||||
let edge_x = end.x.as_f32() - start_x;
|
||||
let edge_y = end.y.as_f32() - start_y;
|
||||
let to_vertex_x = vertex.x.as_f32() - start_x;
|
||||
let to_vertex_y = vertex.y.as_f32() - start_y;
|
||||
|
||||
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
|
||||
}
|
||||
|
||||
/// The intersection of segment `from -> to` with the infinite line `start -> end`.
|
||||
fn line_intersection(
|
||||
start: Point<Pixels>,
|
||||
end: Point<Pixels>,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
) -> Option<Point<Pixels>> {
|
||||
let start_x = start.x.as_f32();
|
||||
let start_y = start.y.as_f32();
|
||||
let edge_x = end.x.as_f32() - start_x;
|
||||
let edge_y = end.y.as_f32() - start_y;
|
||||
let from_x = from.x.as_f32();
|
||||
let from_y = from.y.as_f32();
|
||||
let segment_x = to.x.as_f32() - from_x;
|
||||
let segment_y = to.y.as_f32() - from_y;
|
||||
let denominator = edge_x * segment_y - edge_y * segment_x;
|
||||
|
||||
if denominator.abs() < f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
let offset_x = from_x - start_x;
|
||||
let offset_y = from_y - start_y;
|
||||
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
|
||||
|
||||
Some(point(
|
||||
px(from_x + segment_x * t),
|
||||
px(from_y + segment_y * t),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fills `cell (row, col)` and its horizontal mirror.
|
||||
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
|
||||
pattern[row * PIXEL_GRID + col] = value;
|
||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||
struct PixelRng(u64);
|
||||
|
||||
impl PixelRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
|
||||
fn chance(&mut self, probability: f32) -> bool {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the generated pixel avatar shown in place of a missing picture.
|
||||
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
|
||||
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
|
||||
.with_size(size)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
///
|
||||
/// Entities without a picture still get a stable identity: the avatar falls
|
||||
/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there
|
||||
/// is no picture and when the picture fails to load.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::avatar::Avatar;
|
||||
///
|
||||
/// Avatar::new(None).seed("alice");
|
||||
/// ```
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
image: Img,
|
||||
picture: Option<ImageSource>,
|
||||
grayscale: bool,
|
||||
seed: Option<SharedString>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
@@ -41,11 +383,25 @@ pub struct Avatar {
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Creates a new avatar element with the specified image source.
|
||||
pub fn new(src: impl Into<ImageSource>) -> Self {
|
||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
||||
///
|
||||
/// Use [`Avatar::seed`] to choose the generated
|
||||
/// pixel avatar rendered when `picture` is `None`.
|
||||
pub fn new(picture: Option<SharedString>) -> Self {
|
||||
Self::from_picture(picture.map(ImageSource::from))
|
||||
}
|
||||
|
||||
/// Creates an avatar from an already-resolved source.
|
||||
pub fn from_source(picture: impl Into<ImageSource>) -> Self {
|
||||
Self::from_picture(Some(picture.into()))
|
||||
}
|
||||
|
||||
fn from_picture(picture: Option<ImageSource>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
image: img(src),
|
||||
picture,
|
||||
grayscale: false,
|
||||
seed: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
border_color: None,
|
||||
@@ -53,17 +409,26 @@ impl Avatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the seed for the generated pixel avatar.
|
||||
///
|
||||
/// The seed should be a stable identifier of the entity the avatar
|
||||
/// represents, such as a public key.
|
||||
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
|
||||
self.seed = Some(seed.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Applies a grayscale filter to the avatar image.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::{Avatar, AvatarShape};
|
||||
/// use ui::avatar::Avatar;
|
||||
///
|
||||
/// let avatar = Avatar::new("path/to/image.png").grayscale(true);
|
||||
/// Avatar::new(None).grayscale(true);
|
||||
/// ```
|
||||
pub fn grayscale(mut self, grayscale: bool) -> Self {
|
||||
self.image = self.image.grayscale(grayscale);
|
||||
self.grayscale = grayscale;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -115,8 +480,24 @@ impl RenderOnce for Avatar {
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
let image_size = avatar_size(self.size);
|
||||
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let container_size = image_size + border_width * 2.;
|
||||
|
||||
let content = match self.picture {
|
||||
Some(picture) => {
|
||||
let seed = self.seed;
|
||||
let grayscale = self.grayscale;
|
||||
img(picture)
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.grayscale(grayscale)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
|
||||
.into_any_element()
|
||||
}
|
||||
None => generated_avatar(self.seed.as_deref(), image_size),
|
||||
};
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
@@ -126,18 +507,79 @@ impl RenderOnce for Avatar {
|
||||
.when_some(self.border_color, |this, color| {
|
||||
this.border(border_width).border_color(color)
|
||||
})
|
||||
.child(
|
||||
self.image
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(gpui::ObjectFit::Fill)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || {
|
||||
img("brand/avatar.png")
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.child(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pixel_patterns_are_symmetric_and_stable() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pixel_pattern(seed);
|
||||
let filled = pattern.iter().filter(|&&cell| cell != 0).count();
|
||||
|
||||
assert!(
|
||||
filled >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID {
|
||||
assert_eq!(
|
||||
pattern[row * PIXEL_GRID + col],
|
||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
|
||||
"asymmetric pattern for seed {seed} at ({row}, {col})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for seed in [0, 1, 42, u64::MAX] {
|
||||
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
|
||||
}
|
||||
|
||||
assert_ne!(pixel_pattern(42), pixel_pattern(43));
|
||||
}
|
||||
|
||||
fn area(polygon: &[Point<Pixels>]) -> f32 {
|
||||
let mut sum: f32 = 0.;
|
||||
for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) {
|
||||
sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32();
|
||||
}
|
||||
(sum / 2.).abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_keeps_only_the_part_inside_the_circle() {
|
||||
let circle = circle_polygon(point(px(10.), px(10.)), 10.);
|
||||
let square = |left: f32, top: f32| {
|
||||
[
|
||||
point(px(left), px(top)),
|
||||
point(px(left + 4.), px(top)),
|
||||
point(px(left + 4.), px(top + 4.)),
|
||||
point(px(left), px(top + 4.)),
|
||||
]
|
||||
};
|
||||
|
||||
let inside = clip_polygon(&square(8., 8.), &circle);
|
||||
assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside));
|
||||
|
||||
assert!(clip_polygon(&square(20., 20.), &circle).is_empty());
|
||||
|
||||
let straddling = clip_polygon(&square(0., 0.), &circle);
|
||||
for vertex in &straddling {
|
||||
let delta_x = vertex.x.as_f32() - 10.;
|
||||
let delta_y = vertex.y.as_f32() - 10.;
|
||||
assert!(
|
||||
delta_x.hypot(delta_y) <= 10. + 0.1,
|
||||
"clipped vertex outside the circle"
|
||||
);
|
||||
}
|
||||
|
||||
let area = area(&straddling);
|
||||
assert!(area > 0. && area < 16., "area {area}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, Window, div, relative,
|
||||
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div, relative,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::indicator::Indicator;
|
||||
use crate::tooltip::Tooltip;
|
||||
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
|
||||
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, h_flex};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ButtonCustomVariant {
|
||||
@@ -114,29 +115,21 @@ pub trait ButtonVariants: Sized {
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct Button {
|
||||
id: ElementId,
|
||||
base: Stateful<Div>,
|
||||
style: StyleRefinement,
|
||||
|
||||
base: BaseButton,
|
||||
icon: Option<Icon>,
|
||||
label: Option<SharedString>,
|
||||
tooltip: Option<SharedString>,
|
||||
children: Vec<AnyElement>,
|
||||
|
||||
variant: ButtonVariant,
|
||||
size: Size,
|
||||
|
||||
disabled: bool,
|
||||
loading: bool,
|
||||
|
||||
rounded: bool,
|
||||
compact: bool,
|
||||
caret: bool,
|
||||
indicator: bool,
|
||||
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
|
||||
tab_index: isize,
|
||||
tab_stop: bool,
|
||||
|
||||
@@ -151,12 +144,8 @@ impl From<Button> for AnyElement {
|
||||
|
||||
impl Button {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id = id.into();
|
||||
|
||||
Self {
|
||||
id: id.clone(),
|
||||
base: div().flex_shrink_0().id(id),
|
||||
style: StyleRefinement::default(),
|
||||
base: BaseButton::new(id),
|
||||
icon: None,
|
||||
label: None,
|
||||
variant: ButtonVariant::default(),
|
||||
@@ -301,7 +290,7 @@ impl ButtonVariants for Button {
|
||||
|
||||
impl Styled for Button {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +307,7 @@ impl InteractiveElement for Button {
|
||||
}
|
||||
|
||||
impl RenderOnce for Button {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let style: ButtonVariant = self.variant;
|
||||
let clickable = self.clickable();
|
||||
let hoverable = self.hoverable();
|
||||
@@ -329,18 +318,21 @@ impl RenderOnce for Button {
|
||||
_ => self.size,
|
||||
};
|
||||
|
||||
let focus_handle = window
|
||||
.use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
self.base
|
||||
.when(!self.disabled, |this| {
|
||||
this.track_focus(
|
||||
&focus_handle
|
||||
.tab_index(self.tab_index)
|
||||
.tab_stop(self.tab_stop),
|
||||
)
|
||||
.tab_index(self.tab_index)
|
||||
.tab_stop(self.tab_stop)
|
||||
.disabled(self.disabled)
|
||||
.when_some(self.on_click.clone(), |this, on_click| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
if !clickable {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
|
||||
on_click(event, window, cx);
|
||||
})
|
||||
})
|
||||
.relative()
|
||||
.flex_shrink_0()
|
||||
@@ -349,7 +341,6 @@ impl RenderOnce for Button {
|
||||
.justify_center()
|
||||
.cursor_default()
|
||||
.overflow_hidden()
|
||||
.refine_style(&self.style)
|
||||
.map(|this| match self.rounded {
|
||||
false => this.rounded(cx.theme().radius),
|
||||
true => this.rounded_full(),
|
||||
@@ -399,8 +390,7 @@ impl RenderOnce for Button {
|
||||
}
|
||||
}
|
||||
})
|
||||
.refine_style(&self.style)
|
||||
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
if self.disabled {
|
||||
@@ -410,18 +400,6 @@ impl RenderOnce for Button {
|
||||
// Avoid focus on mouse down.
|
||||
window.prevent_default();
|
||||
})
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
if !clickable {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
|
||||
on_click(event, window, cx);
|
||||
})
|
||||
})
|
||||
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
|
||||
this.on_hover(move |hovered, window, cx| {
|
||||
(on_hover)(hovered, window, cx);
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::icon::IconNamed;
|
||||
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
|
||||
|
||||
/// A Checkbox element.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct Checkbox {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
style: StyleRefinement,
|
||||
label: Option<SharedString>,
|
||||
children: Vec<AnyElement>,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
size: Size,
|
||||
tab_stop: bool,
|
||||
tab_index: isize,
|
||||
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl Checkbox {
|
||||
/// Create a new Checkbox with the given id.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
style: StyleRefinement::default(),
|
||||
label: None,
|
||||
children: Vec::new(),
|
||||
checked: false,
|
||||
disabled: false,
|
||||
size: Size::default(),
|
||||
on_click: None,
|
||||
tab_stop: true,
|
||||
tab_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the label for the checkbox.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the checked state for the checkbox.
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the click handler for the checkbox.
|
||||
///
|
||||
/// The `&bool` parameter indicates the new checked state after the click.
|
||||
pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tab stop for the checkbox, default is true.
|
||||
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
|
||||
self.tab_stop = tab_stop;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tab index for the checkbox, default is 0.
|
||||
pub fn tab_index(mut self, tab_index: isize) -> Self {
|
||||
self.tab_index = tab_index;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_click(
|
||||
on_click: &Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||
checked: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let new_checked = !checked;
|
||||
if let Some(f) = on_click {
|
||||
(f)(&new_checked, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Checkbox {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
impl StatefulInteractiveElement for Checkbox {}
|
||||
|
||||
impl Styled for Checkbox {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Checkbox {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Checkbox {
|
||||
fn selected(self, selected: bool) -> Self {
|
||||
self.checked(selected)
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.checked
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Checkbox {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Checkbox {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checkbox_check_icon(
|
||||
id: ElementId,
|
||||
size: Size,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let toggle_state = window.use_keyed_state(id, cx, |_, _| checked);
|
||||
|
||||
let color = if disabled {
|
||||
cx.theme().text.opacity(0.5)
|
||||
} else {
|
||||
cx.theme().text
|
||||
};
|
||||
|
||||
svg()
|
||||
.absolute()
|
||||
.top_px()
|
||||
.left_px()
|
||||
.map(|this| match size {
|
||||
Size::XSmall => this.size_2(),
|
||||
Size::Small => this.size_2p5(),
|
||||
Size::Medium => this.size_3(),
|
||||
Size::Large => this.size_3p5(),
|
||||
_ => this.size_3(),
|
||||
})
|
||||
.text_color(color)
|
||||
.map(|this| match checked {
|
||||
true => this.path(IconName::Check.path()),
|
||||
_ => this,
|
||||
})
|
||||
.map(|this| {
|
||||
if !disabled && checked != *toggle_state.read(cx) {
|
||||
let duration = Duration::from_secs_f64(0.25);
|
||||
cx.spawn({
|
||||
let toggle_state = toggle_state.clone();
|
||||
async move |cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
toggle_state.update(cx, |this, _| *this = checked);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this.with_animation(
|
||||
ElementId::NamedInteger("toggle".into(), checked as u64),
|
||||
Animation::new(Duration::from_secs_f64(0.25)),
|
||||
move |this, delta| {
|
||||
this.opacity(if checked { 1.0 * delta } else { 1.0 - delta })
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
this.into_any_element()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl RenderOnce for Checkbox {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let focus_handle = window
|
||||
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
let checked = self.checked;
|
||||
let radius = cx.theme().radius.min(px(4.));
|
||||
|
||||
let border_color = if checked {
|
||||
cx.theme().border_focused
|
||||
} else {
|
||||
cx.theme().border
|
||||
};
|
||||
|
||||
let color = if self.disabled {
|
||||
border_color.opacity(0.5)
|
||||
} else {
|
||||
border_color
|
||||
};
|
||||
|
||||
div().child(
|
||||
self.base
|
||||
.id(self.id.clone())
|
||||
.when(!self.disabled, |this| {
|
||||
this.track_focus(
|
||||
&focus_handle
|
||||
.tab_stop(self.tab_stop)
|
||||
.tab_index(self.tab_index),
|
||||
)
|
||||
})
|
||||
.h_flex()
|
||||
.gap_2()
|
||||
.items_start()
|
||||
.line_height(relative(1.))
|
||||
.text_color(cx.theme().text)
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.text_xs(),
|
||||
Size::Small => this.text_sm(),
|
||||
Size::Medium => this.text_base(),
|
||||
Size::Large => this.text_lg(),
|
||||
_ => this,
|
||||
})
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
||||
.rounded(cx.theme().radius * 0.5)
|
||||
.refine_style(&self.style)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.size_3(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Medium => this.size_4(),
|
||||
Size::Large => this.size(rems(1.125)),
|
||||
_ => this.size_4(),
|
||||
})
|
||||
.flex_shrink_0()
|
||||
.border_1()
|
||||
.border_color(color)
|
||||
.rounded(radius)
|
||||
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
|
||||
.map(|this| match checked {
|
||||
false => this.bg(cx.theme().background),
|
||||
_ => this.bg(color),
|
||||
})
|
||||
.child(checkbox_check_icon(
|
||||
self.id,
|
||||
self.size,
|
||||
checked,
|
||||
self.disabled,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.when(self.label.is_some() || !self.children.is_empty(), |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.line_height(relative(1.2))
|
||||
.gap_1()
|
||||
.map(|this| {
|
||||
if let Some(label) = self.label {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.text_color(cx.theme().text)
|
||||
.when(self.disabled, |this| {
|
||||
this.text_color(cx.theme().text_muted)
|
||||
})
|
||||
.line_height(relative(1.))
|
||||
.child(label),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.children(self.children),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
|
||||
// Avoid focus on mouse down.
|
||||
window.prevent_default();
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.on_click({
|
||||
let on_click = self.on_click.clone();
|
||||
move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
Self::handle_click(&on_click, checked, window, cx);
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{DockArea, DockItem};
|
||||
use crate::StyledExt;
|
||||
use crate::dock::panel::PanelView;
|
||||
use crate::dock::tab_panel::TabPanel;
|
||||
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
impl Render for ResizePanel {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DockPlacement {
|
||||
Center,
|
||||
Left,
|
||||
Bottom,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl DockPlacement {
|
||||
fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Self::Left | Self::Right => Axis::Horizontal,
|
||||
Self::Bottom => Axis::Vertical,
|
||||
Self::Center => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
|
||||
pub fn is_bottom(&self) -> bool {
|
||||
matches!(self, Self::Bottom)
|
||||
}
|
||||
|
||||
pub fn is_right(&self) -> bool {
|
||||
matches!(self, Self::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Dock is a fixed container that places at left, bottom, right of the Windows.
|
||||
///
|
||||
/// This is unlike Panel, it can't be move or add any other panel.
|
||||
pub struct Dock {
|
||||
pub(super) placement: DockPlacement,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
|
||||
/// Dock layout
|
||||
pub(crate) panel: DockItem,
|
||||
|
||||
/// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height.
|
||||
pub(super) size: Pixels,
|
||||
|
||||
/// Whether the Dock is open
|
||||
pub(super) open: bool,
|
||||
|
||||
/// Whether the Dock is collapsible, default: true
|
||||
pub(super) collapsible: bool,
|
||||
|
||||
/// Whether the Dock is resizing
|
||||
resizing: bool,
|
||||
}
|
||||
|
||||
impl Dock {
|
||||
pub(crate) fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
placement: DockPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let panel = cx.new(|cx| {
|
||||
let mut tab = TabPanel::new(None, dock_area.clone(), window, cx);
|
||||
tab.closable = true;
|
||||
tab
|
||||
});
|
||||
|
||||
let panel = DockItem::Tabs {
|
||||
items: Vec::new(),
|
||||
active_ix: 0,
|
||||
view: panel.clone(),
|
||||
};
|
||||
|
||||
Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx);
|
||||
|
||||
Self {
|
||||
placement,
|
||||
dock_area,
|
||||
panel,
|
||||
open: true,
|
||||
collapsible: true,
|
||||
size: px(200.0),
|
||||
resizing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new(dock_area, DockPlacement::Left, window, cx)
|
||||
}
|
||||
|
||||
pub fn bottom(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new(dock_area, DockPlacement::Bottom, window, cx)
|
||||
}
|
||||
|
||||
pub fn right(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new(dock_area, DockPlacement::Right, window, cx)
|
||||
}
|
||||
|
||||
/// Update the Dock to be collapsible or not.
|
||||
///
|
||||
/// And if the Dock is not collapsible, it will be open.
|
||||
pub fn set_collapsible(
|
||||
&mut self,
|
||||
collapsible: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.collapsible = collapsible;
|
||||
if !collapsible {
|
||||
self.open = true
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn subscribe_panel_events(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
panel: &DockItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
match panel {
|
||||
DockItem::Tabs { view, .. } => {
|
||||
window.defer(cx, {
|
||||
let view = view.clone();
|
||||
move |window, cx| {
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
this.subscribe_panel(&view, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
DockItem::Split { items, view, .. } => {
|
||||
for item in items {
|
||||
Self::subscribe_panel_events(dock_area.clone(), item, window, cx);
|
||||
}
|
||||
window.defer(cx, {
|
||||
let view = view.clone();
|
||||
move |window, cx| {
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
this.subscribe_panel(&view, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
DockItem::Panel { .. } => {
|
||||
// Not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_panel(&mut self, panel: DockItem, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.panel = panel;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_open(!self.open, window, cx);
|
||||
}
|
||||
|
||||
/// Returns the size of the Dock, the size is means the width or height of
|
||||
/// the Dock, if the placement is left or right, the size is width,
|
||||
/// otherwise the size is height.
|
||||
pub fn size(&self) -> Pixels {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Set the size of the Dock.
|
||||
pub fn set_size(&mut self, size: Pixels, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.size = size.max(PANEL_MIN_SIZE);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the open state of the Dock.
|
||||
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = open;
|
||||
let item = self.panel.clone();
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
item.set_collapsed(!open, window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add item to the Dock.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.panel.add_panel(panel, &self.dock_area, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_resize_handle(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let axis = self.placement.axis();
|
||||
let view = cx.entity().clone();
|
||||
|
||||
resize_handle("resize-handle", axis)
|
||||
.placement(self.placement)
|
||||
.on_drag(ResizePanel {}, move |info, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
view.update(cx, |view, _cx| {
|
||||
view.resizing = true;
|
||||
});
|
||||
cx.new(|_| info.deref().clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn resize(
|
||||
&mut self,
|
||||
mouse_position: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.resizing {
|
||||
return;
|
||||
}
|
||||
|
||||
let dock_area = self
|
||||
.dock_area
|
||||
.upgrade()
|
||||
.expect("DockArea is missing")
|
||||
.read(cx);
|
||||
|
||||
let area_bounds = dock_area.bounds;
|
||||
let mut left_dock_size = px(0.0);
|
||||
let mut right_dock_size = px(0.0);
|
||||
|
||||
// Get the size of the left dock if it's open and not the current dock
|
||||
if let Some(left_dock) = &dock_area.left_dock
|
||||
&& left_dock.entity_id() != cx.entity().entity_id()
|
||||
{
|
||||
let left_dock_read = left_dock.read(cx);
|
||||
if left_dock_read.is_open() {
|
||||
left_dock_size = left_dock_read.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the size of the right dock if it's open and not the current dock
|
||||
if let Some(right_dock) = &dock_area.right_dock
|
||||
&& right_dock.entity_id() != cx.entity().entity_id()
|
||||
{
|
||||
let right_dock_read = right_dock.read(cx);
|
||||
if right_dock_read.is_open() {
|
||||
right_dock_size = right_dock_read.size;
|
||||
}
|
||||
}
|
||||
|
||||
let size = match self.placement {
|
||||
DockPlacement::Left => mouse_position.x - area_bounds.left(),
|
||||
DockPlacement::Right => area_bounds.right() - mouse_position.x,
|
||||
DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y,
|
||||
DockPlacement::Center => unreachable!(),
|
||||
};
|
||||
|
||||
match self.placement {
|
||||
DockPlacement::Left => {
|
||||
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size;
|
||||
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
|
||||
}
|
||||
DockPlacement::Right => {
|
||||
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size;
|
||||
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
|
||||
}
|
||||
DockPlacement::Bottom => {
|
||||
let max_size = area_bounds.size.height - PANEL_MIN_SIZE;
|
||||
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
|
||||
}
|
||||
DockPlacement::Center => unreachable!(),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||
self.resizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Dock {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
|
||||
if !self.open && !self.placement.is_bottom() {
|
||||
return div();
|
||||
}
|
||||
|
||||
let cache_style = StyleRefinement::default().absolute().size_full();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.map(|this| match self.placement {
|
||||
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size),
|
||||
DockPlacement::Bottom => this.w_full().h(self.size),
|
||||
DockPlacement::Center => unreachable!(),
|
||||
})
|
||||
// Bottom Dock should keep the title bar, then user can click the Toggle button
|
||||
.when(!self.open && self.placement.is_bottom(), |this| {
|
||||
this.h(px(29.))
|
||||
})
|
||||
.map(|this| match &self.panel {
|
||||
DockItem::Split { view, .. } => this.child(view.clone()),
|
||||
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
||||
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
|
||||
})
|
||||
.child(self.render_resize_handle(window, cx))
|
||||
.child(DockElement {
|
||||
view: cx.entity().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DockElement {
|
||||
view: Entity<Dock>,
|
||||
}
|
||||
|
||||
impl IntoElement for DockElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DockElement {
|
||||
type PrepaintState = ();
|
||||
type RequestLayoutState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
(window.request_layout(Style::default(), None, cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.on_mouse_event({
|
||||
let view = self.view.clone();
|
||||
let is_resizing = view.read(cx).resizing;
|
||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||
if !is_resizing {
|
||||
return;
|
||||
}
|
||||
if !phase.bubble() {
|
||||
return;
|
||||
}
|
||||
|
||||
view.update(cx, |view, cx| view.resize(e.position, window, cx))
|
||||
}
|
||||
});
|
||||
|
||||
// When any mouse up, stop dragging
|
||||
window.on_mouse_event({
|
||||
let view = self.view.clone();
|
||||
move |_: &MouseUpEvent, phase, window, cx| {
|
||||
if phase.bubble() {
|
||||
view.update(cx, |view, cx| view.done_resizing(window, cx));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
|
||||
SharedString, Window,
|
||||
};
|
||||
use gpui_base::dock::{PanelId, PanelState};
|
||||
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
@@ -13,19 +17,8 @@ pub enum PanelEvent {
|
||||
LayoutChanged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PanelStyle {
|
||||
/// Display the TabBar when there are multiple tabs, otherwise display the simple title.
|
||||
Default,
|
||||
/// Always display the tab bar.
|
||||
TabBar,
|
||||
}
|
||||
|
||||
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||
///
|
||||
/// This is used to identify the panel when deserializing the panel.
|
||||
/// Once you have defined a panel id, this must not be changed.
|
||||
fn panel_id(&self) -> SharedString;
|
||||
|
||||
/// The title of the panel
|
||||
@@ -156,3 +149,84 @@ impl PartialEq for dyn PanelView {
|
||||
self.view() == other.view()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PanelHandle {
|
||||
id: PanelId,
|
||||
panel: Arc<dyn PanelView>,
|
||||
}
|
||||
|
||||
impl PanelHandle {
|
||||
pub fn new<P: Panel>(panel: Entity<P>) -> Self {
|
||||
Self {
|
||||
id: PanelId::from(panel.entity_id()),
|
||||
panel: Arc::new(panel),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the coop handle behind one of base's.
|
||||
pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
|
||||
panel.as_any().downcast_ref::<Self>()
|
||||
}
|
||||
|
||||
/// The coop panel behind this handle.
|
||||
pub fn panel(&self) -> &Arc<dyn PanelView> {
|
||||
&self.panel
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui_base::dock::PanelView for PanelHandle {
|
||||
fn panel_name(&self, _: &App) -> &'static str {
|
||||
"CoopPanel"
|
||||
}
|
||||
|
||||
fn panel_id(&self, _: &App) -> PanelId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn closable(&self, cx: &App) -> bool {
|
||||
self.panel.closable(cx)
|
||||
}
|
||||
|
||||
fn zoomable(&self, cx: &App) -> bool {
|
||||
self.panel.zoomable(cx)
|
||||
}
|
||||
|
||||
fn visible(&self, cx: &App) -> bool {
|
||||
self.panel.visible(cx)
|
||||
}
|
||||
|
||||
fn set_active(&self, active: bool, _: &mut Window, cx: &mut App) {
|
||||
self.panel.set_active(active, cx);
|
||||
}
|
||||
|
||||
fn set_zoomed(&self, zoomed: bool, _: &mut Window, cx: &mut App) {
|
||||
self.panel.set_zoomed(zoomed, cx);
|
||||
}
|
||||
|
||||
fn on_added_to(
|
||||
&self,
|
||||
_group: gpui::WeakEntity<gpui_base::dock::TabGroup>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) {
|
||||
}
|
||||
|
||||
fn on_removed(&self, _: &mut Window, _: &mut App) {}
|
||||
|
||||
fn view(&self) -> AnyView {
|
||||
self.panel.view()
|
||||
}
|
||||
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.panel.focus_handle(cx)
|
||||
}
|
||||
|
||||
fn dump(&self, cx: &App) -> PanelState {
|
||||
PanelState::new(self.panel_name(cx))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
|
||||
|
||||
use super::{DockArea, PanelEvent};
|
||||
use crate::dock::panel::{Panel, PanelView};
|
||||
use crate::dock::tab_panel::TabPanel;
|
||||
use crate::h_flex;
|
||||
use crate::resizable::{
|
||||
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||
resizable_panel,
|
||||
};
|
||||
|
||||
pub struct StackPanel {
|
||||
pub(super) parent: Option<WeakEntity<StackPanel>>,
|
||||
pub(super) axis: Axis,
|
||||
focus_handle: FocusHandle,
|
||||
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
|
||||
state: Entity<ResizableState>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Panel for StackPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"StackPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> gpui::AnyElement {
|
||||
"StackPanel".into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl StackPanel {
|
||||
pub fn new(axis: Axis, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let state = cx.new(|_| ResizableState::default());
|
||||
|
||||
// Bubble up the resize event.
|
||||
let subscriptions =
|
||||
vec![
|
||||
cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| {
|
||||
cx.emit(PanelEvent::LayoutChanged)
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
axis,
|
||||
parent: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
panels: SmallVec::new(),
|
||||
state,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first level of the stack panel is root, will not have a parent.
|
||||
fn is_root(&self) -> bool {
|
||||
self.parent.is_none()
|
||||
}
|
||||
|
||||
/// Return true if self or parent only have last panel.
|
||||
pub fn is_last_panel(&self, cx: &App) -> bool {
|
||||
if self.panels.len() > 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(parent) = &self.parent
|
||||
&& let Some(parent) = parent.upgrade()
|
||||
{
|
||||
return parent.read(cx).is_last_panel(cx);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn panels_len(&self) -> usize {
|
||||
self.panels.len()
|
||||
}
|
||||
|
||||
/// Return the index of the panel.
|
||||
pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
|
||||
self.panels.iter().position(|p| p == &panel)
|
||||
}
|
||||
|
||||
/// Add a panel at the end of the stack.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx);
|
||||
}
|
||||
|
||||
pub fn add_panel_at(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
placement: Placement,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.insert_panel_at(
|
||||
panel,
|
||||
self.panels_len(),
|
||||
placement,
|
||||
size,
|
||||
dock_area,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn insert_panel_at(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
placement: Placement,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match placement {
|
||||
Placement::Top | Placement::Left => {
|
||||
self.insert_panel_before(panel, ix, size, dock_area, window, cx)
|
||||
}
|
||||
Placement::Right | Placement::Bottom => {
|
||||
self.insert_panel_after(panel, ix, size, dock_area, window, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a panel at the index.
|
||||
pub fn insert_panel_before(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.insert_panel(panel, ix, size, dock_area, window, cx);
|
||||
}
|
||||
|
||||
/// Insert a panel after the index.
|
||||
pub fn insert_panel_after(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
|
||||
}
|
||||
|
||||
fn insert_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// If the panel is already in the stack, return.
|
||||
if self.index_of_panel(panel.clone()).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let view = cx.entity().clone();
|
||||
|
||||
window.defer(cx, {
|
||||
let panel = panel.clone();
|
||||
|
||||
move |window, cx| {
|
||||
// If the panel is a TabPanel, set its parent to this.
|
||||
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
|
||||
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
|
||||
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
|
||||
stack_panel.update(cx, |stack_panel, _| {
|
||||
stack_panel.parent = Some(view.downgrade())
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to the panel's layout change event.
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
|
||||
this.subscribe_panel(&tab_panel, window, cx);
|
||||
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
|
||||
this.subscribe_panel(&stack_panel, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let ix = if ix > self.panels.len() {
|
||||
self.panels.len()
|
||||
} else {
|
||||
ix
|
||||
};
|
||||
|
||||
// Get avg size of all panels to insert new panel, if size is None.
|
||||
let size = match size {
|
||||
Some(size) => size,
|
||||
None => {
|
||||
let state = self.state.read(cx);
|
||||
(state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE)
|
||||
}
|
||||
};
|
||||
|
||||
// Insert panel
|
||||
self.panels.insert(ix, panel.clone());
|
||||
|
||||
// Update resizable state
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.insert_panel(Some(size), Some(ix), cx);
|
||||
});
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Remove panel from the stack.
|
||||
///
|
||||
/// If `ix` is not found, do nothing.
|
||||
pub fn remove_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(ix) = self.index_of_panel(panel.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.panels.remove(ix);
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.remove_panel(ix, cx);
|
||||
});
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
|
||||
self.remove_self_if_empty(window, cx);
|
||||
}
|
||||
|
||||
/// Replace the old panel with the new panel at same index.
|
||||
pub fn replace_panel(
|
||||
&mut self,
|
||||
old_panel: Arc<dyn PanelView>,
|
||||
new_panel: Entity<StackPanel>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
|
||||
self.panels[ix] = Arc::new(new_panel.clone());
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.replace_panel(ix, ResizablePanelState::default(), cx);
|
||||
});
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// If children is empty, remove self from parent view.
|
||||
pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.is_root() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.panels.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let view = cx.entity().clone();
|
||||
if let Some(parent) = self.parent.as_ref() {
|
||||
_ = parent.update(cx, |parent, cx| {
|
||||
parent.remove_panel(Arc::new(view.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Find the first top left in the stack.
|
||||
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||
if check_parent
|
||||
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
|
||||
{
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let first_panel = self.panels.first();
|
||||
if let Some(view) = first_panel {
|
||||
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
|
||||
Some(tab_panel)
|
||||
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
|
||||
stack_panel.read(cx).left_top_tab_panel(false, cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first top right in the stack.
|
||||
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||
if check_parent
|
||||
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
|
||||
{
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let panel = if self.axis.is_vertical() {
|
||||
self.panels.first()
|
||||
} else {
|
||||
self.panels.last()
|
||||
};
|
||||
|
||||
if let Some(view) = panel {
|
||||
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
|
||||
Some(tab_panel)
|
||||
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
|
||||
stack_panel.read(cx).right_top_tab_panel(false, cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all panels from the stack.
|
||||
pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.panels.clear();
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.clear();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Change the axis of the stack panel.
|
||||
pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.axis = axis;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for StackPanel {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for StackPanel {}
|
||||
impl EventEmitter<DismissEvent> for StackPanel {}
|
||||
|
||||
impl Render for StackPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().panel_background)
|
||||
.when(cx.theme().platform.is_linux(), |this| {
|
||||
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.child(
|
||||
ResizablePanelGroup::new("stack-panel-group")
|
||||
.with_state(&self.state)
|
||||
.axis(self.axis)
|
||||
.children(self.panels.clone().into_iter().map(|panel| {
|
||||
resizable_panel()
|
||||
.child(panel.view())
|
||||
.visible(panel.visible(cx))
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window};
|
||||
|
||||
/// A trait to extend [`gpui::Element`] with additional functionality.
|
||||
pub trait ElementExt: ParentElement + Sized {
|
||||
/// Add a prepaint callback to the element.
|
||||
///
|
||||
/// This is a helper method to get the bounds of the element after paint.
|
||||
///
|
||||
/// The first argument is the bounds of the element in pixels.
|
||||
///
|
||||
/// See also [`gpui::canvas`].
|
||||
fn on_prepaint<F>(self, f: F) -> Self
|
||||
where
|
||||
F: FnOnce(Bounds<Pixels>, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| f(bounds, window, cx),
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ParentElement> ElementExt for T {}
|
||||
@@ -1,21 +0,0 @@
|
||||
use gpui::{App, ClickEvent, InteractiveElement, Stateful, Window};
|
||||
|
||||
pub trait InteractiveElementExt: InteractiveElement {
|
||||
/// Set the listener for a double click event.
|
||||
fn on_double_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.interactivity().on_click(move |event, window, cx| {
|
||||
if event.click_count() == 2 {
|
||||
listener(event, window, cx);
|
||||
}
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: InteractiveElement> InteractiveElementExt for Stateful<E> {}
|
||||