22 Commits
Author SHA1 Message Date
reya 6ff1eeddbb fix: message list overflow 2026-09-17 19:15:16 +07:00
reya 0c1c23fe9c chore: migrate to gpui-base (#51)
Reviewed-on: #51
2026-09-17 11:50:14 +00:00
reya 874f103752 chore: restructure concord crate (#50)
Reviewed-on: #50
2026-09-17 06:16:01 +00:00
reya fdabfcf026 feat: implement concord protocol (#49)
Only backend, no UI yet.

Reviewed-on: #49
2026-09-17 04:30:12 +00:00
reya 5500082426 chore: update metadata 2026-09-16 10:23:53 +07:00
reya 7a34d66c7c fix: release endpoint 2026-09-16 10:19:53 +07:00
reya 57c85f5b99 fix: auto updater (#48)
Reviewed-on: #48
2026-09-16 02:53:16 +00:00
reya bf7080654d chore: use gpui image cache (#47)
Reviewed-on: #47
2026-09-16 02:21:12 +00:00
reya 42983383b0 chore: update script 2026-09-16 09:05:09 +07:00
reya e2b10c173e fix: markdown rendering (#46)
Reviewed-on: #46
2026-09-16 01:52:52 +00:00
reya 9e33da717b feat: add support for encrypted attachment (#45)
Reviewed-on: #45
2026-09-16 01:25:14 +00:00
reya 584ab34df6 chore: release version 1.0.1 2026-09-07 13:58:43 +07:00
reya 0514c1d982 fix: panic on flatpak installations 2026-09-07 13:49:36 +07:00
reya 2f834a0bcc chore: update deps 2026-08-31 16:54:38 +07:00
reya 49cd5cb9a0 chore: clean up 2026-08-03 09:53:12 +07:00
reya d082f4fad9 chore: release version 1.0.0 2026-08-03 09:19:51 +07:00
reya 8c59eabba3 chore: migrate to gpui updater (#42)
Reviewed-on: https://git.reya.su/reya/coop/pulls/42
2026-08-03 01:54:25 +00:00
reya bc6bdb3c35 feat: add support for logging in via the web extension (#41)
Reviewed-on: https://git.reya.su/reya/coop/pulls/41
2026-08-03 00:31:03 +00:00
reya dbfee32d55 feat: add support for launch arguments (#40)
Reviewed-on: https://git.reya.su/reya/coop/pulls/40
2026-08-02 07:10:44 +00:00
reya 4f52fc52df feat: add native notification (#39)
Reviewed-on: https://git.reya.su/reya/coop/pulls/39
2026-08-02 04:13:11 +00:00
reya fbf06f2c81 chore: refine some ui components (#38)
Reviewed-on: https://git.reya.su/reya/coop/pulls/38
2026-08-01 10:38:08 +00:00
reya b0d1521c49 feat: add support for reaction (#37)
Reviewed-on: https://git.reya.su/reya/coop/pulls/37
2026-07-31 02:58:42 +00:00
159 changed files with 18453 additions and 17652 deletions
+13 -1
View File
@@ -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
+150
View File
@@ -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
```
Generated
+1432 -1163
View File
File diff suppressed because it is too large Load Diff
+29 -10
View File
@@ -4,19 +4,26 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0-beta5"
version = "1.0.2"
edition = "2024"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# GPUI. The `gpui-pre` family is upstream zed's gpui republished unchanged, so these
# aliases keep every `use gpui::..` site as it is while moving off the zed git pin.
gpui = { package = "gpui-pre", version = "0.3.5" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
gpui_util = { package = "gpui-pre-util", version = "0.3.5" }
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
gpui_tokio = { path = "crates/gpui_tokio" }
# Unstyled behavior, state, and infrastructure from GPUI Kit
gpui-base = "0.6.1"
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
@@ -27,6 +34,17 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
sha2 = "0.10"
data-encoding = "2"
hkdf = "0.12"
# Pinned to the instances `nostr` already builds: the NIP-44 message-key disclosure
chacha20 = "0.9"
hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] }
# Others
anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] }
@@ -42,9 +60,10 @@ schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
-120
View File
@@ -1,125 +1,5 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
+3
View File
@@ -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

+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+3 -10
View File
@@ -5,18 +5,11 @@ edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
gpui.workspace = true
instant.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
serde.workspace = true
serde_json.workspace = true
ureq.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
+257 -494
View File
@@ -1,563 +1,326 @@
#![cfg(not(target_arch = "wasm32"))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration;
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
const GITHUB_API_URL: &str = "https://api.github.com";
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
mod source;
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
const GITEA_REPO_OWNER: &str = "reya";
const GITEA_REPO_NAME: &str = "coop";
/// Delay before the automatic check that runs on startup.
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
/// How long a failure stays visible before the status reverts to "Up to date".
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
const 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 {
// Check if app is installed via Flatpak
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.
pub fn init(window: &mut Window, cx: &mut App) {
// Skip auto-update initialization if installed via Flatpak
if is_flatpak_installation() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
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(window, version, filter, cx)),
cx,
);
}
struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { download_url: String },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
/// The blocking engine, driven on the background executor.
engine: Arc<UpdateEngine<GiteaSource>>,
status: UpdateStatus,
/// The newer release found by the last successful check, if any.
available: Option<Release>,
/// Currently running app version.
pub version: Version,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
/// The in-flight check or download, if any.
task: Option<Task<()>>,
}
impl AutoUpdater {
/// Retrieve the global auto updater instance
/// Whether auto-update is available for this installation.
pub fn is_available(cx: &App) -> bool {
cx.try_global::<GlobalAutoUpdater>().is_some()
}
/// Retrieve the global auto updater instance, if one was initialized.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalAutoUpdater>()
.map(|global| global.0.clone())
}
/// Retrieve the global auto updater instance.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
fn new(
window: &mut Window,
version: Version,
filter: AssetFilter,
cx: &mut Context<Self>,
) -> Self {
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config));
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
this.download_and_install(&download_url, cx);
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
// Schedule an auto-check after a 2-minute delay
cx.defer_in(window, |_this, _window, cx| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
this.update(cx, |this, cx| this.check(cx)).ok();
})
.detach();
});
Self {
status: AutoUpdateStatus::Idle,
engine,
status: UpdateStatus::Idle,
available: None,
version,
tasks: vec![],
_subscriptions: subscriptions,
task: None,
}
}
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
/// Whether nothing is happening, so the UI can hide the status line.
pub fn idle(&self) -> bool {
matches!(self.status, UpdateStatus::Idle)
}
fn check(&mut self, cx: &mut Context<Self>) {
let version = self.version.clone();
let duration = Duration::from_secs(120);
let task = self.check_for_updates(version, cx);
/// Whether a verified update is installed and waiting for a restart.
pub fn staged(&self) -> bool {
matches!(self.status, UpdateStatus::Staged(_))
}
// Check for updates after 2 minutes
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
// Update the status to checking
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
})?;
match task.await {
Ok(download_url) => {
// Update the status to checked with download URL
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(download_url), cx);
})?;
}
Err(e) => {
log::warn!("Failed to check for updates: {e}");
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})?;
/// A short, human-readable description of the current status.
pub fn status(&self) -> SharedString {
match &self.status {
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
match total_mb {
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
None => format!("Downloading {downloaded_mb:.1} MB").into(),
}
}
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
}
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
}
}
Ok(())
/// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
self.set_status(UpdateStatus::Checking, cx);
let engine = self.engine.clone();
self.task = Some(cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { engine.check() })
.await;
this.update(cx, |this, cx| {
this.task = None;
match result {
Ok(Some(release)) => {
log::info!("Update {} is available", release.version);
let version = release.version.clone();
this.available = Some(release);
this.set_status(UpdateStatus::Available(version), cx);
this.download_and_install(cx);
}
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
Err(error) => {
log::warn!("Update check failed: {error}");
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
})
.ok();
}));
}
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
let http_client = cx.http_client();
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
/// Download the available update, verify it, and swap it into place.
fn download_and_install(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
let Some(release) = self.available.clone() else {
return;
};
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let engine = self.engine.clone();
self.set_status(
UpdateStatus::Downloading {
downloaded: 0,
total: None,
},
cx,
);
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
self.task = Some(cx.spawn(async move |this, cx| {
let downloaded = Arc::new(AtomicU64::new(0));
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
let done = Arc::new(AtomicBool::new(false));
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
let download_task = {
let (engine, release) = (engine.clone(), release.clone());
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
cx.background_executor().spawn(async move {
let result = engine.download(&release, |got, expected| {
downloaded.store(got, Ordering::Relaxed);
total.store(expected.unwrap_or(0), Ordering::Relaxed);
});
done.store(true, Ordering::Relaxed);
result
})
};
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
}
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
// Parse version from tag (remove 'v' prefix if present)
let tag_version = release.tag_name.trim_start_matches('v');
let new_version = Version::parse(tag_version).context(format!(
"Failed to parse version from tag: {}",
release.tag_name
))?;
if new_version > version {
// Find the appropriate asset for the current platform
let current_os = std::env::consts::OS;
let asset_name = match current_os {
"macos" => "Coop.dmg",
"linux" => "coop.tar.gz",
"windows" => "Coop.exe",
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
};
let download_url = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.browser_download_url.clone())
.context(format!(
"No {} asset found in release {}",
asset_name, release.tag_name
))?;
Ok(download_url)
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let download_url = download_url.to_string();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(&download_url, &target_path, http_client).await?;
Ok((installer_dir, target_path))
});
self.tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
loop {
let got = downloaded.load(Ordering::Relaxed);
let total = total.load(Ordering::Relaxed);
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
this.set_status(
UpdateStatus::Downloading {
downloaded: got,
total: (total != 0).then_some(total),
},
cx,
);
})
.ok();
if done.load(Ordering::Relaxed) {
break;
}
cx.background_executor()
.timer(Duration::from_millis(120))
.await;
}
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})?;
let artifact = match download_task.await {
Ok(artifact) => artifact,
Err(error) => {
log::warn!("Update download failed: {error}");
this.update(cx, |this, cx| {
this.task = None;
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
})
.ok();
return;
}
};
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
let installed = {
let engine = engine.clone();
cx.background_executor()
.spawn(async move { engine.install(&artifact) })
.await
};
this.update(cx, |this, cx| {
this.task = None;
match installed {
Ok(installed) => {
if let Some(path) = installed.restart_path {
cx.set_restart_path(path);
}
let version = release.version.clone();
this.set_status(UpdateStatus::Staged(version), cx);
}
Err(e) => {
// Update the status to error including the error message
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})?;
Err(error) => {
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
Ok(())
}),
);
})
.ok();
}));
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"linux" => Ok("coop.tar.gz"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
/// Relaunch into the staged update.
pub fn restart(&mut self, cx: &mut Context<Self>) {
if !self.staged() {
log::warn!("Ignoring restart request: no update is staged");
return;
}
cx.restart();
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
let errored = matches!(status, UpdateStatus::Errored(_));
self.status = status;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
// Extract the tar.gz file
let extracted = temp_dir.path().join("coop");
smol::fs::create_dir_all(&extracted)
.await
.context("failed to create directory to extract update")?;
let output = Command::new("tar")
.arg("-xzf")
.arg(&downloaded_tar_gz)
.arg("-C")
.arg(&extracted)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to extract {:?} to {:?}: {:?}",
downloaded_tar_gz,
extracted,
String::from_utf8_lossy(&output.stderr)
);
// Find the extracted app directory
let mut entries = smol::fs::read_dir(&extracted).await?;
let mut app_dir = None;
use smol::stream::StreamExt;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
app_dir = Some(path);
break;
if errored {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
this.update(cx, |this, cx| {
this.set_status(UpdateStatus::Idle, cx);
})
.ok();
})
.detach();
}
cx.notify();
}
let from = app_dir.context("No app directory found in archive")?;
// Copy to the current installation directory
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&from)
.arg(
running_app_path
.parent()
.context("No parent directory for app")?,
)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app from {:?} to {:?}: {:?}",
from,
running_app_path.parent(),
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
+369
View File
@@ -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
);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "browser-signer-proxy"
version = "0.1.0"
edition.workspace = true
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
license = "MIT"
repository = "https://github.com/nostrdevkit/nostr"
publish = false
[dependencies]
atomic-destructor = "0.2"
event-listener = "5"
nostr.workspace = true
opaquerr = { version = "0.1", features = ["alloc"] }
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
tracing = { version = "0.1", features = ["std"] }
uuid = { version = "1.23", features = ["serde", "v4"] }
+55
View File
@@ -0,0 +1,55 @@
# browser-signer-proxy
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
## Description
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
the browser extension.
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
## Usage
```rust
use browser_signer_proxy::prelude::*;
async fn example() -> Result<(), Error> {
// Create the proxy with default options (localhost:7400)
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
// Open the proxy URL in a browser
webbrowser::open(&proxy.url())?;
// Start the proxy server
proxy.start().await?;
// Use it as an async Nostr signer
let public_key = proxy.get_public_key_async().await?;
println!("Connected with public key: {public_key}");
Ok(())
}
```
## Differences from the tokio-based version
| Feature | tokio (original) | smol (this crate) |
|---|---|---|
| Async runtime | `tokio` | `smol` |
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
| Task spawning | `tokio::spawn` | `smol::spawn` |
## License
This project is distributed under the MIT software license.
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coop — Web Signer Proxy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
<style>
:root {
--brand: #F8FF37;
--ink: #111111;
--ink-soft: #333333;
--muted: #666666;
--paper: #FFFFFF;
--edge: rgba(17, 17, 17, 0.14);
--radius-sm: 1rem;
--radius-md: 1.5rem;
--radius-lg: 2.5rem;
--green: #2E8B57;
--red: #D32F2F;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background: var(--brand);
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
background: var(--paper);
border-radius: var(--radius-md);
padding: 2.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.logo__mark {
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.2rem;
color: var(--brand);
letter-spacing: -0.02em;
}
.logo__text {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.heading {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--ink-soft);
margin: 0 0 1.75rem;
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.95rem;
transition: background 300ms ease, color 300ms ease;
}
.status--checking {
background: rgba(17, 17, 17, 0.05);
color: var(--muted);
}
.status--connected {
background: rgba(46, 139, 87, 0.1);
color: var(--green);
}
.status--error {
background: rgba(211, 47, 47, 0.08);
color: var(--red);
}
.status__dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.status--checking .status__dot {
background: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
.status--connected .status__dot {
background: var(--green);
}
.status--error .status__dot {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.hint {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--edge);
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.hint strong {
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Web Signer</h1>
<p class="subtitle">
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
</p>
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
</div>
</div>
<script src="proxy.js"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
let isPolling = false;
async function pollForRequests() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch('/api/pending');
const data = await response.json();
console.log('Polled for requests, got:', data);
// Process any new requests
if (data.requests && data.requests.length > 0) {
console.log(`Processing ${data.requests.length} requests`);
for (const request of data.requests) {
await handleNip07Request(request);
}
}
} catch (error) {
console.error('Polling error:', error);
updateStatus('Error: ' + error.message, 'error');
}
isPolling = false;
}
async function handleNip07Request(request) {
console.log('Handling request:', request);
try {
let result;
if (!window.nostr) {
throw new Error('NIP-07 extension not available');
}
switch (request.method) {
case 'get_public_key':
console.log('Calling nostr.getPublicKey()');
result = await window.nostr.getPublicKey();
console.log('Got public key:', result);
break;
case 'sign_event':
console.log('Calling nostr.signEvent() with:', request.params);
result = await window.nostr.signEvent(request.params);
console.log('Got signed event:', result);
break;
case 'nip04_encrypt':
console.log('Calling nostr.nip04.encrypt()');
result = await window.nostr.nip04.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip04_decrypt':
console.log('Calling nostr.nip04.decrypt()');
result = await window.nostr.nip04.decrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_encrypt':
console.log('Calling nostr.nip44.encrypt()');
result = await window.nostr.nip44.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_decrypt':
console.log('Calling nostr.nip44.decrypt()');
result = await window.nostr.nip44.decrypt(
request.params.public_key,
request.params.content
);
break;
default:
throw new Error(`Unknown method: ${request.method}`);
}
// Send response back to server
const responsePayload = {
id: request.id,
result: result,
error: null
};
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(responsePayload)
});
console.log('Response sent successfully');
updateStatus('Request processed successfully', 'connected');
} catch (error) {
console.error('Error handling request:', error);
// Send error response back to server
const errorPayload = {
id: request.id,
result: null,
error: error.message
};
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(errorPayload)
});
updateStatus('Error: ' + error.message, 'error');
}
}
function updateStatus(message, state) {
const container = document.getElementById('nip07-status');
const textEl = document.getElementById('nip07-status-text');
if (container && textEl) {
container.className = 'status status--' + state;
textEl.textContent = message;
}
}
// Start polling when page loads
window.addEventListener('load', () => {
console.log('NIP-07 Proxy loaded');
// Check if NIP-07 extension is available
if (window.nostr) {
console.log('NIP-07 extension detected');
updateStatus('Connected — ready', 'connected');
} else {
console.log('NIP-07 extension not found');
updateStatus('No NIP-07 extension found', 'error');
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Error types for the browser signer proxy.
opaquerr::define_kind! {
/// Nostr browser signer proxy error kind.
pub ErrorKind {
/// Nostr protocol error.
Protocol => "nostr protocol error",
/// I/O error.
IO => "I/O error",
/// JSON error.
Json => "JSON error",
/// The operation timed out.
Timeout => "timeout",
/// The operation cannot be completed in the current state.
State => "invalid state",
/// Anything not covered by the stable categories above.
Other => "other error",
}
}
opaquerr::define_error! {
/// Nostr browser signer proxy error.
pub Error(ErrorKind)
from {
nostr::error::Error => ErrorKind::Protocol,
std::io::Error => ErrorKind::IO,
serde_json::Error => ErrorKind::Json,
}
}
impl Error {
pub(crate) fn generic<S>(message: S) -> Self
where
S: Into<String>,
{
Self::new(ErrorKind::Other, message.into())
}
pub(crate) fn timeout() -> Self {
Self::simple(ErrorKind::Timeout)
}
pub(crate) fn shutdown() -> Self {
Self::with_static_message(ErrorKind::State, "server is shutdown")
}
}
+764
View File
@@ -0,0 +1,764 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use event_listener::Event as ShutdownEvent;
use nostr::prelude::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json};
use smol::channel;
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use smol::lock::Mutex;
use smol::net::{TcpListener, TcpStream};
use uuid::Uuid;
mod error;
pub mod prelude;
pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
struct Message {
id: Uuid,
error: Option<String>,
result: Option<Value>,
}
impl Message {
fn into_result(self) -> Result<Value, String> {
if let Some(error) = self.error {
Err(error)
} else {
Ok(self.result.unwrap_or(Value::Null))
}
}
}
#[derive(Debug, Clone, Copy)]
enum RequestMethod {
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
}
impl RequestMethod {
fn as_str(&self) -> &str {
match self {
Self::GetPublicKey => "get_public_key",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
Self::Nip44Encrypt => "nip44_encrypt",
Self::Nip44Decrypt => "nip44_decrypt",
}
}
}
impl Serialize for RequestMethod {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
struct RequestData {
id: Uuid,
method: RequestMethod,
params: Value,
}
impl RequestData {
#[inline]
fn new(method: RequestMethod, params: Value) -> Self {
Self {
id: Uuid::new_v4(),
method,
params,
}
}
}
#[derive(Serialize)]
struct Requests<'a> {
requests: &'a [RequestData],
}
impl<'a> Requests<'a> {
#[inline]
fn new(requests: &'a [RequestData]) -> Self {
Self { requests }
}
#[inline]
fn len(&self) -> usize {
self.requests.len()
}
}
/// Params for NIP-04 and NIP-44 encryption/decryption
#[derive(Serialize)]
struct CryptoParams<'a> {
public_key: &'a PublicKey,
content: &'a str,
}
impl<'a> CryptoParams<'a> {
#[inline]
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
Self {
public_key,
content,
}
}
}
#[derive(Debug)]
struct ProxyState {
/// Requests waiting to be picked up by browser
pub outgoing_requests: Mutex<Vec<RequestData>>,
/// Map of request ID to response sender
pub pending_responses: Mutex<PendingResponseMap>,
/// Last time the client asked for the pending requests
pub last_pending_request: Arc<AtomicU64>,
}
/// Configuration options for [`BrowserSignerProxy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSignerProxyOptions {
/// Request timeout for the signer extension. Default is 30 seconds.
pub timeout: Duration,
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
pub addr: SocketAddr,
/// Custom HTML page.
// NOTE: not `Option` to move it between threads without reference counter
pub custom_html: &'static str,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Notification trigger for graceful shutdown
shutdown: Arc<ShutdownEvent>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flag indicating if the server is started
is_started: Arc<AtomicBool>,
}
impl AtomicDestroyer for InnerBrowserSignerProxy {
fn on_destroy(&self) {
self.shutdown();
}
}
impl InnerBrowserSignerProxy {
#[inline]
fn is_shutdown(&self) -> bool {
self.is_shutdown.load(Ordering::SeqCst)
}
fn shutdown(&self) {
// Mark the server as shutdown
self.is_shutdown.store(true, Ordering::SeqCst);
// Notify all waiters that the proxy is shutting down
self.shutdown.notify(usize::MAX);
}
}
/// Nostr Browser Signer Proxy
///
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
#[derive(Debug, Clone)]
pub struct BrowserSignerProxy {
inner: AtomicDestructor<InnerBrowserSignerProxy>,
}
impl Default for BrowserSignerProxyOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
custom_html: "",
}
}
}
impl BrowserSignerProxyOptions {
/// Sets the timeout duration.
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Sets the IP address.
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
self.addr = SocketAddr::new(new_ip, self.addr.port());
self
}
/// Sets the port number.
pub const fn port(mut self, new_port: u16) -> Self {
self.addr = SocketAddr::new(self.addr.ip(), new_port);
self
}
/// Sets a custom html page.
///
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
/// which will handle communication with the server and update the element
/// with id `nip07-proxy-status` with the status.
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
self.custom_html = custom_html;
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
shutdown: Arc::new(ShutdownEvent::new()),
is_shutdown: Arc::new(AtomicBool::new(false)),
is_started: Arc::new(AtomicBool::new(false)),
}),
}
}
/// Indicates whether the server is currently running.
#[inline]
pub fn is_started(&self) -> bool {
self.inner.is_started.load(Ordering::SeqCst)
}
/// Checks if there is an open browser tab ready to respond to requests by
/// verifying the time since the last pending request.
#[inline]
pub fn is_session_active(&self) -> bool {
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
}
/// Get the signer proxy webpage URL
#[inline]
pub fn url(&self) -> String {
format!("http://{}", self.inner.options.addr)
}
/// Start the proxy server.
///
/// If this is not called explicitly, the server will be automatically
/// started on the first interaction with the signer.
pub async fn start(&self) -> Result<(), Error> {
// Ensure is not shutdown
if self.inner.is_shutdown() {
return Err(Error::shutdown());
}
// Mark the proxy as started and check if was already started
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
// Immediately return if already started
if is_started {
return Ok(());
}
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
loop {
// Race between accepting a new connection and shutdown signal
let shutdown_listener = shutdown.listen();
enum AcceptEvent {
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
Shutdown,
}
let event = smol::future::or(
async { AcceptEvent::Connection(listener.accept().await) },
async {
shutdown_listener.await;
AcceptEvent::Shutdown
},
)
.await;
match event {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
},
async {
shutdown_listener.await;
tracing::debug!(
"Closing connection, proxy server is shutting down."
);
},
)
.await;
})
.detach();
}
AcceptEvent::Connection(Err(e)) => {
tracing::error!("Failed to accept connection: {e}");
}
AcceptEvent::Shutdown => break,
}
}
tracing::info!("Proxy server shut down.");
})
.detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
let mut pending_responses = self.inner.state.pending_responses.lock().await;
pending_responses.insert(id, tx);
}
#[inline]
async fn store_outgoing_request(&self, request: RequestData) {
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
outgoing_requests.push(request);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
{
// Start the proxy if not already started
self.start().await?;
// Construct the request
let request: RequestData = RequestData::new(method, params);
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
// Store the response sender
self.store_pending_response(request.id, tx).await;
// Add to outgoing requests queue
self.store_outgoing_request(request).await;
// Wait for response with timeout
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
}
}
#[inline]
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
self.request(RequestMethod::GetPublicKey, json!({})).await
}
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
Ok(event)
}
#[inline]
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
.await
}
}
impl AsyncGetPublicKey for BrowserSignerProxy {
type Error = Error;
#[inline]
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
Box::pin(async move { self._get_public_key().await })
}
}
impl AsyncSignEvent for BrowserSignerProxy {
type Error = Error;
#[inline]
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
Box::pin(async move { self._sign_event(unsigned).await })
}
}
impl AsyncNip04 for BrowserSignerProxy {
type Error = Error;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
}
}
impl AsyncNip44 for BrowserSignerProxy {
type Error = Error;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
let mut reader = BufReader::new(stream);
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let method = parts[0].to_uppercase();
let path = parts[1].to_string();
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
DEFAULT_HTML
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
}
// Browser polls this endpoint to get pending requests
("GET", "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests = Requests::new(&outgoing);
let json = match serde_json::to_string(&requests) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize pending requests: {e}");
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
return;
}
};
tracing::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
let mut body_bytes = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse response body: {e}");
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
return;
}
};
tracing::debug!("Received response from browser: {message:?}");
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
}
// 404 - not found
_ => {
send_response(&mut reader, 404, "Not Found", "", "").await;
}
}
}
/// Write an HTTP response to the stream.
async fn send_response(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
) -> Result<T, TimeoutError> {
enum Event<T> {
Value(T),
ChannelClosed,
Timeout,
}
let event = smol::future::or(
async {
match recv.await {
Ok(value) => Event::Value(value),
Err(_) => Event::ChannelClosed,
}
},
async {
smol::Timer::after(duration).await;
Event::Timeout
},
)
.await;
match event {
Event::Value(value) => Ok(value),
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
@@ -0,0 +1,14 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Prelude
#![allow(unknown_lints)]
#![allow(ambiguous_glob_reexports)]
#![doc(hidden)]
pub use nostr::prelude::*;
pub use crate::error::{Error, ErrorKind};
pub use crate::*;
+76 -60
View File
@@ -1,5 +1,5 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, RwLock};
@@ -21,6 +21,7 @@ mod room;
pub use message::*;
pub use room::*;
pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -80,6 +81,9 @@ 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>>,
@@ -170,6 +174,7 @@ impl ChatRegistry {
Self {
rooms: vec![],
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
@@ -361,7 +366,8 @@ impl ChatRegistry {
.query(filter)
.await
.unwrap_or_default()
.first_owned()
.into_iter()
.next()
.is_some();
if !found {
@@ -393,7 +399,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();
@@ -436,12 +443,9 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
}
/// Get all rooms based on the filter.
@@ -511,7 +515,11 @@ impl ChatRegistry {
};
let room: Room = room.into().organize(&public_key);
self.rooms.insert(0, cx.new(|_| room));
let room_id = room.id;
let entity = cx.new(|_| room);
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
cx.emit(ChatEvent::Ping);
cx.notify();
@@ -524,9 +532,11 @@ impl ChatRegistry {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
// If 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
@@ -537,17 +547,23 @@ impl ChatRegistry {
/// Close a room.
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
}
}
/// Sort rooms by their created at.
/// Sort rooms by their created at. Only notifies if order changed.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
}
/// Finding rooms based on a query.
@@ -574,6 +590,7 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -601,7 +618,9 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -611,13 +630,7 @@ impl ChatRegistry {
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task = self.get_rooms_from_database(public_key, cx);
let task = self.query_chat_rooms(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
@@ -638,62 +651,65 @@ impl ChatRegistry {
}));
}
/// Create a task to load rooms from the database
fn get_rooms_from_database(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<HashSet<Room>, Error>> {
/// Query the chat rooms from the database
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let contacts = client
let public_key = signer.get_public_key_async().await?;
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.contacts_public_keys(public_key)
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
// Query all cached rumor events (works with both old and new cache formats)
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
let events = client.database().query(filter).await?;
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
let mut rooms: HashSet<Room> = HashSet::new();
let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().peekable().peek().is_some()
&& rumor.tags.public_keys().next().is_some()
{
if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key)
{
continue;
}
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
let mut rooms = HashSet::with_capacity(grouped.len());
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
for (_id, messages) in grouped.into_iter() {
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
let room = Room::from(latest).organize(&public_key);
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts
let is_contact = room.members.iter().any(|k| contacts.contains(k));
// Set the room's kind based on status
if user_sent || is_contact {
room = room.kind(RoomKind::Ongoing);
}
let room = if user_sent || is_contact {
room.kind(RoomKind::Ongoing)
} else {
room
};
rooms.insert(room);
}
@@ -704,8 +720,8 @@ impl ChatRegistry {
/// Parse a nostr event into a message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
/// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -713,7 +729,7 @@ impl ChatRegistry {
return;
};
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
@@ -808,7 +824,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())
@@ -825,7 +841,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."))
+82 -39
View File
@@ -4,6 +4,9 @@ use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
use state::FileAttachment;
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
/// Rendered message.
#[derive(Debug, Clone)]
@@ -21,61 +24,90 @@ pub struct Message {
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
/// Encrypted file attachment
pub file: Option<FileAttachment>,
}
impl From<&Event> for Message {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
id: val.id,
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
from_parts(
val.id,
val.pubkey,
val.created_at,
val.kind,
&val.content,
&val.tags,
)
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
from_parts(
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
val.id.unwrap(),
val.pubkey,
val.created_at,
val.kind,
&val.content,
&val.tags,
)
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
from_parts(
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: string,
media,
created_at: val.rumor.created_at,
mentions,
replies_to,
}
val.rumor.id.unwrap(),
val.rumor.pubkey,
val.rumor.created_at,
val.rumor.kind,
&val.rumor.content,
&val.rumor.tags,
)
}
}
fn from_parts(
id: EventId,
author: PublicKey,
created_at: Timestamp,
kind: Kind,
content: &str,
tags: &Tags,
) -> Message {
let file = if kind == KIND_FILE_MESSAGE {
FileAttachment::from_tags(content, tags)
} else {
None
};
let has_file = file.is_some();
let replies_to = extract_reply_ids(tags);
// For file messages `.content` is the encrypted blob URL, not text or media
let mentions = if has_file {
Vec::new()
} else {
extract_mentions(content)
};
let (media, content) = if has_file {
(Vec::new(), String::new())
} else {
extract_and_remove_media_urls(content)
};
Message {
id,
author,
content,
media,
created_at,
mentions,
replies_to,
file,
}
}
@@ -105,6 +137,17 @@ impl Hash for Message {
}
}
impl Message {
/// Single-line representation for reply previews, notifications and copy.
pub fn preview(&self) -> SharedString {
if let Some(file) = &self.file {
return format!("[File] {}", file.display_name()).into();
}
self.content.clone().into()
}
}
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
+54 -26
View File
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::NewMessage;
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
pub fn members(&self) -> &[PublicKey] {
&self.members
}
/// Checks if the room has more than two members (group)
@@ -356,7 +356,7 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let members = self.members().to_vec();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
@@ -403,7 +403,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 +439,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,32 +494,20 @@ 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.
@@ -612,7 +640,7 @@ async fn send_gift_wrap(
rumor: &UnsignedEvent,
config: &SignerKind,
) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", vec!["14"]);
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config
+1
View File
@@ -25,4 +25,5 @@ serde.workspace = true
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
+32
View File
@@ -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())
}
+540 -99
View File
@@ -1,44 +1,57 @@
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, RwLock};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
use anyhow::Error;
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
use common::{TimestampExt, coop_cache};
use common::TimestampExt;
use futures::lock::Mutex;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
img, list, px, red, relative, svg, white,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use ui::menu::DropdownMenu;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::tooltip::Tooltip;
use ui::{
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
h_flex, v_flex,
};
use crate::file::*;
use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions;
mod file;
mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
@@ -59,6 +72,9 @@ pub struct ChatPanel {
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
@@ -69,7 +85,7 @@ pub struct ChatPanel {
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
/// Chat input state
input: Entity<InputState>,
input: Entity<TextareaState>,
/// Subject input state
subject_input: Entity<InputState>,
@@ -86,6 +102,12 @@ pub struct ChatPanel {
/// Media Attachment
attachments: Entity<Vec<Url>>,
/// Uploaded, encrypted file attachments which are not sent yet
encrypted_attachments: Entity<Vec<PendingFile>>,
/// Decrypted attachments of file messages, by message id
decrypted_files: HashMap<EventId, DecryptedFile>,
/// Upload state
uploading: bool,
@@ -100,6 +122,7 @@ impl ChatPanel {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// Define attachments and replies_to entities
let attachments = cx.new(|_| vec![]);
let encrypted_attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new());
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
@@ -119,7 +142,7 @@ impl ChatPanel {
// Define input state
let input = cx.new(|cx| {
InputState::new(window, cx)
TextareaState::new(window, cx)
.placeholder(format!("Message {}", name))
.auto_grow(1, 20)
.clean_on_escape()
@@ -166,6 +189,7 @@ impl ChatPanel {
focus_handle: cx.focus_handle(),
id,
messages,
message_index: HashMap::new(),
reactions: BTreeMap::new(),
room,
list_state,
@@ -174,6 +198,8 @@ impl ChatPanel {
subject_bar,
replies_to,
attachments,
encrypted_attachments,
decrypted_files: HashMap::new(),
rendered_texts_by_id: BTreeMap::new(),
reports_by_id,
sent_ids: Arc::new(Mutex::new(Vec::new())),
@@ -231,23 +257,29 @@ impl ChatPanel {
while let Ok(status) = rx.recv_async().await {
{
let mut map = reports.write().unwrap();
for reports in map.values_mut() {
for report in reports.iter_mut() {
let status_id = match &*status {
SendStatus::Ok { id, .. } => *id,
SendStatus::Failed { id, .. } => *id,
};
// Find the matching report and update it (exit early on first match)
'outer: for reports_list in map.values_mut() {
for report in reports_list.iter_mut() {
let Some(output) = report.output.as_mut() else {
continue;
};
if *output.id() != status_id {
continue;
}
match &*status {
SendStatus::Ok { id, relay } => {
if output.id() == id {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
SendStatus::Ok { relay, .. } => {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
SendStatus::Failed { id, relay, message } => {
if output.id() == id {
output.failed.insert(relay.clone(), message.clone());
}
SendStatus::Failed { relay, message, .. } => {
output.failed.insert(relay.clone(), message.clone());
}
}
break 'outer;
}
}
}
@@ -259,30 +291,44 @@ impl ChatPanel {
/// Subscribe to room events
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(room) = self.room.upgrade() {
self.subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
if message.rumor.kind == Kind::Reaction {
this.insert_reaction(&message.rumor, cx);
} else {
this.insert_message(message, false, cx);
let Some(room) = self.room.upgrade() else {
return;
};
self.subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
if message.rumor.kind == Kind::Reaction {
this.insert_reaction(&message.rumor, cx);
} else {
this.insert_message(message, false, cx);
if !window.is_window_active() {
cx.show_system_notification(SystemNotification {
tag: "message".into(),
title: "New Message".into(),
body: "You have a new message.".into(),
actions: vec![SystemNotificationAction {
id: "open".into(),
label: "Open".into(),
}],
});
}
}
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
// emit_refresh holds a write lock (via refresh_rooms).
cx.defer_in(window, |this, window, cx| {
this.get_messages(window, cx);
});
}
};
},
));
}
}
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
// emit_refresh holds a write lock (via refresh_rooms).
cx.defer_in(window, |this, window, cx| {
this.get_messages(window, cx);
});
}
};
},
));
}
/// Load all messages belonging to this room
@@ -339,19 +385,49 @@ impl ChatPanel {
}
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the message which includes all attachments
// Get the message which includes all plain attachments
let content = self.get_input_value(cx);
// Get the replies to this message
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Return if message is empty
if content.trim().is_empty() {
// Uploaded files are sent as encrypted file messages
let files: Vec<FileAttachment> = self
.encrypted_attachments
.read(cx)
.iter()
.map(|pending| pending.file.clone())
.collect();
// Return if there is nothing to send
if content.trim().is_empty() && files.is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
self.send_message(&content, replies, false, window, cx);
// If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message
if replies.len() == 1
&& EMOJI_RE.is_match(&content)
&& self.attachments.read(cx).is_empty()
&& files.is_empty()
{
for reply in &replies {
self.send_reaction(&content, reply, window, cx);
}
self.clear(window, cx);
return;
}
// Send the text part, including the plain attachment urls
if !content.trim().is_empty() {
self.send_message(&content, replies.clone(), false, window, cx);
}
// Send every file as its own encrypted file message
for file in files {
self.send_file(file, replies.clone(), window, cx);
}
}
fn send_reaction(
@@ -384,29 +460,59 @@ impl ChatPanel {
return;
}
let room = self.room.clone();
let content = value.to_string();
let sent_ids = self.sent_ids.clone();
// Upgrade room and create rumor + send task in a single read lock
let Some(room_entity) = room.upgrade() else {
let Some(room) = self.room.upgrade() else {
return;
};
// Create rumor and send task
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
let outcome = room.read_with(cx, |room, cx| {
let rumor = room.rumor(value, replies, reaction, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
}) {
Some(pair) => pair,
None => {
window.push_notification("Failed to create message", cx);
return;
}
});
match outcome {
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
None => window.push_notification("Failed to create message", cx),
}
}
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
fn send_file(
&mut self,
file: FileAttachment,
replies: Vec<EventId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(room) = self.room.upgrade() else {
return;
};
let outcome = room.read_with(cx, |room, cx| {
let rumor = room.file_rumor(file, replies, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
});
match outcome {
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
None => window.push_notification("Failed to create message", cx),
}
}
/// Insert a rumor optimistically and track the send reports of its gift wraps
fn dispatch(
&mut self,
rumor: UnsignedEvent,
send_task: Task<Vec<SendReport>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = rumor.id.expect("rumor must have an id");
let sent_ids = self.sent_ids.clone();
// Insert optimistic message and clear input
if rumor.kind != Kind::Reaction {
@@ -445,6 +551,10 @@ impl ChatPanel {
this.clear();
cx.notify();
});
self.encrypted_attachments.update(cx, |this, cx| {
this.clear();
cx.notify();
});
self.replies_to.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -472,6 +582,10 @@ impl ChatPanel {
if let Err(pos) = self.messages.binary_search(&msg) {
self.messages.insert(pos, msg);
// Rebuild message index after insertion (indices from pos to end shift)
for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i);
}
self.list_state.splice(old_len..old_len, 1);
if scroll {
@@ -514,22 +628,25 @@ impl ChatPanel {
}
/// Check if a message has any reports
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
self.reports_by_id.read().unwrap().get(id).is_some()
fn has_reports(&self, id: &EventId) -> bool {
self.reports_by_id.read().unwrap().contains_key(id)
}
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
/// Clone reports for a message (used for modal display, not called during render)
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
self.reports_by_id.read().unwrap().get(id).cloned()
}
/// Get a message by its ID
/// Get a message by its ID (O(1) lookup)
fn message(&self, id: &EventId) -> Option<&Message> {
self.messages.iter().find(|msg| &msg.id == id)
self.message_index
.get(id)
.and_then(|&ix| self.messages.get(ix))
}
/// Get a reaction by its target ID
fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> {
self.reactions.get(id).cloned().unwrap_or_default()
/// Get a reaction by its target ID (returns reference, no allocation)
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Check if a message has any reactions
@@ -555,7 +672,7 @@ impl ChatPanel {
let Some(message) = self.message(id) else {
return;
};
let content = message.content.to_string();
let content = message.preview().to_string();
let item = ClipboardItem::new_string(content);
cx.write_to_clipboard(item);
@@ -581,6 +698,9 @@ impl ChatPanel {
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
// Encrypt attachments which are not part of a message being written
let encrypted = self.input.read(cx).value().trim().is_empty();
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
@@ -590,36 +710,95 @@ impl ChatPanel {
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
return Ok(());
};
this.update_in(cx, |this, window, cx| {
this.upload_file(server, path, encrypted, window, cx);
})?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
Ok(())
}));
}
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
this.update_in(cx, |this, _window, cx| {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
/// Upload a file, encrypted when the attachment is the whole message
fn upload_file(
&mut self,
server: Url,
path: PathBuf,
encrypted: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_uploading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = if encrypted {
upload_encrypted(server.clone(), path.clone(), cx)
.await
.map(|file| Uploaded::File(file, path.clone()))
} else {
upload(server.clone(), path.clone(), cx)
.await
.map(Uploaded::Url)
};
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
match result {
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
Err(e) if encrypted => {
this.report_encrypted_upload_error(server, path, e, window, cx)
}
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
}
}
}
})?;
Ok(())
}));
}
/// Report a failed encrypted upload, offering to retry it without encryption
fn report_encrypted_upload_error(
&mut self,
server: Url,
path: PathBuf,
error: Error,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = cx.entity().downgrade();
window.push_notification(
Notification::error(error.to_string())
.title("Encrypted upload failed")
.action(move |_this, _window, _cx| {
let view = view.clone();
let server = server.clone();
let path = path.clone();
Button::new("retry-without-encryption")
.label("Upload without encryption")
.on_click(move |_ev, window, cx| {
view.update(cx, |this, cx| {
this.upload_file(server.clone(), path.clone(), false, window, cx);
})
.ok();
})
}),
cx,
);
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading;
cx.notify();
@@ -641,6 +820,88 @@ impl ChatPanel {
});
}
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
this.push(PendingFile { file, path });
cx.notify();
});
}
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
this.remove(ix);
cx.notify();
}
});
}
/// Download and decrypt the attachment of a file message for preview
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
this.update(cx, |this, cx| {
match result {
Ok(path) => {
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
}
Err(e) => {
this.decrypted_files
.insert(id, DecryptedFile::Failed(e.to_string().into()));
}
}
cx.notify();
})?;
Ok(())
}));
}
/// Decrypt the attachment of a file message and open it with the OS
fn open_file(
&mut self,
id: EventId,
file: FileAttachment,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.decrypted_files.get(&id) {
Some(DecryptedFile::Ready(path)) => {
cx.open_url(&file_url(path));
return;
}
Some(DecryptedFile::Loading) => return,
_ => {}
};
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
this.update_in(cx, |this, _window, cx| {
match result {
Ok(path) => {
cx.open_url(&file_url(&path));
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
}
Err(e) => {
this.decrypted_files
.insert(id, DecryptedFile::Failed(e.to_string().into()));
}
}
cx.notify();
})?;
Ok(())
}));
}
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx)
@@ -880,6 +1141,16 @@ impl ChatPanel {
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let file = self.messages.get(ix).and_then(|message| {
let file = message.file.clone()?;
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
.then_some((message.id, file))
});
if let Some((id, file)) = file {
self.load_file(id, file, cx);
}
if let Some(message) = self.messages.get(ix) {
let persons = PersonRegistry::global(cx);
let show_author = self.is_group_start(ix);
@@ -887,7 +1158,7 @@ impl ChatPanel {
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
RenderedText::new(&message.content, &message.mentions, &persons, cx)
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
})
.element(ix.into(), window, cx);
@@ -912,7 +1183,7 @@ impl ChatPanel {
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
let has_reactions = self.has_reaction(&id);
let has_reports = self.has_reports(&id, cx);
let has_reports = self.has_reports(&id);
// Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -967,8 +1238,11 @@ impl ChatPanel {
.when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx))
})
.child(rendered_text)
.when(message.file.is_none(), |this| this.child(rendered_text))
.child(self.render_media(&message.media, cx))
.when_some(message.file.as_ref(), |this, file| {
this.child(self.render_message_file(&id, file, cx))
})
.when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx))
}),
@@ -1074,7 +1348,7 @@ impl ChatPanel {
.w_full()
.text_ellipsis()
.line_clamp(1)
.child(SharedString::from(&message.content)),
.child(message.preview()),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click({
@@ -1095,7 +1369,7 @@ impl ChatPanel {
// Group reactions by emoji and collect authors for each
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
for (emoji, author) in &reactions {
for (emoji, author) in reactions {
grouped.entry(emoji.clone()).or_default().push(*author);
}
@@ -1127,7 +1401,7 @@ impl ChatPanel {
}
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
let reports = self.sent_reports(id, cx);
let reports = self.sent_reports(id);
let pending = reports
.as_ref()
@@ -1378,7 +1652,7 @@ impl ChatPanel {
.size_16()
.when(cx.theme().shadow, |this| this.shadow_lg())
.rounded(cx.theme().radius)
.object_fit(ObjectFit::ScaleDown),
.object_fit(ObjectFit::Cover),
)
.child(
div()
@@ -1415,6 +1689,159 @@ impl ChatPanel {
items
}
/// Render the encrypted file attachment of a message
fn render_message_file(
&self,
id: &EventId,
file: &FileAttachment,
cx: &Context<Self>,
) -> AnyElement {
let state = self.decrypted_files.get(id);
if let Some(path) = state
.and_then(|state| match state {
DecryptedFile::Ready(path) => Some(path),
_ => None,
})
.filter(|_| file.is_image())
{
return div()
.child(
img(path.clone())
.border_1()
.border_color(cx.theme().border_variant)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
)
.into_any_element();
}
let label = match state {
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
Some(DecryptedFile::Failed(error)) => error.clone(),
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
None => SharedString::from("Click to decrypt"),
};
self.render_file_chip(id, file, label, cx)
}
/// Render an encrypted file as a chip which decrypts and opens it on click
fn render_file_chip(
&self,
id: &EventId,
file: &FileAttachment,
label: SharedString,
cx: &Context<Self>,
) -> AnyElement {
h_flex()
.id(SharedString::from(format!("file-{id}")))
.self_start()
.items_start()
.min_w_0()
.gap_2()
.p_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
.child(
v_flex()
.min_w_0()
.overflow_hidden()
.text_sm()
.child(div().line_height(relative(1.2)).child(file.display_name()))
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(label),
),
)
.on_click({
let file = file.clone();
let id = *id;
cx.listener(move |this, _, window, cx| {
this.open_file(id, file.clone(), window, cx);
})
})
.into_any_element()
}
/// Render an uploaded, encrypted file which is not sent yet
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
let file = &pending.file;
let label = file.display_name();
div()
.id(SharedString::from(file.url.to_string()))
.relative()
.w_16()
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
.map(|this| {
if file.is_image() {
this.child(
img(pending.path.clone())
.size_16()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.object_fit(ObjectFit::Cover),
)
} else {
this.child(
div()
.size_16()
.flex()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border_variant)
.bg(cx.theme().surface_background)
.text_xs()
.text_center()
.child("Preview not available"),
)
}
})
.child(
v_flex()
.absolute()
.top_neg_1()
.right_neg_1()
.size_4()
.items_center()
.justify_center()
.rounded_full()
.border_1()
.border_color(cx.theme().border_variant)
.bg(gpui::green())
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
)
.on_click({
let url = file.url.clone();
cx.listener(move |this, _, _, cx| {
this.remove_pending_file(&url, cx);
})
})
}
fn render_pending_file_list(
&self,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoIterator<Item = impl IntoElement> {
let mut items = vec![];
for pending in self.encrypted_attachments.read(cx).iter() {
items.push(self.render_pending_file(pending, cx));
}
items
}
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx);
@@ -1463,7 +1890,7 @@ impl ChatPanel {
.text_sm()
.text_ellipsis()
.line_clamp(1)
.child(SharedString::from(&text.content)),
.child(text.preview()),
)
} else {
div()
@@ -1591,8 +2018,13 @@ impl Focusable for ChatPanel {
impl Render for ChatPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
let is_typing = !self.input.read(cx).value().trim().is_empty();
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
v_flex()
.image_cache(coop_cache(self.id.clone(), 100))
.image_cache(retain_all(self.id.clone()))
.on_action(cx.listener(Self::on_command))
.size_full()
.when(*self.subject_bar.read(cx), |this| {
@@ -1620,14 +2052,13 @@ impl Render for ChatPanel {
.child(
v_flex()
.flex_1()
.min_h_0()
.relative()
.map(|this| {
if self.messages.is_empty() {
this.child(
div()
h_flex()
.size_full()
.flex()
.items_center()
.justify_end()
.child(self.render_announcement(cx)),
)
@@ -1652,7 +2083,17 @@ impl Render for ChatPanel {
.w_full()
.gap_1p5()
.children(self.render_attachment_list(window, cx))
.children(self.render_pending_file_list(window, cx))
.children(self.render_reply_list(window, cx))
.when(is_typing && pending_attachments, |this| {
this.child(
div()
.px_1()
.text_xs()
.text_color(cx.theme().text_warning)
.child(WARNING),
)
})
.child(
h_flex()
.items_end()
@@ -1668,7 +2109,7 @@ impl Render for ChatPanel {
this.upload(window, cx);
})),
)
.child(Input::new(&self.input).appearance(false).flex_1())
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
h_flex()
.pl_1()
+153 -64
View File
@@ -1,17 +1,19 @@
use std::ops::Range;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use chat::Mention;
use common::RangeExt;
use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
};
use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme;
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
#[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Code,
@@ -39,25 +41,61 @@ impl RenderedText {
content: &str,
mentions: &[Mention],
persons: &Entity<PersonRegistry>,
markdown: bool,
cx: &App,
) -> Self {
Self::render(content, mentions, markdown, |mention| {
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
})
}
fn render(
content: &str,
mentions: &[Mention],
markdown: bool,
resolve_mention: impl Fn(&Mention) -> String,
) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
render_text_mut(
content,
mentions,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
persons,
cx,
markdown,
resolve_mention,
);
text.truncate(text.trim_end().len());
// Trim trailing whitespace and adjust highlight and link ranges.
let trimmed_len = text.trim_end().len();
// Retain highlights and link ranges that are within the trimmed text.
if trimmed_len < text.len() {
highlights.retain_mut(|(range, _)| {
range.end = range.end.min(trimmed_len);
range.start < range.end
});
let mut ix = 0;
while ix < link_ranges.len() {
let range = &mut link_ranges[ix];
range.end = range.end.min(trimmed_len);
if range.start < range.end {
ix += 1;
} else {
link_ranges.remove(ix);
link_urls.remove(ix);
}
}
text.truncate(trimmed_len);
}
RenderedText {
text: SharedString::from(text),
@@ -70,55 +108,71 @@ impl RenderedText {
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
let code_font = if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"monospace"
};
InteractiveText::new(
id,
StyledText::new(self.text.clone()).with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
StyledText::new(self.text.clone())
.with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
}
}
}
Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
Highlight::Highlight(highlight) => *highlight,
},
Highlight::Highlight(highlight) => *highlight,
},
)
}),
),
)
}),
)
.with_font_family_overrides(self.highlights.iter().filter_map(
|(range, highlight)| match highlight {
Highlight::Code | Highlight::InlineCode(_) => {
Some((range.clone(), code_font.into()))
}
_ => None,
},
)),
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
if WEB_URL.is_match(url) {
cx.open_url(url);
}
}
@@ -128,15 +182,15 @@ impl RenderedText {
}
#[allow(clippy::too_many_arguments)]
fn render_plain_text_mut(
fn render_text_mut(
block: &str,
mut mentions: &[Mention],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
persons: &Entity<PersonRegistry>,
cx: &App,
markdown: bool,
resolve_mention: impl Fn(&Mention) -> String,
) {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
@@ -145,34 +199,58 @@ fn render_plain_text_mut(
let mut strikethrough_depth = 0;
let mut link_url = None;
let mut list_stack = Vec::new();
let mut code_block = false;
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
// Only enable the extensions that make sense for chat messages. Notably this leaves
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
} else {
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
};
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
for (event, source_range) in events {
let prev_len = text.len();
match event {
Event::Text(t) => {
// Process text with mention replacements
if code_block {
text.push_str(t.as_ref());
highlights.push((prev_len..text.len(), Highlight::Code));
continue;
}
let t_str = t.as_ref();
let mut last_processed = 0;
while let Some(mention) = mentions.first() {
if !source_range.contains_inclusive(&mention.range) {
if mention.range.start >= source_range.end {
break;
}
// Calculate positions within the current text
let mention_start_in_text = mention.range.start - source_range.start;
let mention_end_in_text = mention.range.end - source_range.start;
mentions = &mentions[1..];
if mention.range.start < source_range.start
|| mention.range.end > source_range.end
{
continue;
}
let Some(token) = block.get(mention.range.clone()) else {
continue;
};
let Some(offset) = t_str[last_processed..].find(token) else {
continue;
};
let mention_start_in_text = last_processed + offset;
let mention_end_in_text = mention_start_in_text + token.len();
// Add text before this mention
if mention_start_in_text > last_processed {
let before_mention = &t_str[last_processed..mention_start_in_text];
process_text_segment(
before_mention,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
@@ -185,9 +263,7 @@ fn render_plain_text_mut(
}
// Process the mention replacement
let profile = persons.read(cx).get(&mention.public_key, cx);
let replacement_text = format!("@{}", profile.name());
let replacement_text = resolve_mention(mention);
let replacement_start = text.len();
text.push_str(&replacement_text);
let replacement_end = text.len();
@@ -195,7 +271,6 @@ fn render_plain_text_mut(
highlights.push((replacement_start..replacement_end, Highlight::Mention));
last_processed = mention_end_in_text;
mentions = &mentions[1..];
}
// Add any remaining text after the last mention
@@ -203,7 +278,6 @@ fn render_plain_text_mut(
let remaining_text = &t_str[last_processed..];
process_text_segment(
remaining_text,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
@@ -234,11 +308,14 @@ fn render_plain_text_mut(
}
Tag::CodeBlock(_kind) => {
new_paragraph(text, &mut list_stack);
code_block = true;
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Strikethrough => strikethrough_depth += 1,
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
Tag::Link { dest_url, .. } => {
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
}
Tag::List(number) => {
list_stack.push((number, false));
}
@@ -264,6 +341,7 @@ fn render_plain_text_mut(
_ => {}
},
Event::End(tag) => match tag {
TagEnd::CodeBlock => code_block = false,
TagEnd::Heading(_) => bold_depth -= 1,
TagEnd::Emphasis => italic_depth -= 1,
TagEnd::Strong => bold_depth -= 1,
@@ -272,6 +350,11 @@ fn render_plain_text_mut(
TagEnd::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
Event::Rule => {
new_paragraph(text, &mut list_stack);
text.push_str("────────\n");
}
Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push('\n'),
_ => {}
@@ -282,7 +365,6 @@ fn render_plain_text_mut(
#[allow(clippy::too_many_arguments)]
fn process_text_segment(
segment: &str,
segment_start: usize,
bold_depth: i32,
italic_depth: i32,
strikethrough_depth: i32,
@@ -307,7 +389,8 @@ fn process_text_segment(
});
}
// Add the text
// Ranges always refer to the rendered text, including replaced mentions.
let segment_start = text.len();
text.push_str(segment);
let text_end = text.len();
@@ -330,7 +413,10 @@ fn process_text_segment(
finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0;
for link in finder.links(segment) {
for link in finder
.links(segment)
.filter(|link| WEB_URL.is_match(link.as_str()))
{
let start = link.start();
let end = link.end();
@@ -375,6 +461,7 @@ fn process_text_segment(
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
@@ -390,9 +477,11 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
-135
View File
@@ -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
}
}
-2
View File
@@ -1,4 +1,3 @@
pub use caching::*;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
@@ -7,7 +6,6 @@ pub use parser::*;
pub use paths::*;
pub use range::*;
mod caching;
mod debounced_delay;
mod display;
mod event;
+23
View File
@@ -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
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
smol.workspace = true
+700
View File
@@ -0,0 +1,700 @@
use std::fmt;
use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
UnsignedEvent,
};
use crate::derive::GroupKey;
use crate::{ChannelId, Epoch};
pub const KIND_WRAP: u16 = 1059;
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
const TAG_MS: &str = "ms";
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealForm {
Encrypted,
Plaintext,
}
impl SealForm {
pub fn kind(self) -> u16 {
match self {
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
}
}
fn from_kind(kind: u16) -> Option<Self> {
match kind {
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
_ => None,
}
}
}
#[derive(Debug)]
pub enum StreamError {
Sign(String),
Encrypt(String),
Decrypt(String),
Parse(String),
Oversize(usize),
BadWrapKind(u16),
WrongStream,
BadWrapSignature,
BadSealKind(u16),
BadSealSignature,
AuthorMismatch,
BadRumorId,
BadMs,
ChannelMismatch,
EpochMismatch,
MissingTag(&'static str),
DuplicateTag(&'static str),
NotRewrappable,
}
impl fmt::Display for StreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StreamError::Sign(error) => write!(f, "sign: {error}"),
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
StreamError::Parse(error) => write!(f, "parse: {error}"),
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"),
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
}
}
}
impl std::error::Error for StreamError {}
#[derive(Debug, Clone)]
pub struct OpenedStream {
pub rumor_id: EventId,
pub author: PublicKey,
pub seal_form: SealForm,
pub seal: Event,
pub wrapper_id: EventId,
pub at_ms: u64,
pub rumor: UnsignedEvent,
}
pub fn split_ms(at_ms: u64) -> (u64, u16) {
(at_ms / 1000, (at_ms % 1000) as u16)
}
/// Build a rumor carrying a full epoch-ms time: 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 fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
seal_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
plaintext,
)
}
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
open_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
content,
)
}
pub fn build_seal(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &Keys,
) -> Result<Event, StreamError> {
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize(author)
.map_err(|error| StreamError::Sign(error.to_string()))
}
pub fn wrap_seal(
seal: &Event,
group: &GroupKey,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
wrap_seal_with(
seal,
group.conversation(),
group.keys(),
wrap_kind,
at,
extra,
)
}
pub fn wrap_seal_with(
seal: &Event,
conversation: &ConversationKey,
signer: &Keys,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
let json = seal.as_json();
check_plaintext_cap(json.len())?;
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
let ephemeral = Keys::generate();
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
tags.extend_from_slice(extra);
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
.tags(tags)
.custom_created_at(at)
.finalize(signer)
.map_err(|error| StreamError::Sign(error.to_string()))?;
Ok((wrap, ephemeral))
}
pub fn rewrap_seal(
seal: &Event,
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(())
}
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None;
for tag in rumor.tags.iter() {
let fields = tag.as_slice();
if fields.len() >= 2 && fields[0] == name {
if found.is_some() {
return Err(StreamError::DuplicateTag(name));
}
found = Some(fields[1].clone());
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group(epoch: u64) -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
}
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
wrap.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
.and_then(|tag| tag.as_slice().get(1).cloned())
}
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(
9,
author,
content,
channel_binding_tags(&channel(), Epoch(0)),
at_ms,
)
}
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals")
}
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
.expect("wraps")
.0
}
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
let rumor = bound_rumor(content, author.public_key(), at_ms);
wrapped(
&sealed(&rumor, SealForm::Encrypted, author),
kind,
at_ms / 1000,
)
}
#[test]
fn both_seal_forms_round_trip() {
let author = Keys::generate();
let at_ms = 1_686_840_217_417;
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
let opened = open_wrap(&wrap, &group(0)).expect("opens");
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "Hey chat!");
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
assert_eq!(opened.wrapper_id, wrap.id);
assert_eq!(opened.at_ms, at_ms);
assert_eq!(opened.seal_form, SealForm::Encrypted);
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
// The wrap's `p` tag must identify neither the stream nor the author.
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
assert_ne!(p, group(0).pk_hex());
assert_ne!(p, author.public_key().to_hex());
// Ephemeral actions ride the same structure at a kind relays must drop.
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
assert_eq!(typing.kind.as_u16(), 21059);
assert_eq!(
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
"typing"
);
// The plaintext form carries the rumor's bytes verbatim, which is what
// lets a compaction re-wrap the signed edition into a later epoch.
let edition = build_rumor_secs(
3308,
author.public_key(),
"an edition",
vec![],
1_700_000_000,
);
let seal = sealed(&edition, SealForm::Plaintext, &author);
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
assert_eq!(opened.seal_form, SealForm::Plaintext);
let (rewrapped, _) =
rewrap_seal(&opened.seal, &group(1), &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,890 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::cord01::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
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)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[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 fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
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)?;
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))
}
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&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_authority != Some(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 optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
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 required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
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(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::build_rumor_secs;
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])
}
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 = 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, Some(&carol.public_key()), 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, Some(&carol.public_key()), 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, None, 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 [None, Some(refounder.public_key())] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's 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, None, |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| 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(
&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(
&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(
&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(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
));
}
}
fn snapshot_id() -> String {
"ab".repeat(32)
}
}
+476
View File
@@ -0,0 +1,476 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite};
use crate::{CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302;
pub const MAX_MEMBERSHIPS: usize = 50;
#[derive(Debug)]
pub enum ListError {
Kind(u16),
Crypto(String),
Json(String),
TooManyMemberships(usize),
Oversize(usize),
}
impl fmt::Display for ListError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
ListError::Crypto(error) => write!(f, "crypto: {error}"),
ListError::Json(error) => write!(f, "json: {error}"),
ListError::TooManyMemberships(count) => {
write!(
f,
"list carries {count} memberships (cap {MAX_MEMBERSHIPS})"
)
}
ListError::Oversize(len) => {
write!(f, "list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
}
}
}
impl std::error::Error for ListError {}
impl From<cord01::StreamError> for ListError {
fn from(error: cord01::StreamError) -> Self {
ListError::Crypto(error.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JoinMaterial {
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>,
/// Present only when the holder is staff.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<String>,
#[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(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityListEntry {
pub community_id: CommunityId,
pub seed: JoinMaterial,
pub current: JoinMaterial,
pub added_at: u64,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Tombstone {
pub community_id: CommunityId,
pub removed_at: u64,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<CommunityListEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<Tombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl CommunityList {
pub fn is_live(&self, community_id: &CommunityId) -> bool {
let added = self
.entries
.iter()
.find(|entry| entry.community_id == *community_id)
.map(|entry| entry.added_at);
match added {
None => false,
Some(added) => self
.tombstones
.iter()
.find(|tombstone| tombstone.community_id == *community_id)
.is_none_or(|tombstone| added > tombstone.removed_at),
}
}
pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(ListError::Oversize(json.len()));
}
Ok(())
}
}
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
JoinMaterial {
community_id: invite.community_id,
owner: invite.owner,
owner_salt: invite.owner_salt.clone(),
community_root: invite.community_root.clone(),
root_epoch: invite.root_epoch,
control_pk: invite.control_pk,
control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)),
channels: invite.channels.clone(),
relays: invite.relays.clone(),
name: invite.name.clone(),
extra: Extra::default(),
}
}
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.community_id) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => merge_entry(slot.get_mut(), entry),
}
}
let mut tombstones: BTreeMap<CommunityId, Tombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.community_id) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
let held = slot.get_mut();
held.removed_at = held.removed_at.max(tombstone.removed_at);
union(&mut held.extra, tombstone.extra);
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
CommunityList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> {
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, json.as_bytes())?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> {
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
return Err(ListError::Kind(event.kind.as_u16()));
}
let json = cord01::open_to_self(keys, &event.content)?;
serde_json::from_slice(&json).map_err(json_error)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Snapshot {
Seed,
Current,
}
fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
held.added_at = held.added_at.max(incoming.added_at);
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
union(&mut held.extra, incoming.extra);
}
fn pick<'a>(
held: &'a JoinMaterial,
incoming: &'a JoinMaterial,
which: Snapshot,
) -> &'a JoinMaterial {
let preferred = match which {
Snapshot::Seed => incoming.root_epoch < held.root_epoch,
Snapshot::Current => incoming.root_epoch > held.root_epoch,
};
if preferred {
return incoming;
}
if incoming.root_epoch == held.root_epoch && canonical(incoming) < canonical(held) {
return incoming;
}
held
}
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);
}
}
}
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
fn json_error(error: serde_json::Error) -> ListError {
ListError::Json(error.to_string())
}
fn crypto_error(error: impl fmt::Display) -> ListError {
ListError::Crypto(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn id(byte: u8) -> CommunityId {
CommunityId::from_bytes([byte; 32])
}
fn material(
community_id: CommunityId,
owner: PublicKey,
name: &str,
epoch: u64,
) -> JoinMaterial {
JoinMaterial {
community_id,
owner,
owner_salt: "33".repeat(32),
community_root: "44".repeat(32),
root_epoch: Epoch(epoch),
control_pk: None,
control_root: None,
channels: vec![],
relays: vec!["wss://relay.example".to_owned()],
name: name.to_owned(),
extra: Extra::default(),
}
}
fn entry(
community_id: CommunityId,
seed: JoinMaterial,
current: JoinMaterial,
added_at: u64,
) -> CommunityListEntry {
CommunityListEntry {
community_id,
seed,
current,
added_at,
extra: Extra::default(),
}
}
fn list(entries: Vec<CommunityListEntry>) -> CommunityList {
CommunityList {
entries,
..Default::default()
}
}
fn removal(community_id: CommunityId, removed_at: u64) -> CommunityList {
CommunityList {
tombstones: vec![Tombstone {
community_id,
removed_at,
extra: Extra::default(),
}],
..Default::default()
}
}
#[test]
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
let owner = Keys::generate().public_key();
let older = material(id(0x11), owner, "Room", 1);
let newer = material(id(0x11), owner, "Room", 3);
let a = list(vec![entry(id(0x11), older.clone(), newer.clone(), 5_000)]);
let b = list(vec![entry(id(0x11), newer, older, 5_000)]);
for merged in [merge(a.clone(), b.clone()), merge(b, a)] {
let merged = merged.entries.first().expect("one membership");
assert_eq!(
merged.seed.root_epoch,
Epoch(1),
"seed anchors the earliest epoch held"
);
assert_eq!(merged.current.root_epoch, Epoch(3));
assert_eq!(merged.added_at, 5_000);
}
// An epoch tie breaks on the whole snapshot's bytes, and does so for both
// orders, so two devices never flap competing republishes.
let alpha = material(id(0x11), owner, "Alpha", 2);
let beta = material(id(0x11), owner, "Beta", 2);
let a = list(vec![entry(id(0x11), alpha.clone(), alpha, 1)]);
let b = list(vec![entry(id(0x11), beta.clone(), beta, 1)]);
let first = merge(a.clone(), b.clone());
assert_eq!(first, merge(b, a));
assert_eq!(first.entries[0].current.name, "Alpha");
}
#[test]
fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() {
let owner = Keys::generate().public_key();
let joined = entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
5_000,
);
let left = merge(list(vec![joined.clone()]), removal(id(0x11), 6_000));
assert!(!left.is_live(&id(0x11)));
assert_eq!(
left.entries.len(),
1,
"a retired entry stays in the document"
);
// A stale device re-merging the entry cannot resurrect it.
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
// A re-join genuinely newer than the removal does.
let rejoined = list(vec![entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
7_000,
)]);
let live = merge(left, rejoined);
assert!(live.is_live(&id(0x11)));
// And the older removal is not re-applied on top of it.
assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11)));
}
#[test]
fn a_second_device_reconstructs_membership_from_13302() {
let me = Keys::generate();
let owner = Keys::generate().public_key();
let mine = CommunityList {
entries: vec![
entry(
id(0x11),
material(id(0x11), owner, "Room", 1),
material(id(0x11), owner, "Room", 4),
AT,
),
entry(
id(0x22),
material(id(0x22), owner, "Other", 0),
material(id(0x22), owner, "Other", 0),
AT + 1,
),
],
tombstones: vec![Tombstone {
community_id: id(0x33),
removed_at: AT,
extra: Extra::default(),
}],
extra: Extra::default(),
};
let event = build_list_event(&me, &mine).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine);
assert!(
!parse_list_event(&me, &event)
.expect("parses")
.is_live(&id(0x33))
);
// Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate();
assert!(parse_list_event(&stranger, &event).is_err());
// Unknown fields survive the round trip, so a republish cannot wipe them.
let mut held = mine.clone();
held.extra
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
held.entries[0]
.current
.extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
let rebuilt =
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
assert_eq!(rebuilt, held);
// The write gate refuses an over-cap or oversized List before publishing.
let crowded = list(
(0..=MAX_MEMBERSHIPS)
.map(|index| {
let community_id = CommunityId::from_bytes([index as u8; 32]);
entry(
community_id,
material(community_id, owner, "Room", 0),
material(community_id, owner, "Room", 0),
AT,
)
})
.collect(),
);
assert!(matches!(
build_list_event(&me, &crowded),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
));
let oversized = list(vec![entry(
id(0x11),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
material(id(0x11), owner, "Room", 0),
AT,
)]);
assert!(matches!(oversized.fits(), Err(ListError::Oversize(_))));
}
const AT: u64 = 1_719_800_000_000;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+555
View File
@@ -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
}
pub fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> [u8; 32] {
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn 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)]
pub struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
if edition.version < floor {
continue;
}
match by_version.get(&edition.version) {
Some(&current) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
_ => {
by_version.insert(edition.version, index);
}
}
}
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
return FoldResult::default();
};
let lowest = editions[lowest_index];
let anchored = if floor == 0 {
lowest_version == 1 && lowest.prev.is_none()
} else if lowest_version == floor {
floor_hash == Some(&lowest.self_hash)
} else if lowest_version == floor + 1 {
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
} else {
false
};
let mut head = Some(lowest_index);
let mut gap = !anchored;
let mut previous_version = lowest_version;
let mut previous_hash = lowest.self_hash;
for (&version, &index) in by_version.range(lowest_version + 1..) {
let edition = editions[index];
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
head = Some(index);
previous_version = version;
previous_hash = edition.self_hash;
} else {
gap = true;
break;
}
}
FoldResult {
head,
gap,
anchored,
}
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.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);
}
}
+817
View File
@@ -0,0 +1,817 @@
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, _) = 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, _) = 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, _) = 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, _) = 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()
);
}
}
}
File diff suppressed because it is too large Load Diff
+954
View File
@@ -0,0 +1,954 @@
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::list::{canonical, union};
use crate::cord02::{ImageRef, MAX_RELAYS};
use crate::cord04::{TAG_SUBKIND, vsk};
use crate::derive::{TOKEN_LEN, verify_community_id};
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 fn build_direct_invite(
inviter: &Keys,
recipient: &PublicKey,
invite: &CommunityInvite,
) -> Result<Event, InviteError> {
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
.finalize_unsigned(inviter.public_key());
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(inviter)
.map_err(crypto_error)
}
pub fn unwrap_direct_invite(
wrap: &Event,
recipient: &Keys,
) -> Result<(PublicKey, CommunityInvite), InviteError> {
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).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 fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, json.as_bytes())?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
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)?;
serde_json::from_slice(&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 = 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) = 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!(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!(
unwrap_direct_invite(&wrap, &recipient),
Err(InviteError::BadEvent(_))
));
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
pub mod cord01;
pub mod cord02;
pub mod cord03;
pub mod cord04;
pub mod cord05;
pub mod cord06;
+12
View File
@@ -0,0 +1,12 @@
mod cords;
mod types;
mod utils;
pub mod store;
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
pub use utils::derive::{self, GroupKey};
pub(crate) use types::Extra;
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
+648
View File
@@ -0,0 +1,648 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::cord03::{self, ChatRumor, plane_keys};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const MAX_PAGES: usize = 8;
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<bool> {
if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author),
];
let at = Timestamp::from_secs(opened.at_ms / 1000);
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags)
.custom_created_at(at)
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(true)
}
pub async fn purge_expired(
database: &dyn NostrDatabase,
channel: &ChannelId,
now: Timestamp,
) -> Result<usize> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut expired = Vec::new();
for event in database.query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue;
};
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
continue;
};
if expiration <= now {
expired.push(event.id);
}
}
let purged = expired.len();
if purged > 0 {
database.delete(Filter::new().ids(expired)).await?;
}
Ok(purged)
}
pub async fn query_rumors(
database: &dyn NostrDatabase,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<UnsignedEvent>> {
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
if let Some(until) = until {
filter = filter.until(until);
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in database.query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
match newest.get(&rumor_id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(rumor_id, event);
}
}
}
let mut events: Vec<Event> = newest.into_values().collect();
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
events.truncate(limit);
let mut rumors = Vec::with_capacity(events.len());
for event in events {
let rumor = UnsignedEvent::from_json(event.content)
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
rumors.push(rumor);
}
Ok(rumors)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub epoch: Epoch,
}
/// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub control_pks: BTreeMap<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
#[serde(default)]
pub dissolved: bool,
pub added_at_ms: u64,
}
impl CommunityState {
pub fn from_genesis(
genesis: &CommunityGenesis,
editions: &[ParsedEdition],
added_at_ms: u64,
) -> Result<Self> {
let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new();
for edition in editions {
heads.push(EntityHead {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
});
match edition.subkind.as_str() {
vsk::COMMUNITY_METADATA => {
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
relays.extend(
metadata
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()),
);
}
vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
channels.push(ChannelKeyRef {
id: ChannelId::from_bytes(edition.entity),
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
});
}
_ => {}
}
}
let control_pks = BTreeMap::from([(
ROOT_EPOCH.0,
control_signer_group_key(
&genesis.control_root,
&genesis.identity.community_id,
ROOT_EPOCH,
)?
.pk(),
)]);
Ok(Self {
id: genesis.identity.community_id,
owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root,
root_epoch: ROOT_EPOCH,
control_root: Some(genesis.control_root),
control_pks,
channels,
relays,
heads,
banned: BTreeSet::new(),
dissolved: false,
added_at_ms,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
}),
None => {}
}
}
}
}
fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
where
D: NostrDatabase,
{
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(())
}
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
where
D: NostrDatabase,
{
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match database.query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
pub async fn backfill(
client: &Client,
database: &dyn NostrDatabase,
channel: &ChannelId,
held: &[(Epoch, [u8; 32])],
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<ChatRumor>> {
let planes = plane_keys(held, channel)?;
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
let mut cursor = until;
let mut seen: BTreeSet<EventId> = BTreeSet::new();
let mut found: Vec<ChatRumor> = Vec::new();
for _ in 0..MAX_PAGES {
let page = fetch_page(client, &authors, cursor, limit).await?;
if page.is_empty() {
break;
}
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh {
if cache_rumor(database, channel, &opened).await? {
found.push(rumor);
}
}
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
found.truncate(limit);
Ok(found)
}
fn advance(
page: &BTreeSet<Event>,
planes: &[(Epoch, GroupKey)],
channel: &ChannelId,
cursor: Option<Timestamp>,
limit: usize,
seen: &mut BTreeSet<EventId>,
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
let mut fresh = Vec::new();
for wrap in page {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let Ok((opened, rumor)) = cord03::open(wrap, group, channel, *epoch) else {
continue;
};
if seen.insert(rumor.id) {
fresh.push((opened, rumor));
}
}
if fresh.is_empty() || page.len() < limit {
return (fresh, None);
}
let oldest = page.iter().map(|event| event.created_at).min();
match oldest {
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
_ => (fresh, None),
}
}
async fn fetch_page(
client: &Client,
authors: &[PublicKey],
until: Option<Timestamp>,
limit: usize,
) -> Result<BTreeSet<Event>> {
let mut filter = Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors.iter().copied())
.limit(limit);
if let Some(until) = until {
filter = filter.until(until);
}
Ok(client.fetch_events(filter).await?)
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::Epoch;
use crate::cord01::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
use crate::cord03::{build_message, seal_rumor};
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
/// What a relay does with an inclusive `until` and a `limit`.
fn serve_page(
relay: &BTreeSet<Event>,
cursor: Option<Timestamp>,
limit: usize,
) -> BTreeSet<Event> {
let mut events: Vec<Event> = relay
.iter()
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
.cloned()
.collect();
events.sort_by_key(|event| Reverse(event.created_at));
events.truncate(limit);
events.into_iter().collect()
}
#[test]
fn history_pages_back_across_a_rekey() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let author = Keys::generate();
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
let planes = plane_keys(&held, &channel).expect("derives");
// Three messages a second apart: a page boundary falls between each.
let base = 1_700_000_000_000;
let mut relay: BTreeSet<Event> = BTreeSet::new();
for (content, secret, epoch, at_ms) in [
("before the rekey", &SECRET, Epoch(0), base),
("still before", &SECRET, Epoch(0), base + 1_000),
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
] {
let group = channel_group_key(secret, &channel, epoch).expect("derives");
let rumor = build_message(
author.public_key(),
&channel,
epoch,
content,
None,
at_ms,
None,
);
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
}
let mut seen = BTreeSet::new();
let mut found = Vec::new();
let mut cursor = None;
for _ in 0..3 {
let page = serve_page(&relay, cursor, 2);
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
assert_eq!(
contents,
["after the rekey", "still before", "before the rekey"]
);
}
#[test]
fn rumors_read_back_after_a_restart() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0xabu8; 32]);
let author = Keys::generate();
smol::block_on(async {
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
let rumor = build_rumor_ms(
9,
author.public_key(),
content,
channel_binding_tags(&channel, Epoch(0)),
at_ms,
);
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(at_ms / 1000),
&[],
)
.expect("wraps");
let opened = open_wrap(&wrap, &group).expect("opens");
cache_rumor(&database, &channel, &opened)
.await
.expect("caches");
}
// The group key is gone; only the local cache stands in for it.
let rumors = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(rumors.len(), 2, "both messages come back");
assert_eq!(rumors[0].content, "second", "newest first");
assert_eq!(rumors[1].content, "first");
// A page boundary in message time, not in cache time.
let until = Timestamp::from_secs(1_500);
let page = query_rumors(&database, &channel, Some(until), 10)
.await
.expect("queries");
assert_eq!(page.len(), 1);
assert_eq!(page[0].content, "first");
let capped = query_rumors(&database, &channel, None, 1)
.await
.expect("queries");
assert_eq!(capped.len(), 1);
assert_eq!(capped[0].content, "second");
});
}
#[test]
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0x77u8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let now = Timestamp::now().as_secs();
smol::block_on(async {
// A live timer is stored; one that already elapsed is refused at ingest.
assert!(
cache(
&database,
&group,
&channel,
&author,
"live",
Some(3_600),
now
)
.await
);
assert!(
!cache(
&database,
&group,
&channel,
&author,
"gone",
Some(1),
now - 120
)
.await
);
let stored = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].content, "live");
// Hiding is not disappearing: the sweep removes the row itself,
// judged on the rumor's own signed tag.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
.await
.expect("sweeps");
assert_eq!(purged, 1);
assert!(
query_rumors(&database, &channel, None, 10)
.await
.expect("queries")
.is_empty()
);
// An untimed rumor is never swept, whatever the clock says.
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
.await
.expect("sweeps");
assert_eq!(purged, 0);
});
}
async fn cache(
database: &MemoryDatabase,
group: &GroupKey,
channel: &ChannelId,
author: &Keys,
content: &str,
timer: Option<u64>,
at_secs: u64,
) -> bool {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
content,
None,
at_secs * 1_000,
timer,
);
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
let opened = open_wrap(&wrap, group).expect("opens");
cache_rumor(database, channel, &opened)
.await
.expect("caches")
}
}
+98
View File
@@ -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)
}
}
+407
View File
@@ -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
);
}
}
+40
View File
@@ -0,0 +1,40 @@
pub mod derive;
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
use rand::TryRng as _;
use rand::rngs::SysRng;
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) 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)
}
+2 -2
View File
@@ -4,13 +4,13 @@ use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -414,7 +414,7 @@ impl DeviceRegistry {
.pubkey(app_pubkey)
.limit(1);
match client.database().query(filter).await?.first_owned() {
match client.database().query(filter).await?.into_iter().next() {
// Found an approval event
Some(event) => Ok(Some(event)),
// No approval event found, construct a request event
+11
View File
@@ -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"] }
+102
View File
@@ -0,0 +1,102 @@
//! Vendored from zed's `crates/gpui_tokio` (Apache-2.0) because the `gpui-pre` family
//! does not republish it, and `nostr-sdk`'s reqwest client needs a Tokio runtime.
use std::future::Future;
use gpui::{App, AppContext, Global, ReadGlobal, Task};
use gpui_util::defer;
pub use tokio::task::JoinError;
/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads.
///
/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime
/// yourself and pass a Handle to `init_from_handle`.
pub fn init(cx: &mut App) {
let runtime = tokio::runtime::Builder::new_multi_thread()
// Since we now have two executors, let's try to keep our footprint small
.worker_threads(2)
.enable_all()
.build()
.expect("Failed to initialize Tokio");
let handle = runtime.handle().clone();
cx.set_global(GlobalTokio {
owned_runtime: Some(runtime),
handle,
});
}
/// Initializes the Tokio wrapper using a Tokio runtime handle.
pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) {
cx.set_global(GlobalTokio {
owned_runtime: None,
handle,
});
}
struct GlobalTokio {
owned_runtime: Option<tokio::runtime::Runtime>,
handle: tokio::runtime::Handle,
}
impl Global for GlobalTokio {}
impl Drop for GlobalTokio {
fn drop(&mut self) {
if let Some(runtime) = self.owned_runtime.take() {
runtime.shutdown_background();
}
}
}
pub struct Tokio {}
impl Tokio {
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
pub fn spawn<C, Fut, R>(cx: &C, f: Fut) -> Task<Result<R, JoinError>>
where
C: AppContext,
Fut: Future<Output = R> + Send + 'static,
R: Send + 'static,
{
cx.read_global(|tokio: &GlobalTokio, cx| {
let join_handle = tokio.handle.spawn(f);
let abort_handle = join_handle.abort_handle();
let cancel = defer(move || {
abort_handle.abort();
});
cx.background_spawn(async move {
let result = join_handle.await;
drop(cancel);
result
})
})
}
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
pub fn spawn_result<C, Fut, R>(cx: &C, f: Fut) -> Task<anyhow::Result<R>>
where
C: AppContext,
Fut: Future<Output = anyhow::Result<R>> + Send + 'static,
R: Send + 'static,
{
cx.read_global(|tokio: &GlobalTokio, cx| {
let join_handle = tokio.handle.spawn(f);
let abort_handle = join_handle.abort_handle();
let cancel = defer(move || {
abort_handle.abort();
});
cx.background_spawn(async move {
let result = join_handle.await?;
drop(cancel);
result
})
})
}
pub fn handle(cx: &App) -> tokio::runtime::Handle {
GlobalTokio::global(cx).handle.clone()
}
}
-1
View File
@@ -15,4 +15,3 @@ anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"
+1 -1
View File
@@ -1,10 +1,10 @@
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
+1 -9
View File
@@ -5,8 +5,6 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -111,13 +109,7 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}
+10 -2
View File
@@ -12,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
}
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
@@ -138,7 +141,7 @@ 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(),
}
}
}
@@ -217,7 +220,12 @@ impl AppSettings {
});
cx.spawn_in(window, async move |this, cx| {
let settings = task.await.unwrap_or(Settings::default());
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| {
+5
View File
@@ -24,10 +24,15 @@ serde_json.workspace = true
mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
data-encoding.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
+32 -10
View File
@@ -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"))
-3
View File
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
/// Default timeout for subscription
pub const TIMEOUT: u64 = 2;
/// Default image cache size
pub const IMAGE_CACHE_SIZE: usize = 20;
/// Default delay for searching
pub const FIND_DELAY: u64 = 600;
+337
View File
@@ -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 const ALGORITHM: &str = "aes-gcm";
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
const TAG_SHA256: &str = "x";
const TAG_ORIGINAL_SHA256: &str = "ox";
const TAG_FILE_TYPE: &str = "file-type";
const TAG_ALGORITHM: &str = "encryption-algorithm";
const TAG_KEY: &str = "decryption-key";
const TAG_NONCE: &str = "decryption-nonce";
const TAG_SIZE: &str = "size";
const TAG_DIM: &str = "dim";
const TAG_ALT: &str = "alt";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedFile {
pub data: Vec<u8>,
pub key: String,
pub nonce: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileAttachment {
pub url: Url,
pub mime: String,
pub key: String,
pub nonce: String,
pub sha256: Option<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
pub name: Option<String>,
}
impl FileAttachment {
pub fn tags(&self) -> Vec<Tag> {
let mut tags = vec![
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
Tag::custom(TAG_KEY, [self.key.clone()]),
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
];
if let Some(sha256) = &self.sha256 {
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
}
if let Some(original_sha256) = &self.original_sha256 {
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
}
if let Some(size) = self.size {
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
}
if let Some((width, height)) = self.dim {
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
}
if let Some(name) = &self.name {
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
}
tags
}
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
return None;
}
Some(Self {
url: Url::parse(content).ok()?,
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
key: tag_value(tags, TAG_KEY)?.to_string(),
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
name: tag_value(tags, TAG_ALT).map(str::to_string),
})
}
pub fn is_image(&self) -> bool {
self.mime.starts_with("image/")
}
pub fn display_name(&self) -> SharedString {
if let Some(name) = &self.name {
return name.clone().into();
}
match self.size {
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
None => self.mime.clone().into(),
}
}
}
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng);
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
let cipher = AesGcm::<Aes256, U16>::new(&key);
let data = cipher
.encrypt(&nonce, data)
.map_err(|_| anyhow!("Failed to encrypt file"))?;
Ok(EncryptedFile {
data,
key: HEXLOWER.encode(key.as_slice()),
nonce: HEXLOWER.encode(nonce.as_slice()),
})
}
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
let key = decode(key, "decryption key")?;
let nonce = decode(nonce, "decryption nonce")?;
if key.len() != 32 {
bail!(
"Invalid decryption key length: expected 32 bytes, got {}",
key.len()
);
}
match nonce.len() {
12 => Aes256Gcm::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
len => bail!("Unsupported decryption nonce length: {len} bytes"),
}
}
pub fn sha256_hex(data: &[u8]) -> String {
let hash: [u8; 32] = Sha256::digest(data).into();
Sha256Hash::from_byte_array(hash).to_hex()
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_encrypted(
server: Url,
path: PathBuf,
cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
let mime = from_path(&path).first_or_octet_stream().to_string();
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned());
let data = smol::fs::read(&path).await?;
let encrypted = encrypt(&data)?;
let sha256 = sha256_hex(&encrypted.data);
let original_sha256 = sha256_hex(&data);
let size = encrypted.data.len() as u64;
let base_url = server.to_string();
let url = crate::blossom::upload_blob(
&server,
encrypted.data,
"application/octet-stream",
&sha256,
cx,
)
.await
.map_err(|e| {
let message = e.to_string();
if !message.contains("415") {
return anyhow!(message);
}
anyhow!(
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
opaque data, which this file server does not accept. Choose a different file
server in the settings."
)
})?;
Ok(FileAttachment {
url,
mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256: Some(sha256),
original_sha256: Some(original_sha256),
size: Some(size),
dim: None,
name,
})
}
#[cfg(target_arch = "wasm32")]
pub async fn upload_encrypted(
_server: Url,
_path: PathBuf,
_cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
Err(anyhow!("File upload not supported on web"))
}
pub async fn download_and_decrypt(
url: &Url,
key: &str,
nonce: &str,
expected_sha256: Option<&str>,
cx: &AsyncApp,
) -> Result<Vec<u8>, Error> {
let client = cx.update(|app| app.http_client());
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
if !response.status().is_success() {
bail!("Failed to download file: HTTP {}", response.status());
}
let mut data = Vec::new();
response
.into_body()
.take(MAX_FILE_SIZE as u64 + 1)
.read_to_end(&mut data)
.await?;
if data.len() > MAX_FILE_SIZE {
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
}
if let Some(expected) = expected_sha256
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
{
bail!("File hash mismatch");
}
decrypt(&data, key, nonce)
}
/// Download and decrypt a file attachment into a temporary file.
///
/// The same attachment always maps to the same path, so callers can render the
/// result directly (e.g. with `img`) without downloading it more than once.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_file(
file: &FileAttachment,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let name = file
.sha256
.clone()
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
let extension = mime_guess::get_mime_extensions_str(&file.mime)
.and_then(|extensions| extensions.first())
.copied()
.unwrap_or("bin");
let path = std::env::temp_dir()
.join("coop-files")
.join(format!("{name}.{extension}"));
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
}
let data = download_and_decrypt(
&file.url,
&file.key,
&file.nonce,
file.sha256.as_deref(),
cx,
)
.await?;
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path)
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_file(
_file: &FileAttachment,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("File download not supported on web"))
}
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"))
}
+96 -7
View File
@@ -1,8 +1,11 @@
use std::collections::HashMap;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
@@ -14,17 +17,19 @@ use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod file;
mod nip05;
mod nip4e;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use file::*;
pub use nip4e::*;
pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App) {
pub fn init(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`.
@@ -37,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -58,16 +63,16 @@ pub enum StateEvent {
}
impl StateEvent {
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
pub fn error<T>(error: T) -> Self
where
T: Into<String>,
{
Self::Error(error.into())
}
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
}
/// Nostr Registry
@@ -100,7 +105,7 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
@@ -133,6 +138,10 @@ impl NostrRegistry {
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);
}
@@ -258,6 +267,11 @@ impl NostrRegistry {
this.set_signer(signer, cx);
cx.notify();
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
}
}
_ => {
@@ -303,6 +317,81 @@ impl NostrRegistry {
})
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
let (tx, rx) = flume::bounded::<String>(1);
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
// Start the proxy and get the web url
proxy.start().await?;
// Notify GPUI
let url = proxy.url();
tx.send(url).ok();
Ok(())
}
}));
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
loop {
if proxy.is_session_active() {
break;
}
smol::Timer::after(Duration::from_secs(1)).await;
}
Ok(())
}
}));
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
async move |this, cx| {
while let Ok(url) = rx.recv_async().await {
this.update(cx, |this, cx| {
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
cx.background_spawn(async move { save.await.ok() }).detach();
cx.open_url(&url);
this.set_signer(proxy.clone(), cx);
})?;
}
Ok(())
}
}));
// Monitor the session, if the browser disconnects, notify user to reconnect
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
let executor = cx.background_executor().clone();
async move |this, cx| {
// Wait for the signer to be confirmed (timeout is 30s)
executor.timer(Duration::from_secs(30)).await;
loop {
executor.timer(Duration::from_secs(5)).await;
if !proxy.is_session_active() {
_ = this.update(cx, |this, cx| {
// Only notify if this proxy is still the active signer
if this.current_user.is_some() {
this.signer.swap_inner(Keys::generate());
this.current_user = None;
cx.emit(StateEvent::NoSigner);
cx.notify();
}
});
break;
}
}
Ok(())
}
}));
}
/// Get the public key of a NIP-05 address
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
+1
View File
@@ -6,6 +6,7 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-base.workspace = true
anyhow.workspace = true
log.workspace = true
serde.workspace = true
+60
View File
@@ -46,6 +46,63 @@ pub fn init(cx: &mut App) {
Theme::sync_scrollbar_appearance(cx);
}
/// Mirror the active coop theme into the `gpui-base` global theme.
///
/// Base paints a few things from its own tokens -- the focus ring, the wash
/// behind selected text, scrollbars, and overlay backdrops -- so the two
/// globals have to agree or those details drift away from the palette.
///
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
/// mapping would be invented rather than derived.
///
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
/// authoritative hook that keeps the projection current.
pub fn sync_base(cx: &mut App) {
let Some(theme) = cx.try_global::<Theme>() else {
return;
};
let appearance = if theme.mode.is_dark() {
gpui_base::ThemeAppearance::Dark
} else {
gpui_base::ThemeAppearance::Light
};
let scrollbar_mode = match theme.scrollbar_mode {
ScrollbarMode::Scrolling => gpui_base::ScrollbarMode::Scrolling,
ScrollbarMode::Hover => gpui_base::ScrollbarMode::Hover,
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
};
let colors = theme.colors;
let font_family = theme.font_family.clone();
let base = gpui_base::Theme::global_mut(cx);
base.appearance = appearance;
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
base.tokens.typography.sans = font_family;
let tokens = &mut base.tokens.colors;
tokens.background = colors.background;
tokens.foreground = colors.text;
tokens.surface = colors.surface_background;
tokens.surface_foreground = colors.text;
tokens.primary = colors.element_background;
tokens.primary_foreground = colors.element_foreground;
tokens.secondary = colors.secondary_background;
tokens.secondary_foreground = colors.secondary_foreground;
tokens.muted = colors.ghost_element_background_alt;
tokens.muted_foreground = colors.text_muted;
tokens.accent = colors.ghost_element_hover;
tokens.accent_foreground = colors.text;
tokens.destructive = colors.danger_background;
tokens.destructive_foreground = colors.danger_foreground;
tokens.border = colors.border;
tokens.input = colors.border;
tokens.ring = colors.ring;
tokens.selection = colors.selection;
}
pub trait ActiveTheme {
fn theme(&self) -> &Theme;
}
@@ -183,6 +240,9 @@ impl Theme {
if let Some(window) = window {
window.refresh();
}
// Keep the base-layer projection in step with the coop palette
sync_base(cx);
}
}
+2 -2
View File
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
id: "coop".into(),
name: "Coop Default Theme".into(),
author: "Coop".into(),
url: "https://github.com/lumehq/coop".into(),
url: "https://github.com/reyakov/coop".into(),
light: ThemeColors::light(),
dark: ThemeColors::dark(),
}
@@ -186,7 +186,7 @@ mod tests {
"id": "test-theme",
"name": "Test Theme",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://github.com/reyakov/coop",
"light": {
"background": "#ffffff",
"surface_background": "#fafafa",
+1 -6
View File
@@ -9,6 +9,7 @@ common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
gpui-base.workspace = true
instant.workspace = true
serde.workspace = true
smallvec.workspace = true
@@ -16,13 +17,7 @@ anyhow.workspace = true
itertools.workspace = true
log.workspace = true
unicode-segmentation = "1.12.0"
uuid = "1.10"
regex = "1"
lsp-types = "0.97.0"
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
sum_tree = { git = "https://github.com/zed-industries/zed" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
tree-sitter = "0.26"
-12
View File
@@ -1,12 +0,0 @@
use gpui::{actions, Action};
use serde::Deserialize;
/// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
#[action(namespace = list, no_json)]
pub struct Confirm {
/// Is confirm with secondary.
pub secondary: bool,
}
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);
+4 -6
View File
@@ -1,8 +1,8 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
px,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
};
use theme::ActiveTheme;
@@ -26,9 +26,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
@@ -130,7 +128,7 @@ impl RenderOnce for Avatar {
self.image
.size(image_size)
.rounded_full()
.object_fit(gpui::ObjectFit::Fill)
.object_fit(ObjectFit::Cover)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
+24 -40
View File
@@ -2,15 +2,16 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, relative,
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, relative,
};
use gpui_base::Button as BaseButton;
use theme::ActiveTheme;
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, h_flex};
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
@@ -114,9 +115,7 @@ pub trait ButtonVariants: Sized {
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct Button {
id: ElementId,
base: Stateful<Div>,
style: StyleRefinement,
base: BaseButton,
icon: Option<Icon>,
label: Option<SharedString>,
@@ -151,12 +150,8 @@ impl From<Button> for AnyElement {
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
Self {
id: id.clone(),
base: div().flex_shrink_0().id(id),
style: StyleRefinement::default(),
base: BaseButton::new(id),
icon: None,
label: None,
variant: ButtonVariant::default(),
@@ -301,7 +296,7 @@ impl ButtonVariants for Button {
impl Styled for Button {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
self.base.style()
}
}
@@ -318,7 +313,7 @@ impl InteractiveElement for Button {
}
impl RenderOnce for Button {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let clickable = self.clickable();
let hoverable = self.hoverable();
@@ -329,18 +324,21 @@ impl RenderOnce for Button {
_ => self.size,
};
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
.read(cx)
.clone();
self.base
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_index(self.tab_index)
.tab_stop(self.tab_stop),
)
.tab_index(self.tab_index)
.tab_stop(self.tab_stop)
.disabled(self.disabled)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
})
})
.relative()
.flex_shrink_0()
@@ -349,7 +347,6 @@ impl RenderOnce for Button {
.justify_center()
.cursor_default()
.overflow_hidden()
.refine_style(&self.style)
.map(|this| match self.rounded {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
@@ -399,8 +396,7 @@ impl RenderOnce for Button {
}
}
})
.refine_style(&self.style)
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if self.disabled {
@@ -410,18 +406,6 @@ impl RenderOnce for Button {
// Avoid focus on mouse down.
window.prevent_default();
})
.when_some(self.on_click, |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
})
})
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
this.on_hover(move |hovered, window, cx| {
(on_hover)(hovered, window, cx);
-312
View File
@@ -1,312 +0,0 @@
use std::rc::Rc;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
use crate::icon::IconNamed;
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
/// A Checkbox element.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct Checkbox {
id: ElementId,
base: Div,
style: StyleRefinement,
label: Option<SharedString>,
children: Vec<AnyElement>,
checked: bool,
disabled: bool,
size: Size,
tab_stop: bool,
tab_index: isize,
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
}
impl Checkbox {
/// Create a new Checkbox with the given id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
style: StyleRefinement::default(),
label: None,
children: Vec::new(),
checked: false,
disabled: false,
size: Size::default(),
on_click: None,
tab_stop: true,
tab_index: 0,
}
}
/// Set the label for the checkbox.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the checked state for the checkbox.
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Set the click handler for the checkbox.
///
/// The `&bool` parameter indicates the new checked state after the click.
pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
/// Set the tab stop for the checkbox, default is true.
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
/// Set the tab index for the checkbox, default is 0.
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
#[allow(clippy::type_complexity)]
fn handle_click(
on_click: &Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
checked: bool,
window: &mut Window,
cx: &mut App,
) {
let new_checked = !checked;
if let Some(f) = on_click {
(f)(&new_checked, window, cx);
}
}
}
impl InteractiveElement for Checkbox {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Checkbox {}
impl Styled for Checkbox {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl Disableable for Checkbox {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Checkbox {
fn selected(self, selected: bool) -> Self {
self.checked(selected)
}
fn is_selected(&self) -> bool {
self.checked
}
}
impl ParentElement for Checkbox {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Sizable for Checkbox {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
pub(crate) fn checkbox_check_icon(
id: ElementId,
size: Size,
checked: bool,
disabled: bool,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let toggle_state = window.use_keyed_state(id, cx, |_, _| checked);
let color = if disabled {
cx.theme().text.opacity(0.5)
} else {
cx.theme().text
};
svg()
.absolute()
.top_px()
.left_px()
.map(|this| match size {
Size::XSmall => this.size_2(),
Size::Small => this.size_2p5(),
Size::Medium => this.size_3(),
Size::Large => this.size_3p5(),
_ => this.size_3(),
})
.text_color(color)
.map(|this| match checked {
true => this.path(IconName::Check.path()),
_ => this,
})
.map(|this| {
if !disabled && checked != *toggle_state.read(cx) {
let duration = Duration::from_secs_f64(0.25);
cx.spawn({
let toggle_state = toggle_state.clone();
async move |cx| {
cx.background_executor().timer(duration).await;
toggle_state.update(cx, |this, _| *this = checked);
}
})
.detach();
this.with_animation(
ElementId::NamedInteger("toggle".into(), checked as u64),
Animation::new(Duration::from_secs_f64(0.25)),
move |this, delta| {
this.opacity(if checked { 1.0 * delta } else { 1.0 - delta })
},
)
.into_any_element()
} else {
this.into_any_element()
}
})
}
impl RenderOnce for Checkbox {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
.read(cx)
.clone();
let checked = self.checked;
let radius = cx.theme().radius.min(px(4.));
let border_color = if checked {
cx.theme().border_focused
} else {
cx.theme().border
};
let color = if self.disabled {
border_color.opacity(0.5)
} else {
border_color
};
div().child(
self.base
.id(self.id.clone())
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_stop(self.tab_stop)
.tab_index(self.tab_index),
)
})
.h_flex()
.gap_2()
.items_start()
.line_height(relative(1.))
.text_color(cx.theme().text)
.map(|this| match self.size {
Size::XSmall => this.text_xs(),
Size::Small => this.text_sm(),
Size::Medium => this.text_base(),
Size::Large => this.text_lg(),
_ => this,
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.rounded(cx.theme().radius * 0.5)
.refine_style(&self.style)
.child(
div()
.relative()
.map(|this| match self.size {
Size::XSmall => this.size_3(),
Size::Small => this.size_3p5(),
Size::Medium => this.size_4(),
Size::Large => this.size(rems(1.125)),
_ => this.size_4(),
})
.flex_shrink_0()
.border_1()
.border_color(color)
.rounded(radius)
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
.map(|this| match checked {
false => this.bg(cx.theme().background),
_ => this.bg(color),
})
.child(checkbox_check_icon(
self.id,
self.size,
checked,
self.disabled,
window,
cx,
)),
)
.when(self.label.is_some() || !self.children.is_empty(), |this| {
this.child(
v_flex()
.w_full()
.line_height(relative(1.2))
.gap_1()
.map(|this| {
if let Some(label) = self.label {
this.child(
div()
.size_full()
.text_color(cx.theme().text)
.when(self.disabled, |this| {
this.text_color(cx.theme().text_muted)
})
.line_height(relative(1.))
.child(label),
)
} else {
this
}
})
.children(self.children),
)
})
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
// Avoid focus on mouse down.
window.prevent_default();
})
.when(!self.disabled, |this| {
this.on_click({
let on_click = self.on_click.clone();
move |_, window, cx| {
window.prevent_default();
Self::handle_click(&on_click, checked, window, cx);
}
})
}),
)
}
}
-438
View File
@@ -1,438 +0,0 @@
use std::ops::Deref;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
};
use super::{DockArea, DockItem};
use crate::StyledExt;
use crate::dock::panel::PanelView;
use crate::dock::tab_panel::TabPanel;
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockPlacement {
Center,
Left,
Bottom,
Right,
}
impl DockPlacement {
fn axis(&self) -> Axis {
match self {
Self::Left | Self::Right => Axis::Horizontal,
Self::Bottom => Axis::Vertical,
Self::Center => unreachable!(),
}
}
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
pub fn is_bottom(&self) -> bool {
matches!(self, Self::Bottom)
}
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// The Dock is a fixed container that places at left, bottom, right of the Windows.
///
/// This is unlike Panel, it can't be move or add any other panel.
pub struct Dock {
pub(super) placement: DockPlacement,
dock_area: WeakEntity<DockArea>,
/// Dock layout
pub(crate) panel: DockItem,
/// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height.
pub(super) size: Pixels,
/// Whether the Dock is open
pub(super) open: bool,
/// Whether the Dock is collapsible, default: true
pub(super) collapsible: bool,
/// Whether the Dock is resizing
resizing: bool,
}
impl Dock {
pub(crate) fn new(
dock_area: WeakEntity<DockArea>,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let panel = cx.new(|cx| {
let mut tab = TabPanel::new(None, dock_area.clone(), window, cx);
tab.closable = true;
tab
});
let panel = DockItem::Tabs {
items: Vec::new(),
active_ix: 0,
view: panel.clone(),
};
Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx);
Self {
placement,
dock_area,
panel,
open: true,
collapsible: true,
size: px(200.0),
resizing: false,
}
}
pub fn left(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Left, window, cx)
}
pub fn bottom(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Bottom, window, cx)
}
pub fn right(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Right, window, cx)
}
/// Update the Dock to be collapsible or not.
///
/// And if the Dock is not collapsible, it will be open.
pub fn set_collapsible(
&mut self,
collapsible: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.collapsible = collapsible;
if !collapsible {
self.open = true
}
cx.notify();
}
fn subscribe_panel_events(
dock_area: WeakEntity<DockArea>,
panel: &DockItem,
window: &mut Window,
cx: &mut App,
) {
match panel {
DockItem::Tabs { view, .. } => {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Split { items, view, .. } => {
for item in items {
Self::subscribe_panel_events(dock_area.clone(), item, window, cx);
}
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
pub fn set_panel(&mut self, panel: DockItem, _window: &mut Window, cx: &mut Context<Self>) {
self.panel = panel;
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_open(!self.open, window, cx);
}
/// Returns the size of the Dock, the size is means the width or height of
/// the Dock, if the placement is left or right, the size is width,
/// otherwise the size is height.
pub fn size(&self) -> Pixels {
self.size
}
/// Set the size of the Dock.
pub fn set_size(&mut self, size: Pixels, _window: &mut Window, cx: &mut Context<Self>) {
self.size = size.max(PANEL_MIN_SIZE);
cx.notify();
}
/// Set the open state of the Dock.
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
// Use defer_in (not window.defer) so the callback is cancelled
// if this Dock entity is dropped before the deferred frame runs.
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
cx.notify();
}
/// Add item to the Dock.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.panel.add_panel(panel, &self.dock_area, window, cx);
cx.notify();
}
fn render_resize_handle(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let axis = self.placement.axis();
let view = cx.entity().clone();
resize_handle("resize-handle", axis)
.placement(self.placement)
.on_drag(ResizePanel {}, move |info, _, _, cx| {
cx.stop_propagation();
view.update(cx, |view, _cx| {
view.resizing = true;
});
cx.new(|_| info.deref().clone())
})
}
fn resize(
&mut self,
mouse_position: Point<Pixels>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.resizing {
return;
}
let dock_area = self
.dock_area
.upgrade()
.expect("DockArea is missing")
.read(cx);
let area_bounds = dock_area.bounds;
let mut left_dock_size = px(0.0);
let mut right_dock_size = px(0.0);
// Get the size of the left dock if it's open and not the current dock
if let Some(left_dock) = &dock_area.left_dock
&& left_dock.entity_id() != cx.entity().entity_id()
{
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
}
// Get the size of the right dock if it's open and not the current dock
if let Some(right_dock) = &dock_area.right_dock
&& right_dock.entity_id() != cx.entity().entity_id()
{
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
}
let size = match self.placement {
DockPlacement::Left => mouse_position.x - area_bounds.left(),
DockPlacement::Right => area_bounds.right() - mouse_position.x,
DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y,
DockPlacement::Center => unreachable!(),
};
match self.placement {
DockPlacement::Left => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Right => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Bottom => {
let max_size = area_bounds.size.height - PANEL_MIN_SIZE;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Center => unreachable!(),
}
cx.notify();
}
fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.resizing = false;
}
}
impl Render for Dock {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
if !self.open && !self.placement.is_bottom() {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
.map(|this| match self.placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size),
DockPlacement::Bottom => this.w_full().h(self.size),
DockPlacement::Center => unreachable!(),
})
// Bottom Dock should keep the title bar, then user can click the Toggle button
.when(!self.open && self.placement.is_bottom(), |this| {
this.h(px(29.))
})
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {
view: cx.entity().clone(),
})
}
}
struct DockElement {
view: Entity<Dock>,
}
impl IntoElement for DockElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockElement {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut gpui::Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut gpui::Window,
cx: &mut App,
) {
window.on_mouse_event({
let view = self.view.clone();
let is_resizing = view.read(cx).resizing;
move |e: &MouseMoveEvent, phase, window, cx| {
if !is_resizing {
return;
}
if !phase.bubble() {
return;
}
view.update(cx, |view, cx| view.resize(e.position, window, cx))
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let view = self.view.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(window, cx));
}
}
})
}
}
+824 -728
View File
File diff suppressed because it is too large Load Diff
+85 -8
View File
@@ -1,7 +1,11 @@
use std::any::Any;
use std::sync::Arc;
use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window,
};
use gpui_base::dock::{PanelId, PanelState};
use crate::button::Button;
use crate::menu::PopupMenu;
@@ -13,14 +17,6 @@ pub enum PanelEvent {
LayoutChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelStyle {
/// Display the TabBar when there are multiple tabs, otherwise display the simple title.
Default,
/// Always display the tab bar.
TabBar,
}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
///
@@ -156,3 +152,84 @@ impl PartialEq for dyn PanelView {
self.view() == other.view()
}
}
#[derive(Clone)]
pub struct PanelHandle {
id: PanelId,
panel: Arc<dyn PanelView>,
}
impl PanelHandle {
pub fn new<P: Panel>(panel: Entity<P>) -> Self {
Self {
id: PanelId::from(panel.entity_id()),
panel: Arc::new(panel),
}
}
/// Recover the coop handle behind one of base's.
pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
panel.as_any().downcast_ref::<Self>()
}
/// The coop panel behind this handle.
pub fn panel(&self) -> &Arc<dyn PanelView> {
&self.panel
}
}
impl gpui_base::dock::PanelView for PanelHandle {
fn panel_name(&self, _: &App) -> &'static str {
"CoopPanel"
}
fn panel_id(&self, _: &App) -> PanelId {
self.id
}
fn closable(&self, cx: &App) -> bool {
self.panel.closable(cx)
}
fn zoomable(&self, cx: &App) -> bool {
self.panel.zoomable(cx)
}
fn visible(&self, cx: &App) -> bool {
self.panel.visible(cx)
}
fn set_active(&self, active: bool, _: &mut Window, cx: &mut App) {
self.panel.set_active(active, cx);
}
fn set_zoomed(&self, zoomed: bool, _: &mut Window, cx: &mut App) {
self.panel.set_zoomed(zoomed, cx);
}
fn on_added_to(
&self,
_group: gpui::WeakEntity<gpui_base::dock::TabGroup>,
_: &mut Window,
_: &mut App,
) {
}
fn on_removed(&self, _: &mut Window, _: &mut App) {}
fn view(&self) -> AnyView {
self.panel.view()
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.panel.focus_handle(cx)
}
fn dump(&self, cx: &App) -> PanelState {
PanelState::new(self.panel_name(cx))
}
fn as_any(&self) -> &dyn Any {
self
}
}
-387
View File
@@ -1,387 +0,0 @@
use std::sync::Arc;
use gpui::{
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
Window,
};
use smallvec::SmallVec;
use theme::{AxisExt as _, Placement};
use super::{DockArea, PanelEvent};
use crate::dock::panel::{Panel, PanelView};
use crate::dock::tab_panel::TabPanel;
use crate::h_flex;
use crate::resizable::{
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
resizable_panel,
};
pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>,
pub(super) axis: Axis,
focus_handle: FocusHandle,
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
state: Entity<ResizableState>,
_subscriptions: Vec<Subscription>,
}
impl Panel for StackPanel {
fn panel_id(&self) -> SharedString {
"StackPanel".into()
}
fn title(&self, _cx: &App) -> gpui::AnyElement {
"StackPanel".into_any_element()
}
}
impl StackPanel {
pub fn new(axis: Axis, window: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| ResizableState::default());
// Bubble up the resize event.
let subscriptions =
vec![
cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| {
cx.emit(PanelEvent::LayoutChanged)
}),
];
Self {
axis,
parent: None,
focus_handle: cx.focus_handle(),
panels: SmallVec::new(),
state,
_subscriptions: subscriptions,
}
}
/// The first level of the stack panel is root, will not have a parent.
fn is_root(&self) -> bool {
self.parent.is_none()
}
/// Return true if self or parent only have last panel.
pub fn is_last_panel(&self, cx: &App) -> bool {
if self.panels.len() > 1 {
return false;
}
if let Some(parent) = &self.parent
&& let Some(parent) = parent.upgrade()
{
return parent.read(cx).is_last_panel(cx);
}
true
}
pub fn panels_len(&self) -> usize {
self.panels.len()
}
/// Return the index of the panel.
pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
self.panels.iter().position(|p| p == &panel)
}
/// Add a panel at the end of the stack.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx);
}
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel_at(
panel,
self.panels_len(),
placement,
size,
dock_area,
window,
cx,
);
}
#[allow(clippy::too_many_arguments)]
pub fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match placement {
Placement::Top | Placement::Left => {
self.insert_panel_before(panel, ix, size, dock_area, window, cx)
}
Placement::Right | Placement::Bottom => {
self.insert_panel_after(panel, ix, size, dock_area, window, cx)
}
}
}
/// Insert a panel at the index.
pub fn insert_panel_before(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix, size, dock_area, window, cx);
}
/// Insert a panel after the index.
pub fn insert_panel_after(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
}
fn insert_panel(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the panel is already in the stack, return.
if self.index_of_panel(panel.clone()).is_some() {
return;
}
let view = cx.entity().clone();
window.defer(cx, {
let panel = panel.clone();
move |window, cx| {
// If the panel is a TabPanel, set its parent to this.
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
stack_panel.update(cx, |stack_panel, _| {
stack_panel.parent = Some(view.downgrade())
});
}
// Subscribe to the panel's layout change event.
_ = dock_area.update(cx, |this, cx| {
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
this.subscribe_panel(&tab_panel, window, cx);
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
this.subscribe_panel(&stack_panel, window, cx);
}
});
}
});
let ix = if ix > self.panels.len() {
self.panels.len()
} else {
ix
};
// Get avg size of all panels to insert new panel, if size is None.
let size = match size {
Some(size) => size,
None => {
let state = self.state.read(cx);
(state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE)
}
};
// Insert panel
self.panels.insert(ix, panel.clone());
// Update resizable state
self.state.update(cx, |state, cx| {
state.insert_panel(Some(size), Some(ix), cx);
});
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove panel from the stack.
///
/// If `ix` is not found, do nothing.
pub fn remove_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.index_of_panel(panel.clone()) else {
return;
};
self.panels.remove(ix);
self.state.update(cx, |state, cx| {
state.remove_panel(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(window, cx);
}
/// Replace the old panel with the new panel at same index.
pub fn replace_panel(
&mut self,
old_panel: Arc<dyn PanelView>,
new_panel: Entity<StackPanel>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone());
self.state.update(cx, |state, cx| {
state.replace_panel(ix, ResizablePanelState::default(), cx);
});
cx.emit(PanelEvent::LayoutChanged);
}
}
/// If children is empty, remove self from parent view.
pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_root() {
return;
}
if !self.panels.is_empty() {
return;
}
let view = cx.entity().clone();
if let Some(parent) = self.parent.as_ref() {
_ = parent.update(cx, |parent, cx| {
parent.remove_panel(Arc::new(view.clone()), window, cx);
});
}
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Find the first top left in the stack.
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
{
return Some(panel);
}
let first_panel = self.panels.first();
if let Some(view) = first_panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).left_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Find the first top right in the stack.
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
{
return Some(panel);
}
let panel = if self.axis.is_vertical() {
self.panels.first()
} else {
self.panels.last()
};
if let Some(view) = panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).right_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Remove all panels from the stack.
pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.panels.clear();
self.state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
}
/// Change the axis of the stack panel.
pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
self.axis = axis;
cx.notify();
}
}
impl Focusable for StackPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex().size_full().overflow_hidden().child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window};
/// A trait to extend [`gpui::Element`] with additional functionality.
pub trait ElementExt: ParentElement + Sized {
/// Add a prepaint callback to the element.
///
/// This is a helper method to get the bounds of the element after paint.
///
/// The first argument is the bounds of the element in pixels.
///
/// See also [`gpui::canvas`].
fn on_prepaint<F>(self, f: F) -> Self
where
F: FnOnce(Bounds<Pixels>, &mut Window, &mut App) + 'static,
{
self.child(
canvas(
move |bounds, window, cx| f(bounds, window, cx),
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
}
}
impl<T: ParentElement> ElementExt for T {}
-21
View File
@@ -1,21 +0,0 @@
use gpui::{App, ClickEvent, InteractiveElement, Stateful, Window};
pub trait InteractiveElementExt: InteractiveElement {
/// Set the listener for a double click event.
fn on_double_click(
mut self,
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self
where
Self: Sized,
{
self.interactivity().on_click(move |event, window, cx| {
if event.click_count() == 2 {
listener(event, window, cx);
}
});
self
}
}
impl<E: InteractiveElement> InteractiveElementExt for Stateful<E> {}
-39
View File
@@ -1,39 +0,0 @@
use gpui::{Context, FocusHandle, Window};
/// A trait for views that can cycle focus between its children.
///
/// This will provide a default implementation for the `cycle_focus` method that will cycle focus.
///
/// You should implement the `cycle_focus_handles` method to return a list of focus handles that
/// should be cycled, and the cycle will follow the order of the list.
pub trait FocusableCycle {
/// Returns a list of focus handles that should be cycled.
fn cycle_focus_handles(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<FocusHandle>
where
Self: Sized;
/// Cycles focus between the focus handles returned by `cycle_focus_handles`.
/// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev.
fn cycle_focus(&self, is_next: bool, window: &mut Window, cx: &mut Context<Self>)
where
Self: Sized,
{
let focused_handle = window.focused(cx);
let handles = self.cycle_focus_handles(window, cx);
let handles = if is_next {
handles
} else {
handles.into_iter().rev().collect()
};
let fallback_handle = handles[0].clone();
let target_focus_handle = handles
.into_iter()
.skip_while(|handle| Some(handle) != focused_handle.as_ref())
.nth(1)
.unwrap_or(fallback_handle);
target_focus_handle.focus(window, cx);
cx.stop_propagation();
}
}
-184
View File
@@ -1,184 +0,0 @@
use std::fmt::Debug;
use instant::{Duration, Instant};
/// A HistoryItem represents a single change in the history.
/// It must implement Clone and PartialEq to be used in the History.
pub trait HistoryItem: Clone + PartialEq {
fn version(&self) -> usize;
fn set_version(&mut self, version: usize);
}
/// The History is used to keep track of changes to a model and to allow undo and redo operations.
///
/// This is now used in Input for undo/redo operations. You can also use this in
/// your own models to keep track of changes, for example to track the tab
/// history for prev/next features.
///
/// ## Use cases
///
/// - Undo/redo operations in Input
/// - Tracking tab history for prev/next features
#[derive(Debug)]
pub struct History<I: HistoryItem> {
undos: Vec<I>,
redos: Vec<I>,
last_changed_at: Instant,
version: usize,
pub(crate) ignore: bool,
max_undos: usize,
group_interval: Option<Duration>,
grouping: bool,
unique: bool,
}
impl<I> History<I>
where
I: HistoryItem,
{
pub fn new() -> Self {
Self {
undos: Default::default(),
redos: Default::default(),
ignore: false,
last_changed_at: Instant::now(),
version: 0,
max_undos: 1000,
group_interval: None,
grouping: false,
unique: false,
}
}
/// Set the maximum number of undo steps to keep, defaults to 1000.
pub fn max_undos(mut self, max_undos: usize) -> Self {
self.max_undos = max_undos;
self
}
/// Set the history to be unique, defaults to false.
/// If set to true, the history will only keep unique changes.
pub fn unique(mut self) -> Self {
self.unique = true;
self
}
/// Set the interval in milliseconds to group changes, defaults to None.
pub fn group_interval(mut self, group_interval: Duration) -> Self {
self.group_interval = Some(group_interval);
self
}
/// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called.
pub fn start_grouping(&mut self) {
self.grouping = true;
}
/// End grouping changes, this will allow the version to be incremented again.
pub fn end_grouping(&mut self) {
self.grouping = false;
}
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
fn inc_version(&mut self) -> usize {
let t = Instant::now();
if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval {
self.version += 1;
}
self.last_changed_at = t;
self.version
}
/// Get the current version number.
pub fn version(&self) -> usize {
self.version
}
/// Push a new change to the history.
pub fn push(&mut self, item: I) {
let version = self.inc_version();
if self.undos.len() >= self.max_undos {
self.undos.remove(0);
}
if self.unique {
self.undos.retain(|c| *c != item);
self.redos.retain(|c| *c != item);
}
let mut item = item;
item.set_version(version);
self.undos.push(item);
}
/// Get the undo stack.
pub fn undos(&self) -> &Vec<I> {
&self.undos
}
/// Get the redo stack.
pub fn redos(&self) -> &Vec<I> {
&self.redos
}
/// Clear the undo and redo stacks.
pub fn clear(&mut self) {
self.undos.clear();
self.redos.clear();
}
/// Undo the last change and return the changes that were undone.
pub fn undo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.undos.pop() {
let mut changes = vec![first_change.clone()];
// pick the next all changes with the same version
while self
.undos
.iter()
.filter(|c| c.version() == first_change.version())
.count()
> 0
{
let change = self.undos.pop().unwrap();
changes.push(change);
}
self.redos.extend(changes.clone());
Some(changes)
} else {
None
}
}
/// Redo the last undone change and return the changes that were redone.
pub fn redo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.redos.pop() {
let mut changes = vec![first_change.clone()];
// pick the next all changes with the same version
while self
.redos
.iter()
.filter(|c| c.version() == first_change.version())
.count()
> 0
{
let change = self.redos.pop().unwrap();
changes.push(change);
}
self.undos.extend(changes.clone());
Some(changes)
} else {
None
}
}
}
impl<I> Default for History<I>
where
I: HistoryItem,
{
fn default() -> Self {
Self::new()
}
}
+2
View File
@@ -46,6 +46,7 @@ pub enum IconName {
InboxFill,
Link,
Loader,
Lock,
Moon,
Plus,
PlusCircle,
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
-69
View File
@@ -1,69 +0,0 @@
use std::fmt::{Debug, Display};
use gpui::ElementId;
/// Represents an index path in a list, which consists of a section index,
///
/// The default values for section, row, and column are all set to 0.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IndexPath {
/// The section index.
pub section: usize,
/// The item index in the section.
pub row: usize,
/// The column index.
pub column: usize,
}
impl From<IndexPath> for ElementId {
fn from(path: IndexPath) -> Self {
ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into())
}
}
impl Display for IndexPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"IndexPath(section: {}, row: {}, column: {})",
self.section, self.row, self.column
)
}
}
impl IndexPath {
/// Create a new index path with the specified section and row.
///
/// The `section` is set to 0 by default.
/// The `column` is set to 0 by default.
pub fn new(row: usize) -> Self {
IndexPath {
section: 0,
row,
..Default::default()
}
}
/// Set the section for the index path.
pub fn section(mut self, section: usize) -> Self {
self.section = section;
self
}
/// Set the row for the index path.
pub fn row(mut self, row: usize) -> Self {
self.row = row;
self
}
/// Set the column for the index path.
pub fn column(mut self, column: usize) -> Self {
self.column = column;
self
}
/// Check if the self is equal to the given index path (Same section and row).
pub fn eq_row(&self, index: IndexPath) -> bool {
self.section == index.section && self.row == index.row
}
}
-96
View File
@@ -1,96 +0,0 @@
use instant::Duration;
use gpui::{Context, Pixels, Task, px};
static INTERVAL: Duration = Duration::from_millis(500);
static PAUSE_DELAY: Duration = Duration::from_millis(300);
// On Windows, Linux, we should use integer to avoid blurry cursor.
#[cfg(not(target_os = "macos"))]
pub(super) const CURSOR_WIDTH: Pixels = px(2.);
#[cfg(target_os = "macos")]
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
/// To manage the Input cursor blinking.
///
/// It will start blinking with a interval of 500ms.
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
///
/// The input painter will check if this in visible state, then it will draw the cursor.
pub(crate) struct BlinkCursor {
visible: bool,
paused: bool,
epoch: usize,
_task: Task<()>,
}
impl BlinkCursor {
pub fn new() -> Self {
Self {
visible: false,
paused: false,
epoch: 0,
_task: Task::ready(()),
}
}
/// Start the blinking
pub fn start(&mut self, cx: &mut Context<Self>) {
self.blink(self.epoch, cx);
}
pub fn stop(&mut self, cx: &mut Context<Self>) {
self.epoch = 0;
cx.notify();
}
fn next_epoch(&mut self) -> usize {
self.epoch += 1;
self.epoch
}
fn blink(&mut self, epoch: usize, cx: &mut Context<Self>) {
if self.paused || epoch != self.epoch {
self.visible = true;
return;
}
self.visible = !self.visible;
cx.notify();
// Schedule the next blink
let epoch = self.next_epoch();
self._task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(INTERVAL).await;
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| this.blink(epoch, cx));
}
});
}
pub fn visible(&self) -> bool {
// Keep showing the cursor if paused
self.paused || self.visible
}
/// Pause the blinking, and delay 500ms to resume the blinking.
pub fn pause(&mut self, cx: &mut Context<Self>) {
self.paused = true;
self.visible = true;
cx.notify();
// delay 500ms to start the blinking
let epoch = self.next_epoch();
self._task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(PAUSE_DELAY).await;
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| {
this.paused = false;
this.blink(epoch, cx);
});
}
});
}
}
-39
View File
@@ -1,39 +0,0 @@
use std::fmt::Debug;
use crate::{history::HistoryItem, input::Selection};
#[derive(Debug, PartialEq, Clone)]
pub struct Change {
pub(crate) old_range: Selection,
pub(crate) old_text: String,
pub(crate) new_range: Selection,
pub(crate) new_text: String,
version: usize,
}
impl Change {
pub fn new(
old_range: impl Into<Selection>,
old_text: &str,
new_range: impl Into<Selection>,
new_text: &str,
) -> Self {
Self {
old_range: old_range.into(),
old_text: old_text.to_string(),
new_range: new_range.into(),
new_text: new_text.to_string(),
version: 0,
}
}
}
impl HistoryItem for Change {
fn version(&self) -> usize {
self.version
}
fn set_version(&mut self, version: usize) {
self.version = version;
}
}
-53
View File
@@ -1,53 +0,0 @@
use std::ops::{Range, RangeBounds};
/// A selection in the text, represented by start and end byte indices.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub struct Selection {
pub start: usize,
pub end: usize,
}
impl Selection {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
/// Clears the selection, setting start and end to 0.
pub fn clear(&mut self) {
self.start = 0;
self.end = 0;
}
/// Checks if the given offset is within the selection range.
pub fn contains(&self, offset: usize) -> bool {
offset >= self.start && offset < self.end
}
}
impl From<Range<usize>> for Selection {
fn from(value: Range<usize>) -> Self {
Self::new(value.start, value.end)
}
}
impl From<Selection> for Range<usize> {
fn from(value: Selection) -> Self {
value.start..value.end
}
}
impl RangeBounds<usize> for Selection {
fn start_bound(&self) -> std::ops::Bound<&usize> {
std::ops::Bound::Included(&self.start)
}
fn end_bound(&self) -> std::ops::Bound<&usize> {
std::ops::Bound::Excluded(&self.end)
}
}
@@ -1,172 +0,0 @@
use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::text_wrapper::{LineItem, WrapDisplayPoint};
use super::wrap_map::WrapMap;
use crate::input::Point as TreeSitterPoint;
/// DisplayMap is the main interface for Input coordinate mapping.
pub struct DisplayMap {
wrap_map: WrapMap,
}
impl DisplayMap {
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
wrap_map: WrapMap::new(font, font_size, wrap_width),
}
}
/// Get total number of display rows (same as wrap rows without folding)
#[inline]
pub fn display_row_count(&self) -> usize {
self.wrap_map.wrap_row_count()
}
/// Get the buffer line for a given display row
pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize {
self.wrap_map.wrap_row_to_buffer_line(display_row)
}
/// Get the display row range for a buffer line: [start, end)
pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option<Range<usize>> {
let range = self.wrap_map.buffer_line_to_wrap_row_range(line);
if range.is_empty() { None } else { Some(range) }
}
/// Check if a buffer line is completely hidden (never true without folding)
#[inline]
pub fn is_buffer_line_hidden(&self, _line: usize) -> bool {
false
}
/// All wrap rows are visible since there's no folding.
#[inline]
pub fn folded_ranges(&self) -> &[()] {
&[]
}
/// Adjust folds for edit (no-op without folding)
pub fn adjust_folds_for_edit(
&mut self,
_old_text: &Rope,
_range: &Range<usize>,
_new_text: &str,
) {
// No-op: no folding
}
/// Update text (incremental or full)
pub fn on_text_changed(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
cx: &mut App,
) {
self.wrap_map
.on_text_changed(changed_text, range, new_text, cx);
}
/// Update layout parameters (wrap width or font)
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
self.wrap_map.on_layout_changed(wrap_width, cx);
}
/// Set font parameters
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
self.wrap_map.set_font(font, font_size, cx);
}
/// Ensure text is prepared (initializes wrapper if needed)
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) {
self.wrap_map.ensure_text_prepared(text, cx);
}
/// Initialize with text
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
self.wrap_map.set_text(text, cx);
}
/// Convert byte offset to wrap display point (with soft wrap info).
#[inline]
pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint {
self.wrap_map.wrapper().offset_to_display_point(offset)
}
/// Convert wrap display point to byte offset.
#[inline]
pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
self.wrap_map.wrapper().display_point_to_offset(point)
}
/// Convert wrap display point to TreeSitterPoint (buffer line/col).
#[inline]
pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
self.wrap_map.wrapper().display_point_to_point(point)
}
/// Since there's no folding, wrap row == display row.
#[inline]
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
if wrap_row < self.wrap_row_count() {
Some(wrap_row)
} else {
None
}
}
/// Since there's no folding, nearest visible row is the row itself.
#[inline]
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
wrap_row.min(self.wrap_row_count().saturating_sub(1))
}
/// Since there's no folding, display row == wrap row.
#[inline]
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
if display_row < self.wrap_row_count() {
Some(display_row)
} else {
None
}
}
/// Get the longest row index (by byte length).
#[inline]
pub(crate) fn longest_row(&self) -> usize {
self.wrap_map.wrapper().longest_row.row
}
/// Get access to line items (for rendering)
#[inline]
pub(crate) fn lines(&self) -> &[LineItem] {
self.wrap_map.lines()
}
/// Get the rope text
#[inline]
pub fn text(&self) -> &Rope {
self.wrap_map.text()
}
/// Calculate how many wrap rows of a buffer line are visible
#[inline]
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
self.wrap_map.visible_wrap_row_count_for_buffer_line(line)
}
/// Get the wrap row count
#[inline]
pub fn wrap_row_count(&self) -> usize {
self.wrap_map.wrap_row_count()
}
/// Get the buffer line count (logical lines)
#[inline]
pub fn buffer_line_count(&self) -> usize {
self.wrap_map.buffer_line_count()
}
}
-7
View File
@@ -1,7 +0,0 @@
#[allow(clippy::module_inception)]
mod display_map;
mod text_wrapper;
mod wrap_map;
pub use self::display_map::DisplayMap;
pub(crate) use self::text_wrapper::LineLayout;
@@ -1,582 +0,0 @@
use std::ops::Range;
use gpui::{
App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px,
size,
};
use ropey::Rope;
use smallvec::SmallVec;
use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators};
/// A line with soft wrapped lines info.
#[derive(Debug, Clone)]
pub(crate) struct LineItem {
/// The original line text, without end `\n`.
line: Rope,
/// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line).
///
/// Not contains the line end `\n`.
pub(crate) wrapped_lines: Vec<Range<usize>>,
}
impl LineItem {
/// Get the bytes length of this line.
#[inline]
pub(crate) fn len(&self) -> usize {
self.line.len()
}
/// Get number of soft wrapped lines of this line (include the first line).
#[inline]
pub(crate) fn lines_len(&self) -> usize {
self.wrapped_lines.len()
}
}
#[derive(Debug, Default)]
pub(crate) struct LongestRow {
/// The 0-based row index.
pub row: usize,
/// The bytes length of the longest line.
pub len: usize,
}
/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor.
///
/// After use lines to calculate the scroll size of the Editor.
pub(crate) struct TextWrapper {
text: Rope,
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
soft_lines: usize,
font: Font,
font_size: Pixels,
/// If is none, it means the text is not wrapped
wrap_width: Option<Pixels>,
/// The longest (row, bytes len) in characters, used to calculate the horizontal scroll width.
pub(crate) longest_row: LongestRow,
/// The lines by split \n
pub(crate) lines: Vec<LineItem>,
_initialized: bool,
}
#[allow(unused)]
impl TextWrapper {
pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
text: Rope::new(),
font,
font_size,
wrap_width,
soft_lines: 0,
longest_row: LongestRow::default(),
lines: Vec::new(),
_initialized: false,
}
}
#[inline]
pub(crate) fn set_default_text(&mut self, text: &Rope) {
self.text = text.clone();
}
/// Get reference to the rope text.
#[inline]
pub(crate) fn text(&self) -> &Rope {
&self.text
}
/// Get the total number of lines including wrapped lines.
#[inline]
pub(crate) fn len(&self) -> usize {
self.soft_lines
}
/// Get the line item by row index.
#[inline]
pub(crate) fn line(&self, row: usize) -> Option<&LineItem> {
self.lines.get(row)
}
pub(crate) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
if wrap_width == self.wrap_width {
return;
}
self.wrap_width = wrap_width;
self.update_all(&self.text.clone(), cx);
}
pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
if self.font.eq(&font) && self.font_size == font_size {
return;
}
self.font = font;
self.font_size = font_size;
self.update_all(&self.text.clone(), cx);
}
pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool {
if self._initialized {
return false;
}
self._initialized = true;
self.update_all(text, cx);
true
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.
///
/// - `changed_text`: The text [`Rope`] that has changed.
/// - `range`: The `selected_range` before change.
/// - `new_text`: The inserted text.
/// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same.
/// - `cx`: The application context.
pub(crate) fn update(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
cx: &mut App,
) {
let mut line_wrapper = cx
.text_system()
.line_wrapper(self.font.clone(), self.font_size);
self._update(
changed_text,
range,
new_text,
&mut |line_str, wrap_width| {
line_wrapper
.wrap_line(&[LineFragment::text(line_str)], wrap_width)
.collect()
},
);
}
fn _update<F>(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
wrap_line: &mut F,
) where
F: FnMut(&str, Pixels) -> Vec<gpui::Boundary>,
{
// Remove the old changed lines.
let start_row = self.text.offset_to_point(range.start).row;
let start_row = start_row.min(self.lines.len().saturating_sub(1));
let end_row = self.text.offset_to_point(range.end).row;
let end_row = end_row.min(self.lines.len().saturating_sub(1));
let rows_range = start_row..=end_row;
if rows_range.contains(&self.longest_row.row) {
self.longest_row = LongestRow::default();
}
let mut longest_row_ix = self.longest_row.row;
let mut longest_row_len = self.longest_row.len;
// To add the new lines.
let new_start_row = changed_text.offset_to_point(range.start).row;
let new_start_offset = changed_text.line_start_offset(new_start_row);
let new_end_row = changed_text
.offset_to_point(range.start + new_text.len())
.row;
let new_end_offset = changed_text.line_end_offset(new_end_row);
let new_range = new_start_offset..new_end_offset;
let mut new_lines = vec![];
let wrap_width = self.wrap_width;
// line not contains `\n`.
for (ix, line) in Rope::from(changed_text.slice(new_range))
.iter_lines()
.enumerate()
{
let line_str = line.to_string();
let mut wrapped_lines = vec![];
let mut prev_boundary_ix = 0;
if line_str.len() > longest_row_len {
longest_row_ix = new_start_row + ix;
longest_row_len = line_str.len();
}
// If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
if let Some(wrap_width) = wrap_width {
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
for boundary in wrap_line(&line_str, wrap_width) {
wrapped_lines.push(prev_boundary_ix..boundary.ix);
prev_boundary_ix = boundary.ix;
}
}
// Reset of the line
if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
wrapped_lines.push(prev_boundary_ix..line.len());
}
new_lines.push(LineItem {
line: Rope::from(line),
wrapped_lines,
});
}
if self.lines.is_empty() {
self.lines = new_lines;
} else {
self.lines.splice(rows_range, new_lines);
}
self.text = changed_text.clone();
self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum();
self.longest_row = LongestRow {
row: longest_row_ix,
len: longest_row_len,
}
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.
fn update_all(&mut self, text: &Rope, cx: &mut App) {
self.update(text, &(0..text.len()), text, cx);
}
/// Return display point (with soft wrap) from the given byte offset in the text.
///
/// Panics if the `offset` is out of bounds.
pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint {
let row = self.text.offset_to_point(offset).row;
let start = self.text.line_start_offset(row);
let line = &self.lines[row];
let mut wrapped_row = self
.lines
.iter()
.take(row)
.map(|l| l.lines_len())
.sum::<usize>();
let local_offset = offset.saturating_sub(start);
for (ix, range) in line.wrapped_lines.iter().enumerate() {
if range.contains(&local_offset) {
return WrapDisplayPoint::new(
wrapped_row + ix,
ix,
local_offset.saturating_sub(range.start),
);
}
}
// Otherwise return the eof of the line.
let last_range = line.wrapped_lines.last().unwrap_or(&(0..0));
let ix = line.lines_len().saturating_sub(1);
WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len())
}
/// Return byte offset in the text from the given display point (with soft wrap).
///
/// Panics if the `point.row` is out of bounds.
pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
let mut wrapped_row = 0;
for (row, line) in self.lines.iter().enumerate() {
if wrapped_row + line.lines_len() > point.row {
let line_start = self.text.line_start_offset(row);
let local_row = point.row.saturating_sub(wrapped_row);
if let Some(range) = line.wrapped_lines.get(local_row) {
return line_start + (range.start + point.column).min(range.end);
} else {
// If not found, return the end of the line.
return line_start + line.len();
}
}
wrapped_row += line.lines_len();
}
self.text.len()
}
pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
let offset = self.display_point_to_offset(point);
self.text.offset_to_point(offset)
}
pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint {
let offset = self.text.point_to_offset(point);
self.offset_to_display_point(offset)
}
}
/// A display point within the soft-wrapped text.
///
/// This represents a position in the text after soft-wrapping,
/// with an additional `local_row` field tracking the wrap line
/// within the original buffer line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct WrapDisplayPoint {
/// The 0-based soft wrapped row index in the text.
pub row: usize,
/// The 0-based row index in local line (include first line).
///
/// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored.
pub local_row: usize,
/// The 0-based column byte index in the display line (with soft wrap).
pub column: usize,
}
impl WrapDisplayPoint {
pub fn new(row: usize, local_row: usize, column: usize) -> Self {
Self {
row,
local_row,
column,
}
}
}
/// The layout info of a line with soft wrapped lines.
pub(crate) struct LineLayout {
/// Total bytes length of this line.
len: usize,
/// The soft wrapped lines of this line (Include the first line).
pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>,
pub(crate) longest_width: Pixels,
pub(crate) whitespace_indicators: Option<WhitespaceIndicators>,
/// Whitespace indicators: (line_index, x_position, is_tab)
pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>,
}
impl LineLayout {
pub(crate) fn new() -> Self {
Self {
len: 0,
longest_width: px(0.),
wrapped_lines: SmallVec::new(),
whitespace_chars: Vec::new(),
whitespace_indicators: None,
}
}
pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self {
self.set_wrapped_lines(wrapped_lines);
self
}
pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) {
self.len = wrapped_lines.iter().map(|l| l.len).sum();
let width = wrapped_lines
.iter()
.map(|l| l.width)
.max()
.unwrap_or_default();
self.longest_width = width;
self.wrapped_lines = wrapped_lines;
}
pub(crate) fn with_whitespaces(mut self, indicators: Option<WhitespaceIndicators>) -> Self {
self.whitespace_indicators = indicators;
let Some(indicators) = self.whitespace_indicators.as_ref() else {
return self;
};
let space_indicator_offset = indicators.space.width.half();
for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() {
for (relative_offset, c) in wrapped_line.text.char_indices() {
if matches!(c, ' ' | '\t') {
let is_tab = c == '\t';
let start_x = wrapped_line.x_for_index(relative_offset);
let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8());
// Center the indicator in the actual character's space
let x_position = if c == ' ' {
(start_x + end_x).half() - space_indicator_offset
} else {
start_x
};
self.whitespace_chars.push((line_index, x_position, is_tab));
}
}
}
self
}
#[inline]
pub(crate) fn len(&self) -> usize {
self.len
}
/// Get the position (x, y) for the given index in this line layout.
///
/// - The `offset` is a local byte index in this line layout.
/// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at
/// the end of the current visual line rather than the start of the next one.
/// - The return value is relative to the top-left corner of this line layout, start from (0, 0)
pub(crate) fn position_for_index(
&self,
offset: usize,
last_layout: &LastLayout,
line_end_affinity: bool,
) -> Option<Point<Pixels>> {
let mut acc_len = 0;
let mut offset_y = px(0.);
let x_offset = last_layout.alignment_offset(self.longest_width);
for (i, line) in self.wrapped_lines.iter().enumerate() {
let is_last = i + 1 == self.wrapped_lines.len();
let matches = if line.len == 0 {
// Empty visual lines still own their boundary offset.
offset == acc_len
} else if is_last || line_end_affinity {
// Inclusive: cursor can sit at end of this visual line.
offset >= acc_len && offset <= acc_len + line.len
} else {
// Exclusive: boundary offset belongs to the next visual line.
offset >= acc_len && offset < acc_len + line.len
};
if matches {
let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset;
return Some(point(x, offset_y));
}
// Always advance by actual line length. The last line gets +1 so the
// cursor can be placed after the final character.
acc_len += if is_last { line.len + 1 } else { line.len };
offset_y += last_layout.line_height;
}
None
}
/// Get the closest index for the given x in this line layout.
pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize {
let mut acc_len = 0;
let x_offset = last_layout.alignment_offset(self.longest_width);
let x = x - x_offset;
for (i, line) in self.wrapped_lines.iter().enumerate() {
let is_last = i + 1 == self.wrapped_lines.len();
if x <= line.width {
let mut ix = line.closest_index_for_x(x);
if !is_last && ix == line.text.len() {
// For soft wrap line, we can't put the cursor at the end of the line.
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
ix = ix.saturating_sub(c_len);
}
return acc_len + ix;
}
acc_len += line.text.len();
}
acc_len
}
/// Get the index for the given position (x, y) in this line layout.
///
/// The `pos` is relative to the top-left corner of this line layout, start from (0, 0)
/// The return value is a local byte index in this line layout, start from 0.
pub(crate) fn closest_index_for_position(
&self,
pos: Point<Pixels>,
last_layout: &LastLayout,
) -> Option<usize> {
let mut offset = 0;
let mut line_top = px(0.);
let x_offset = last_layout.alignment_offset(self.longest_width);
for (i, line) in self.wrapped_lines.iter().enumerate() {
let is_last = i + 1 == self.wrapped_lines.len();
let line_bottom = line_top + last_layout.line_height;
if pos.y >= line_top && pos.y < line_bottom {
let mut ix = line.closest_index_for_x(pos.x - x_offset);
if !is_last && ix == line.text.len() {
// For soft wrap line, we can't put the cursor at the end of the line.
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
ix = ix.saturating_sub(c_len);
}
return Some(offset + ix);
}
offset += line.text.len();
line_top = line_bottom;
}
None
}
pub(crate) fn index_for_position(
&self,
pos: Point<Pixels>,
last_layout: &LastLayout,
) -> Option<usize> {
let mut offset = 0;
let mut line_top = px(0.);
let x_offset = last_layout.alignment_offset(self.longest_width);
for line in self.wrapped_lines.iter() {
let line_bottom = line_top + last_layout.line_height;
if pos.y >= line_top && pos.y < line_bottom {
let ix = line.index_for_x(pos.x - x_offset)?;
return Some(offset + ix);
}
offset += line.text.len();
line_top = line_bottom;
}
None
}
pub(crate) fn size(&self, line_height: Pixels) -> Size<Pixels> {
size(self.longest_width, self.wrapped_lines.len() * line_height)
}
pub(crate) fn paint(
&self,
pos: Point<Pixels>,
line_height: Pixels,
text_align: TextAlign,
align_width: Option<Pixels>,
window: &mut Window,
cx: &mut App,
) {
for (ix, line) in self.wrapped_lines.iter().enumerate() {
_ = line.paint(
pos + point(px(0.), ix * line_height),
line_height,
text_align,
align_width,
window,
cx,
);
}
// Paint whitespace indicators
if let Some(indicators) = self.whitespace_indicators.as_ref() {
for (line_index, x_position, is_tab) in &self.whitespace_chars {
let invisible = if *is_tab {
indicators.tab.clone()
} else {
indicators.space.clone()
};
let origin = point(
pos.x + *x_position,
pos.y + *line_index as f32 * line_height,
);
_ = invisible.paint(origin, line_height, text_align, align_width, window, cx);
}
}
}
}
-172
View File
@@ -1,172 +0,0 @@
/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows).
///
/// This module wraps the existing TextWrapper and provides:
/// - BufferPoint ↔ WrapPoint mapping
/// - Efficient buffer_line → wrap_row queries via prefix sum cache
/// - Incremental updates when text or layout changes
use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::text_wrapper::{LineItem, TextWrapper};
/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping.
pub struct WrapMap {
/// The underlying text wrapper (reuses existing implementation)
wrapper: TextWrapper,
/// Prefix sum cache: buffer_line_starts[line] = first wrap_row for buffer line `line`
/// This allows O(1) lookup of buffer_line → wrap_row
buffer_line_starts: Vec<usize>,
/// Cached line count from last rebuild
cached_line_count: usize,
/// Cached total wrap row count from last rebuild.
/// Used together with `cached_line_count` to detect if the cache is stale.
/// When soft wrap changes a line's wrap count without changing buffer line count,
/// this catches the staleness.
cached_wrap_row_count: usize,
}
impl WrapMap {
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
wrapper: TextWrapper::new(font, font_size, wrap_width),
buffer_line_starts: Vec::new(),
cached_line_count: 0,
cached_wrap_row_count: 0,
}
}
/// Get total number of wrap rows (visual rows after soft-wrapping)
#[inline]
pub fn wrap_row_count(&self) -> usize {
self.wrapper.len()
}
/// Get total number of buffer lines (logical lines)
#[inline]
pub fn buffer_line_count(&self) -> usize {
self.wrapper.lines.len()
}
/// Get the buffer line for a given wrap row
pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize {
if wrap_row >= self.wrap_row_count() {
return self.buffer_line_count().saturating_sub(1);
}
// Binary search in prefix sum cache
match self.buffer_line_starts.binary_search(&wrap_row) {
Ok(line) => line,
Err(insert_pos) => insert_pos.saturating_sub(1),
}
}
/// Get the first wrap row for a given buffer line
pub fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize {
if line >= self.buffer_line_starts.len() {
return self.wrap_row_count();
}
self.buffer_line_starts[line]
}
/// Get the wrap row range for a buffer line: [start, end)
pub fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range<usize> {
let start = self.buffer_line_to_first_wrap_row(line);
let end = if line + 1 < self.buffer_line_starts.len() {
self.buffer_line_starts[line + 1]
} else {
self.wrap_row_count()
};
start..end
}
/// Update text (incremental or full)
pub fn on_text_changed(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
cx: &mut App,
) {
self.wrapper.update(changed_text, range, new_text, cx);
self.rebuild_cache();
}
/// Update layout parameters (wrap width or font)
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
self.wrapper.set_wrap_width(wrap_width, cx);
self.rebuild_cache();
}
/// Set font parameters
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
self.wrapper.set_font(font, font_size, cx);
self.rebuild_cache();
}
/// Ensure text is prepared (initializes wrapper if needed)
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool {
let did_initialize = self.wrapper.prepare_if_need(text, cx);
if did_initialize {
self.rebuild_cache();
}
did_initialize
}
/// Initialize with text
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
self.wrapper.set_default_text(text);
self.wrapper.prepare_if_need(text, cx);
self.rebuild_cache();
}
/// Rebuild the prefix sum cache: buffer_line_starts
fn rebuild_cache(&mut self) {
let line_count = self.wrapper.lines.len();
let wrap_row_count = self.wrapper.len();
// Skip if nothing changed: both buffer line count and total wrap row count must match.
if line_count == self.cached_line_count
&& wrap_row_count == self.cached_wrap_row_count
&& !self.buffer_line_starts.is_empty()
{
return;
}
self.buffer_line_starts.clear();
let mut wrap_row = 0;
for line_item in &self.wrapper.lines {
self.buffer_line_starts.push(wrap_row);
wrap_row += line_item.lines_len();
}
self.cached_line_count = line_count;
self.cached_wrap_row_count = wrap_row_count;
}
/// Get access to the underlying wrapper (for rendering/hit-testing)
pub(crate) fn wrapper(&self) -> &TextWrapper {
&self.wrapper
}
/// Get access to line items (for rendering)
pub(crate) fn lines(&self) -> &[LineItem] {
&self.wrapper.lines
}
/// Get the rope text
pub fn text(&self) -> &Rope {
self.wrapper.text()
}
/// Calculate how many wrap rows of a buffer line are visible.
/// Without folding, all wrap rows are visible.
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
self.buffer_line_to_wrap_row_range(line).len()
}
}
File diff suppressed because it is too large Load Diff
-269
View File
@@ -1,269 +0,0 @@
use gpui::{Context, EntityInputHandler, SharedString, Window};
use ropey::RopeSlice;
use crate::input::mode::InputMode;
use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline};
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
/// Default is 2
pub tab_size: usize,
/// Set true to use `\t` as tab indent, default is false
pub hard_tabs: bool,
}
impl Default for TabSize {
fn default() -> Self {
Self {
tab_size: 2,
hard_tabs: false,
}
}
}
impl TabSize {
pub(super) fn to_string(self) -> SharedString {
if self.hard_tabs {
"\t".into()
} else {
" ".repeat(self.tab_size).into()
}
}
/// Count the indent size of the line in spaces.
pub fn indent_count(&self, line: &RopeSlice) -> usize {
let mut count = 0;
for ch in line.chars() {
match ch {
'\t' => count += self.tab_size,
' ' => count += 1,
_ => break,
}
}
count
}
}
impl InputState {
/// Set the tab size for the input.
///
/// Only for [`InputMode::PlainText`] mode with multi_line.
pub fn tab_size(mut self, tab: TabSize) -> Self {
debug_assert!(self.mode.is_multi_line());
if let InputMode::PlainText { tab: t, .. } = &mut self.mode {
*t = tab;
}
self
}
pub(super) fn indent_inline(
&mut self,
_: &IndentInline,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.indent(false, window, cx);
}
pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context<Self>) {
self.indent(true, window, cx);
}
pub(super) fn outdent_inline(
&mut self,
_: &OutdentInline,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.outdent(false, window, cx);
}
pub(super) fn outdent_block(
&mut self,
_: &Outdent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.outdent(true, window, cx);
}
pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
if !self.mode.is_indentable() {
cx.propagate();
return;
};
let tab_indent = self.mode.tab_size().to_string();
let selected_range = self.selected_range;
let mut added_len = 0;
let is_selected = !self.selected_range.is_empty();
if is_selected || block {
let start_offset = self.start_of_line_of_selection(window, cx);
let mut offset = start_offset;
let selected_text = self
.text_for_range(
self.range_to_utf16(&(offset..selected_range.end)),
&mut None,
window,
cx,
)
.unwrap_or("".into());
for line in selected_text.split('\n') {
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset))),
&tab_indent,
window,
cx,
);
added_len += tab_indent.len();
// +1 for "\n", the `\r` is included in the `line`.
offset += line.len() + tab_indent.len() + 1;
}
if is_selected {
self.selected_range = (start_offset..selected_range.end + added_len).into();
} else {
self.selected_range =
(selected_range.start + added_len..selected_range.end + added_len).into();
}
} else {
// Selected none
let offset = self.selected_range.start;
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset))),
&tab_indent,
window,
cx,
);
added_len = tab_indent.len();
self.selected_range =
(selected_range.start + added_len..selected_range.end + added_len).into();
}
}
pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
if !self.mode.is_indentable() {
cx.propagate();
return;
};
let tab_indent = self.mode.tab_size().to_string();
let selected_range = self.selected_range;
let mut removed_len = 0;
let is_selected = !self.selected_range.is_empty();
if is_selected || block {
let start_offset = self.start_of_line_of_selection(window, cx);
let mut offset = start_offset;
let selected_text = self
.text_for_range(
self.range_to_utf16(&(offset..selected_range.end)),
&mut None,
window,
cx,
)
.unwrap_or("".into());
for line in selected_text.split('\n') {
if line.starts_with(tab_indent.as_ref()) {
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
"",
window,
cx,
);
removed_len += tab_indent.len();
// +1 for "\n"
offset += line.len().saturating_sub(tab_indent.len()) + 1;
} else {
offset += line.len() + 1;
}
}
if is_selected {
self.selected_range =
(start_offset..selected_range.end.saturating_sub(removed_len)).into();
} else {
self.selected_range = (selected_range.start.saturating_sub(removed_len)
..selected_range.end.saturating_sub(removed_len))
.into();
}
} else {
// Selected none
let start_offset = self.selected_range.start;
let offset = self.start_of_line_of_selection(window, cx);
let offset = self.offset_from_utf16(self.offset_to_utf16(offset));
// FIXME: To improve performance
if self
.text
.slice(offset..self.text.len())
.to_string()
.starts_with(tab_indent.as_ref())
{
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
"",
window,
cx,
);
removed_len = tab_indent.len();
let new_offset = start_offset.saturating_sub(removed_len);
self.selected_range = (new_offset..new_offset).into();
}
}
}
}
#[cfg(test)]
mod tests {
use ropey::RopeSlice;
use super::TabSize;
#[test]
fn test_tab_size() {
let tab = TabSize {
tab_size: 2,
hard_tabs: false,
};
assert_eq!(tab.to_string(), " ");
let tab = TabSize {
tab_size: 4,
hard_tabs: false,
};
assert_eq!(tab.to_string(), " ");
let tab = TabSize {
tab_size: 2,
hard_tabs: true,
};
assert_eq!(tab.to_string(), "\t");
let tab = TabSize {
tab_size: 4,
hard_tabs: true,
};
assert_eq!(tab.to_string(), "\t");
}
#[test]
fn test_tab_size_indent_count() {
let tab = TabSize {
tab_size: 4,
hard_tabs: false,
};
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2);
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4);
assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4);
assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6);
assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6);
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
}
}
+112 -169
View File
@@ -1,31 +1,76 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled,
TextAlign, Window, div, px, relative,
AnyElement, App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement,
MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign,
Window, div, px, relative,
};
use gpui_base::InputBase;
use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode};
use theme::ActiveTheme;
use super::InputState;
use super::element::EditorScrollbar;
use crate::button::{Button, ButtonVariants as _};
use crate::indicator::Indicator;
use crate::input::clear_button;
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
/// Returns `(background, foreground)` colors for input-like components.
pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) {
/// The background of an input frame, which reads muted while the input is disabled.
fn input_background(disabled: bool, cx: &App) -> Hsla {
if disabled {
(cx.theme().surface_background, cx.theme().text_muted)
cx.theme().surface_background
} else {
(cx.theme().elevated_surface_background, cx.theme().text)
cx.theme().elevated_surface_background
}
}
/// A text input element bind to an [`InputState`].
/// The colors base paints input text with, read from the coop theme.
///
/// Base fills in any color left transparent from its own palette, and that
/// palette is only a projection of this one, so every color coop paints with is
/// named here rather than left to resolve.
fn input_editor_style(cx: &App) -> InputEditorStyle {
let theme = cx.theme();
InputEditorStyle {
foreground: theme.text,
muted_foreground: theme.text_muted,
background: theme.elevated_surface_background,
border: theme.border,
selection: theme.selection,
caret: theme.cursor,
..InputEditorStyle::default()
}
}
/// The input's own padding, resolved to pixels.
///
/// Base applies the multi-line padding itself so that the text, the gutter, and
/// the scrollbar share one inset, and the single-line frame carries its own.
/// Both come from the same size table, resolved through the window's rem size.
fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges<Pixels> {
let mut probe = div().input_px(size).input_py(size).refine_style(style);
let padding = probe.style().padding.clone();
let base_size = window.text_style().font_size;
let rem_size = window.rem_size();
let resolve = |value: Option<DefiniteLength>| {
value
.map(|value| value.to_pixels(base_size, rem_size))
.unwrap_or(px(0.))
};
Edges {
left: resolve(padding.left),
right: resolve(padding.right),
top: resolve(padding.top),
bottom: resolve(padding.bottom),
}
}
/// A text input element bound to an [`InputState`] or a [`TextareaState`].
///
/// The editing kind lives on the state, so `Input::new` accepts either and
/// infers which one is rendered.
#[derive(IntoElement)]
pub struct Input {
state: Entity<InputState>,
pub struct Input<M: InputModeKind = InputMode> {
state: Entity<InputBaseState<M>>,
style: StyleRefinement,
size: Size,
prefix: Option<AnyElement>,
@@ -39,14 +84,17 @@ pub struct Input {
selected: bool,
}
impl Sizable for Input {
/// A styled multi-line text input.
pub type Textarea = Input<TextareaMode>;
impl<M: InputModeKind> Sizable for Input<M> {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Selectable for Input {
impl<M: InputModeKind> Selectable for Input<M> {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
@@ -57,9 +105,9 @@ impl Selectable for Input {
}
}
impl Input {
/// Create a new [`Input`] element bind to the [`InputState`].
pub fn new(state: &Entity<InputState>) -> Self {
impl<M: InputModeKind> Input<M> {
/// Create a new [`Input`] element bind to the given state.
pub fn new(state: &Entity<InputBaseState<M>>) -> Self {
Self {
state: state.clone(),
size: Size::default(),
@@ -128,8 +176,7 @@ impl Input {
self
}
fn render_toggle_mask_button(state: &Entity<InputState>, cx: &App) -> impl IntoElement {
let _masked = state.read(cx).masked;
fn render_toggle_mask_button(state: &Entity<InputBaseState<M>>) -> impl IntoElement {
Button::new("toggle-mask")
.icon(IconName::Eye)
.xsmall()
@@ -137,78 +184,42 @@ impl Input {
.tab_stop(false)
.on_click({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.set_masked(!state.masked, window, cx);
})
}
move |_, window, cx| state.update(cx, |state, cx| state.toggle_masked(window, cx))
})
}
/// This method must after the refine_style.
fn render_editor(
paddings: EdgesRefinement<DefiniteLength>,
input_state: &Entity<InputState>,
state: &InputState,
window: &Window,
) -> impl IntoElement {
let base_size = window.text_style().font_size;
let rem_size = window.rem_size();
let paddings = Edges {
left: paddings
.left
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
right: paddings
.right
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
top: paddings
.top
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
bottom: paddings
.bottom
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
};
state.editor_scrollbar_paddings.set(paddings);
state.editor_scrollbar_snapshot.set(None);
v_flex().size_full().child(
div()
.relative()
.flex_1()
.child(input_state.clone())
.child(EditorScrollbar::new(input_state.clone())),
)
}
}
impl Styled for Input {
impl<M: InputModeKind> Styled for Input<M> {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Input {
impl<M: InputModeKind> RenderOnce for Input<M> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25);
let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
self.state.update(cx, |state, _| {
state.disabled = self.disabled;
state.size = self.size;
// Only for single line mode
if state.mode.is_single_line() {
state.text_align = text_align;
let multi_line = self.state.read(cx).is_multi_line();
let editor_paddings = if multi_line {
input_paddings(self.size, &self.style, window)
} else {
Edges::default()
};
self.state.update(cx, |state, cx| {
state.set_editor_style(input_editor_style(cx));
state.set_editor_paddings(editor_paddings);
state.set_disabled(self.disabled, cx);
if state.is_single_line() {
state.set_text_align(text_align, cx);
}
});
let state = self.state.read(cx);
let _focused = state.focus_handle.is_focused(window) && !state.disabled;
let presentation = state.presentation();
let disabled = presentation.is_disabled();
let loading = presentation.is_loading();
let text_is_empty = state.text().len() == 0;
let gap_x = match self.size {
Size::Small => px(4.),
@@ -216,117 +227,49 @@ impl RenderOnce for Input {
_ => px(6.),
};
let (bg, _) = input_style(state.disabled, cx);
let background = input_background(disabled, cx);
let show_clear_button =
self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line;
let has_suffix = self.suffix.is_some() || loading || self.mask_toggle || show_clear_button;
let prefix = self.prefix;
let suffix = self.suffix;
let show_clear_button = self.cleanable
&& !state.disabled
&& !state.loading
&& state.text.len() > 0
&& state.mode.is_single_line();
let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button;
let state_entity = self.state.clone();
div()
.id(("input", self.state.entity_id()))
InputBase::new(("input", self.state.entity_id()))
.flex()
.key_context(crate::input::CONTEXT)
.track_focus(&state.focus_handle.clone())
.tab_index(self.tab_index)
.when(!state.disabled, |this| {
this.on_action(window.listener_for(&self.state, InputState::backspace))
.on_action(window.listener_for(&self.state, InputState::delete))
.on_action(
window.listener_for(&self.state, InputState::delete_to_beginning_of_line),
)
.on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line))
.on_action(window.listener_for(&self.state, InputState::delete_previous_word))
.on_action(window.listener_for(&self.state, InputState::delete_next_word))
.on_action(window.listener_for(&self.state, InputState::enter))
.on_action(window.listener_for(&self.state, InputState::escape))
.on_action(window.listener_for(&self.state, InputState::paste))
.on_action(window.listener_for(&self.state, InputState::cut))
.on_action(window.listener_for(&self.state, InputState::undo))
.on_action(window.listener_for(&self.state, InputState::redo))
.when(state.mode.is_multi_line(), |this| {
this.on_action(window.listener_for(&self.state, InputState::indent_inline))
.on_action(window.listener_for(&self.state, InputState::outdent_inline))
.on_action(window.listener_for(&self.state, InputState::indent_block))
.on_action(window.listener_for(&self.state, InputState::outdent_block))
})
})
.on_action(window.listener_for(&self.state, InputState::left))
.on_action(window.listener_for(&self.state, InputState::right))
.on_action(window.listener_for(&self.state, InputState::select_left))
.on_action(window.listener_for(&self.state, InputState::select_right))
.when(state.mode.is_multi_line(), |this| {
this.on_action(window.listener_for(&self.state, InputState::up))
.on_action(window.listener_for(&self.state, InputState::down))
.on_action(window.listener_for(&self.state, InputState::select_up))
.on_action(window.listener_for(&self.state, InputState::select_down))
.on_action(window.listener_for(&self.state, InputState::page_up))
.on_action(window.listener_for(&self.state, InputState::page_down))
})
.on_action(window.listener_for(&self.state, InputState::select_all))
.on_action(window.listener_for(&self.state, InputState::select_to_start_of_line))
.on_action(window.listener_for(&self.state, InputState::select_to_end_of_line))
.on_action(window.listener_for(&self.state, InputState::select_to_previous_word))
.on_action(window.listener_for(&self.state, InputState::select_to_next_word))
.on_action(window.listener_for(&self.state, InputState::home))
.on_action(window.listener_for(&self.state, InputState::end))
.on_action(window.listener_for(&self.state, InputState::move_to_start))
.on_action(window.listener_for(&self.state, InputState::move_to_end))
.on_action(window.listener_for(&self.state, InputState::move_to_previous_word))
.on_action(window.listener_for(&self.state, InputState::move_to_next_word))
.on_action(window.listener_for(&self.state, InputState::select_to_start))
.on_action(window.listener_for(&self.state, InputState::select_to_end))
.on_action(window.listener_for(&self.state, InputState::show_character_palette))
.on_action(window.listener_for(&self.state, InputState::copy))
.on_key_down(window.listener_for(&self.state, InputState::on_key_down))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&self.state, InputState::on_mouse_down),
)
.on_mouse_down(
MouseButton::Right,
window.listener_for(&self.state, InputState::on_mouse_down),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&self.state, InputState::on_mouse_up),
)
.on_mouse_up(
MouseButton::Right,
window.listener_for(&self.state, InputState::on_mouse_up),
)
.on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel))
.size_full()
.line_height(LINE_HEIGHT)
.input_px(self.size)
.input_py(self.size)
.when(!multi_line, |this| {
this.input_px(self.size).input_py(self.size)
})
.input_h(self.size)
.input_font_size(self.size)
.when(!self.disabled, |this| this.cursor_text())
.when(!disabled, |this| this.cursor_text())
.on_mouse_down(MouseButton::Left, {
let state_entity = state_entity.clone();
move |_, window, cx| state_entity.update(cx, |state, cx| state.focus(window, cx))
})
.items_center()
.when(state.mode.is_multi_line(), |this| {
.when(multi_line, |this| {
this.h_auto()
.when_some(self.height, |this, height| this.h(height))
})
.when(self.appearance, |this| {
this.bg(bg)
this.bg(background)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(cx.theme().radius)
})
.items_center()
.tab_index(self.tab_index)
.gap(gap_x)
.refine_style(&self.style)
.children(prefix)
.when(state.mode.is_multi_line(), |mut this| {
let paddings = this.style().padding.clone();
this.child(Self::render_editor(paddings, &self.state, state, window))
})
.when(!state.mode.is_multi_line(), |this| {
this.child(self.state.clone())
.when(!multi_line, |this| this.child(state_entity.clone()))
.when(multi_line, |this| {
this.child(
v_flex()
.size_full()
.child(div().relative().flex_1().child(state_entity.clone())),
)
})
.when(has_suffix, |this| {
this.pr_2().child(
@@ -334,13 +277,13 @@ impl RenderOnce for Input {
.id("suffix")
.gap(gap_x)
.items_center()
.when(state.loading, |this| this.child(Indicator::new()))
.when(loading, |this| this.child(Indicator::new()))
.when(self.mask_toggle, |this| {
this.child(Self::render_toggle_mask_button(&self.state, cx))
this.child(Self::render_toggle_mask_button(&state_entity))
})
.when(show_clear_button, |this| {
this.child(clear_button(cx).on_click({
let state = self.state.clone();
let state = state_entity.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.clean(window, cx);
-409
View File
@@ -1,409 +0,0 @@
use gpui::SharedString;
#[derive(Clone, PartialEq, Debug)]
pub enum MaskToken {
/// 0 Digit, equivalent to `[0]`
// Digit0,
/// Digit, equivalent to `[0-9]`
Digit,
/// Letter, equivalent to `[a-zA-Z]`
Letter,
/// Letter or digit, equivalent to `[a-zA-Z0-9]`
LetterOrDigit,
/// Separator
Sep(char),
/// Any character
Any,
}
#[allow(unused)]
impl MaskToken {
/// Check if the token is any character.
pub fn is_any(&self) -> bool {
matches!(self, MaskToken::Any)
}
/// Check if the token is a match for the given character.
///
/// The separator is always a match any input character.
fn is_match(&self, ch: char) -> bool {
match self {
MaskToken::Digit => ch.is_ascii_digit(),
MaskToken::Letter => ch.is_ascii_alphabetic(),
MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(),
MaskToken::Any => true,
MaskToken::Sep(c) => *c == ch,
}
}
/// Is the token a separator (Can be ignored)
fn is_sep(&self) -> bool {
matches!(self, MaskToken::Sep(_))
}
/// Check if the token is a number.
pub fn is_number(&self) -> bool {
matches!(self, MaskToken::Digit)
}
pub fn placeholder(&self) -> char {
match self {
MaskToken::Sep(c) => *c,
_ => '_',
}
}
fn mask_char(&self, ch: char) -> char {
match self {
MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch,
MaskToken::Sep(c) => *c,
MaskToken::Any => ch,
}
}
fn unmask_char(&self, ch: char) -> Option<char> {
match self {
MaskToken::Digit => Some(ch),
MaskToken::Letter => Some(ch),
MaskToken::LetterOrDigit => Some(ch),
MaskToken::Any => Some(ch),
_ => None,
}
}
}
#[derive(Clone, Default)]
pub enum MaskPattern {
#[default]
None,
Pattern {
pattern: SharedString,
tokens: Vec<MaskToken>,
},
Number {
/// Group separator, e.g. "," or " "
separator: Option<char>,
/// Number of fraction digits, e.g. 2 for 123.45
fraction: Option<usize>,
},
}
impl From<&str> for MaskPattern {
fn from(pattern: &str) -> Self {
Self::new(pattern)
}
}
impl MaskPattern {
/// Create a new mask pattern
///
/// - `9` - Digit
/// - `A` - Letter
/// - `#` - Letter or Digit
/// - `*` - Any character
/// - other characters - Separator
///
/// For example:
///
/// - `(999)999-9999` - US phone number: (123)456-7890
/// - `99999-9999` - ZIP code: 12345-6789
/// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4
/// - `*999*` - Custom pattern: (123) or [123]
pub fn new(pattern: &str) -> Self {
let tokens = pattern
.chars()
.map(|ch| match ch {
// '0' => MaskToken::Digit0,
'9' => MaskToken::Digit,
'A' => MaskToken::Letter,
'#' => MaskToken::LetterOrDigit,
'*' => MaskToken::Any,
_ => MaskToken::Sep(ch),
})
.collect();
Self::Pattern {
pattern: pattern.to_owned().into(),
tokens,
}
}
#[allow(unused)]
fn tokens(&self) -> Option<&Vec<MaskToken>> {
match self {
Self::Pattern { tokens, .. } => Some(tokens),
Self::Number { .. } => None,
Self::None => None,
}
}
/// Create a new mask pattern with group separator, e.g. "," or " "
pub fn number(sep: Option<char>) -> Self {
Self::Number {
separator: sep,
fraction: None,
}
}
pub fn placeholder(&self) -> Option<String> {
match self {
Self::Pattern { tokens, .. } => {
Some(tokens.iter().map(|token| token.placeholder()).collect())
}
Self::Number { .. } => None,
Self::None => None,
}
}
/// Return true if the mask pattern is None or no any pattern.
pub fn is_none(&self) -> bool {
match self {
Self::Pattern { tokens, .. } => tokens.is_empty(),
Self::Number { .. } => false,
Self::None => true,
}
}
/// Check is the mask text is valid.
///
/// If the mask pattern is None, always return true.
pub fn is_valid(&self, mask_text: &str) -> bool {
if self.is_none() {
return true;
}
let mut text_index = 0;
let mask_text_chars: Vec<char> = mask_text.chars().collect();
match self {
Self::Pattern { tokens, .. } => {
for token in tokens {
if text_index >= mask_text_chars.len() {
break;
}
let ch = mask_text_chars[text_index];
if token.is_match(ch) {
text_index += 1;
}
}
text_index == mask_text.len()
}
Self::Number { separator, .. } => {
if mask_text.is_empty() {
return true;
}
// check if the text is valid number
let mut parts = mask_text.split('.');
let int_part = parts.next().unwrap_or("");
let frac_part = parts.next();
if int_part.is_empty() {
return false;
}
let sign_positions: Vec<usize> = int_part
.chars()
.enumerate()
.filter_map(|(i, ch)| match is_sign(&ch) {
true => Some(i),
false => None,
})
.collect();
// only one sign is valid
// sign is only valid at the beginning of the string
if sign_positions.len() > 1 || sign_positions.first() > Some(&0) {
return false;
}
// check if the integer part is valid
if !int_part.chars().enumerate().all(|(i, ch)| {
ch.is_ascii_digit() || is_sign(&ch) && i == 0 || Some(ch) == *separator
}) {
return false;
}
// check if the fraction part is valid
if let Some(frac) = frac_part
&& !frac
.chars()
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
{
return false;
}
true
}
Self::None => true,
}
}
/// Check if valid input char at the given position.
pub fn is_valid_at(&self, ch: char, pos: usize) -> bool {
if self.is_none() {
return true;
}
match self {
Self::Pattern { tokens, .. } => {
if let Some(token) = tokens.get(pos) {
if token.is_match(ch) {
return true;
}
if token.is_sep() {
// If next token is match, it's valid
if let Some(next_token) = tokens.get(pos + 1)
&& next_token.is_match(ch)
{
return true;
}
}
}
false
}
Self::Number { .. } => true,
Self::None => true,
}
}
/// Format the text according to the mask pattern
///
/// For example:
///
/// - pattern: (999)999-999
/// - text: 123456789
/// - mask_text: (123)456-789
pub fn mask(&self, text: &str) -> SharedString {
if self.is_none() {
return text.to_owned().into();
}
match self {
Self::Number {
separator,
fraction,
} => {
if let Some(sep) = *separator {
// Remove the existing group separator
let text = text.replace(sep, "");
let mut parts = text.split('.');
let int_part = parts.next().unwrap_or("");
// Limit the fraction part to the given range, if not enough, pad with 0
let frac_part = parts.next().map(|part| {
part.chars()
.take(fraction.unwrap_or(usize::MAX))
.collect::<String>()
});
// Reverse the integer part for easier grouping
let mut chars: Vec<char> = int_part.chars().rev().collect();
// Removing the sign from formatting to avoid cases such as: -,123
let maybe_signed = chars.iter().position(is_sign).map(|pos| chars.remove(pos));
let mut result = String::new();
for (i, ch) in chars.iter().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(sep);
}
result.push(*ch);
}
let int_with_sep: String = result.chars().rev().collect();
let final_str = if let Some(frac) = frac_part {
if fraction == &Some(0) {
int_with_sep
} else {
format!("{}.{}", int_with_sep, frac)
}
} else {
int_with_sep
};
let final_str = if let Some(sign) = maybe_signed {
format!("{}{}", sign, final_str)
} else {
final_str
};
return final_str.into();
}
text.to_owned().into()
}
Self::Pattern { tokens, .. } => {
let mut result = String::new();
let mut text_index = 0;
let text_chars: Vec<char> = text.chars().collect();
for (pos, token) in tokens.iter().enumerate() {
if text_index >= text_chars.len() {
break;
}
let ch = text_chars[text_index];
// Break if expected char is not match
if !token.is_sep() && !self.is_valid_at(ch, pos) {
break;
}
let mask_ch = token.mask_char(ch);
result.push(mask_ch);
if ch == mask_ch {
text_index += 1;
continue;
}
}
result.into()
}
Self::None => text.to_owned().into(),
}
}
/// Extract original text from masked text
pub fn unmask(&self, mask_text: &str) -> String {
match self {
Self::Number { separator, .. } => {
if let Some(sep) = *separator {
let mut result = String::new();
for ch in mask_text.chars() {
if ch == sep {
continue;
}
result.push(ch);
}
if result.contains('.') {
result = result.trim_end_matches('0').to_string();
}
return result;
}
mask_text.to_owned()
}
Self::Pattern { tokens, .. } => {
let mut result = String::new();
let mask_text_chars: Vec<char> = mask_text.chars().collect();
for (text_index, token) in tokens.iter().enumerate() {
if text_index >= mask_text_chars.len() {
break;
}
let ch = mask_text_chars[text_index];
let unmask_ch = token.unmask_char(ch);
if let Some(ch) = unmask_ch {
result.push(ch);
}
}
result
}
Self::None => mask_text.to_owned(),
}
}
}
#[inline]
fn is_sign(ch: &char) -> bool {
matches!(ch, '+' | '-')
}
+1 -21
View File
@@ -1,27 +1,7 @@
pub(super) const MASK_CHAR: char = '*';
mod blink_cursor;
mod change;
mod clear_button;
mod cursor;
mod display_map;
mod element;
mod indent;
#[allow(clippy::module_inception)]
mod input;
mod mask_pattern;
mod mode;
mod movement;
mod rope_ext;
mod selection;
mod state;
pub(crate) use clear_button::*;
pub use cursor::*;
pub use display_map::DisplayMap;
pub use indent::TabSize;
pub use gpui_base::input::{InputEvent, InputState, TextareaState};
pub use input::*;
pub use mask_pattern::MaskPattern;
pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines};
pub use ropey::Rope;
pub use state::*;
-145
View File
@@ -1,145 +0,0 @@
use super::display_map::DisplayMap;
#[derive(Clone)]
pub(crate) enum InputMode {
/// A plain text input mode.
PlainText {
multi_line: bool,
tab: crate::input::indent::TabSize,
rows: usize,
},
/// An auto grow input mode.
AutoGrow {
rows: usize,
min_rows: usize,
max_rows: usize,
},
}
impl Default for InputMode {
fn default() -> Self {
InputMode::plain_text()
}
}
#[allow(unused)]
impl InputMode {
/// Create a plain input mode with default settings.
pub(super) fn plain_text() -> Self {
InputMode::PlainText {
multi_line: false,
tab: crate::input::indent::TabSize::default(),
rows: 1,
}
}
/// Create an auto grow input mode with given min and max rows.
pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self {
InputMode::AutoGrow {
rows: min_rows,
min_rows,
max_rows,
}
}
pub(super) fn multi_line(mut self, multi_line: bool) -> Self {
match &mut self {
InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line,
InputMode::AutoGrow { .. } => {}
}
self
}
#[inline]
pub(super) fn is_single_line(&self) -> bool {
!self.is_multi_line()
}
#[inline]
pub(super) fn is_auto_grow(&self) -> bool {
matches!(self, InputMode::AutoGrow { .. })
}
#[inline]
pub(super) fn is_multi_line(&self) -> bool {
match self {
InputMode::PlainText { multi_line, .. } => *multi_line,
InputMode::AutoGrow { max_rows, .. } => *max_rows > 1,
}
}
pub(super) fn set_rows(&mut self, new_rows: usize) {
match self {
InputMode::PlainText { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
max_rows,
} => {
*rows = new_rows.clamp(*min_rows, *max_rows);
}
}
}
pub(super) fn update_auto_grow(&mut self, display_map: &DisplayMap) {
if self.is_single_line() {
return;
}
let wrapped_lines = display_map.wrap_row_count();
self.set_rows(wrapped_lines);
}
/// At least 1 row be return.
pub(super) fn rows(&self) -> usize {
if !self.is_multi_line() {
return 1;
}
match self {
InputMode::PlainText { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
}
.max(1)
}
/// At least 1 row be return.
#[allow(unused)]
pub(super) fn min_rows(&self) -> usize {
match self {
InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1,
}
.max(1)
}
#[allow(unused)]
pub(super) fn max_rows(&self) -> usize {
if !self.is_multi_line() {
return 1;
}
match self {
InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => usize::MAX,
}
}
#[inline]
pub(super) fn is_indentable(&self) -> bool {
match self {
InputMode::PlainText { multi_line, .. } => *multi_line,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> crate::input::indent::TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
_ => crate::input::indent::TabSize::default(),
}
}
}
-264
View File
@@ -1,264 +0,0 @@
use gpui::{Context, Point, Window};
use crate::input::{
InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight,
MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MoveDirection {
Up,
Down,
}
impl InputState {
/// Called after moving the cursor. Updates preferred_column if we know where the cursor now is.
pub(super) fn update_preferred_column(&mut self) {
let Some(last_layout) = &self.last_layout else {
self.preferred_column = None;
return;
};
let point = self.text.offset_to_point(self.cursor());
let Some(line) = last_layout.line(point.row) else {
self.preferred_column = None;
return;
};
let Some(pos) = line.position_for_index(point.column, last_layout, false) else {
self.preferred_column = None;
return;
};
self.preferred_column = Some((pos.x, point.column));
}
/// Move the cursor to the given offset.
///
/// The offset is the UTF-8 offset.
///
/// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset.
pub(crate) fn move_to(
&mut self,
offset: usize,
direction: Option<MoveDirection>,
cx: &mut Context<Self>,
) {
let offset = offset.clamp(0, self.text.len());
self.cursor_line_end_affinity = false;
self.selected_range = (offset..offset).into();
self.scroll_to(offset, direction, cx);
self.pause_blink_cursor(cx);
self.update_preferred_column();
cx.notify()
}
/// Move the cursor vertically by one line (up or down) while preserving the column if possible.
///
/// move_lines: Number of lines to move vertically (positive for down, negative for up).
pub(super) fn move_vertical(
&mut self,
move_lines: isize,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let offset = self.cursor();
let was_preferred_column = self.preferred_column;
let mut display_point = self.display_map.offset_to_wrap_display_point(offset);
// Convert wrap row → display row (skips folded rows), move, then convert back
let current_display_row = self
.display_map
.wrap_row_to_display_row(display_point.row)
.unwrap_or_else(|| {
self.display_map
.nearest_visible_display_row(display_point.row)
});
let max_display_row = self.display_map.display_row_count().saturating_sub(1);
let target_display_row = current_display_row
.saturating_add_signed(move_lines)
.min(max_display_row);
let target_wrap_row = self
.display_map
.display_row_to_wrap_row(target_display_row)
.unwrap_or(display_point.row);
display_point.row = target_wrap_row;
display_point.column = 0;
let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point);
if let Some((preferred_x, column)) = was_preferred_column {
// Get display point again to update local_row.
let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset);
next_display_point.column = 0;
let next_point = self
.display_map
.wrap_display_point_to_point(next_display_point);
let line_start_offset = self.text.line_start_offset(next_point.row);
// If in visible range, prefer to use position to get column.
if let Some(line) = last_layout.line(next_point.row) {
if let Some(x) = line.closest_index_for_position(
Point {
x: preferred_x,
y: next_display_point.local_row * last_layout.line_height,
},
last_layout,
) {
new_offset = line_start_offset + x;
}
} else {
// Not in visible range, use column directly.
let max_line_len = self.text.slice_line(next_point.row).len();
new_offset = line_start_offset + column.min(max_line_len);
}
}
self.pause_blink_cursor(cx);
let direction = if move_lines < 0 {
MoveDirection::Up
} else {
MoveDirection::Down
};
self.move_to(new_offset, Some(direction), cx);
// Set back the preferred_column
self.preferred_column = was_preferred_column;
cx.notify();
}
pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor()), None, cx);
} else {
self.move_to(self.selected_range.start, None, cx)
}
}
pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.selected_range.end), None, cx);
} else {
self.move_to(self.selected_range.end, None, cx)
}
}
pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(
self.previous_boundary(self.selected_range.start.saturating_sub(1)),
Some(MoveDirection::Up),
cx,
);
}
self.pause_blink_cursor(cx);
self.move_vertical(-1, window, cx);
}
pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(
self.next_boundary(self.selected_range.end.saturating_sub(1)),
Some(MoveDirection::Down),
cx,
);
}
self.pause_blink_cursor(cx);
self.move_vertical(1, window, cx);
}
pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize;
self.move_vertical(-display_lines, window, cx);
}
pub(super) fn page_down(
&mut self,
_: &MovePageDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize;
self.move_vertical(display_lines, window, cx);
}
pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
let offset = self.start_of_line();
self.move_to(offset, Some(MoveDirection::Up), cx);
}
pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
let offset = self.end_of_line();
self.move_to(offset, Some(MoveDirection::Down), cx);
self.cursor_line_end_affinity = true;
}
pub(super) fn move_to_start(
&mut self,
_: &MoveToStart,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.move_to(0, None, cx);
}
pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.text.len(), None, cx);
}
pub(super) fn move_to_previous_word(
&mut self,
_: &MoveToPreviousWord,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.previous_start_of_word();
self.move_to(offset, None, cx);
}
pub(super) fn move_to_next_word(
&mut self,
_: &MoveToNextWord,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.next_end_of_word();
self.move_to(offset, None, cx);
}
}
-456
View File
@@ -1,456 +0,0 @@
use std::ops::Range;
use ropey::{LineType, Rope, RopeSlice};
use sum_tree::Bias;
#[cfg(not(target_family = "wasm"))]
pub use tree_sitter::{InputEdit, Point};
#[cfg(target_family = "wasm")]
/// Stub type for tree-sitter Point on WASM (tree-sitter not available).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Point {
pub row: usize,
pub column: usize,
}
#[cfg(target_family = "wasm")]
impl Point {
pub fn new(row: usize, column: usize) -> Self {
Self { row, column }
}
}
#[cfg(target_family = "wasm")]
/// Stub type for tree-sitter InputEdit on WASM (tree-sitter not available).
#[derive(Debug, Clone, Copy)]
pub struct InputEdit {
pub start_byte: usize,
pub old_end_byte: usize,
pub new_end_byte: usize,
pub start_position: Point,
pub old_end_position: Point,
pub new_end_position: Point,
}
pub type Position = lsp_types::Position;
/// An iterator over the lines of a `Rope`.
pub struct RopeLines<'a> {
rope: &'a Rope,
row: usize,
end_row: usize,
}
impl<'a> RopeLines<'a> {
/// Create a new `RopeLines` iterator.
pub fn new(rope: &'a Rope) -> Self {
let end_row = rope.lines_len();
Self {
row: 0,
end_row,
rope,
}
}
}
impl<'a> Iterator for RopeLines<'a> {
type Item = RopeSlice<'a>;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if self.row >= self.end_row {
return None;
}
let line = self.rope.slice_line(self.row);
self.row += 1;
Some(line)
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.row = self.row.saturating_add(n);
self.next()
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.end_row - self.row;
(len, Some(len))
}
}
impl std::iter::ExactSizeIterator for RopeLines<'_> {}
impl std::iter::FusedIterator for RopeLines<'_> {}
/// An extension trait for [`Rope`] to provide additional utility methods.
pub trait RopeExt {
/// Start offset of the line at the given row (0-based) index.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
///
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_start_offset(0), 0);
/// assert_eq!(rope.line_start_offset(1), 6);
/// ```
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n"
/// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n"
/// ```
fn line_end_offset(&self, row: usize) -> usize;
/// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.slice_line(0).to_string(), "Hello");
/// assert_eq!(rope.slice_line(1).to_string(), "World\r");
/// assert_eq!(rope.slice_line(2).to_string(), "This is a test 中文");
/// assert_eq!(rope.slice_line(6).to_string(), ""); // out of bounds
/// ```
fn slice_line(&self, row: usize) -> RopeSlice<'_>;
/// Return a slice of rows in the given range (0-based, end exclusive).
///
/// If the range is out of bounds, it will be clamped to the valid range.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r");
/// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文");
/// assert_eq!(rope.slice_lines(2..5).to_string(), "This is a test 中文\nRope");
/// assert_eq!(rope.slice_lines(3..10).to_string(), "Rope");
/// assert_eq!(rope.slice_lines(5..10).to_string(), ""); // out of bounds
/// ```
fn slice_lines(&self, rows_range: Range<usize>) -> RopeSlice<'_>;
/// Return an iterator over all lines in the rope.
///
/// Each line slice includes `\r` if present, but not `\n`.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect();
/// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]);
/// ```
fn iter_lines(&self) -> RopeLines<'_>;
/// Return the number of lines in the rope.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.lines_len(), 4);
/// ```
fn lines_len(&self) -> usize;
/// Return the length of the row (0-based) in characters, including `\r` if present, but not `\n`.
///
/// If the row is out of bounds, return 0.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_len(0), 5); // "Hello"
/// assert_eq!(rope.line_len(1), 6); // "World\r"
/// assert_eq!(rope.line_len(2), 21); // "This is a test 中文"
/// assert_eq!(rope.line_len(4), 0); // out of bounds
/// ```
fn line_len(&self, row: usize) -> usize;
/// Replace the text in the given byte range with new text.
///
/// # Panics
///
/// - If the range is not on char boundary.
/// - If the range is out of bounds.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// rope.replace(6..11, "Universe");
/// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope");
/// ```
fn replace(&mut self, range: Range<usize>, new_text: &str);
/// Get char at the given offset (byte).
///
/// - If the offset is in the middle of a multi-byte character will panic.
/// - If the offset is out of bounds, return None.
fn char_at(&self, offset: usize) -> Option<char>;
/// Get the byte offset from the given line, column [`Position`] (0-based).
///
/// The column is in characters.
fn position_to_offset(&self, line_col: &Position) -> usize;
/// Get the line, column [`Position`] (0-based) from the given byte offset.
///
/// The column is in characters.
fn offset_to_position(&self, offset: usize) -> Position;
/// Get point (row, column) from the given byte offset.
///
/// The column is in bytes.
fn offset_to_point(&self, offset: usize) -> Point;
/// Get byte offset from the given point (row, column).
///
/// The column is 0-based in bytes.
fn point_to_offset(&self, point: Point) -> usize;
/// Get the word byte range at the given byte offset (0-based).
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
/// Get word at the given byte offset (0-based).
fn word_at(&self, offset: usize) -> String;
/// Convert offset in UTF-16 to byte offset (0-based).
///
/// Runs in O(log N) time.
fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize;
/// Convert byte offset (0-based) to offset in UTF-16.
///
/// Runs in O(log N) time.
fn offset_to_offset_utf16(&self, offset: usize) -> usize;
/// Get a clipped offset (avoid in a char boundary).
///
/// - If Bias::Left and inside the char boundary, return the ix - 1;
/// - If Bias::Right and in inside char boundary, return the ix + 1;
/// - Otherwise return the ix.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// use sum_tree::Bias;
///
/// let rope = Rope::from("Hello 中文🎉 test\nRope");
/// assert_eq!(rope.clip_offset(5, Bias::Left), 5);
/// // Inside multi-byte character '中' (3 bytes)
/// assert_eq!(rope.clip_offset(7, Bias::Left), 6);
/// assert_eq!(rope.clip_offset(7, Bias::Right), 9);
/// ```
fn clip_offset(&self, offset: usize, bias: Bias) -> usize;
/// Convert offset in characters to byte offset (0-based).
///
/// Run in O(n) time.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("a 中文🎉 test\nRope");
/// assert_eq!(rope.char_index_to_offset(0), 0);
/// assert_eq!(rope.char_index_to_offset(1), 1);
/// assert_eq!(rope.char_index_to_offset(3), "a 中".len());
/// assert_eq!(rope.char_index_to_offset(5), "a 中文🎉".len());
/// ```
fn char_index_to_offset(&self, char_index: usize) -> usize;
/// Convert byte offset (0-based) to offset in characters.
///
/// Run in O(n) time.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("a 中文🎉 test\nRope");
/// assert_eq!(rope.offset_to_char_index(0), 0);
/// assert_eq!(rope.offset_to_char_index(1), 1);
/// assert_eq!(rope.offset_to_char_index(3), 3);
/// assert_eq!(rope.offset_to_char_index(4), 3);
/// ```
fn offset_to_char_index(&self, offset: usize) -> usize;
}
impl RopeExt for Rope {
fn slice_line(&self, row: usize) -> RopeSlice<'_> {
let total_lines = self.lines_len();
if row >= total_lines {
return self.slice(0..0);
}
let line = self.line(row, LineType::LF);
if line.len() > 0 {
let line_end = line.len() - 1;
if line.is_char_boundary(line_end) && line.char(line_end) == '\n' {
return line.slice(..line_end);
}
}
line
}
fn slice_lines(&self, rows_range: Range<usize>) -> RopeSlice<'_> {
let start = self.line_start_offset(rows_range.start);
let end = self.line_end_offset(rows_range.end.saturating_sub(1));
self.slice(start..end)
}
fn iter_lines(&self) -> RopeLines<'_> {
RopeLines::new(self)
}
fn line_len(&self, row: usize) -> usize {
self.slice_line(row).len()
}
fn line_start_offset(&self, row: usize) -> usize {
self.point_to_offset(Point::new(row, 0))
}
fn offset_to_point(&self, offset: usize) -> Point {
let offset = self.clip_offset(offset, Bias::Left);
let row = self.byte_to_line_idx(offset, LineType::LF);
let line_start = self.line_to_byte_idx(row, LineType::LF);
let column = offset.saturating_sub(line_start);
Point::new(row, column)
}
fn point_to_offset(&self, point: Point) -> usize {
if point.row >= self.lines_len() {
return self.len();
}
let line_start = self.line_to_byte_idx(point.row, LineType::LF);
line_start + point.column
}
fn position_to_offset(&self, pos: &Position) -> usize {
let line = self.slice_line(pos.line as usize);
self.line_start_offset(pos.line as usize)
+ line
.chars()
.take(pos.character as usize)
.map(|c| c.len_utf8())
.sum::<usize>()
}
fn offset_to_position(&self, offset: usize) -> Position {
let point = self.offset_to_point(offset);
let line = self.slice_line(point.row);
let offset = line.utf16_to_byte_idx(line.byte_to_utf16_idx(point.column));
let character = line.slice(..offset).chars().count();
Position::new(point.row as u32, character as u32)
}
fn line_end_offset(&self, row: usize) -> usize {
if row > self.lines_len() {
return self.len();
}
self.line_start_offset(row) + self.line_len(row)
}
fn lines_len(&self) -> usize {
self.len_lines(LineType::LF)
}
fn char_at(&self, offset: usize) -> Option<char> {
if offset > self.len() {
return None;
}
self.get_char(offset).ok()
}
fn word_range(&self, offset: usize) -> Option<Range<usize>> {
if offset >= self.len() {
return None;
}
let mut left = String::new();
let offset = self.clip_offset(offset, Bias::Left);
for c in self.chars_at(offset).reversed() {
if c.is_alphanumeric() || c == '_' {
left.insert(0, c);
} else {
break;
}
}
let start = offset.saturating_sub(left.len());
let right = self
.chars_at(offset)
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>();
let end = offset + right.len();
if start == end { None } else { Some(start..end) }
}
fn word_at(&self, offset: usize) -> String {
if let Some(range) = self.word_range(offset) {
self.slice(range).to_string()
} else {
String::new()
}
}
#[inline]
fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize {
if offset_utf16 > self.len_utf16() {
return self.len();
}
self.utf16_to_byte_idx(offset_utf16)
}
#[inline]
fn offset_to_offset_utf16(&self, offset: usize) -> usize {
if offset > self.len() {
return self.len_utf16();
}
self.byte_to_utf16_idx(offset)
}
fn replace(&mut self, range: Range<usize>, new_text: &str) {
let range =
self.clip_offset(range.start, Bias::Left)..self.clip_offset(range.end, Bias::Right);
self.remove(range.clone());
self.insert(range.start, new_text);
}
fn clip_offset(&self, offset: usize, bias: Bias) -> usize {
if offset > self.len() {
return self.len();
}
if self.is_char_boundary(offset) {
return offset;
}
if bias == Bias::Left {
self.floor_char_boundary(offset)
} else {
self.ceil_char_boundary(offset)
}
}
fn char_index_to_offset(&self, char_offset: usize) -> usize {
self.chars().take(char_offset).map(|c| c.len_utf8()).sum()
}
fn offset_to_char_index(&self, offset: usize) -> usize {
let offset = self.clip_offset(offset, Bias::Right);
self.slice(..offset).chars().count()
}
}
-140
View File
@@ -1,140 +0,0 @@
use std::ops::Range;
use gpui::{Context, Window};
use ropey::Rope;
use sum_tree::Bias;
use crate::input::{InputState, RopeExt};
impl InputState {
/// Select the word at the given offset on double-click.
///
/// The offset is the UTF-8 offset.
pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let Some(range) = TextSelector::word_range(&self.text, offset) else {
return;
};
self.selected_range = (range.start..range.end).into();
self.selected_word_range = Some(self.selected_range);
cx.notify()
}
/// Select the line at the given offset on triple-click.
///
/// The offset is the UTF-8 offset.
pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let range = TextSelector::line_range(&self.text, offset);
self.selected_range = (range.start..range.end).into();
self.selected_word_range = None;
cx.notify()
}
}
struct TextSelector;
impl TextSelector {
/// Select a line in the given text at the specified offset.
///
/// The offset is the UTF-8 offset.
///
/// Returns the start and end offsets of the selected line.
pub fn line_range(text: &Rope, offset: usize) -> Range<usize> {
let offset = text.clip_offset(offset, Bias::Left);
let row = text.offset_to_point(offset).row;
let start = text.line_start_offset(row);
let end = text.line_end_offset(row);
start..end
}
/// Select a word in the given text at the specified offset.
///
/// The offset is the UTF-8 offset.
///
/// Returns the start and end offsets of the selected word.
pub fn word_range(text: &Rope, offset: usize) -> Option<Range<usize>> {
let offset = text.clip_offset(offset, Bias::Left);
let char = text.char_at(offset)?;
let end = offset + char.len_utf8();
let prev_chars = text.chars_at(offset).reversed().take(128);
let next_chars = text.chars_at(end).take(128);
Some(word_range_from_chars(offset, char, prev_chars, next_chars))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CharType {
/// a-z, A-Z, 0-9, _
Word,
/// '\t', ' ', '\u{00A0}' etc.
Whitespace,
/// \n, \r
Newline,
/// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `🎉` etc.
Other,
}
impl From<char> for CharType {
fn from(c: char) -> Self {
match c {
c if is_word_char(c) => CharType::Word,
c if c == '\n' || c == '\r' => CharType::Newline,
c if c.is_whitespace() => CharType::Whitespace,
_ => CharType::Other,
}
}
}
impl CharType {
fn is_connectable(self, c: char) -> bool {
matches!(
(self, CharType::from(c)),
(CharType::Word, CharType::Word) | (CharType::Whitespace, CharType::Whitespace)
)
}
}
fn is_word_char(c: char) -> bool {
matches!(c, '_')
// ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
|| c.is_ascii_alphanumeric()
// Latin script in Unicode for French, German, Spanish, etc.
|| matches!(c, '\u{00C0}'..='\u{00FF}')
|| matches!(c, '\u{0100}'..='\u{017F}')
|| matches!(c, '\u{0180}'..='\u{024F}')
// Cyrillic for Russian, Ukrainian, etc.
|| matches!(c, '\u{0400}'..='\u{04FF}')
// Vietnamese
|| matches!(c, '\u{1E00}'..='\u{1EFF}')
|| matches!(c, '\u{0300}'..='\u{036F}')
}
pub(crate) fn word_range_from_chars(
offset: usize,
c: char,
prev_chars: impl Iterator<Item = char>,
next_chars: impl Iterator<Item = char>,
) -> Range<usize> {
let char_type = CharType::from(c);
let mut start = offset;
let mut end = offset + c.len_utf8();
for prev in prev_chars.take(128) {
if char_type.is_connectable(prev) {
start -= prev.len_utf8();
} else {
break;
}
}
for next in next_chars.take(128) {
if char_type.is_connectable(next) {
end += next.len_utf8();
} else {
break;
}
}
start..end
}
File diff suppressed because it is too large Load Diff
+3 -16
View File
@@ -1,8 +1,5 @@
pub use element_ext::ElementExt;
pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle;
pub use gpui_base::{ElementExt, IndexPath, InteractiveElementExt};
pub use icon::*;
pub use index_path::IndexPath;
pub use kbd::*;
pub use root::{Root, window_paddings};
pub use styled::*;
@@ -11,18 +8,14 @@ pub use window_ext::*;
pub use crate::Disableable;
pub mod actions;
pub mod animation;
pub mod avatar;
pub mod button;
pub mod checkbox;
pub mod divider;
pub mod dock;
pub mod group_box;
pub mod history;
pub mod indicator;
pub mod input;
pub mod list;
pub mod menu;
pub mod modal;
pub mod notification;
@@ -34,11 +27,7 @@ pub mod switch;
pub mod tab;
pub mod tooltip;
mod element_ext;
mod event;
mod focusable;
mod icon;
mod index_path;
mod kbd;
mod root;
mod styled;
@@ -50,9 +39,7 @@ mod window_ext;
/// This must be called before using any of the UI components.
/// You can initialize the UI module at your application's entry point.
pub fn init(cx: &mut gpui::App) {
input::init(cx);
list::init(cx);
modal::init(cx);
popover::init(cx);
gpui_base::init(cx);
theme::sync_base(cx);
menu::init(cx);
}
-221
View File
@@ -1,221 +0,0 @@
use std::rc::Rc;
use gpui::{App, Pixels, Size};
use crate::IndexPath;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RowEntry {
Entry(IndexPath),
SectionHeader(usize),
SectionFooter(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct MeasuredEntrySize {
pub(crate) item_size: Size<Pixels>,
pub(crate) section_header_size: Size<Pixels>,
pub(crate) section_footer_size: Size<Pixels>,
}
impl RowEntry {
#[inline]
#[allow(unused)]
pub(crate) fn is_section_header(&self) -> bool {
matches!(self, RowEntry::SectionHeader(_))
}
pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool {
match self {
RowEntry::Entry(index_path) => index_path == path,
RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false,
}
}
#[allow(unused)]
pub(crate) fn index(&self) -> IndexPath {
match self {
RowEntry::Entry(index_path) => *index_path,
RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix),
RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix),
}
}
#[inline]
#[allow(unused)]
pub(crate) fn is_section_footer(&self) -> bool {
matches!(self, RowEntry::SectionFooter(_))
}
#[inline]
pub(crate) fn is_entry(&self) -> bool {
matches!(self, RowEntry::Entry(_))
}
#[inline]
#[allow(unused)]
pub(crate) fn section_ix(&self) -> Option<usize> {
match self {
RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix),
_ => None,
}
}
}
#[derive(Default, Clone)]
pub(crate) struct RowsCache {
/// Only have section's that have rows.
pub(crate) entities: Rc<Vec<RowEntry>>,
pub(crate) items_count: usize,
/// The sections, the item is number of rows in each section.
pub(crate) sections: Rc<Vec<usize>>,
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
measured_size: MeasuredEntrySize,
}
impl RowsCache {
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
self.entities.get(flatten_ix).cloned()
}
/// Returns the number of flattened rows (Includes header, item, footer).
pub(crate) fn len(&self) -> usize {
self.entities.len()
}
/// Return the number of items in the cache.
pub(crate) fn items_count(&self) -> usize {
self.items_count
}
/// Returns the index of the Entry with given path in the flattened rows.
pub(crate) fn position_of(&self, path: &IndexPath) -> Option<usize> {
self.entities
.iter()
.position(|p| p.is_entry() && p.eq_index_path(path))
}
/// Return prev row, if the row is the first in the first section, goes to the last row.
///
/// Empty rows section are skipped.
pub(crate) fn prev(&self, path: Option<IndexPath>) -> IndexPath {
let path = path.unwrap_or_default();
let Some(pos) = self.position_of(&path) else {
return self
.entities
.iter()
.rfind(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default();
};
if let Some(path) = self
.entities
.iter()
.take(pos)
.rev()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
{
path
} else {
self.entities
.iter()
.rfind(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default()
}
}
/// Returns the next row, if the row is the last in the last section, goes to the first row.
///
/// Empty rows section are skipped.
pub(crate) fn next(&self, path: Option<IndexPath>) -> IndexPath {
let Some(mut path) = path else {
return IndexPath::default();
};
let Some(pos) = self.position_of(&path) else {
return self
.entities
.iter()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default();
};
if let Some(next_path) = self
.entities
.iter()
.skip(pos + 1)
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
{
path = next_path;
} else {
path = self
.entities
.iter()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default()
}
path
}
pub(crate) fn prepare_if_needed<F>(
&mut self,
sections_count: usize,
measured_size: MeasuredEntrySize,
cx: &App,
rows_count_f: F,
) where
F: Fn(usize, &App) -> usize,
{
let mut new_sections = vec![];
for section_ix in 0..sections_count {
new_sections.push(rows_count_f(section_ix, cx));
}
let need_update = new_sections != *self.sections || self.measured_size != measured_size;
if !need_update {
return;
}
let mut entries_sizes = vec![];
let mut total_items_count = 0;
self.measured_size = measured_size;
self.sections = Rc::new(new_sections);
self.entities = Rc::new(
self.sections
.iter()
.enumerate()
.flat_map(|(section, items_count)| {
total_items_count += items_count;
let mut children = vec![];
if *items_count == 0 {
return children;
}
children.push(RowEntry::SectionHeader(section));
entries_sizes.push(measured_size.section_header_size);
for row in 0..*items_count {
children.push(RowEntry::Entry(IndexPath {
section,
row,
..Default::default()
}));
entries_sizes.push(measured_size.item_size);
}
children.push(RowEntry::SectionFooter(section));
entries_sizes.push(measured_size.section_footer_size);
children
})
.collect(),
);
self.entries_sizes = Rc::new(entries_sizes);
self.items_count = total_items_count;
}
}
-171
View File
@@ -1,171 +0,0 @@
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window};
use theme::ActiveTheme;
use crate::list::loading::Loading;
use crate::list::ListState;
use crate::{h_flex, Icon, IconName, IndexPath, Selectable};
/// A delegate for the List.
#[allow(unused)]
pub trait ListDelegate: Sized + 'static {
type Item: Selectable + IntoElement;
/// When Query Input change, this method will be called.
/// You can perform search here.
fn perform_search(
&mut self,
query: &str,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Task<()> {
Task::ready(())
}
/// Return the number of sections in the list, default is 1.
///
/// Min value is 1.
fn sections_count(&self, cx: &App) -> usize {
1
}
/// Return the number of items in the section at the given index.
///
/// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items,
/// the section header and footer will also be skipped.
fn items_count(&self, section: usize, cx: &App) -> usize;
/// Render the item at the given index.
///
/// Return None will skip the item.
///
/// NOTE: Every item should have same height.
fn render_item(
&mut self,
ix: IndexPath,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item>;
/// Render the section header at the given index, default is None.
///
/// NOTE: Every header should have same height.
fn render_section_header(
&mut self,
section: usize,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Render the section footer at the given index, default is None.
///
/// NOTE: Every footer should have same height.
fn render_section_footer(
&mut self,
section: usize,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Return a Element to show when list is empty.
fn render_empty(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_color(cx.theme().text_muted.opacity(0.6))
.child(Icon::new(IconName::Inbox).size_12())
.into_any_element()
}
/// Returns Some(AnyElement) to render the initial state of the list.
///
/// This can be used to show a view for the list before the user has
/// interacted with it.
///
/// For example: The last search results, or the last selected item.
///
/// Default is None, that means no initial state.
fn render_initial(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<AnyElement> {
None
}
/// Returns the loading state to show the loading view.
fn loading(&self, cx: &App) -> bool {
false
}
/// Returns a Element to show when loading, default is built-in Skeleton
/// loading view.
fn render_loading(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
Loading
}
/// Set the selected index, just store the ix, don't confirm.
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
);
/// Set the index of the item that has been right clicked.
fn set_right_clicked_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
}
/// Set the confirm and give the selected index,
/// this is means user have clicked the item or pressed Enter.
///
/// This will always to `set_selected_index` before confirm.
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
}
/// Cancel the selection, e.g.: Pressed ESC.
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
/// Return true to enable load more data when scrolling to the bottom.
///
/// Default: false
fn has_more(&self, cx: &App) -> bool {
false
}
/// Returns a threshold value (n entities), of course,
/// when scrolling to the bottom, the remaining number of rows
/// triggers `load_more`.
///
/// This should smaller than the total number of first load rows.
///
/// Default: 20 entities (section header, footer and row)
fn load_more_threshold(&self) -> usize {
20
}
/// Load more data when the table is scrolled to the bottom.
///
/// This will performed in a background task.
///
/// This is always called when the table is near the bottom,
/// so you must check if there is more data to load or lock
/// the loading state.
fn load_more(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
}
-747
View File
@@ -1,747 +0,0 @@
use std::ops::Range;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::{Input, InputEvent, InputState};
use crate::list::ListDelegate;
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::scroll::{Scrollbar, ScrollbarHandle};
use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex};
pub(crate) fn init(cx: &mut App) {
let context: Option<&str> = Some("List");
cx.bind_keys([
KeyBinding::new("escape", Cancel, context),
KeyBinding::new("enter", Confirm { secondary: false }, context),
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
KeyBinding::new("up", SelectUp, context),
KeyBinding::new("down", SelectDown, context),
]);
}
#[derive(Clone)]
pub enum ListEvent {
/// Move to select item.
Select(IndexPath),
/// Click on item or pressed Enter.
Confirm(IndexPath),
/// Pressed ESC to deselect the item.
Cancel,
}
struct ListOptions {
size: Size,
scrollbar_visible: bool,
search_placeholder: Option<SharedString>,
max_height: Option<Length>,
paddings: EdgesRefinement<DefiniteLength>,
}
impl Default for ListOptions {
fn default() -> Self {
Self {
size: Size::default(),
scrollbar_visible: true,
max_height: None,
search_placeholder: None,
paddings: EdgesRefinement::default(),
}
}
}
/// The state for List.
///
/// List required all items has the same height.
pub struct ListState<D: ListDelegate> {
pub(crate) focus_handle: FocusHandle,
pub(crate) query_input: Entity<InputState>,
options: ListOptions,
delegate: D,
last_query: Option<String>,
scroll_handle: UniformListScrollHandle,
rows_cache: RowsCache,
selected_index: Option<IndexPath>,
item_to_measure_index: IndexPath,
deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>,
mouse_right_clicked_index: Option<IndexPath>,
reset_on_cancel: bool,
searchable: bool,
selectable: bool,
_search_task: Task<()>,
_load_more_task: Task<()>,
_query_input_subscription: Subscription,
}
impl<D> ListState<D>
where
D: ListDelegate,
{
pub fn new(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
let query_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let _query_input_subscription =
cx.subscribe_in(&query_input, window, Self::on_query_input_event);
Self {
focus_handle: cx.focus_handle(),
options: ListOptions::default(),
delegate,
rows_cache: RowsCache::default(),
query_input,
last_query: None,
selected_index: None,
selectable: true,
searchable: false,
item_to_measure_index: IndexPath::default(),
deferred_scroll_to_index: None,
mouse_right_clicked_index: None,
scroll_handle: UniformListScrollHandle::new(),
reset_on_cancel: true,
_search_task: Task::ready(()),
_load_more_task: Task::ready(()),
_query_input_subscription,
}
}
/// Sets whether the list is searchable, default is `false`.
///
/// When `true`, there will be a search input at the top of the list.
pub fn searchable(mut self, searchable: bool) -> Self {
self.searchable = searchable;
self
}
pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context<Self>) {
self.searchable = searchable;
cx.notify();
}
/// Sets whether the list is selectable, default is true.
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
/// Sets whether the list is selectable, default is true.
pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
self.selectable = selectable;
cx.notify();
}
pub fn delegate(&self) -> &D {
&self.delegate
}
pub fn delegate_mut(&mut self) -> &mut D {
&mut self.delegate
}
/// Focus the list, if the list is searchable, focus the search input.
pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
self.focus_handle(cx).focus(window, cx);
}
/// Return true if either the list or the search input is focused.
#[allow(dead_code)]
pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool {
self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window)
}
/// Set the selected index of the list,
/// this will also scroll to the selected item.
pub(crate) fn _set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.selectable {
return;
}
self.selected_index = ix;
self.delegate.set_selected_index(ix, window, cx);
self.scroll_to_selected_item(window, cx);
}
/// Set the selected index of the list,
/// this method will not scroll to the selected item.
pub fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.selected_index = ix;
self.delegate.set_selected_index(ix, window, cx);
}
pub fn selected_index(&self) -> Option<IndexPath> {
self.selected_index
}
/// Set the index of the item that has been right clicked.
pub fn set_right_clicked_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mouse_right_clicked_index = ix;
self.delegate.set_right_clicked_index(ix, window, cx);
}
/// Returns the index of the item that has been right clicked.
pub fn right_clicked_index(&self) -> Option<IndexPath> {
self.mouse_right_clicked_index
}
/// Set a specific list item for measurement.
pub fn set_item_to_measure_index(
&mut self,
ix: IndexPath,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.item_to_measure_index = ix;
cx.notify();
}
/// Scroll to the item at the given index.
pub fn scroll_to_item(
&mut self,
ix: IndexPath,
strategy: ScrollStrategy,
_: &mut Window,
cx: &mut Context<Self>,
) {
if ix.section == 0 && ix.row == 0 {
// If the item is the first item, scroll to the top.
let mut offset = self.scroll_handle.offset();
offset.y = px(0.);
self.scroll_handle.set_offset(offset);
cx.notify();
return;
}
self.deferred_scroll_to_index = Some((ix, strategy));
cx.notify();
}
/// Get scroll handle
pub fn scroll_handle(&self) -> &UniformListScrollHandle {
&self.scroll_handle
}
pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if let Some(ix) = self.selected_index {
self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top));
cx.notify();
}
}
fn on_query_input_event(
&mut self,
state: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
InputEvent::Change => {
let text = state.read(cx).value();
let text = text.trim().to_string();
if Some(&text) == self.last_query.as_ref() {
return;
}
self.set_searching(true, window, cx);
let search = self.delegate.perform_search(&text, window, cx);
if self.rows_cache.len() > 0 {
self._set_selected_index(Some(IndexPath::default()), window, cx);
} else {
self._set_selected_index(None, window, cx);
}
let executor = cx.background_executor().clone();
self._search_task = cx.spawn_in(window, async move |this, window| {
search.await;
_ = this.update_in(window, |this, _, _| {
this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
this.last_query = Some(text);
});
// Always wait 100ms to avoid flicker
executor.timer(Duration::from_millis(100)).await;
_ = this.update_in(window, |this, window, cx| {
this.set_searching(false, window, cx);
});
});
}
InputEvent::PressEnter { secondary, .. } => self.on_action_confirm(
&Confirm {
secondary: *secondary,
},
window,
cx,
),
_ => {}
}
}
fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context<Self>) {
self.query_input
.update(cx, |input, cx| input.set_loading(searching, cx));
}
/// Dispatch delegate's `load_more` method when the
/// visible range is near the end.
fn load_more_if_need(
&mut self,
entities_count: usize,
visible_end: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
// FIXME: Here need void sections items count.
let threshold = self.delegate.load_more_threshold();
// Securely handle subtract logic to prevent attempt
// to subtract with overflow
if visible_end >= entities_count.saturating_sub(threshold) {
if !self.delegate.has_more(cx) {
return;
}
self._load_more_task = cx.spawn_in(window, async move |view, cx| {
_ = view.update_in(cx, |view, window, cx| {
view.delegate.load_more(window, cx);
});
});
}
}
#[allow(dead_code)]
pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self {
self.reset_on_cancel = reset;
self
}
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
cx.propagate();
if self.reset_on_cancel {
self._set_selected_index(None, window, cx);
}
self.delegate.cancel(window, cx);
cx.emit(ListEvent::Cancel);
cx.notify();
}
fn on_action_confirm(
&mut self,
confirm: &Confirm,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.rows_cache.len() == 0 {
return;
}
let Some(ix) = self.selected_index else {
return;
};
self.delegate
.set_selected_index(self.selected_index, window, cx);
self.delegate.confirm(confirm.secondary, window, cx);
cx.emit(ListEvent::Confirm(ix));
cx.notify();
}
fn select_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context<Self>) {
if !self.selectable {
return;
}
self.selected_index = Some(ix);
self.delegate.set_selected_index(Some(ix), window, cx);
self.scroll_to_selected_item(window, cx);
cx.emit(ListEvent::Select(ix));
cx.notify();
}
pub(crate) fn on_action_select_prev(
&mut self,
_: &SelectUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.rows_cache.len() == 0 {
return;
}
let prev_ix = self.rows_cache.prev(self.selected_index);
self.select_item(prev_ix, window, cx);
}
pub(crate) fn on_action_select_next(
&mut self,
_: &SelectDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.rows_cache.len() == 0 {
return;
}
let next_ix = self.rows_cache.next(self.selected_index);
self.select_item(next_ix, window, cx);
}
fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let sections_count = self.delegate.sections_count(cx).max(1);
let mut measured_size = MeasuredEntrySize::default();
// Measure the item_height and section header/footer height.
let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
measured_size.item_size = self
.render_list_item(self.item_to_measure_index, window, cx)
.into_any_element()
.layout_as_root(available_space, window, cx);
if let Some(mut el) = self
.delegate
.render_section_header(0, window, cx)
.map(|r| r.into_any_element())
{
measured_size.section_header_size = el.layout_as_root(available_space, window, cx);
}
if let Some(mut el) = self
.delegate
.render_section_footer(0, window, cx)
.map(|r| r.into_any_element())
{
measured_size.section_footer_size = el.layout_as_root(available_space, window, cx);
}
self.rows_cache
.prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| {
self.delegate.items_count(section_ix, cx)
});
}
fn render_list_item(
&mut self,
ix: IndexPath,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let selectable = self.selectable;
let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false);
let mouse_right_clicked = self
.mouse_right_clicked_index
.map(|s| s.eq_row(ix))
.unwrap_or(false);
let id = SharedString::from(format!("list-item-{}", ix));
div()
.id(id)
.w_full()
.relative()
.overflow_hidden()
.children(self.delegate.render_item(ix, window, cx).map(|item| {
item.selected(selected)
.secondary_selected(mouse_right_clicked)
}))
.when(selectable, |this| {
this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
this.set_right_clicked_index(None, window, cx);
this.selected_index = Some(ix);
this.on_action_confirm(
&Confirm {
secondary: e.modifiers().secondary(),
},
window,
cx,
);
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _, window, cx| {
this.set_right_clicked_index(Some(ix), window, cx);
cx.notify();
}),
)
})
}
fn render_items(
&mut self,
items_count: usize,
entities_count: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let rows_cache = self.rows_cache.clone();
let scrollbar_visible = self.options.scrollbar_visible;
let scroll_handle = self.scroll_handle.clone();
v_flex()
.flex_grow_1()
.relative()
.size_full()
.when_some(self.options.max_height, |this, h| this.max_h(h))
.overflow_hidden()
.when(items_count == 0, |this| {
this.child(self.delegate.render_empty(window, cx))
})
.when(items_count > 0, {
|this| {
this.child(
uniform_list(
"virtual-list",
rows_cache.items_count(),
cx.processor(move |this, range: Range<usize>, window, cx| {
this.load_more_if_need(entities_count, range.end, window, cx);
// NOTE: Here the v_virtual_list would not able to have gap_y,
// because the section header, footer is always have rendered as a empty child item,
// even the delegate give a None result.
range
.map(|ix| {
let Some(entry) = rows_cache.get(ix) else {
return div();
};
div().children(match entry {
RowEntry::Entry(index) => Some(
this.render_list_item(index, window, cx)
.into_any_element(),
),
RowEntry::SectionHeader(section_ix) => this
.delegate_mut()
.render_section_header(section_ix, window, cx)
.map(|r| r.into_any_element()),
RowEntry::SectionFooter(section_ix) => this
.delegate_mut()
.render_section_footer(section_ix, window, cx)
.map(|r| r.into_any_element()),
})
})
.collect::<Vec<_>>()
}),
)
.when(self.options.max_height.is_some(), |this| {
this.with_sizing_behavior(ListSizingBehavior::Infer)
})
.track_scroll(&scroll_handle)
.into_any_element(),
)
}
})
.when(scrollbar_visible, |this| {
this.child(Scrollbar::vertical(&scroll_handle))
})
}
}
impl<D> Focusable for ListState<D>
where
D: ListDelegate,
{
fn focus_handle(&self, cx: &App) -> FocusHandle {
if self.searchable {
self.query_input.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl<D> EventEmitter<ListEvent> for ListState<D> where D: ListDelegate {}
impl<D> Render for ListState<D>
where
D: ListDelegate,
{
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.prepare_items_if_needed(window, cx);
// Scroll to the selected item if it is set.
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
&& let Some(item_ix) = self.rows_cache.position_of(&ix)
{
self.scroll_handle.scroll_to_item(item_ix, strategy);
}
let loading = self.delegate().loading(cx);
let query_input = if self.searchable {
// sync placeholder
if let Some(placeholder) = &self.options.search_placeholder {
self.query_input.update(cx, |input, cx| {
input.set_placeholder(placeholder.clone(), window, cx);
});
}
Some(self.query_input.clone())
} else {
None
};
let loading_view = if loading {
Some(self.delegate.render_loading(window, cx).into_any_element())
} else {
None
};
let initial_view = if let Some(input) = &query_input {
if input.read(cx).value().is_empty() {
self.delegate.render_initial(window, cx)
} else {
None
}
} else {
None
};
let items_count = self.rows_cache.items_count();
let entities_count = self.rows_cache.len();
let mouse_right_clicked_index = self.mouse_right_clicked_index;
v_flex()
.key_context("List")
.id("list-state")
.track_focus(&self.focus_handle)
.size_full()
.relative()
.overflow_hidden()
.when_some(query_input, |this, input| {
this.child(
div()
.map(|this| match self.options.size {
Size::Small => this.px_1p5(),
_ => this.px_2(),
})
.border_b_1()
.border_color(cx.theme().border)
.child(
Input::new(&input)
.with_size(self.options.size)
.appearance(false)
.cleanable(true)
.p_0()
.prefix(
Icon::new(IconName::Search).text_color(cx.theme().text_muted),
),
),
)
})
.when(!loading, |this| {
this.on_action(cx.listener(Self::on_action_cancel))
.on_action(cx.listener(Self::on_action_confirm))
.on_action(cx.listener(Self::on_action_select_next))
.on_action(cx.listener(Self::on_action_select_prev))
.map(|this| {
if let Some(view) = initial_view {
this.child(view)
} else {
this.child(self.render_items(items_count, entities_count, window, cx))
}
})
// Click out to cancel right clicked row
.when(mouse_right_clicked_index.is_some(), |this| {
this.on_mouse_down_out(cx.listener(|this, _, window, cx| {
this.set_right_clicked_index(None, window, cx);
cx.notify();
}))
})
})
.children(loading_view)
}
}
/// The List element.
#[derive(IntoElement)]
pub struct List<D: ListDelegate + 'static> {
state: Entity<ListState<D>>,
style: StyleRefinement,
options: ListOptions,
}
impl<D> List<D>
where
D: ListDelegate + 'static,
{
/// Create a new List element with the given ListState entity.
pub fn new(state: &Entity<ListState<D>>) -> Self {
Self {
state: state.clone(),
style: StyleRefinement::default(),
options: ListOptions::default(),
}
}
/// Set whether the scrollbar is visible, default is `true`.
pub fn scrollbar_visible(mut self, visible: bool) -> Self {
self.options.scrollbar_visible = visible;
self
}
/// Sets the placeholder text for the search input.
pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.options.search_placeholder = Some(placeholder.into());
self
}
}
impl<D> Styled for List<D>
where
D: ListDelegate + 'static,
{
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl<D> Sizable for List<D>
where
D: ListDelegate + 'static,
{
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.options.size = size.into();
self
}
}
impl<D> RenderOnce for List<D>
where
D: ListDelegate + 'static,
{
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
// Take paddings, max_height to options, and clear them from style,
// because they would be applied to the inner virtual list.
self.options.paddings = self.style.padding.clone();
self.options.max_height = self.style.max_size.height;
self.style.padding = EdgesRefinement::default();
self.style.max_size.height = None;
self.state.update(cx, |state, _| {
state.options = self.options;
});
div()
.id("list")
.size_full()
.refine_style(&self.style)
.child(self.state.clone())
}
}
-226
View File
@@ -1,226 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ListItemMode {
#[default]
Entry,
Separator,
}
impl ListItemMode {
#[inline]
fn is_separator(&self) -> bool {
matches!(self, ListItemMode::Separator)
}
}
#[derive(IntoElement)]
pub struct ListItem {
base: Stateful<Div>,
mode: ListItemMode,
style: StyleRefinement,
disabled: bool,
selected: bool,
secondary_selected: bool,
confirmed: bool,
check_icon: Option<Icon>,
#[allow(clippy::type_complexity)]
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
mode: ListItemMode::Entry,
base: h_flex().id(id),
style: StyleRefinement::default(),
disabled: false,
selected: false,
secondary_selected: false,
confirmed: false,
on_click: None,
on_mouse_enter: None,
check_icon: None,
suffix: None,
children: SmallVec::new(),
}
}
/// Set this list item to as a separator, it not able to be selected.
pub fn separator(mut self) -> Self {
self.mode = ListItemMode::Separator;
self
}
/// Set to show check icon, default is None.
pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
self.check_icon = Some(icon.into());
self
}
/// Set ListItem as the selected item style.
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set ListItem as the confirmed item style, it will show a check icon.
pub fn confirmed(mut self, confirmed: bool) -> Self {
self.confirmed = confirmed;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the suffix element of the input field, for example a clear button.
pub fn suffix<F, E>(mut self, builder: F) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.suffix = Some(Box::new(move |window, cx| {
builder(window, cx).into_any_element()
}));
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_mouse_enter(
mut self,
handler: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_mouse_enter = Some(Box::new(handler));
self
}
}
impl Disableable for ListItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for ListItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
fn secondary_selected(mut self, selected: bool) -> Self {
self.secondary_selected = selected;
self
}
}
impl Styled for ListItem {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for ListItem {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ListItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_active = self.confirmed || self.selected;
let corner_radii = self.style.corner_radii.clone();
let _selected_style = StyleRefinement {
corner_radii,
..Default::default()
};
let is_selectable = !(self.disabled || self.mode.is_separator());
self.base
.relative()
.gap_x_1()
.py_1()
.px_3()
.text_base()
.text_color(cx.theme().text)
.relative()
.items_center()
.justify_between()
.refine_style(&self.style)
.when(is_selectable, |this| {
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
})
.when(!is_active, |this| {
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
})
.when(!is_selectable, |this| {
this.text_color(cx.theme().text_muted)
})
.child(
h_flex()
.w_full()
.items_center()
.justify_between()
.gap_x_1()
.child(div().w_full().children(self.children))
.when_some(self.check_icon, |this, icon| {
this.child(
div()
.w_5()
.items_center()
.justify_center()
.when(self.confirmed, |this| {
this.child(icon.small().text_color(cx.theme().text_muted))
}),
)
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
.map(|this| {
if is_selectable && (self.selected || self.secondary_selected) {
let bg = if self.selected {
cx.theme().ghost_element_active
} else {
cx.theme().ghost_element_background
};
this.bg(bg)
} else {
this
}
})
}
}
-34
View File
@@ -1,34 +0,0 @@
use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled};
use super::ListItem;
use crate::skeleton::Skeleton;
use crate::v_flex;
#[derive(IntoElement)]
pub struct Loading;
#[derive(IntoElement)]
struct LoadingItem;
impl RenderOnce for LoadingItem {
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement {
ListItem::new("skeleton").disabled(true).child(
v_flex()
.gap_1p5()
.overflow_hidden()
.child(Skeleton::new().h_5().w_48().max_w_full())
.child(Skeleton::new().secondary().h_3().w_64().max_w_full()),
)
}
}
impl RenderOnce for Loading {
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement {
v_flex()
.py_2p5()
.gap_3()
.child(LoadingItem)
.child(LoadingItem)
.child(LoadingItem)
}
}
-28
View File
@@ -1,28 +0,0 @@
pub(crate) mod cache;
mod delegate;
#[allow(clippy::module_inception)]
mod list;
mod list_item;
mod loading;
mod separator_item;
pub use delegate::*;
pub use list::*;
pub use list_item::*;
pub use separator_item::*;
use serde::{Deserialize, Serialize};
/// Settings for List.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSettings {
/// Whether to use active highlight style on ListItem, default
pub active_highlight: bool,
}
impl Default for ListSettings {
fn default() -> Self {
Self {
active_highlight: true,
}
}
}

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