Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193df36dae | ||
|
|
09fb58c5a4 | ||
|
|
fff0f6a753 | ||
|
|
33785f183d | ||
|
|
3ada21b728 | ||
|
|
e1eff61856 | ||
|
|
2bce050c3b | ||
|
|
2a404591d8 | ||
|
|
c544dd4e36 | ||
|
|
acb07126c9 | ||
|
|
2fc1358530 | ||
|
|
e14b815404 | ||
|
|
88d3840fd2 | ||
|
|
1f350f511e | ||
|
|
21f96c7bbe | ||
|
|
be15873e0d | ||
|
|
e4e13b0889 | ||
|
|
1032ac3751 | ||
|
|
6ff1eeddbb | ||
|
|
0c1c23fe9c | ||
|
|
874f103752 | ||
|
|
fdabfcf026 | ||
|
|
5500082426 | ||
|
|
7a34d66c7c | ||
|
|
57c85f5b99 | ||
|
|
bf7080654d | ||
|
|
42983383b0 | ||
|
|
e2b10c173e | ||
|
|
9e33da717b | ||
|
|
584ab34df6 | ||
|
|
0514c1d982 | ||
|
|
2f834a0bcc | ||
|
|
49cd5cb9a0 |
@@ -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
|
||||
|
||||
@@ -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,21 @@ 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 = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
gpui-base = "0.7.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
@@ -25,7 +27,19 @@ 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" ] }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
data-encoding = "2"
|
||||
hkdf = "0.12"
|
||||
# Pinned to the instances `nostr` already builds: the NIP-44 message-key disclosure
|
||||
chacha20 = "0.9"
|
||||
hmac = "0.12"
|
||||
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
||||
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
@@ -33,7 +47,6 @@ chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -43,12 +56,8 @@ smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta5"
|
||||
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" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C9.44568 21.25 7.13318 20.2147 5.45926 18.5407C3.78534 16.8668 2.75 14.5543 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C14.5543 2.75 16.8668 3.78534 18.5407 5.45926C20.2147 7.13318 21.25 9.44568 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/><path d="M18 6L6 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/> </svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 1014 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 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M12.75 11.2499V8.24994L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L15.75 11.2499H12.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.25 3.75H6.95C5.8299 3.75 5.26984 3.75 4.84202 3.96799C4.46569 4.15973 4.15973 4.46569 3.96799 4.84202C3.75 5.26984 3.75 5.8299 3.75 6.95V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.8299 20.25 6.95 20.25H17.05C18.1701 20.25 18.7302 20.25 19.158 20.032C19.5343 19.8403 19.8403 19.5343 20.032 19.158C20.25 18.7302 20.25 18.1701 20.25 17.05V12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 857 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="M8.75 3.75L6.75 20.25M17.25 3.75L15.25 20.25M3.75 7.75H20.25M20.25 16.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 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 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M14.75 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25H14.75M15 12H3.75M15 12L11.5 15.5M15 12L11.5 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M20.25 12L9 12M20.25 12L15.75 16.5M20.25 12L15.75 7.5M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25L3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75L11.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 346 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 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 624 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 849 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",
|
||||
|
||||
@@ -7,5 +7,4 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
@@ -30,22 +30,3 @@ impl AssetSource for Assets {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,8 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ureq.workspace = true
|
||||
|
||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
||||
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||
|
||||
@@ -1,157 +1,129 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
||||
use instant::{Duration, Instant};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
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;
|
||||
|
||||
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 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 get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
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")
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
///
|
||||
/// Skips initialization when running as a Flatpak (updates are handled by the
|
||||
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
|
||||
/// entity and schedules a check for updates after a 2-minute delay.
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
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 {}
|
||||
|
||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
||||
|
||||
/// The global auto-updater entity.
|
||||
///
|
||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
||||
///
|
||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
||||
pub struct AutoUpdater {
|
||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
||||
pub updater: Entity<Updater>,
|
||||
/// Currently running app version.
|
||||
pub version: Version,
|
||||
/// Keeps the observer subscription alive.
|
||||
_subscription: Subscription,
|
||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
||||
error_time: Option<Instant>,
|
||||
/// 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>,
|
||||
/// The in-flight check or download, if any.
|
||||
task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the global auto updater instance.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
/// 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())
|
||||
}
|
||||
|
||||
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();
|
||||
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));
|
||||
|
||||
let repo_owner = get_github_repo_owner();
|
||||
let repo_name = get_github_repo_name();
|
||||
|
||||
let source =
|
||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
||||
"macos" => "macos",
|
||||
"linux" => "linux",
|
||||
_ => "",
|
||||
});
|
||||
|
||||
let updater: Entity<Updater> =
|
||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
||||
|
||||
// When an update becomes available, automatically download and install it.
|
||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
||||
let status = this.updater.read(cx).status().clone();
|
||||
|
||||
if matches!(status, UpdateStatus::Available(_)) {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.download_and_install(cx);
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(status, UpdateStatus::Errored(_)) {
|
||||
this.error_time = Some(Instant::now());
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
this.error_time = None;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
||||
// end of the current frame so the window is fully set up).
|
||||
cx.defer_in(window, |_this, _window, cx| {
|
||||
let duration = Duration::from_secs(120);
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
// 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 {
|
||||
updater,
|
||||
version,
|
||||
_subscription: subscription,
|
||||
error_time: None,
|
||||
engine,
|
||||
status: UpdateStatus::Idle,
|
||||
available: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn idle(&self, cx: &App) -> bool {
|
||||
let status = self.updater.read(cx).status();
|
||||
if status == &UpdateStatus::Idle {
|
||||
return true;
|
||||
}
|
||||
if matches!(status, UpdateStatus::Errored(_))
|
||||
&& self
|
||||
.error_time
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
/// 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)
|
||||
}
|
||||
|
||||
pub fn status(&self, cx: &App) -> SharedString {
|
||||
let status = self.updater.read(cx).status();
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
pub fn staged(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Staged(_))
|
||||
}
|
||||
|
||||
match status {
|
||||
UpdateStatus::Idle => "Up to date".into(),
|
||||
/// 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::UpToDate => "Up to date".into(),
|
||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||
UpdateStatus::Downloading { downloaded, total } => {
|
||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||
@@ -165,16 +137,168 @@ impl AutoUpdater {
|
||||
UpdateStatus::Staged(version) => {
|
||||
format!("Version {version} ready — restart to apply").into()
|
||||
}
|
||||
UpdateStatus::Errored(msg) => {
|
||||
if self
|
||||
.error_time
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
"Up to date".into()
|
||||
} else {
|
||||
format!("Update failed: {msg}").into()
|
||||
}
|
||||
}
|
||||
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Download the available update, verify it, and swap it into place.
|
||||
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||
if self.status.is_busy() {
|
||||
return;
|
||||
}
|
||||
let Some(release) = self.available.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let engine = self.engine.clone();
|
||||
self.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
self.task = Some(cx.spawn(async move |this, cx| {
|
||||
let downloaded = Arc::new(AtomicU64::new(0));
|
||||
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let download_task = {
|
||||
let (engine, release) = (engine.clone(), release.clone());
|
||||
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||
cx.background_executor().spawn(async move {
|
||||
let result = engine.download(&release, |got, expected| {
|
||||
downloaded.store(got, Ordering::Relaxed);
|
||||
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||
});
|
||||
done.store(true, Ordering::Relaxed);
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
loop {
|
||||
let got = downloaded.load(Ordering::Relaxed);
|
||||
let total = total.load(Ordering::Relaxed);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: got,
|
||||
total: (total != 0).then_some(total),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
if done.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(120))
|
||||
.await;
|
||||
}
|
||||
|
||||
let artifact = match download_task.await {
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
log::warn!("Update download failed: {error}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
||||
|
||||
let installed = {
|
||||
let engine = engine.clone();
|
||||
cx.background_executor()
|
||||
.spawn(async move { engine.install(&artifact) })
|
||||
.await
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
match installed {
|
||||
Ok(installed) => {
|
||||
if let Some(path) = installed.restart_path {
|
||||
cx.set_restart_path(path);
|
||||
}
|
||||
let version = release.version.clone();
|
||||
this.set_status(UpdateStatus::Staged(version), cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Relaunch into the staged update.
|
||||
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.staged() {
|
||||
log::warn!("Ignoring restart request: no update is staged");
|
||||
return;
|
||||
}
|
||||
cx.restart();
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||
self.status = status;
|
||||
|
||||
if errored {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(UpdateStatus::Idle, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ publish = false
|
||||
atomic-destructor = "0.2"
|
||||
event-listener = "5"
|
||||
nostr.workspace = true
|
||||
opaquerr = "0.1"
|
||||
opaquerr = { version = "0.1", features = ["alloc"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -49,8 +49,6 @@ impl Message {
|
||||
enum RequestMethod {
|
||||
GetPublicKey,
|
||||
SignEvent,
|
||||
Nip04Encrypt,
|
||||
Nip04Decrypt,
|
||||
Nip44Encrypt,
|
||||
Nip44Decrypt,
|
||||
}
|
||||
@@ -60,8 +58,6 @@ impl RequestMethod {
|
||||
match self {
|
||||
Self::GetPublicKey => "get_public_key",
|
||||
Self::SignEvent => "sign_event",
|
||||
Self::Nip04Encrypt => "nip04_encrypt",
|
||||
Self::Nip04Decrypt => "nip04_decrypt",
|
||||
Self::Nip44Encrypt => "nip44_encrypt",
|
||||
Self::Nip44Decrypt => "nip44_decrypt",
|
||||
}
|
||||
@@ -205,36 +201,6 @@ impl Default for BrowserSignerProxyOptions {
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserSignerProxyOptions {
|
||||
/// Sets the timeout duration.
|
||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IP address.
|
||||
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
||||
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the port number.
|
||||
pub const fn port(mut self, new_port: u16) -> Self {
|
||||
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a custom html page.
|
||||
///
|
||||
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
||||
/// which will handle communication with the server and update the element
|
||||
/// with id `nip07-proxy-status` with the status.
|
||||
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
||||
self.custom_html = custom_html;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserSignerProxy {
|
||||
/// Construct a new browser signer proxy
|
||||
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
||||
@@ -255,14 +221,8 @@ impl BrowserSignerProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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
|
||||
@@ -419,20 +379,6 @@ impl BrowserSignerProxy {
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
@@ -471,26 +417,6 @@ impl AsyncSignEvent for BrowserSignerProxy {
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip04 for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
fn nip04_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip04_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
encrypted_content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip44 for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
nip4e = { path = "../nip4e" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
@@ -17,6 +17,7 @@ instant.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
rand.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
|
||||
@@ -9,24 +9,30 @@ 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 nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
mod search;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
pub use search::SearchMessage;
|
||||
pub use state::FileAttachment;
|
||||
|
||||
use crate::search::MessageSearchIndex;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
@@ -36,15 +42,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),
|
||||
}
|
||||
|
||||
@@ -79,41 +78,30 @@ impl Signal {
|
||||
pub struct ChatRegistry {
|
||||
/// Chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// O(1) room lookup by room ID
|
||||
room_index: HashMap<u64, Entity<Room>>,
|
||||
|
||||
/// Events that failed to unwrap for any reason
|
||||
trash: Entity<BTreeSet<FailedMessage>>,
|
||||
|
||||
/// Tracking events seen on which relays in the current session
|
||||
seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
|
||||
|
||||
/// Mapping of unwrapped event ids to their gift wrap event ids
|
||||
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
|
||||
|
||||
/// In-memory message content index for search
|
||||
search: Arc<RwLock<MessageSearchIndex>>,
|
||||
/// True while the initial event backlog is still loading
|
||||
tracking: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
|
||||
/// Channel for receiving signals from the UI.
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
/// Notification listener task (cancelled on signer change)
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Signal consumer task (cancelled on signer change)
|
||||
signal_consumer: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Fuzzy matcher for room search (cached; intentionally excluded from Debug)
|
||||
#[allow(dead_code)]
|
||||
matcher: CachedMatcher,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -149,26 +137,36 @@ 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 nostr = NostrRegistry::global(cx);
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer event
|
||||
cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.get_rooms(cx);
|
||||
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&device, |this, _device, event, cx| {
|
||||
if matches!(event, DeviceEvent::Set) {
|
||||
this.handle_notifications(cx);
|
||||
this.get_messages(cx);
|
||||
this.reprocess_trash(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -177,6 +175,7 @@ impl ChatRegistry {
|
||||
trash: cx.new(|_| BTreeSet::default()),
|
||||
seen: Arc::new(RwLock::new(HashMap::default())),
|
||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||
search: Arc::default(),
|
||||
tracking: Arc::new(AtomicBool::new(true)),
|
||||
matcher: CachedMatcher(SkimMatcherV2::default()),
|
||||
signal_rx: rx,
|
||||
@@ -198,6 +197,9 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
let seen = self.seen.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trash = self.trash.downgrade();
|
||||
@@ -220,11 +222,26 @@ impl ChatRegistry {
|
||||
};
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
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;
|
||||
}
|
||||
@@ -249,7 +266,14 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
match extract_rumor(
|
||||
&client,
|
||||
&signer,
|
||||
device_signer.as_ref(),
|
||||
event.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rumor) => {
|
||||
let Some(rumor_id) = rumor.id else {
|
||||
log::error!("Rumor missing id after ensure_id");
|
||||
@@ -365,7 +389,8 @@ impl ChatRegistry {
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.first_owned()
|
||||
.into_iter()
|
||||
.next()
|
||||
.is_some();
|
||||
|
||||
if !found {
|
||||
@@ -384,6 +409,9 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -397,7 +425,8 @@ impl ChatRegistry {
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.first_owned()
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||
|
||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||
@@ -415,6 +444,20 @@ impl ChatRegistry {
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
// Also pick up gift wraps addressed to this account's decoupled encryption key
|
||||
if let Some(device_signer) = device_signer.as_ref() {
|
||||
let device_public_key = device_signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_public_key);
|
||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
|
||||
let target: HashMap<RelayUrl, Filter> = nip17::extract_relay_list(&event)
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -428,6 +471,49 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Retry unwrapping trashed gift wraps with the newly available encryption key.
|
||||
fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let device_signer = DeviceRegistry::global(cx).read(cx).signer(cx);
|
||||
|
||||
let failed: Vec<FailedMessage> = self.trash.read(cx).iter().cloned().collect();
|
||||
|
||||
if failed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
for message in failed {
|
||||
let Ok(event) = Event::from_json(message.raw_event.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if extract_rumor(&client, &signer, device_signer.as_ref(), &event)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(rumor) = get_rumor(&client, event.id).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.trash.update(cx, |trash, cx| {
|
||||
trash.remove(&message);
|
||||
cx.notify();
|
||||
});
|
||||
this.new_message(NewMessage::new(event.id, rumor), cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reload the chat registry, fetching messages and contact list from relays.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
@@ -462,26 +548,6 @@ impl ChatRegistry {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of messages seen by a given relay.
|
||||
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
|
||||
self.seen
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|s| s.contains(relay_url))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count the number of trash messages.
|
||||
pub fn count_trash_messages(&self, cx: &App) -> usize {
|
||||
self.trash.read(cx).len()
|
||||
}
|
||||
|
||||
/// Get the trash messages entity.
|
||||
pub fn trash(&self) -> Entity<BTreeSet<FailedMessage>> {
|
||||
self.trash.clone()
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given rumor id.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
self.event_map
|
||||
@@ -492,7 +558,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Get the relays that have seen a given gift wrap id.
|
||||
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
pub(crate) fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
self.seen
|
||||
.read()
|
||||
.unwrap()
|
||||
@@ -502,7 +568,7 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
pub(crate) fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room>,
|
||||
{
|
||||
@@ -522,39 +588,21 @@ impl ChatRegistry {
|
||||
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>, window: &mut Window, 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 and index.
|
||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||
let entity = room.to_owned();
|
||||
e.insert(entity.clone());
|
||||
|
||||
self.rooms.insert(0, entity);
|
||||
}
|
||||
|
||||
// Emit the open room event deferred to avoid re-entrant reads
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
});
|
||||
}
|
||||
|
||||
/// Close a room.
|
||||
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.room_index.contains_key(&id) {
|
||||
self.room_index.remove(&id);
|
||||
self.rooms.retain(|r| r.read(cx).id != id);
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(ChatEvent::CloseRoom(id));
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort rooms by their created at. Only notifies if order changed.
|
||||
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn sort(&mut self, cx: &mut Context<Self>) {
|
||||
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
||||
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
||||
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
||||
@@ -584,10 +632,16 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the content of all indexed messages, newest first.
|
||||
pub fn search_messages(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
|
||||
self.search.read().unwrap().search(terms, limit)
|
||||
}
|
||||
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.clear();
|
||||
self.room_index.clear();
|
||||
self.search.write().unwrap().clear();
|
||||
self.trash.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
@@ -626,8 +680,8 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.get_rooms_task(cx);
|
||||
pub(crate) fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.query_chat_rooms(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
@@ -648,23 +702,35 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn get_rooms_task(&self, 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();
|
||||
let search = self.search.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let contacts = client
|
||||
|
||||
// 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();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
@@ -673,6 +739,7 @@ impl ChatRegistry {
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||
&& rumor.tags.public_keys().next().is_some()
|
||||
{
|
||||
search.write().unwrap().insert(&rumor);
|
||||
if rumor.pubkey != public_key
|
||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
||||
{
|
||||
@@ -706,9 +773,11 @@ 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.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
self.search.write().unwrap().insert(&message.rumor);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
@@ -731,32 +800,33 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor(
|
||||
async fn extract_rumor<S, D>(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
signer: &S,
|
||||
device_signer: Option<&D>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
) -> Result<UnsignedEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
D: AsyncNip44 + ?Sized,
|
||||
{
|
||||
// Try to get cached rumor first
|
||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(rumor);
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
|
||||
let unwrapped = match try_unwrap_with(signer, gift_wrap).await {
|
||||
Ok(unwrapped) => unwrapped,
|
||||
Err(error) => {
|
||||
let Some(device_signer) = device_signer else {
|
||||
return Err(error);
|
||||
};
|
||||
try_unwrap_with(device_signer, gift_wrap).await?
|
||||
}
|
||||
};
|
||||
let mut rumor = unwrapped.rumor;
|
||||
|
||||
// Verify rumor author matches the seal sender (as per mobile implementation)
|
||||
@@ -776,10 +846,10 @@ async fn extract_rumor(
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with(
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
@@ -789,9 +859,17 @@ async fn try_unwrap_with(
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify()?;
|
||||
|
||||
let sender_encryption_key = seal
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.unwrap_or(seal.pubkey);
|
||||
|
||||
// Get the rumor event
|
||||
let rumor = signer
|
||||
.nip44_decrypt_async(&seal.pubkey, &seal.content)
|
||||
.nip44_decrypt_async(&sender_encryption_key, &seal.content)
|
||||
.await?;
|
||||
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
@@ -810,7 +888,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
Tag::identifier(id),
|
||||
Tag::public_key(rumor.pubkey),
|
||||
Tag::custom("r", [room_id]),
|
||||
Tag::custom("k", ["14"]),
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||
@@ -827,7 +905,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||
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."))
|
||||
|
||||
@@ -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(crate) 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,35 +137,41 @@ 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 {
|
||||
pub room: u64,
|
||||
pub gift_wrap: EventId,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
pub fn new(_gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
let room = rumor.uniq_id();
|
||||
|
||||
Self {
|
||||
room,
|
||||
gift_wrap,
|
||||
rumor,
|
||||
}
|
||||
Self { room, rumor }
|
||||
}
|
||||
}
|
||||
|
||||
/// Trash message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FailedMessage {
|
||||
pub(crate) struct FailedMessage {
|
||||
pub raw_event: SharedString,
|
||||
pub reason: SharedString,
|
||||
}
|
||||
|
||||
impl FailedMessage {
|
||||
pub fn new<T>(event: &Event, reason: T) -> Self
|
||||
pub(crate) fn new<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
@@ -151,7 +189,7 @@ pub struct Mention {
|
||||
}
|
||||
|
||||
impl Mention {
|
||||
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
pub(crate) fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
Self { public_key, range }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,99 +3,52 @@ use std::hash::{Hash, Hasher};
|
||||
|
||||
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 nip4e::DeviceRegistry;
|
||||
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.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
|
||||
|
||||
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
/// The receiver the rumor was addressed to.
|
||||
pub receiver: PublicKey,
|
||||
pub gift_wrap_id: Option<EventId>,
|
||||
pub error: Option<SharedString>,
|
||||
pub output: Option<Output<EventId, EventSendStatus>>,
|
||||
/// The per-relay send output, or the error that prevented the send.
|
||||
result: Result<Output<EventId, EventSendStatus>, SharedString>,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
gift_wrap_id: None,
|
||||
error: None,
|
||||
output: None,
|
||||
}
|
||||
/// The error that prevented the send, if any.
|
||||
pub fn error(&self) -> Option<&SharedString> {
|
||||
self.result.as_ref().err()
|
||||
}
|
||||
|
||||
/// Set the gift wrap ID.
|
||||
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
|
||||
self.gift_wrap_id = Some(gift_wrap_id);
|
||||
self
|
||||
/// The per-relay send output, if the gift wrap was dispatched.
|
||||
pub fn output(&self) -> Option<&Output<EventId, EventSendStatus>> {
|
||||
self.result.as_ref().ok()
|
||||
}
|
||||
|
||||
/// Set the output.
|
||||
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
|
||||
self.output = Some(output);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the error message.
|
||||
pub fn error<T>(mut self, error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns true if the send is pending.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.error.is_none()
|
||||
&& self
|
||||
.output
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send was successful.
|
||||
/// Whether at least one relay accepted the event.
|
||||
pub fn success(&self) -> bool {
|
||||
self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
|
||||
self.output()
|
||||
.is_some_and(|output| !output.success.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send failed.
|
||||
/// Whether no relay accepted the event.
|
||||
pub fn failed(&self) -> bool {
|
||||
self.error.is_some() && self.output.as_ref().is_some_and(|o| !o.failed.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SendStatus {
|
||||
Ok {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
},
|
||||
Failed {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SendStatus {
|
||||
pub fn ok(id: EventId, relay: RelayUrl) -> Self {
|
||||
Self::Ok { id, relay }
|
||||
}
|
||||
|
||||
pub fn failed(id: EventId, relay: RelayUrl, message: String) -> Self {
|
||||
Self::Failed { id, relay, message }
|
||||
match &self.result {
|
||||
Err(_) => true,
|
||||
Ok(output) => output.success.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +78,7 @@ pub struct Room {
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
pub(crate) subject: Option<SharedString>,
|
||||
|
||||
/// All members of the room
|
||||
pub(super) members: Vec<PublicKey>,
|
||||
@@ -233,13 +186,16 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
|
||||
pub(crate) fn set_created_at<T>(&mut self, created_at: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<Timestamp>,
|
||||
{
|
||||
self.created_at = created_at.into();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -289,12 +245,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) -> String {
|
||||
if self.is_group() {
|
||||
self.id.to_string()
|
||||
} else {
|
||||
self.display_member(cx).avatar_seed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +300,7 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Push a new message to the current room
|
||||
pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
pub(crate) fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let created_at = message.rumor.created_at;
|
||||
let new_message = created_at > self.created_at;
|
||||
|
||||
@@ -347,11 +312,6 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a signal to reload the current room's messages.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomEvent::Reload);
|
||||
}
|
||||
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -403,7 +363,7 @@ impl Room {
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
@@ -439,12 +399,52 @@ impl Room {
|
||||
let content: String = content.into();
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(self.conversation_tags(&replies, sender, cx))
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
|
||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(file.tags());
|
||||
|
||||
// Construct a file message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
@@ -454,59 +454,23 @@ 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 (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(
|
||||
Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}
|
||||
.to_tag(),
|
||||
);
|
||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => encryption_signer
|
||||
.clone()
|
||||
.expect("encryption signer must be set"),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
/// Send the rumor to every member's messaging relays.
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let config = self.config.clone();
|
||||
|
||||
@@ -523,7 +487,7 @@ impl Room {
|
||||
let sender = persons.read(cx).get(¤t_user, cx);
|
||||
|
||||
// Get all members (excluding sender)
|
||||
let members: Vec<Person> = self
|
||||
let receivers: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender.public_key())
|
||||
@@ -532,71 +496,35 @@ impl Room {
|
||||
|
||||
Some(cx.background_spawn(async move {
|
||||
let signer_kind = config.signer_kind();
|
||||
let backup = config.backup();
|
||||
|
||||
let mut sents = 0;
|
||||
let mut reports = Vec::new();
|
||||
|
||||
// Process each member
|
||||
for member in members {
|
||||
let announcement = member.announcement();
|
||||
let public_key = member.public_key();
|
||||
|
||||
// Handle encryption signer requirements
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = Self::select_signer(
|
||||
// Deliver to every receiver concurrently
|
||||
let sends = receivers.into_iter().map(|receiver| {
|
||||
deliver(
|
||||
&client,
|
||||
signer_kind,
|
||||
announcement.is_some(),
|
||||
&encryption_signer,
|
||||
receiver,
|
||||
&rumor,
|
||||
encryption_signer.as_ref(),
|
||||
&user_signer,
|
||||
)
|
||||
});
|
||||
|
||||
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await;
|
||||
let minimum_success = reports.iter().any(SendReport::success);
|
||||
|
||||
// Back up the message to ourselves once at least one receiver got it
|
||||
if config.backup() && minimum_success {
|
||||
reports.push(
|
||||
deliver(
|
||||
&client,
|
||||
signer_kind,
|
||||
sender,
|
||||
&rumor,
|
||||
encryption_signer.as_ref(),
|
||||
&user_signer,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||
Ok(report) => {
|
||||
reports.push(report);
|
||||
sents += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send backup to current user if needed
|
||||
if backup && sents >= 1 {
|
||||
let public_key = sender.public_key();
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = Self::select_signer(
|
||||
signer_kind,
|
||||
sender.announcement().is_some(),
|
||||
&encryption_signer,
|
||||
&user_signer,
|
||||
);
|
||||
|
||||
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reports
|
||||
@@ -604,55 +532,151 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to send a gift-wrapped event
|
||||
async fn send_gift_wrap(
|
||||
/// Deliver a rumor to a single receiver.
|
||||
async fn deliver<S>(
|
||||
client: &Client,
|
||||
signer_kind: &SignerKind,
|
||||
receiver: Person,
|
||||
rumor: &UnsignedEvent,
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> SendReport
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
let public_key = receiver.public_key();
|
||||
let announcement = receiver.announcement();
|
||||
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
return SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(NO_DEKEY.into()),
|
||||
};
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
return SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(USER_NO_DEKEY.into()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match send_gift_wrap(
|
||||
client,
|
||||
&receiver,
|
||||
rumor,
|
||||
signer_kind,
|
||||
encryption_signer,
|
||||
user_signer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Ok(output),
|
||||
},
|
||||
Err(error) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(error.to_string().into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the gift-wrapped event for a rumor and send it.
|
||||
async fn send_gift_wrap<S>(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", vec!["14"]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> Result<Output<EventId, EventSendStatus>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
let mut extra_tags = vec![Tag::custom("k", [rumor.kind.to_string()])];
|
||||
|
||||
// Determine the receiver public key based on the config
|
||||
let receiver = match config {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
let receiver_key = match config {
|
||||
SignerKind::Auto => match receiver.announcement().as_ref() {
|
||||
Some(announcement) => {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
receiver.public_key()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
None => receiver.public_key(),
|
||||
},
|
||||
SignerKind::Encryption => match receiver.announcement().as_ref() {
|
||||
Some(announcement) => {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
return Err(anyhow!("User has no encryption announcement"));
|
||||
}
|
||||
}
|
||||
None => return Err(anyhow!("User has no encryption announcement")),
|
||||
},
|
||||
SignerKind::User => receiver.public_key(),
|
||||
};
|
||||
|
||||
// Construct the gift wrap event
|
||||
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
|
||||
.extra_tags(extra_tags)
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
// Make sure the rumor carries an event ID before it's serialized
|
||||
let mut rumor = rumor.clone();
|
||||
rumor.ensure_id();
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
let report = client
|
||||
let rumor_json = rumor.as_json();
|
||||
let has_encryption_announcement = receiver.announcement().is_some();
|
||||
|
||||
let seal: Event = match (encryption_signer, has_encryption_announcement) {
|
||||
(Some(encryption_signer), true) => {
|
||||
let encryption_pubkey = encryption_signer.get_public_key_async().await?;
|
||||
let content = encryption_signer
|
||||
.nip44_encrypt_async(&receiver_key, &rumor_json)
|
||||
.await?;
|
||||
|
||||
EventBuilder::new(Kind::Seal, content)
|
||||
.tags(vec![Tag::custom("n", [encryption_pubkey.to_hex()])])
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize_async(user_signer)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
let content = user_signer
|
||||
.nip44_encrypt_async(&receiver_key, &rumor_json)
|
||||
.await?;
|
||||
|
||||
EventBuilder::new(Kind::Seal, content)
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize_async(user_signer)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let ephemeral_keys = Keys::generate();
|
||||
let content = nip44::encrypt(
|
||||
ephemeral_keys.secret_key(),
|
||||
&receiver_key,
|
||||
seal.as_json(),
|
||||
nip44::Version::default(),
|
||||
)?;
|
||||
extra_tags.push(Tag::public_key(receiver_key));
|
||||
|
||||
let event = EventBuilder::new(Kind::GiftWrap, content)
|
||||
.tags(extra_tags)
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize(&ephemeral_keys)?;
|
||||
|
||||
// Send to the receiver's NIP-17 relays.
|
||||
client
|
||||
.send_event(&event)
|
||||
.to_nip17()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
.map(|output| {
|
||||
SendReport::new(receiver)
|
||||
.gift_wrap_id(event.id)
|
||||
.output(output)
|
||||
})?;
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
/// A tweaked timestamp to thwart time-analysis attacks, per NIP-59
|
||||
fn tweaked_timestamp() -> Timestamp {
|
||||
const MAX_TWEAK_SECONDS: u64 = 2 * 24 * 60 * 60;
|
||||
|
||||
let seconds = rand::random_range(0..=MAX_TWEAK_SECONDS);
|
||||
Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(seconds))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::EventExt;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A chat message kept in memory for content search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchMessage {
|
||||
/// ID of the room the message belongs to
|
||||
pub room: u64,
|
||||
/// Author of the message
|
||||
pub author: PublicKey,
|
||||
/// The content of the message
|
||||
pub content: Arc<str>,
|
||||
/// When the message was created
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IndexedMessage {
|
||||
message: SearchMessage,
|
||||
/// Lowercase content so repeated searches don't re-allocate per message
|
||||
normalized: Arc<str>,
|
||||
}
|
||||
|
||||
/// In-memory index of chat message rumors, keyed by room and rumor id.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageSearchIndex(BTreeMap<u64, BTreeMap<EventId, Arc<IndexedMessage>>>);
|
||||
|
||||
impl MessageSearchIndex {
|
||||
/// Index a rumor for search, ignoring reactions and empty content.
|
||||
pub fn insert(&mut self, rumor: &UnsignedEvent) {
|
||||
if !is_chat(rumor.kind) || rumor.content.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(id) = rumor.id else { return };
|
||||
|
||||
let indexed = Arc::new(IndexedMessage {
|
||||
message: SearchMessage {
|
||||
room: rumor.uniq_id(),
|
||||
author: rumor.pubkey,
|
||||
content: rumor.content.as_str().into(),
|
||||
created_at: rumor.created_at,
|
||||
},
|
||||
normalized: rumor.content.to_lowercase().into(),
|
||||
});
|
||||
|
||||
self.0
|
||||
.entry(rumor.uniq_id())
|
||||
.or_default()
|
||||
.entry(id)
|
||||
.or_insert(indexed);
|
||||
}
|
||||
|
||||
/// Find messages whose content contains all of the terms, newest first.
|
||||
pub fn search(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
|
||||
let mut matches: Vec<&Arc<IndexedMessage>> = self
|
||||
.0
|
||||
.values()
|
||||
.flat_map(|messages| messages.values())
|
||||
.filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term)))
|
||||
.collect();
|
||||
|
||||
matches.sort_by_key(|indexed| Reverse(indexed.message.created_at));
|
||||
matches.truncate(limit);
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|indexed| indexed.message.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop all indexed messages.
|
||||
pub fn clear(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chat(kind: Kind) -> bool {
|
||||
kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent {
|
||||
let sender = Keys::generate();
|
||||
let peer = Keys::generate().public_key();
|
||||
|
||||
let mut rumor = EventBuilder::new(kind, content)
|
||||
.tag(Tag::public_key(peer))
|
||||
.finalize_unsigned(sender.public_key());
|
||||
rumor.ensure_id();
|
||||
rumor.created_at = Timestamp::from(created_at);
|
||||
rumor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_all_terms_newest_first_and_deduplicates() {
|
||||
let mut index = MessageSearchIndex::default();
|
||||
let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10);
|
||||
let second = rumor(Kind::PrivateDirectMessage, "hello again", 20);
|
||||
let reaction = rumor(Kind::Reaction, "hello world", 30);
|
||||
|
||||
index.insert(&first);
|
||||
index.insert(&first);
|
||||
index.insert(&second);
|
||||
index.insert(&reaction);
|
||||
|
||||
let hits = index.search(&["hello".to_owned()], 10);
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(&*hits[0].content, "hello again");
|
||||
assert_eq!(hits[0].created_at, Timestamp::from(20));
|
||||
|
||||
let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10);
|
||||
assert_eq!(hits.len(), 1);
|
||||
|
||||
let hits = index.search(&["hello".to_owned()], 1);
|
||||
assert_eq!(hits.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_not_indexed() {
|
||||
let mut index = MessageSearchIndex::default();
|
||||
index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10));
|
||||
|
||||
assert!(index.search(&["hello".to_owned()], 10).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,6 @@ nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
regex = "1"
|
||||
|
||||
|
||||
@@ -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,399 +1,25 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chat::Mention;
|
||||
use common::RangeExt;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use gpui::{App, Entity};
|
||||
use person::PersonRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::markdown::InlineReplacement;
|
||||
pub use ui::markdown::RenderedText;
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Highlight(HighlightStyle),
|
||||
Mention,
|
||||
}
|
||||
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Highlight(style)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) -> 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(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
persons,
|
||||
cx,
|
||||
);
|
||||
|
||||
text.truncate(text.trim_end().len());
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone()).with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.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") {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_plain_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>,
|
||||
/// Render message `content`, replacing mentions with their display names.
|
||||
pub fn rendered_text(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
) -> RenderedText {
|
||||
let replacements = mentions
|
||||
.iter()
|
||||
.map(|mention| {
|
||||
InlineReplacement::new(
|
||||
mention.range.clone(),
|
||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut bold_depth = 0;
|
||||
let mut italic_depth = 0;
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
|
||||
let mut options = Options::all();
|
||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||
|
||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
// Process text with mention replacements
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
while let Some(mention) = mentions.first() {
|
||||
if !source_range.contains_inclusive(&mention.range) {
|
||||
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;
|
||||
|
||||
// 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,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// Process the mention replacement
|
||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||
let replacement_text = format!("@{}", profile.name());
|
||||
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
mentions = &mentions[1..];
|
||||
}
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
if last_processed < t_str.len() {
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
text.push_str(t.as_ref());
|
||||
let is_link = link_url.is_some();
|
||||
|
||||
if let Some(link_url) = link_url.clone() {
|
||||
link_ranges.push(prev_len..text.len());
|
||||
link_urls.push(link_url);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
Tag::Item => {
|
||||
let len = list_stack.len();
|
||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||
*has_content = false;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..len - 1 {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if let Some(number) = list_number {
|
||||
text.push_str(&format!("{}. ", number));
|
||||
*number += 1;
|
||||
*has_content = false;
|
||||
} else {
|
||||
text.push_str("- ");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||
TagEnd::Link => link_url = None,
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
segment_start: usize,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
link_url: Option<String>,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
if bold_depth > 0 {
|
||||
style.font_weight = Some(FontWeight::BOLD);
|
||||
}
|
||||
if italic_depth > 0 {
|
||||
style.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
if strikethrough_depth > 0 {
|
||||
style.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Add the text
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||
}
|
||||
} else {
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder.links(segment) {
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
// Add non-link text before this link
|
||||
if start > last_link_pos {
|
||||
let non_link_start = segment_start + last_link_pos;
|
||||
let non_link_end = segment_start + start;
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
highlights.push((range, Highlight::Highlight(link_style)));
|
||||
|
||||
last_link_pos = end;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last link
|
||||
if last_link_pos < segment.len() {
|
||||
let remaining_start = segment_start + last_link_pos;
|
||||
let remaining_end = segment_start + segment.len();
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
} else {
|
||||
*has_content = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
RenderedText::new(content, &replacements)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ nostr-sdk.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
regex = "1.10"
|
||||
|
||||
@@ -1,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,9 +1,9 @@
|
||||
use std::marker::PhantomData;
|
||||
use instant::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{Context, Task, Window};
|
||||
use instant::Duration;
|
||||
|
||||
pub struct DebouncedDelay<E: 'static> {
|
||||
task: Option<Task<()>>,
|
||||
@@ -11,12 +11,6 @@ pub struct DebouncedDelay<E: 'static> {
|
||||
_phantom_data: PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> DebouncedDelay<E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -64,3 +58,9 @@ impl<E: 'static> DebouncedDelay<E> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::QrCode;
|
||||
use qrcode::render::svg;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
@@ -59,25 +55,3 @@ impl TimestampExt for Timestamp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StringExt {
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> StringExt for T {
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
let svg = code
|
||||
.render()
|
||||
.min_dimensions(256, 256)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#FFFFFF"))
|
||||
.build();
|
||||
|
||||
Some(Arc::new(Image::from_bytes(
|
||||
ImageFormat::Svg,
|
||||
svg.into_bytes(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
pub use caching::*;
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use media_extractor::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod caching;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod media_extractor;
|
||||
mod parser;
|
||||
mod paths;
|
||||
mod range;
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
use gpui::SharedUri;
|
||||
use regex::Regex;
|
||||
|
||||
/// Extracts media URLs from a string and returns both the extracted URLs
|
||||
/// and the string with media URLs removed
|
||||
pub struct MediaExtractor {
|
||||
/// Extracts media URLs from a string and returns both
|
||||
/// the extracted URLs and the string with media URLs removed
|
||||
struct MediaExtractor {
|
||||
image_regex: Regex,
|
||||
video_regex: Regex,
|
||||
}
|
||||
|
||||
impl MediaExtractor {
|
||||
/// Creates a new MediaExtractor with compiled regex patterns
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
MediaExtractor {
|
||||
// Match common image extensions
|
||||
image_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
// Match common video extensions
|
||||
video_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all media URLs from a string
|
||||
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
// Extract image URLs
|
||||
@@ -32,30 +27,22 @@ impl MediaExtractor {
|
||||
urls.push(capture.as_str().to_string().into());
|
||||
}
|
||||
|
||||
// Extract video URLs
|
||||
// for capture in self.video_regex.find_iter(text) {
|
||||
// urls.push(capture.as_str().to_string().into());
|
||||
// }
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
/// Removes all media URLs from a string and returns the cleaned text
|
||||
pub fn remove_media_urls(&self, text: &str) -> String {
|
||||
fn remove_media_urls(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove image URLs
|
||||
result = self.image_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove video URLs
|
||||
// result = self.video_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Clean up extra whitespace that might result from removal
|
||||
self.cleanup_text(&result)
|
||||
}
|
||||
|
||||
/// Extracts media URLs and removes them from the string, returning both
|
||||
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
let urls = self.extract_media_urls(text);
|
||||
let cleaned_text = self.remove_media_urls(text);
|
||||
(urls, cleaned_text)
|
||||
@@ -69,33 +56,6 @@ impl MediaExtractor {
|
||||
let re = Regex::new(r"\s+").unwrap();
|
||||
re.replace_all(text, " ").trim().to_string()
|
||||
}
|
||||
|
||||
/// Validates if a URL is a valid media URL
|
||||
pub fn is_media_url(&self, url: &str) -> bool {
|
||||
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
||||
}
|
||||
|
||||
/// Categorizes extracted URLs into images and videos
|
||||
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
||||
let mut images = Vec::new();
|
||||
let mut videos = Vec::new();
|
||||
|
||||
for url in urls {
|
||||
if self.image_regex.is_match(url) {
|
||||
images.push(url.clone());
|
||||
} else if self.video_regex.is_match(url) {
|
||||
videos.push(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(images, videos)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for one-time extraction and removal
|
||||
@@ -103,15 +63,3 @@ pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_and_remove(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just extracting media URLs
|
||||
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_media_urls(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just removing media URLs
|
||||
pub fn remove_media_urls(text: &str) -> String {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.remove_media_urls(text)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,6 @@ struct Match {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NostrParser;
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrParser {
|
||||
/// Create new parser
|
||||
pub const fn new() -> Self {
|
||||
@@ -44,6 +38,12 @@ impl NostrParser {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct FindMatches<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns the path to the user's home directory.
|
||||
pub fn home_dir() -> &'static PathBuf {
|
||||
fn home_dir() -> &'static PathBuf {
|
||||
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
||||
}
|
||||
@@ -36,30 +36,3 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
home_dir().join(".config").join("coop")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the support directory used by Coop.
|
||||
pub fn support_dir() -> &'static PathBuf {
|
||||
static SUPPORT_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
SUPPORT_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
return home_dir().join("Library/Application Support/Coop");
|
||||
}
|
||||
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
return if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
|
||||
flatpak_xdg_data.into()
|
||||
} else {
|
||||
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
|
||||
}
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
return dirs::data_local_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
config_dir().clone()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
use std::cmp::{self};
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
|
||||
pub trait RangeExt<T> {
|
||||
fn sorted(&self) -> Self;
|
||||
fn to_inclusive(&self) -> RangeInclusive<T>;
|
||||
fn overlaps(&self, other: &Range<T>) -> bool;
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool;
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.start.clone()..=self.end.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start < other.end && other.start < self.end
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start <= other.start && other.end <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start() < &other.end && &other.start <= self.end()
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start() <= &other.start && &other.end <= self.end()
|
||||
}
|
||||
}
|
||||
@@ -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,631 @@
|
||||
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 relays = material
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
|
||||
let control_root = match &material.control_root {
|
||||
Some(root) => Some(decode_hex_32(root)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
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,
|
||||
control_pks,
|
||||
channels,
|
||||
relays,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,84 @@
|
||||
use std::future::Future;
|
||||
|
||||
use gpui::{App, AppContext, Global, Task};
|
||||
use gpui_util::defer;
|
||||
use tokio::task::JoinError;
|
||||
|
||||
/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads.
|
||||
pub fn init(cx: &mut App) {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
// Since we now have two executors, let's try to keep our footprint small
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to initialize Tokio");
|
||||
|
||||
let handle = runtime.handle().clone();
|
||||
cx.set_global(GlobalTokio {
|
||||
owned_runtime: Some(runtime),
|
||||
handle,
|
||||
});
|
||||
}
|
||||
|
||||
struct GlobalTokio {
|
||||
owned_runtime: Option<tokio::runtime::Runtime>,
|
||||
handle: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl Global for GlobalTokio {}
|
||||
|
||||
impl Drop for GlobalTokio {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.owned_runtime.take() {
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tokio {}
|
||||
|
||||
impl Tokio {
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn<C, Fut, R>(cx: &C, f: Fut) -> Task<Result<R, JoinError>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = R> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn_result<C, Fut, R>(cx: &C, f: Fut) -> Task<anyhow::Result<R>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = anyhow::Result<R>> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await?;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "nip29"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
state = { path = "../state" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,287 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use futures::future::join_all;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList, GroupMetadata};
|
||||
use crate::utils::parse_members;
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct Discovery {
|
||||
refs: HashMap<RelayUrl, HashSet<GroupId>>,
|
||||
discovered: Vec<GroupCandidate>,
|
||||
members: HashMap<GroupKey, (Timestamp, usize)>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
/// The discovered groups, sorted by name.
|
||||
pub fn discovered(&self) -> &[GroupCandidate] {
|
||||
&self.discovered
|
||||
}
|
||||
|
||||
/// The member count of a group, when its member list is known.
|
||||
pub fn members(&self, key: &GroupKey) -> Option<usize> {
|
||||
self.members.get(key).map(|(_, members)| *members)
|
||||
}
|
||||
|
||||
/// The (relay, group) references, for asking the relays for metadata.
|
||||
pub(crate) fn refs(&self) -> &HashMap<RelayUrl, HashSet<GroupId>> {
|
||||
&self.refs
|
||||
}
|
||||
|
||||
/// Absorb the references of a group list, skipping the joined groups.
|
||||
pub(crate) fn absorb_list(&mut self, list: &GroupList, joined: &HashSet<(RelayUrl, GroupId)>) {
|
||||
for entry in list.entries() {
|
||||
if joined.contains(&(entry.relay.clone(), entry.id.clone())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.refs
|
||||
.entry(entry.relay.clone())
|
||||
.or_default()
|
||||
.insert(entry.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb the newest group list of every author.
|
||||
pub(crate) fn absorb_lists(
|
||||
&mut self,
|
||||
lists: Vec<Event>,
|
||||
joined: &HashSet<(RelayUrl, GroupId)>,
|
||||
) {
|
||||
let newest: HashMap<PublicKey, Event> =
|
||||
lists.into_iter().fold(HashMap::new(), |mut newest, event| {
|
||||
match newest.get(&event.pubkey) {
|
||||
Some(current) if current.created_at >= event.created_at => newest,
|
||||
_ => {
|
||||
newest.insert(event.pubkey, event);
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for event in newest.into_values() {
|
||||
let Ok(list) = GroupList::parse(&event) else {
|
||||
log::warn!("ignoring a malformed group list from {}", event.pubkey);
|
||||
continue;
|
||||
};
|
||||
|
||||
self.absorb_list(&list, joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a discovered group, keeping the newest metadata.
|
||||
pub(crate) fn upsert(&mut self, candidate: GroupCandidate) {
|
||||
match self.discovered.iter_mut().find(|d| d.key == candidate.key) {
|
||||
Some(existing) if existing.created_at <= candidate.created_at => {
|
||||
*existing = candidate;
|
||||
}
|
||||
Some(_) => return,
|
||||
None => self.discovered.push(candidate),
|
||||
}
|
||||
|
||||
self.discovered.sort_by(|a, b| {
|
||||
a.display_name()
|
||||
.to_lowercase()
|
||||
.cmp(&b.display_name().to_lowercase())
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply a member count, keeping the newest list.
|
||||
pub fn set_members(&mut self, key: GroupKey, created_at: Timestamp, members: usize) {
|
||||
if self
|
||||
.members
|
||||
.get(&key)
|
||||
.is_some_and(|(current, _)| *current > created_at)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.members.insert(key, (created_at, members));
|
||||
}
|
||||
|
||||
/// Seed the discoveries from the cached group metadata.
|
||||
pub(crate) fn absorb_cached_previews(&mut self, previews: Vec<Event>) {
|
||||
let newest: HashMap<GroupId, (Timestamp, GroupMetadata)> =
|
||||
previews
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut newest, event| {
|
||||
let metadata = match GroupMetadata::parse(&event) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
log::warn!("ignoring malformed cached group metadata: {error}");
|
||||
return newest;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(id) = metadata.id().cloned() else {
|
||||
return newest;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some((created_at, _)) if *created_at >= event.created_at => newest,
|
||||
_ => {
|
||||
newest.insert(id, (event.created_at, metadata));
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (relay, ids) in self.refs.clone() {
|
||||
for id in ids {
|
||||
let Some((created_at, metadata)) = newest.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Hidden groups are not discoverable (NIP-29).
|
||||
if metadata.hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.upsert(GroupCandidate {
|
||||
key: GroupKey::new(relay.clone(), id.clone()),
|
||||
created_at: *created_at,
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the member counts from the cached member lists.
|
||||
pub(crate) fn absorb_cached_members(&mut self, members: Vec<Event>) {
|
||||
let newest: HashMap<GroupId, (Timestamp, usize)> =
|
||||
members
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut newest, event| {
|
||||
let Some((id, created_at, count)) = parse_members(&event) else {
|
||||
return newest;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some((current, _)) if *current >= created_at => newest,
|
||||
_ => {
|
||||
newest.insert(id, (created_at, count));
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (relay, ids) in self.refs.clone() {
|
||||
for id in ids {
|
||||
let Some((created_at, count)) = newest.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.set_members(
|
||||
GroupKey::new(relay.clone(), id.clone()),
|
||||
*created_at,
|
||||
*count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the discoverable groups from the local database only.
|
||||
pub async fn cached(client: Client, joined: HashSet<(RelayUrl, GroupId)>) -> Self {
|
||||
let mut discovery = Self::default();
|
||||
|
||||
// The group lists of every cached author.
|
||||
let lists: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::SimpleGroups))
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_lists(lists, &joined);
|
||||
|
||||
let refs = discovery.refs().clone();
|
||||
|
||||
if refs.is_empty() {
|
||||
return discovery;
|
||||
}
|
||||
|
||||
let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect();
|
||||
|
||||
// The cached metadata of the referenced groups.
|
||||
let previews: Vec<Event> = client
|
||||
.database()
|
||||
.query(
|
||||
Filter::new()
|
||||
.kind(Kind::GroupMetadata)
|
||||
.identifiers(ids.clone()),
|
||||
)
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_cached_previews(previews);
|
||||
|
||||
// The cached member lists of the referenced groups.
|
||||
let members: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::GroupMembers).identifiers(ids))
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_cached_members(members);
|
||||
|
||||
discovery
|
||||
}
|
||||
|
||||
/// Reconcile the discoverable groups with the relays.
|
||||
pub async fn sync(
|
||||
&mut self,
|
||||
client: Client,
|
||||
me: PublicKey,
|
||||
joined: HashSet<(RelayUrl, GroupId)>,
|
||||
) {
|
||||
let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1);
|
||||
|
||||
let authors: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|events| events.into_iter().next())
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||
let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups);
|
||||
client.sync(filter.clone()).with(urls).await.ok();
|
||||
|
||||
let lists: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.absorb_lists(lists, &joined);
|
||||
|
||||
// The pool-wide sweep of the group metadata and members.
|
||||
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||
let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]);
|
||||
client.sync(filter).with(urls).await.ok();
|
||||
|
||||
// The previews cover the relays the lists just referenced.
|
||||
let fetches = self.refs.clone().into_iter().map(|(relay, ids)| {
|
||||
let client = client.clone();
|
||||
|
||||
async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds([Kind::GroupMetadata, Kind::GroupMembers])
|
||||
.identifiers(ids.iter().map(GroupId::as_str));
|
||||
|
||||
let urls = vec![relay];
|
||||
client.sync(filter).with(urls).await.ok();
|
||||
}
|
||||
});
|
||||
|
||||
join_all(fetches).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chat::Message;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::LOCAL_KEYS;
|
||||
use crate::protocol::{
|
||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
||||
TimelineWindow,
|
||||
};
|
||||
|
||||
const REPLAY_LIMIT: usize = 100;
|
||||
const PAGE_LIMIT: usize = 50;
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LEAVE_WINDOW_SECS: u64 = 5 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Absorbed {
|
||||
Ignored,
|
||||
Shown { fresh: bool, updated: bool },
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Row {
|
||||
Message(Box<Message>),
|
||||
Activity(Activity),
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn id(&self) -> EventId {
|
||||
match self {
|
||||
Self::Message(message) => message.id,
|
||||
Self::Activity(activity) => activity.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn author(&self) -> PublicKey {
|
||||
match self {
|
||||
Self::Message(message) => message.author,
|
||||
Self::Activity(activity) => activity.author,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> Timestamp {
|
||||
match self {
|
||||
Self::Message(message) => message.created_at,
|
||||
Self::Activity(activity) => activity.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GroupEvent {
|
||||
Incoming(Event),
|
||||
Sent(Event),
|
||||
Updated,
|
||||
Eose,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub struct Group {
|
||||
key: GroupKey,
|
||||
metadata: Option<GroupMetadata>,
|
||||
members: GroupMembers,
|
||||
admins: GroupAdmins,
|
||||
roles: GroupRoles,
|
||||
pins: GroupPins,
|
||||
membership: Membership,
|
||||
membership_events: Vec<Event>,
|
||||
elsewhere: Option<GroupKey>,
|
||||
state_ids: HashMap<Kind, EventId>,
|
||||
rows: Vec<Row>,
|
||||
index: HashSet<EventId>,
|
||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||
window: TimelineWindow,
|
||||
oldest: Option<Timestamp>,
|
||||
loading: bool,
|
||||
loading_more: bool,
|
||||
active: bool,
|
||||
refused: Option<String>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupEvent> for Group {}
|
||||
|
||||
impl Group {
|
||||
pub(crate) fn new(key: GroupKey) -> Self {
|
||||
Self {
|
||||
key,
|
||||
metadata: None,
|
||||
members: GroupMembers::default(),
|
||||
admins: GroupAdmins::default(),
|
||||
roles: GroupRoles::default(),
|
||||
pins: GroupPins::default(),
|
||||
membership: Membership::Unknown,
|
||||
membership_events: Vec::new(),
|
||||
elsewhere: None,
|
||||
state_ids: HashMap::new(),
|
||||
rows: Vec::new(),
|
||||
index: HashSet::new(),
|
||||
leave_requests: HashMap::new(),
|
||||
window: TimelineWindow::new(),
|
||||
oldest: None,
|
||||
loading: true,
|
||||
loading_more: false,
|
||||
active: false,
|
||||
refused: None,
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &GroupKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> Option<&GroupMetadata> {
|
||||
self.metadata.as_ref()
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
let name = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.unwrap_or_else(|| self.key.id().as_str());
|
||||
|
||||
SharedString::from(name.to_owned())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.picture.clone())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
pub fn total_members(&self) -> usize {
|
||||
self.members.len()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &GroupMembers {
|
||||
&self.members
|
||||
}
|
||||
|
||||
pub fn admins(&self) -> &GroupAdmins {
|
||||
&self.admins
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &GroupRoles {
|
||||
&self.roles
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &GroupPins {
|
||||
&self.pins
|
||||
}
|
||||
|
||||
pub fn membership(&self) -> &Membership {
|
||||
&self.membership
|
||||
}
|
||||
|
||||
pub fn elsewhere(&self) -> Option<&GroupKey> {
|
||||
self.elsewhere.as_ref()
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> &[Row] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn message(&self, id: EventId) -> Option<&Message> {
|
||||
self.rows.iter().find_map(|row| match row {
|
||||
Row::Message(message) if message.id == id => Some(message.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn loading_more(&self) -> bool {
|
||||
self.loading_more
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub(crate) fn activate(&mut self) {
|
||||
self.active = true;
|
||||
}
|
||||
|
||||
pub fn oldest(&self) -> Option<Timestamp> {
|
||||
self.oldest
|
||||
}
|
||||
|
||||
pub fn refused(&self) -> Option<&str> {
|
||||
self.refused.as_deref()
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.message(content, &replies, prev), cx)
|
||||
}
|
||||
|
||||
pub fn send_reaction(
|
||||
&mut self,
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx)
|
||||
}
|
||||
|
||||
pub fn remove_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(
|
||||
&mut self,
|
||||
metadata: &GroupMetadata,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||
}
|
||||
|
||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||
}
|
||||
|
||||
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
|
||||
}
|
||||
|
||||
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
|
||||
where
|
||||
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
|
||||
};
|
||||
|
||||
let builder = build(self.key.id(), self.window.previous_tag(me));
|
||||
let published = self.publish(builder, cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let event = published.await?;
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(GroupEvent::Sent(event));
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task<Result<Event>> {
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.client();
|
||||
let signer = nostr.signer();
|
||||
let relay = self.key.relay().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).to([relay]).await?;
|
||||
|
||||
if let Some(reason) = output.failed.into_values().next() {
|
||||
bail!("{reason}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context<Self>) {
|
||||
if self.membership != Membership::Member {
|
||||
self.membership = Membership::Pending { since };
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Refused {
|
||||
reason: Some(reason),
|
||||
};
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_elsewhere(&mut self, elsewhere: Option<GroupKey>, cx: &mut Context<Self>) {
|
||||
if self.elsewhere == elsewhere {
|
||||
return;
|
||||
}
|
||||
self.elsewhere = elsewhere;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = self.key.clone();
|
||||
let id = crate::subscription_id(&key);
|
||||
let relay = key.relay().clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let filters = [
|
||||
key.id().timeline_filter(REPLAY_LIMIT),
|
||||
key.id().membership_filter(me),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(key.id().state_filters())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, filters))
|
||||
.with_id(id)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to {} failed: {error}", key.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(until) = self.oldest else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_more = true;
|
||||
cx.notify();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = crate::page_subscription(&self.key);
|
||||
let relay = self.key.relay().clone();
|
||||
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, [filter]))
|
||||
.with_id(id)
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: loading older messages failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading_more = false;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let tags = vec![
|
||||
Tag::identifier(event.id),
|
||||
Tag::custom("r", [cache_tag]),
|
||||
Tag::custom("k", [event.kind.to_string()]),
|
||||
];
|
||||
|
||||
let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&envelope).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task<Result<Vec<Event>>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
if state_only {
|
||||
let kinds = STATE_KINDS
|
||||
.iter()
|
||||
.chain(MEMBERSHIP_KINDS.iter())
|
||||
.map(|kind| kind.to_string());
|
||||
|
||||
filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds);
|
||||
}
|
||||
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|envelope| Event::from_json(&envelope.content).ok())
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate(&mut self, events: Vec<Event>, me: PublicKey, cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
for event in &events {
|
||||
match self.absorb(event, me) {
|
||||
Absorbed::Shown {
|
||||
fresh: true,
|
||||
updated,
|
||||
} => {
|
||||
rendered.push(event.clone());
|
||||
changed |= updated;
|
||||
}
|
||||
Absorbed::Shown {
|
||||
fresh: false,
|
||||
updated,
|
||||
} => changed |= updated,
|
||||
Absorbed::Updated => changed = true,
|
||||
Absorbed::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
for event in rendered {
|
||||
cx.emit(GroupEvent::Incoming(event));
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
self.window.remove(id);
|
||||
|
||||
if !self.index.remove(&id) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.rows.retain(|row| row.id() != id);
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
cx.emit(GroupEvent::Eose);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
self.refused = Some(reason.clone());
|
||||
cx.emit(GroupEvent::Refused(reason));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||
if STATE_KINDS.contains(&event.kind) {
|
||||
return if self.absorb_state(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
self.track(event);
|
||||
|
||||
let updated = self.absorb_membership(event, me);
|
||||
|
||||
if ACTIVITY_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_activity(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if RENDER_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_row(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if updated {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_membership(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self
|
||||
.membership_events
|
||||
.iter()
|
||||
.any(|existing| existing.id == event.id)
|
||||
{
|
||||
self.membership_events.push(event.clone());
|
||||
}
|
||||
|
||||
self.refresh_membership(me)
|
||||
}
|
||||
|
||||
fn track(&mut self, event: &Event) {
|
||||
self.window.push(event.clone());
|
||||
|
||||
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
|
||||
self.oldest = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_state(&mut self, event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||
Ok(admins) => self.admins = admins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||
Ok(members) => self.members = members,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||
Ok(roles) => self.roles = roles,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::Custom(PIN_LIST) => match GroupPins::parse(event) {
|
||||
Ok(pins) => self.pins = pins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
||||
}
|
||||
|
||||
fn push_row(&mut self, event: &Event) -> bool {
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let message = Message::from(event);
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= message.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Message(Box::new(message)));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_activity(&mut self, event: &Event) -> bool {
|
||||
let activity = match Activity::parse(event) {
|
||||
Ok(activity) => activity,
|
||||
Err(error) => {
|
||||
log::warn!("nip29: ignoring a malformed group activity: {error}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if activity.kind == Kind::GroupPutUser && activity.roles.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupRemoveUser
|
||||
&& self
|
||||
.leave_requests
|
||||
.get(&activity.subject())
|
||||
.is_some_and(|left| {
|
||||
activity.created_at >= *left
|
||||
&& activity.created_at.as_secs().saturating_sub(left.as_secs())
|
||||
<= LEAVE_WINDOW_SECS
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.index.insert(activity.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupLeaveRequest {
|
||||
self.leave_requests
|
||||
.insert(activity.subject(), activity.created_at);
|
||||
}
|
||||
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= activity.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Activity(activity));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn refresh_membership(&mut self, me: PublicKey) -> bool {
|
||||
let derived = Membership::from_events(&self.membership_events, me);
|
||||
|
||||
if derived != Membership::Unknown && derived != self.membership {
|
||||
self.membership = derived;
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub(crate) const TIMELINE_WINDOW: usize = 50;
|
||||
|
||||
const PREVIOUS_REFS: usize = 3;
|
||||
const PREVIOUS_REF_LEN: usize = 8;
|
||||
|
||||
pub(crate) const RENDER_KINDS: [Kind; 5] = [
|
||||
Kind::TextNote,
|
||||
Kind::ChatMessage,
|
||||
Kind::Thread,
|
||||
Kind::Comment,
|
||||
Kind::Reaction,
|
||||
];
|
||||
|
||||
pub(crate) const PIN_LIST: u16 = 39_005;
|
||||
pub(crate) const UPDATE_PIN_LIST: u16 = 9_010;
|
||||
|
||||
pub(crate) const STATE_KINDS: [Kind; 5] = [
|
||||
Kind::GroupMetadata,
|
||||
Kind::GroupAdmins,
|
||||
Kind::GroupMembers,
|
||||
Kind::GroupRoles,
|
||||
Kind::Custom(PIN_LIST),
|
||||
];
|
||||
|
||||
pub(crate) const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser];
|
||||
|
||||
pub(crate) const ACTIVITY_KINDS: [Kind; 4] = [
|
||||
Kind::GroupPutUser,
|
||||
Kind::GroupRemoveUser,
|
||||
Kind::GroupJoinRequest,
|
||||
Kind::GroupLeaveRequest,
|
||||
];
|
||||
|
||||
fn has_tag(tags: &Tags, name: &str) -> bool {
|
||||
tags.iter().any(|tag| tag.kind() == name)
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct GroupId(String);
|
||||
|
||||
impl GroupId {
|
||||
pub fn new(id: impl Into<String>) -> Result<Self> {
|
||||
let id = id.into();
|
||||
let id = id.trim();
|
||||
|
||||
if id.is_empty() {
|
||||
bail!("group id is empty");
|
||||
}
|
||||
|
||||
Ok(Self(id.to_owned()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn h_tag(&self) -> Tag {
|
||||
Tag::custom("h", [self.0.clone()])
|
||||
}
|
||||
|
||||
pub fn timeline_filter(&self, limit: usize) -> Filter {
|
||||
Filter::new()
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||
.kinds(RENDER_KINDS)
|
||||
.kinds(ACTIVITY_KINDS)
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter {
|
||||
self.timeline_filter(limit).until(until)
|
||||
}
|
||||
|
||||
pub fn state_filters(&self) -> Vec<Filter> {
|
||||
STATE_KINDS
|
||||
.iter()
|
||||
.map(|kind| Filter::new().identifier(self.0.clone()).kind(*kind))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn membership_filter(&self, me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
|
||||
.kinds(MEMBERSHIP_KINDS)
|
||||
}
|
||||
|
||||
pub fn message(
|
||||
&self,
|
||||
content: &str,
|
||||
replies: &[EventId],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
EventBuilder::new(Kind::ChatMessage, content)
|
||||
.tag(self.h_tag())
|
||||
.tags(replies.iter().copied().map(Tag::event))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn reaction(&self, target: EventId, emoji: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::Reaction, emoji)
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(target))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupJoinRequest, reason)
|
||||
.tag(self.h_tag())
|
||||
.tag_maybe(code.map(|code| Tag::custom("code", [code])))
|
||||
}
|
||||
|
||||
pub fn leave_request(&self, reason: &str) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag())
|
||||
}
|
||||
|
||||
pub fn remove_user(&self, public_key: PublicKey, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupRemoveUser, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::public_key(public_key))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&self,
|
||||
public_key: PublicKey,
|
||||
roles: &[String],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupPutUser, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::custom(
|
||||
"p",
|
||||
std::iter::once(public_key.to_hex()).chain(roles.iter().cloned()),
|
||||
))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupEditMetadata, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateGroup, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec<Tag> {
|
||||
let mut tags = vec![self.h_tag()];
|
||||
|
||||
for (name, value) in [
|
||||
("name", &metadata.name),
|
||||
("picture", &metadata.picture),
|
||||
("banner", &metadata.banner),
|
||||
("about", &metadata.about),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
tags.push(Tag::custom(name, [value.clone()]));
|
||||
}
|
||||
}
|
||||
|
||||
for (name, set) in [
|
||||
("private", metadata.private),
|
||||
("restricted", metadata.restricted),
|
||||
("hidden", metadata.hidden),
|
||||
("closed", metadata.closed),
|
||||
("livekit", metadata.livekit),
|
||||
] {
|
||||
if set {
|
||||
tags.push(Tag::custom(name, Vec::<String>::new()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = &metadata.parent {
|
||||
tags.push(Tag::custom("parent", [parent.as_str()]));
|
||||
}
|
||||
|
||||
for child in &metadata.children {
|
||||
tags.push(Tag::custom("child", [child.as_str()]));
|
||||
}
|
||||
|
||||
if let Some(kinds) = &metadata.supported_kinds {
|
||||
tags.push(Tag::custom(
|
||||
"supported_kinds",
|
||||
kinds.iter().map(ToString::to_string),
|
||||
));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn delete_event(&self, id: EventId, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupDeleteEvent, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(id))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateInvite, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::custom("code", [code]))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn update_pin_list(&self, pins: &[Pin], previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "")
|
||||
.tag(self.h_tag())
|
||||
.tags(pins.iter().map(Pin::tag))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GroupId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GroupKey {
|
||||
relay: RelayUrl,
|
||||
id: GroupId,
|
||||
}
|
||||
|
||||
impl GroupKey {
|
||||
pub fn new(relay: RelayUrl, id: GroupId) -> Self {
|
||||
Self { relay, id }
|
||||
}
|
||||
|
||||
pub fn relay(&self) -> &RelayUrl {
|
||||
&self.relay
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &GroupId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn cache_tag(&self) -> String {
|
||||
format!("{}|{}", self.relay.as_str(), self.id)
|
||||
}
|
||||
|
||||
pub fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
self.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupReference {
|
||||
id: GroupId,
|
||||
relay: RelayUrl,
|
||||
invite: Option<String>,
|
||||
}
|
||||
|
||||
impl GroupReference {
|
||||
pub fn parse(input: &str) -> Result<Self> {
|
||||
let input = input.trim();
|
||||
|
||||
let (address, query) = match input.split_once('?') {
|
||||
Some((address, query)) => (address, Some(query)),
|
||||
None => (input, None),
|
||||
};
|
||||
|
||||
let coordinate = Nip19Coordinate::from_bech32(address)?;
|
||||
|
||||
if coordinate.kind != Kind::GroupMetadata {
|
||||
bail!("not a group reference: kind {}", coordinate.kind);
|
||||
}
|
||||
|
||||
let relay = coordinate
|
||||
.relays
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("group reference has no relay hint"))?;
|
||||
|
||||
let id = GroupId::new(coordinate.identifier.as_str())?;
|
||||
|
||||
let invite = query
|
||||
.and_then(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("invite="))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
|
||||
Ok(Self { id, relay, invite })
|
||||
}
|
||||
|
||||
pub fn invite(&self) -> Option<&str> {
|
||||
self.invite.as_deref()
|
||||
}
|
||||
|
||||
pub fn key(&self) -> GroupKey {
|
||||
GroupKey::new(self.relay.clone(), self.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GroupReference {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
Self::parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupMetadata {
|
||||
pub name: Option<String>,
|
||||
pub picture: Option<String>,
|
||||
pub(crate) banner: Option<String>,
|
||||
pub about: Option<String>,
|
||||
pub private: bool,
|
||||
pub restricted: bool,
|
||||
pub hidden: bool,
|
||||
pub closed: bool,
|
||||
pub livekit: bool,
|
||||
pub parent: Option<GroupId>,
|
||||
pub children: Vec<GroupId>,
|
||||
pub supported_kinds: Option<Vec<Kind>>,
|
||||
id: Option<GroupId>,
|
||||
author: Option<PublicKey>,
|
||||
}
|
||||
|
||||
impl GroupMetadata {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupMetadata {
|
||||
bail!("not a group metadata event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let identifier = event
|
||||
.tags
|
||||
.identifier()
|
||||
.ok_or_else(|| anyhow!("group metadata has no d tag"))?;
|
||||
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(event.content.as_str()).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let field = |key: &str| -> Option<String> {
|
||||
content
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| tag_value(&event.tags, key).map(str::to_owned))
|
||||
};
|
||||
|
||||
let mut supported_kinds: Option<Vec<Kind>> = None;
|
||||
|
||||
for tag in event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "supported_kinds")
|
||||
{
|
||||
let kinds = supported_kinds.get_or_insert_with(Vec::new);
|
||||
|
||||
for value in tag.as_slice().iter().skip(1) {
|
||||
kinds.push(Kind::from_str(value)?);
|
||||
}
|
||||
}
|
||||
|
||||
let parent = match tag_value(&event.tags, "parent") {
|
||||
Some(parent) => Some(GroupId::new(parent)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut children = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "child") {
|
||||
if let Some(child) = tag.content() {
|
||||
children.push(GroupId::new(child)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
name: field("name"),
|
||||
picture: field("picture"),
|
||||
banner: field("banner"),
|
||||
about: field("about"),
|
||||
private: has_tag(&event.tags, "private"),
|
||||
restricted: has_tag(&event.tags, "restricted"),
|
||||
hidden: has_tag(&event.tags, "hidden"),
|
||||
closed: has_tag(&event.tags, "closed"),
|
||||
livekit: has_tag(&event.tags, "livekit"),
|
||||
parent,
|
||||
children,
|
||||
supported_kinds,
|
||||
id: Some(GroupId::new(identifier)?),
|
||||
author: Some(event.pubkey),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn id(&self) -> Option<&GroupId> {
|
||||
self.id.as_ref()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
pub fn supports(&self, kind: Kind) -> bool {
|
||||
match &self.supported_kinds {
|
||||
None => true,
|
||||
Some(kinds) => kinds.contains(&kind),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String> {
|
||||
let id = self.id.as_ref().ok_or_else(|| anyhow!("no id"))?;
|
||||
let author = self.author.ok_or_else(|| anyhow!("no author"))?;
|
||||
|
||||
let coordinate = Coordinate::new(Kind::GroupMetadata, author).identifier(id.as_str());
|
||||
let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?;
|
||||
|
||||
Ok(match invite {
|
||||
Some(invite) => format!("{address}?invite={invite}"),
|
||||
None => address,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupCandidate {
|
||||
pub key: GroupKey,
|
||||
/// The timestamp of the metadata event the candidate was built from.
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) metadata: GroupMetadata,
|
||||
}
|
||||
|
||||
impl GroupCandidate {
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.metadata
|
||||
.name()
|
||||
.unwrap_or_else(|| self.key.id().as_str())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<&str> {
|
||||
self.metadata.picture.as_deref()
|
||||
}
|
||||
|
||||
pub fn display_about(&self) -> Option<&str> {
|
||||
self.metadata.about.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
|
||||
|
||||
impl GroupAdmins {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupAdmins {
|
||||
bail!("not a group admins event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut admins = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "p") {
|
||||
let mut fields = tag.as_slice().iter().skip(1);
|
||||
|
||||
let public_key = fields
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("admin tag has no public key"))?;
|
||||
let public_key = PublicKey::from_hex(public_key)?;
|
||||
|
||||
admins.push((public_key, fields.cloned().collect()));
|
||||
}
|
||||
|
||||
Ok(Self(admins))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[(PublicKey, Vec<String>)] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.iter().any(|(key, _)| key == public_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupMembers(Vec<PublicKey>);
|
||||
|
||||
impl GroupMembers {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupMembers {
|
||||
bail!("not a group members event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
Ok(Self(event.tags.public_keys().collect()))
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
||||
self.0.iter()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Role {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupRoles(Vec<Role>);
|
||||
|
||||
impl GroupRoles {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupRoles {
|
||||
bail!("not a group roles event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut roles = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "role") {
|
||||
let mut fields = tag.as_slice().iter().skip(1);
|
||||
let name = fields
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("role tag has no name"))?;
|
||||
|
||||
roles.push(Role { name: name.clone() });
|
||||
}
|
||||
|
||||
Ok(Self(roles))
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &[Role] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Pin {
|
||||
Event(EventId),
|
||||
Address(Coordinate),
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
fn tag(&self) -> Tag {
|
||||
match self {
|
||||
Pin::Event(id) => Tag::event(*id),
|
||||
Pin::Address(coordinate) => Tag::coordinate(coordinate.clone(), None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupPins(Vec<Pin>);
|
||||
|
||||
impl GroupPins {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::Custom(PIN_LIST) {
|
||||
bail!("not a pinned list event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut pins = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match tag.kind() {
|
||||
"e" => {
|
||||
let Some(id) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
pins.push(Pin::Event(EventId::from_hex(id)?));
|
||||
}
|
||||
"a" => {
|
||||
let Some(address) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
pins.push(Pin::Address(Coordinate::from_str(address)?));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self(pins))
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &[Pin] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GroupListEntry {
|
||||
pub id: GroupId,
|
||||
pub relay: RelayUrl,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct GroupList(Vec<GroupListEntry>);
|
||||
|
||||
impl GroupList {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::SimpleGroups {
|
||||
bail!("not a group list event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "group") {
|
||||
let fields = tag.as_slice();
|
||||
|
||||
let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(id) = GroupId::new(id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(relay) = RelayUrl::parse(relay.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
entries.push(GroupListEntry {
|
||||
id,
|
||||
relay,
|
||||
name: fields.get(3).cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(entries))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[GroupListEntry] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, entry: GroupListEntry) {
|
||||
match self
|
||||
.0
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
||||
{
|
||||
Some(existing) => *existing = entry,
|
||||
None => self.0.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &GroupKey) {
|
||||
self.0
|
||||
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
||||
}
|
||||
|
||||
pub fn to_builder(&self) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
|
||||
|
||||
for entry in &self.0 {
|
||||
let mut values = vec![
|
||||
entry.id.as_str().to_owned(),
|
||||
entry.relay.as_str().to_owned(),
|
||||
];
|
||||
|
||||
if let Some(name) = &entry.name {
|
||||
values.push(name.clone());
|
||||
}
|
||||
|
||||
tags.push(Tag::custom("group", values));
|
||||
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
||||
}
|
||||
|
||||
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Membership {
|
||||
Unknown,
|
||||
Pending { since: Timestamp },
|
||||
Member,
|
||||
Refused { reason: Option<String> },
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl Membership {
|
||||
pub fn status(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Pending { .. } => Some("Requested"),
|
||||
Self::Refused { .. } => Some("Refused"),
|
||||
Self::Removed => Some("Removed"),
|
||||
Self::Member | Self::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_events(events: &[Event], me: PublicKey) -> Self {
|
||||
let mut latest: Option<&Event> = None;
|
||||
|
||||
for event in events {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let newer = match latest {
|
||||
None => true,
|
||||
Some(current) => {
|
||||
event.created_at > current.created_at
|
||||
|| (event.created_at == current.created_at
|
||||
&& current.kind == Kind::GroupPutUser
|
||||
&& event.kind == Kind::GroupRemoveUser)
|
||||
}
|
||||
};
|
||||
|
||||
if newer {
|
||||
latest = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
match latest {
|
||||
Some(event) if event.kind == Kind::GroupRemoveUser => Membership::Removed,
|
||||
Some(_) => Membership::Member,
|
||||
None => Membership::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Activity {
|
||||
pub id: EventId,
|
||||
pub kind: Kind,
|
||||
pub author: PublicKey,
|
||||
pub target: Option<PublicKey>,
|
||||
pub roles: Vec<String>,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if !ACTIVITY_KINDS.contains(&event.kind) {
|
||||
bail!("not a group activity event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut fields = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "p")
|
||||
.map(|tag| tag.as_slice().iter().skip(1))
|
||||
.into_iter()
|
||||
.flatten();
|
||||
|
||||
let target = match fields.next() {
|
||||
Some(value) => Some(PublicKey::from_hex(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if target.is_none() && MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
bail!("a membership event has no p tag");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
author: event.pubkey,
|
||||
target,
|
||||
roles: fields.cloned().collect(),
|
||||
created_at: event.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> PublicKey {
|
||||
match self.kind {
|
||||
Kind::GroupPutUser | Kind::GroupRemoveUser => self.target.unwrap_or(self.author),
|
||||
_ => self.author,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct TimelineWindow(Vec<Event>);
|
||||
|
||||
impl TimelineWindow {
|
||||
pub fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
pub fn push(&mut self, event: Event) {
|
||||
self.0.retain(|existing| existing.id != event.id);
|
||||
let position = self.0.partition_point(|e| e.created_at > event.created_at);
|
||||
|
||||
self.0.insert(position, event);
|
||||
self.0.truncate(TIMELINE_WINDOW);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: EventId) {
|
||||
self.0.retain(|event| event.id != id);
|
||||
}
|
||||
|
||||
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
||||
let references: Vec<String> = self
|
||||
.0
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me)
|
||||
.take(PREVIOUS_REFS)
|
||||
.map(|event| event.id.to_hex().chars().take(PREVIOUS_REF_LEN).collect())
|
||||
.collect();
|
||||
|
||||
if references.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Tag::custom("previous", references))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::{GroupId, GroupKey};
|
||||
|
||||
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
||||
const FORK_SUBSCRIPTION: &str = "nip29-forks";
|
||||
const GROUP_PREFIX: &str = "grp-";
|
||||
const BROWSE_PREFIX: &str = "brw-";
|
||||
const PAGE_PREFIX: &str = "pg-";
|
||||
|
||||
pub(crate) fn list_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub(crate) fn fork_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(FORK_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relay.hash(&mut hasher);
|
||||
|
||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn is_page(id: &SubscriptionId) -> bool {
|
||||
id.as_str().starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_subscription(id: &SubscriptionId) -> bool {
|
||||
let id = id.as_str();
|
||||
id == LIST_SUBSCRIPTION
|
||||
|| id == FORK_SUBSCRIPTION
|
||||
|| id.starts_with(GROUP_PREFIX)
|
||||
|| id.starts_with(BROWSE_PREFIX)
|
||||
|| id.starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> {
|
||||
if event.kind != Kind::GroupMembers {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = GroupId::new(event.tags.identifier()?).ok()?;
|
||||
let members = event.tags.public_keys().collect::<HashSet<_>>().len();
|
||||
|
||||
Some((id, event.created_at, members))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nip29_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
common = { path = "../common" }
|
||||
nip29 = { path = "../nip29" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
state = { path = "../state" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,215 @@
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||
relative,
|
||||
};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
.show_close(true)
|
||||
.title("About Group")
|
||||
.child(body(&group, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let Some(entity) = group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_sm().child(header(group, cx));
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
body = body.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(about.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
let flags = flags(metadata);
|
||||
|
||||
if !flags.is_empty() {
|
||||
body = body.child(section(
|
||||
"Flags",
|
||||
wrap(flags.into_iter().map(|f| chip(f, cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = metadata.parent.as_ref() {
|
||||
body = body.child(section(
|
||||
"Parent",
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !metadata.children.is_empty() {
|
||||
body = body.child(section(
|
||||
"Children",
|
||||
wrap(
|
||||
metadata
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| chip(child.as_str().to_owned(), cx)),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(kinds) = metadata.supported_kinds.as_ref() {
|
||||
let items: Vec<AnyElement> = kinds
|
||||
.iter()
|
||||
.map(|kind| chip(kind.to_string(), cx))
|
||||
.collect();
|
||||
|
||||
body = body.child(section(
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
wrap(items)
|
||||
},
|
||||
cx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let roles = group.roles().roles();
|
||||
if !roles.is_empty() {
|
||||
body = body.child(section(
|
||||
"Roles",
|
||||
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
let pins = group.pins().pins();
|
||||
if !pins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Pinned",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
body.into_any_element()
|
||||
}
|
||||
|
||||
fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
let key = group.key();
|
||||
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::from_source(group.display_image())
|
||||
.seed(key.cache_tag())
|
||||
.large(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.font_semibold()
|
||||
.truncate()
|
||||
.child(group.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.truncate()
|
||||
.child(SharedString::from(format!(
|
||||
"{} · {}",
|
||||
key.id().as_str(),
|
||||
key.relay().as_str()
|
||||
))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label),
|
||||
)
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(items)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
if metadata.private {
|
||||
flags.push("Private");
|
||||
}
|
||||
if metadata.restricted {
|
||||
flags.push("Restricted");
|
||||
}
|
||||
if metadata.hidden {
|
||||
flags.push("Hidden");
|
||||
}
|
||||
if metadata.closed {
|
||||
flags.push("Closed");
|
||||
}
|
||||
if metadata.livekit {
|
||||
flags.push("LiveKit");
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use anyhow::Result;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
|
||||
px,
|
||||
};
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use theme::ActiveTheme;
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
use crate::util::field;
|
||||
|
||||
const CODE_LEN: usize = 12;
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let code = SecretKey::generate().to_secret_hex();
|
||||
let code: String = code.chars().take(CODE_LEN).collect();
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Invite code")
|
||||
.default_value(code)
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let group = group.clone();
|
||||
let input = input.clone();
|
||||
|
||||
this.width(px(420.))
|
||||
.confirm()
|
||||
.title("Invite link")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Copy link"),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_3().child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Share the code with whoever should be able to join. The relay must accept it."),
|
||||
)
|
||||
.child(field("Code", &input, cx)),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let code = input.read(cx).value().trim().to_owned();
|
||||
|
||||
if code.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let link = group
|
||||
.read_with(cx, |group, _cx| {
|
||||
group.metadata().and_then(|metadata| {
|
||||
metadata
|
||||
.naddr(group.key().relay(), Some(&code))
|
||||
.ok()
|
||||
.map(SharedString::from)
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(link) = link else {
|
||||
window.push_notification(
|
||||
Notification::error(
|
||||
"The relay doesn't publish group details, so no invite link can be made",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
||||
Ok(task) => {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cx.update(|window, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
link.to_string(),
|
||||
));
|
||||
window.push_notification(
|
||||
Notification::success("Invite link copied"),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use chat::Message;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
|
||||
SharedUri, Styled, StyledImage, WeakEntity, div, img, px,
|
||||
};
|
||||
use nip29::{Activity, Group};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||
use ui::message::MessageRow;
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
|
||||
use crate::GroupPanel;
|
||||
|
||||
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
||||
|
||||
pub(crate) struct Actions {
|
||||
pub group: WeakEntity<Group>,
|
||||
pub panel: WeakEntity<GroupPanel>,
|
||||
pub admin: bool,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
actions: Actions,
|
||||
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);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(media(&message.media, cx))
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
let id = message.id;
|
||||
let admin = actions.admin;
|
||||
let pinned = actions.pinned;
|
||||
let group = actions.group;
|
||||
let panel = actions.panel;
|
||||
|
||||
ContextMenu::new(
|
||||
format!("message-menu-{}", id.to_hex()),
|
||||
row,
|
||||
move |menu, window, cx| {
|
||||
let menu = menu.submenu("React", window, cx, {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
|
||||
});
|
||||
|
||||
if !admin {
|
||||
return menu;
|
||||
}
|
||||
|
||||
menu.separator()
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||
move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_pin(group.clone(), id, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: pinning a message failed: {error}");
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.delete_message(group.clone(), id, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: deleting a message failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn react_menu(
|
||||
menu: PopupMenu,
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
id: EventId,
|
||||
) -> PopupMenu {
|
||||
REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let subject = persons.read(cx).get(&activity.subject(), cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(subject.avatar())
|
||||
.seed(subject.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(subject.name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(action(activity))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(activity.created_at.to_ago()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn badge(activity: &Activity, cx: &App) -> AnyElement {
|
||||
let (icon, color) = match activity.kind {
|
||||
Kind::GroupJoinRequest => (IconName::Login, cx.theme().icon_accent),
|
||||
Kind::GroupPutUser => (IconName::Login, cx.theme().icon_accent),
|
||||
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().text_danger),
|
||||
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().text_danger),
|
||||
_ => (IconName::Info, cx.theme().icon_muted),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.size_8()
|
||||
.child(Icon::new(icon).small().text_color(color))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn action(activity: &Activity) -> String {
|
||||
match activity.kind {
|
||||
Kind::GroupJoinRequest => "joined the group".to_owned(),
|
||||
Kind::GroupLeaveRequest => "left the group".to_owned(),
|
||||
Kind::GroupRemoveUser => "was removed from the group".to_owned(),
|
||||
Kind::GroupPutUser => format!("is now {}", activity.roles.join(", ")),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||
if let [only] = media {
|
||||
return div()
|
||||
.child(
|
||||
img(only.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.children(media.iter().enumerate().map(|(ix, item)| {
|
||||
div().id(format!("media-{ix}")).child(
|
||||
img(item.clone())
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::switch::Switch;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
use crate::util::field;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Flags {
|
||||
private: bool,
|
||||
restricted: bool,
|
||||
hidden: bool,
|
||||
closed: bool,
|
||||
livekit: bool,
|
||||
}
|
||||
|
||||
impl Flags {
|
||||
fn of(metadata: &GroupMetadata) -> Self {
|
||||
Self {
|
||||
private: metadata.private,
|
||||
restricted: metadata.restricted,
|
||||
hidden: metadata.hidden,
|
||||
closed: metadata.closed,
|
||||
livekit: metadata.livekit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let base = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Group name")
|
||||
.default_value(base.name.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let about = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("About")
|
||||
.default_value(base.about.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let picture = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://example.com/picture.png")
|
||||
.default_value(base.picture.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let flags = cx.new(|_cx| Flags::of(&base));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let current = *flags.read(cx);
|
||||
|
||||
this.confirm()
|
||||
.title("Edit Group")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Save"),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("About", &about, cx))
|
||||
.child(field("Picture", &picture, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.label("Private")
|
||||
.description("Only members can read messages.")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("restricted")
|
||||
.label("Restricted")
|
||||
.description("Only members can send messages.")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("hidden")
|
||||
.label("Hidden")
|
||||
.description("Hide the group from non-members.")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("closed")
|
||||
.label("Closed")
|
||||
.description("Ignore join requests.")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("livekit")
|
||||
.label("LiveKit")
|
||||
.description("Announce audio and video support.")
|
||||
.checked(current.livekit)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.livekit)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.on_ok({
|
||||
let group = group.clone();
|
||||
let name = name.clone();
|
||||
let about = about.clone();
|
||||
let picture = picture.clone();
|
||||
let flags = flags.clone();
|
||||
|
||||
move |_event, window, cx| {
|
||||
let mut next = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
next.name = value(&name, cx);
|
||||
next.about = value(&about, cx);
|
||||
next.picture = value(&picture, cx);
|
||||
|
||||
let current = flags.read(cx);
|
||||
next.private = current.private;
|
||||
next.restricted = current.restricted;
|
||||
next.hidden = current.hidden;
|
||||
next.closed = current.closed;
|
||||
next.livekit = current.livekit;
|
||||
|
||||
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
|
||||
Ok(task) => crate::util::report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle(
|
||||
flags: &Entity<Flags>,
|
||||
select: fn(&mut Flags) -> &mut bool,
|
||||
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
let flags = flags.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
flags.update(cx, |flags, cx| {
|
||||
*select(flags) = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn value(input: &Entity<InputState>, cx: &App) -> Option<String> {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use anyhow::Result;
|
||||
use gpui::{App, Div, Entity, ParentElement, SharedString, Styled, Task, Window};
|
||||
use nip29::{Group, Pin, Row};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
const RUN_WINDOW_SECS: u64 = 300;
|
||||
|
||||
pub(crate) fn display_name(group: &Group) -> SharedString {
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.map_or_else(
|
||||
|| SharedString::from(group.key().id().as_str()),
|
||||
SharedString::from,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn attachment_name(url: &Url) -> SharedString {
|
||||
url.path_segments()
|
||||
.and_then(|mut segments| segments.next_back())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map_or_else(
|
||||
|| SharedString::from(url.as_str()),
|
||||
|name| SharedString::from(name.to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||
let (Some(current), Some(previous)) = (
|
||||
rows.get(index),
|
||||
index.checked_sub(1).and_then(|index| rows.get(index)),
|
||||
) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if !matches!((current, previous), (Row::Message(_), Row::Message(_))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
current.author() != previous.author()
|
||||
|| current
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_sub(previous.created_at().as_secs())
|
||||
> RUN_WINDOW_SECS
|
||||
}
|
||||
|
||||
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
}
|
||||
|
||||
pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString {
|
||||
match pin {
|
||||
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
|
||||
Pin::Event(id) => {
|
||||
let snippet = group
|
||||
.message(*id)
|
||||
.and_then(|message| message.content.lines().next())
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty());
|
||||
|
||||
match snippet {
|
||||
Some(snippet) => SharedString::from(snippet.chars().take(48).collect::<String>()),
|
||||
None => SharedString::from(id.to_hex().chars().take(8).collect::<String>()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a group write and surface any relay error as a notification.
|
||||
pub(crate) fn report(window: &mut Window, cx: &mut App, task: Task<Result<()>>) {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
[package]
|
||||
name = "device"
|
||||
name = "nip4e"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
@@ -14,7 +13,6 @@ settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
@@ -4,7 +4,6 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use instant::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
@@ -24,8 +23,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>);
|
||||
@@ -40,13 +39,13 @@ pub enum DeviceEvent {
|
||||
/// User have not setup encryption key
|
||||
NotSet,
|
||||
/// The device is requesting an encryption key
|
||||
Requesting,
|
||||
Requesting(SharedString),
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
impl DeviceEvent {
|
||||
pub fn error<T>(error: T) -> Self
|
||||
pub(crate) fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
@@ -59,19 +58,14 @@ impl DeviceEvent {
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
/// Whether there is a pending request for encryption key approval
|
||||
pub pending_request: bool,
|
||||
|
||||
/// Whether an announcement has been made for this device
|
||||
pub announcement_existed: Arc<AtomicBool>,
|
||||
|
||||
/// Signer
|
||||
signer: Entity<Option<UniversalSigner>>,
|
||||
|
||||
/// Async tasks
|
||||
/// Whether there is a pending request for encryption key approval
|
||||
pub(crate) pending_request: bool,
|
||||
/// Whether an announcement has been made for this device
|
||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||
/// Relay notification listener
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscription
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
@@ -89,7 +83,8 @@ 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);
|
||||
|
||||
@@ -109,25 +104,33 @@ impl DeviceRegistry {
|
||||
// Observe the user signer
|
||||
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
|
||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||
// The old account's state must not leak into the new one
|
||||
this.announcement_existed.store(false, Ordering::Relaxed);
|
||||
this.handle_notifications(cx);
|
||||
this.get_announcement(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_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 {
|
||||
signer,
|
||||
pending_request: false,
|
||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||
notification_listener: None,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -135,7 +138,7 @@ impl DeviceRegistry {
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
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();
|
||||
let current_user = signer.get_public_key_async().await.ok();
|
||||
@@ -168,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
|
||||
@@ -242,39 +245,41 @@ impl DeviceRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(current_user)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe(filter)
|
||||
.close_on(opts)
|
||||
.with_id(SubscriptionId::new("nip4e"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Wait for 5 seconds
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
// Then check if the msg relays have been found
|
||||
if announcement_existed.load(Ordering::Acquire) {
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(current_user)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe(filter)
|
||||
.close_on(opts)
|
||||
.with_id(SubscriptionId::new("nip4e"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
if !announcement_existed.load(Ordering::Acquire) {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -314,13 +319,13 @@ impl DeviceRegistry {
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
||||
.tags(vec![
|
||||
Tag::custom("n", vec![n]),
|
||||
Tag::custom("client", vec![CLIENT_NAME]),
|
||||
])
|
||||
.tag(Tag::custom("n", vec![n]))
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Persist the key locally before announcing it
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
|
||||
// Publish announcement
|
||||
client
|
||||
.send_event(&event)
|
||||
@@ -328,20 +333,21 @@ impl DeviceRegistry {
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Save device keys to the database
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
|
||||
Ok(keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// Set encryption key from the announcement event
|
||||
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
log::warn!("Ignoring malformed encryption announcement");
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let announcement = Announcement::from(event);
|
||||
let device_pubkey = announcement.public_key();
|
||||
|
||||
// Get encryption key from the database and compare with the announcement
|
||||
@@ -381,11 +387,8 @@ impl DeviceRegistry {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let id = SubscriptionId::new("dekey-requests");
|
||||
|
||||
// Construct a filter for encryption key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4454))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
// Construct a filter for encryption key requests.
|
||||
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(vec![filter]).with_id(id).await?;
|
||||
@@ -395,7 +398,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Request encryption keys from other device
|
||||
pub fn request(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -414,26 +417,27 @@ impl DeviceRegistry {
|
||||
.pubkey(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.first_owned() {
|
||||
// Found an approval event
|
||||
Some(event) => Ok(Some(event)),
|
||||
// No approval event found, construct a request event
|
||||
None => {
|
||||
// Construct an event for device key request
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![
|
||||
Tag::custom("P", vec![app_pubkey]),
|
||||
Tag::custom("client", vec![CLIENT_NAME]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
// Only reuse the stored approval if its key is still valid,
|
||||
// otherwise it's stale and a fresh request is needed
|
||||
if decrypt_approval(&client, &app_keys, public_key, &event)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Some(event));
|
||||
}
|
||||
}
|
||||
|
||||
// No usable approval event found, construct a request event
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![Tag::custom("P", vec![app_pubkey])])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -463,20 +467,45 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
cx.emit(DeviceEvent::Requesting);
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let app_keys = match app_keys_task.await {
|
||||
Ok(app_keys) => app_keys,
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::Requesting(authorization_code(
|
||||
&app_keys.public_key(),
|
||||
)));
|
||||
})?;
|
||||
|
||||
// Construct a filter for device key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
// Only approvals addressed to this device's app key matter
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.pubkey(app_keys.public_key());
|
||||
|
||||
// Subscribe to the device key approvals on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -485,22 +514,16 @@ impl DeviceRegistry {
|
||||
/// Parse the approval event to get encryption key then set it
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let approval_id = event.id;
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let payload = event.content.as_str();
|
||||
let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
let identity = signer.get_public_key_async().await?;
|
||||
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
||||
|
||||
Ok(keys)
|
||||
});
|
||||
@@ -510,6 +533,8 @@ impl DeviceRegistry {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
// Clean up our request and the approval event from relays
|
||||
this.cleanup_sync_events(approval_id, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -522,6 +547,47 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Delete our key request and the consumed approval event from relays
|
||||
fn cleanup_sync_events(&mut self, approval_id: EventId, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let result: Result<(), Error> = async {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Collect our key request events
|
||||
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
|
||||
|
||||
let mut ids: Vec<EventId> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
ids.push(approval_id);
|
||||
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(Tags::from_list(ids.into_iter().map(Tag::event).collect()))
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
client.send_event(&deletion).to_nip65().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to delete sync events: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Approve requests for device keys from other devices
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -567,9 +633,14 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
// Allow future requests to surface again
|
||||
this.set_pending_request(false, cx);
|
||||
})?;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.clear_notification_by_id::<DeviceNotification>(id, cx);
|
||||
})
|
||||
@@ -591,32 +662,58 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
self.set_pending_request(true, cx);
|
||||
|
||||
// Show notification
|
||||
let notification = self.notification(event, cx);
|
||||
window.push_notification(notification, cx);
|
||||
let notification = match self.notification(event, cx) {
|
||||
Ok(notification) => notification,
|
||||
Err(e) => {
|
||||
log::warn!("Ignoring invalid encryption key request: {e}");
|
||||
self.set_pending_request(false, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(window) = cx.windows().first().copied() {
|
||||
if let Err(error) = window.update(cx, |_view, window, cx| {
|
||||
window.push_notification(notification, cx);
|
||||
}) {
|
||||
log::warn!("Failed to show encryption key request: {error}");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to show encryption key request: no open window");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a notification for the encryption request.
|
||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Result<Notification, Error> {
|
||||
const MSG: &str = "You've requested an encryption key from another device. \
|
||||
Approve to allow Coop to share with it.";
|
||||
Approve to allow Coop to share with it. Before approving, \
|
||||
compare the code with the one shown on the requesting \
|
||||
device.";
|
||||
|
||||
let request = Announcement::from(&event);
|
||||
let requester = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Request has no valid requester key")?;
|
||||
|
||||
let code = authorization_code(&requester);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&request.public_key(), cx);
|
||||
let profile = persons.read(cx).get(&requester, cx);
|
||||
let code_label = code.clone();
|
||||
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
let key = SharedString::from(event.id.to_hex());
|
||||
|
||||
Notification::new()
|
||||
Ok(Notification::new()
|
||||
.type_id::<DeviceNotification>(key)
|
||||
.autohide(false)
|
||||
.with_kind(NotificationKind::Info)
|
||||
@@ -655,7 +752,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()),
|
||||
),
|
||||
),
|
||||
@@ -669,7 +770,7 @@ impl DeviceRegistry {
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Client:")),
|
||||
.child(SharedString::from("Code:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -678,7 +779,7 @@ impl DeviceRegistry {
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(request.client_name()),
|
||||
.child(code_label.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -704,12 +805,19 @@ impl DeviceRegistry {
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// The first eight hexadecimal characters of the client public key.
|
||||
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
||||
let hex = public_key.to_hex();
|
||||
let code = hex[..8].to_uppercase();
|
||||
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
|
||||
}
|
||||
|
||||
/// Get or create new app keys (async, returns a task)
|
||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let read = cx.read_credentials(CLIENT_NAME);
|
||||
@@ -740,8 +848,56 @@ fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt and validate an approval event against the current announcement.
|
||||
async fn decrypt_approval(
|
||||
client: &Client,
|
||||
app_keys: &Keys,
|
||||
identity: PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<Keys, Error> {
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let decrypted = app_keys
|
||||
.nip44_decrypt_async(&master, event.content.as_str())
|
||||
.await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
// Reject approvals carrying a key that no longer matches the published announcement.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(identity)
|
||||
.limit(1);
|
||||
|
||||
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
|
||||
let announced = announcement
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Announcement has no valid encryption key")?;
|
||||
|
||||
if keys.public_key() != announced {
|
||||
return Err(anyhow!("Approval key doesn't match the announcement"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Result<(), Error> {
|
||||
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
|
||||
|
||||
@@ -758,7 +914,10 @@ async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Re
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
|
||||
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
@@ -3,7 +3,7 @@ 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};
|
||||
@@ -13,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>);
|
||||
@@ -56,7 +56,8 @@ 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();
|
||||
|
||||
@@ -96,8 +97,8 @@ impl PersonRegistry {
|
||||
}));
|
||||
|
||||
// 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 {
|
||||
@@ -215,7 +216,10 @@ impl PersonRegistry {
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
let announcement = Announcement::from(event);
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
log::warn!("Ignoring announcement with missing or invalid key tag");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
|
||||
@@ -47,12 +47,6 @@ impl Hash for Person {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PublicKey> for Person {
|
||||
fn from(public_key: PublicKey) -> Self {
|
||||
Self::new(public_key, Metadata::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
@@ -64,13 +58,13 @@ impl Person {
|
||||
}
|
||||
|
||||
/// Build profile encryption keys announcement
|
||||
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
pub(crate) fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
self.announcement = Some(announcement);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build profile messaging relays
|
||||
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
pub(crate) fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
@@ -103,14 +97,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| picture.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) -> String {
|
||||
self.public_key().to_hex()
|
||||
}
|
||||
|
||||
/// Get profile name
|
||||
@@ -131,17 +129,17 @@ impl Person {
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
pub(crate) fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
pub(crate) fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
|
||||
@@ -8,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 {
|
||||
@@ -35,16 +38,33 @@ macro_rules! setting_accessors {
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub theme: Option<String>,
|
||||
pub theme_mode: ThemeMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub nip4e: bool,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
@@ -55,14 +75,6 @@ pub enum SignerKind {
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn auto(&self) -> bool {
|
||||
matches!(self, SignerKind::Auto)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> bool {
|
||||
matches!(self, SignerKind::User)
|
||||
}
|
||||
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
@@ -127,6 +139,14 @@ pub struct Settings {
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Pinned chat rooms, by conversation ID
|
||||
#[serde(default)]
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
|
||||
/// Pinned groups, by cache tag
|
||||
#[serde(default)]
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -138,17 +158,13 @@ impl Default for Settings {
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
pinned_rooms: vec![],
|
||||
pinned_groups: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Settings> for Settings {
|
||||
fn as_ref(&self) -> &Settings {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalAppSettings(Entity<AppSettings>);
|
||||
|
||||
impl Global for GlobalAppSettings {}
|
||||
@@ -157,7 +173,6 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
/// Settings
|
||||
inner: Entity<Settings>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -173,7 +188,8 @@ impl AppSettings {
|
||||
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![];
|
||||
|
||||
@@ -185,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 {
|
||||
@@ -204,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"))]
|
||||
{
|
||||
@@ -216,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();
|
||||
})
|
||||
@@ -254,7 +276,7 @@ impl AppSettings {
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
@@ -263,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,27 +309,41 @@ impl AppSettings {
|
||||
self.inner.read(cx).nip4e
|
||||
}
|
||||
|
||||
/// Check if the given relay is already authenticated
|
||||
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
|
||||
self.inner
|
||||
.read(cx)
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
/// Check if the chat room is pinned
|
||||
pub fn pinned_room(&self, id: u64, cx: &App) -> bool {
|
||||
self.inner.read(cx).pinned_rooms.contains(&id)
|
||||
}
|
||||
|
||||
/// Add a relay to the trusted list
|
||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
/// Pin or unpin the chat room
|
||||
pub fn toggle_pinned_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if !this
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
{
|
||||
this.trusted_relays
|
||||
.push(url.as_str_without_trailing_slash().to_string());
|
||||
cx.notify();
|
||||
if let Some(index) = this.pinned_rooms.iter().position(|pinned| *pinned == id) {
|
||||
this.pinned_rooms.remove(index);
|
||||
} else {
|
||||
this.pinned_rooms.push(id);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if the group is pinned by its cache tag
|
||||
pub fn pinned_group(&self, tag: &str, cx: &App) -> bool {
|
||||
self.inner
|
||||
.read(cx)
|
||||
.pinned_groups
|
||||
.iter()
|
||||
.any(|pinned| pinned == tag)
|
||||
}
|
||||
|
||||
/// Pin or unpin the group by its cache tag
|
||||
pub fn toggle_pinned_group(&mut self, tag: &str, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if let Some(index) = this.pinned_groups.iter().position(|pinned| pinned == tag) {
|
||||
this.pinned_groups.remove(index);
|
||||
} else {
|
||||
this.pinned_groups.push(tag.to_owned());
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,16 @@ gpui.workspace = true
|
||||
instant.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
/// COOP's public key
|
||||
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x";
|
||||
|
||||
/// App ID
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Keyring name
|
||||
pub const MASTER_KEYRING: &str = "Coop Master Key";
|
||||
pub(crate) const MASTER_KEYRING: &str = "Coop Master Key";
|
||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
||||
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 2;
|
||||
|
||||
/// Default image cache size
|
||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
|
||||
@@ -29,20 +23,12 @@ pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
/// Default subscription id for user gift wrap events
|
||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||
|
||||
/// Default vertex relays
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
/// Default search relays
|
||||
pub(crate) const INDEXER_RELAYS: [&str; 2] =
|
||||
["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// Default search relays
|
||||
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"];
|
||||
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
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(crate) const ALGORITHM: &str = "aes-gcm";
|
||||
|
||||
pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let client = cx.update(|app| app.http_client());
|
||||
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Failed to download file: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
response
|
||||
.into_body()
|
||||
.take(MAX_FILE_SIZE as u64 + 1)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
|
||||
if data.len() > MAX_FILE_SIZE {
|
||||
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_sha256
|
||||
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||
{
|
||||
bail!("File hash mismatch");
|
||||
}
|
||||
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment into a temporary file.
|
||||
///
|
||||
/// The same attachment always maps to the same path, so callers can render the
|
||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let name = file
|
||||
.sha256
|
||||
.clone()
|
||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||
|
||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{name}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(
|
||||
&file.url,
|
||||
&file.key,
|
||||
&file.nonce,
|
||||
file.sha256.as_deref(),
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
_file: &FileAttachment,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
|
||||
HEXLOWER
|
||||
.decode(value.to_ascii_lowercase().as_bytes())
|
||||
.map_err(|_| anyhow!("Invalid {label} encoding"))
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -17,20 +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, 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`.
|
||||
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
@@ -40,7 +39,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
|
||||
}
|
||||
|
||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
@@ -50,8 +49,6 @@ impl Global for GlobalNostrRegistry {}
|
||||
/// Signer event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum StateEvent {
|
||||
/// The state is busy
|
||||
Busy,
|
||||
/// User has no signer
|
||||
NoSigner,
|
||||
/// The signer has changed
|
||||
@@ -65,7 +62,7 @@ impl StateEvent {
|
||||
matches!(self, StateEvent::SignerChanged)
|
||||
}
|
||||
|
||||
pub fn error<T>(error: T) -> Self
|
||||
pub(crate) fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
@@ -85,6 +82,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>>>,
|
||||
}
|
||||
@@ -103,7 +103,8 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> 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());
|
||||
|
||||
@@ -130,25 +131,29 @@ impl NostrRegistry {
|
||||
})
|
||||
.build();
|
||||
|
||||
// Connect to bootstrap relays after the window is ready
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
} else if let Some(secret) = cli_key {
|
||||
// Use CLI-provided key -- same path as get_user_credential
|
||||
let keys = Keys::new(secret);
|
||||
this.set_signer(keys, cx);
|
||||
} else {
|
||||
this.get_user_credential(cx);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
ready: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -168,6 +173,15 @@ impl NostrRegistry {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
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
|
||||
@@ -182,6 +196,7 @@ impl NostrRegistry {
|
||||
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();
|
||||
})?;
|
||||
@@ -268,12 +283,16 @@ impl NostrRegistry {
|
||||
} 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);
|
||||
})?;
|
||||
}
|
||||
@@ -494,68 +513,4 @@ impl NostrRegistry {
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform a WoT (via Vertex) search for a given query.
|
||||
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||
let client = self.client();
|
||||
let query = query.to_string();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct a vertex request event
|
||||
let event = EventBuilder::new(Kind::Custom(5315), "")
|
||||
.tags(vec![
|
||||
Tag::custom("param", vec!["search", &query]),
|
||||
Tag::custom("param", vec!["limit", "10"]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to vertex relays
|
||||
let output = client.send_event(&event).to(WOT_RELAYS).await?;
|
||||
|
||||
// Construct a filter to get the response or error from vertex
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Custom(6315), Kind::Custom(7000)])
|
||||
.event(output.id().to_owned());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = WOT_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
// Stream events from the wot relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
match event.kind {
|
||||
Kind::Custom(6315) => {
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||
let pubkeys: Vec<PublicKey> = content
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| item.as_object())
|
||||
.filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str()))
|
||||
.filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok())
|
||||
.collect();
|
||||
|
||||
return Ok(pubkeys);
|
||||
}
|
||||
Kind::Custom(7000) => {
|
||||
return Err(anyhow!("Search error"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No results for query: {query}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Announcement
|
||||
@@ -6,50 +5,22 @@ use nostr_sdk::prelude::*;
|
||||
pub struct Announcement {
|
||||
/// The public key of the device that created this announcement.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// The name of the device that created this announcement.
|
||||
client_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Announcement {
|
||||
fn from(val: &Event) -> Self {
|
||||
let public_key = val
|
||||
impl Announcement {
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
let public_key = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())
|
||||
.unwrap_or(val.pubkey);
|
||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||
|
||||
let client_name = val
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "client")
|
||||
.and_then(|tag| tag.content())
|
||||
.map(|c| c.to_string());
|
||||
|
||||
Self::new(public_key, client_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
client_name,
|
||||
}
|
||||
Some(Self { public_key })
|
||||
}
|
||||
|
||||
/// Returns the public key of the device that created this announcement.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
/// Returns the client name of the device that created this announcement.
|
||||
pub fn client_name(&self) -> SharedString {
|
||||
self.client_name
|
||||
.as_ref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("Unknown"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ impl UniversalSigner {
|
||||
}
|
||||
|
||||
/// Swap the inner signer in-place. All clones see the new signer.
|
||||
pub fn swap_inner<T>(&self, new_signer: T)
|
||||
pub(crate) fn swap_inner<T>(&self, new_signer: T)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||
@@ -137,14 +137,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSigner {
|
||||
#[allow(dead_code)]
|
||||
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
|
||||
let guard = self.inner.read().expect("RwLock poisoned");
|
||||
f(&**guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
schemars.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||