Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa86fef16f | ||
|
|
bbc69c9861 | ||
|
|
a249326c28 | ||
|
|
61289edb9a | ||
|
|
a365a8f584 | ||
|
|
7c42466b0a | ||
|
|
291471075b | ||
|
|
b4ce9368e2 | ||
|
|
05f934c93a | ||
|
|
f8032b2074 | ||
|
|
f1192492d2 | ||
|
|
440d6dfea3 | ||
|
|
4c279a0686 | ||
|
|
f83922df2e | ||
|
|
4b6f4b3797 | ||
|
|
602a91b4f0 | ||
|
|
4ef764984b | ||
|
|
193df36dae | ||
|
|
09fb58c5a4 | ||
|
|
fff0f6a753 | ||
|
|
33785f183d | ||
|
|
3ada21b728 | ||
|
|
e1eff61856 | ||
|
|
2bce050c3b | ||
|
|
2a404591d8 | ||
|
|
c544dd4e36 | ||
|
|
acb07126c9 | ||
|
|
2fc1358530 | ||
|
|
e14b815404 | ||
|
|
88d3840fd2 | ||
|
|
1f350f511e | ||
|
|
21f96c7bbe | ||
|
|
be15873e0d | ||
|
|
e4e13b0889 | ||
|
|
1032ac3751 | ||
|
|
6ff1eeddbb | ||
|
|
0c1c23fe9c | ||
|
|
874f103752 | ||
|
|
fdabfcf026 | ||
|
|
5500082426 | ||
|
|
7a34d66c7c | ||
|
|
57c85f5b99 | ||
|
|
bf7080654d | ||
|
|
42983383b0 | ||
|
|
e2b10c173e | ||
|
|
9e33da717b | ||
|
|
584ab34df6 | ||
|
|
0514c1d982 | ||
|
|
2f834a0bcc | ||
|
|
49cd5cb9a0 | ||
|
|
d082f4fad9 | ||
|
|
8c59eabba3 | ||
|
|
bc6bdb3c35 | ||
|
|
dbfee32d55 | ||
|
|
4f52fc52df | ||
|
|
fbf06f2c81 | ||
|
|
b0d1521c49 | ||
|
|
9dbac5308d | ||
|
|
c8cabb7cd2 | ||
|
|
6d9284b37a |
@@ -152,11 +152,23 @@ jobs:
|
||||
echo "Artifacts structure:"
|
||||
find artifacts -type f -exec ls -la {} \;
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||
# this to verify a download before installing it, so it must be
|
||||
# published alongside every release. Written outside the directory
|
||||
# being hashed so the checksums file never includes itself.
|
||||
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||
cat artifacts/SHA256SUMS
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
server_url: "https://git.reya.su/"
|
||||
server_url: "https://git.reya.info/"
|
||||
repository: "reya/coop"
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
draft: true
|
||||
|
||||
@@ -19,8 +19,8 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
.cargo/
|
||||
vendor/
|
||||
wasm/
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# 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.
|
||||
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
|
||||
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `GlobalState::global(cx)` inline.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- 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)`.
|
||||
|
||||
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
@@ -4,36 +4,49 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta5"
|
||||
version = "2.0.0-alpha"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
gpui-base = "0.7.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ], branch = "send-bound" }
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
data-encoding = "2"
|
||||
hkdf = "0.12"
|
||||
# Pinned to the instances `nostr` already builds: the NIP-44 message-key disclosure
|
||||
chacha20 = "0.9"
|
||||
hmac = "0.12"
|
||||
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
||||
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -42,12 +55,8 @@ schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
instant = "0.1"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
@@ -1,125 +1,5 @@
|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
|
||||
</a>
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
|
||||
</p>
|
||||
|
||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<p float="left">
|
||||
<img src="/docs/mac_01.png" width="250" />
|
||||
<img src="/docs/mac_02.png" width="250" />
|
||||
<img src="/docs/mac_03.png" width="250" />
|
||||
<img src="/docs/mac_04.png" width="250" />
|
||||
<img src="/docs/mac_05.png" width="250" />
|
||||
<img src="/docs/mac_06.png" width="250" />
|
||||
<img src="/docs/mac_07.png" width="250" />
|
||||
<img src="/docs/mac_08.png" width="250" />
|
||||
<img src="/docs/mac_09.png" width="250" />
|
||||
<img src="/docs/linux_01.png" width="250" />
|
||||
<img src="/docs/linux_02.png" width="250" />
|
||||
<img src="/docs/linux_03.png" width="250" />
|
||||
<img src="/docs/linux_04.png" width="250" />
|
||||
<img src="/docs/linux_05.png" width="250" />
|
||||
</p>
|
||||
|
||||
### Installation
|
||||
|
||||
To install Coop, follow these steps:
|
||||
|
||||
1. **Download the Latest Release**:
|
||||
|
||||
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
|
||||
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
||||
|
||||
2. **Install**:
|
||||
|
||||
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
||||
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
|
||||
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
|
||||
|
||||
3. **Run Coop**:
|
||||
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
||||
|
||||
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
|
||||
|
||||
### Developing Coop
|
||||
|
||||
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
|
||||
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
|
||||
- **Git**: To clone the repository and manage version control.
|
||||
|
||||
#### Setting Up the Development Environment
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/reyakov/coop.git
|
||||
cd coop
|
||||
```
|
||||
|
||||
2.1 Install Linux dependencies:
|
||||
|
||||
```bash
|
||||
./script/linux
|
||||
```
|
||||
|
||||
2.2 Install FreeBSD dependencies:
|
||||
|
||||
```bash
|
||||
./script/freebsd
|
||||
```
|
||||
|
||||
3. Install Rust dependencies:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
4. Run the app:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
#### Building for Production
|
||||
|
||||
To build Coop for production, use the following command:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
This will generate an optimized binary in the `target/release` directory.
|
||||
|
||||
#### Contributing Code
|
||||
|
||||
If you'd like to contribute to Coop, please follow these steps:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a new branch for your feature or bugfix.
|
||||
3. Make your changes and ensure all tests pass.
|
||||
4. Submit a pull request with a detailed description of your changes.
|
||||
|
||||
For more information, see the [Contributing](#contributing) section.
|
||||
|
||||
#### Additional Resources
|
||||
|
||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||
- [GPUI](https://www.gpui.rs/)
|
||||
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
||||
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
|
||||
|
||||
### License
|
||||
|
||||
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
||||
|
||||
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C9.44568 21.25 7.13318 20.2147 5.45926 18.5407C3.78534 16.8668 2.75 14.5543 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C14.5543 2.75 16.8668 3.78534 18.5407 5.45926C20.2147 7.13318 21.25 9.44568 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/><path d="M18 6L6 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/> </svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 1014 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M12.75 11.2499V8.24994L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L15.75 11.2499H12.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.25 3.75H6.95C5.8299 3.75 5.26984 3.75 4.84202 3.96799C4.46569 4.15973 4.15973 4.46569 3.96799 4.84202C3.75 5.26984 3.75 5.8299 3.75 6.95V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.8299 20.25 6.95 20.25H17.05C18.1701 20.25 18.7302 20.25 19.158 20.032C19.5343 19.8403 19.8403 19.5343 20.032 19.158C20.25 18.7302 20.25 18.1701 20.25 17.05V12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 857 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 5.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H19.25C20.3546 19.25 21.25 18.3546 21.25 17.25V8.75C21.25 7.64543 20.3546 6.75 19.25 6.75H13.0704C12.4017 6.75 11.7772 6.4158 11.4063 5.8594L10.5937 4.6406C10.2228 4.0842 9.59834 3.75 8.92963 3.75H4.75C3.64543 3.75 2.75 4.64543 2.75 5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8.75 3.75L6.75 20.25M17.25 3.75L15.25 20.25M3.75 7.75H20.25M20.25 16.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M14.75 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25H14.75M15 12H3.75M15 12L11.5 15.5M15 12L11.5 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M20.25 12L9 12M20.25 12L15.75 16.5M20.25 12L15.75 7.5M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25L3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75L11.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM3.5 6.75C3.5 6.05964 4.05964 5.5 4.75 5.5H10.5V18.5H4.75C4.05964 18.5 3.5 17.9404 3.5 17.25V6.75Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 9.5C6.44772 9.5 6 9.05228 6 8.5C6 7.94772 6.44772 7.5 7 7.5C7.55228 7.5 8 7.94772 8 8.5C8 9.05228 7.55228 9.5 7 9.5ZM7 13C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11C7.55228 11 8 11.4477 8 12C8 12.5523 7.55228 13 7 13ZM7 16.5C6.44772 16.5 6 16.0523 6 15.5C6 14.9477 6.44772 14.5 7 14.5C7.55228 14.5 8 14.9477 8 15.5C8 16.0523 7.55228 16.5 7 16.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 826 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M11.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H11.25M11.25 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H11.25M11.25 4.75V19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M6.25 8.5C6.25 8.91421 6.58579 9.25 7 9.25C7.41421 9.25 7.75 8.91421 7.75 8.5C7.75 8.08579 7.41421 7.75 7 7.75C6.58579 7.75 6.25 8.08579 6.25 8.5ZM6.25 12C6.25 12.4142 6.58579 12.75 7 12.75C7.41421 12.75 7.75 12.4142 7.75 12C7.75 11.5858 7.41421 11.25 7 11.25C6.58579 11.25 6.25 11.5858 6.25 12ZM6.25 15.5C6.25 15.9142 6.58579 16.25 7 16.25C7.41421 16.25 7.75 15.9142 7.75 15.5C7.75 15.0858 7.41421 14.75 7 14.75C6.58579 14.75 6.25 15.0858 6.25 15.5Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 931 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 624 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 849 B |
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "aurora",
|
||||
"name": "Aurora",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"light": {
|
||||
"background": "#fdfcfeff",
|
||||
"surface_background": "#f8f8ffff",
|
||||
"elevated_surface_background": "#f0f1feff",
|
||||
"panel_background": "#fdfcfeff",
|
||||
"overlay": "#211f4300",
|
||||
"title_bar": "#f0f1feff",
|
||||
"title_bar_inactive": "#fdfcfeff",
|
||||
"window_border": "#dadcffff",
|
||||
"border": "#dadcffff",
|
||||
"border_variant": "#cbcdffff",
|
||||
"border_focused": "#5b5bd6ff",
|
||||
"border_selected": "#5b5bd6ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e6e7ffff",
|
||||
"ring": "#5151cdff",
|
||||
"text": "#1f2d5cff",
|
||||
"text_muted": "#5753c6ff",
|
||||
"text_placeholder": "#9b9ef0ff",
|
||||
"text_accent": "#5b5bd6ff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#5753c6ff",
|
||||
"icon_muted": "#9b9ef0ff",
|
||||
"icon_accent": "#5151cdff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#5b5bd6ff",
|
||||
"element_hover": "#5151cdff",
|
||||
"element_active": "#6e56cfff",
|
||||
"element_selected": "#654dc4ff",
|
||||
"element_disabled": "#5b5bd64d",
|
||||
"secondary_foreground": "#1f2d5cff",
|
||||
"secondary_background": "#f0f1feff",
|
||||
"secondary_hover": "#e6e7ffff",
|
||||
"secondary_active": "#dadcffff",
|
||||
"secondary_selected": "#dadcffff",
|
||||
"secondary_disabled": "#5b5bd64d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f0f1feff",
|
||||
"ghost_element_hover": "#211f430d",
|
||||
"ghost_element_active": "#211f431a",
|
||||
"ghost_element_selected": "#211f431a",
|
||||
"ghost_element_disabled": "#211f4305",
|
||||
"tab_background": "#f0f1feff",
|
||||
"tab_foreground": "#5753c6ff",
|
||||
"tab_hover_background": "#211f430d",
|
||||
"tab_active_background": "#fdfcfeff",
|
||||
"tab_active_foreground": "#1f2d5cff",
|
||||
"scrollbar_thumb_background": "#211f431a",
|
||||
"scrollbar_thumb_hover_background": "#211f4326",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#5b5bd61a",
|
||||
"cursor": "#5b5bd6ff",
|
||||
"selection": "#5b5bd640"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#14121fff",
|
||||
"surface_background": "#1b1525ff",
|
||||
"elevated_surface_background": "#291f43ff",
|
||||
"panel_background": "#14121fff",
|
||||
"overlay": "#baa7ff1a",
|
||||
"title_bar": "#291f43ff",
|
||||
"title_bar_inactive": "#14121fff",
|
||||
"window_border": "#473876ff",
|
||||
"border": "#473876ff",
|
||||
"border_variant": "#3c2e69ff",
|
||||
"border_focused": "#7d66d9ff",
|
||||
"border_selected": "#7d66d9ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#33255bff",
|
||||
"ring": "#6e56cfff",
|
||||
"text": "#e2ddfeff",
|
||||
"text_muted": "#baa7ffff",
|
||||
"text_placeholder": "#6958adff",
|
||||
"text_accent": "#baa7ffff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#baa7ffff",
|
||||
"icon_muted": "#6958adff",
|
||||
"icon_accent": "#6e56cfff",
|
||||
"element_foreground": "#14121fff",
|
||||
"element_background": "#7d66d9ff",
|
||||
"element_hover": "#baa7ffff",
|
||||
"element_active": "#6e56cfff",
|
||||
"element_selected": "#654dc4ff",
|
||||
"element_disabled": "#7d66d94d",
|
||||
"secondary_foreground": "#e2ddfeff",
|
||||
"secondary_background": "#291f43ff",
|
||||
"secondary_hover": "#33255bff",
|
||||
"secondary_active": "#3c2e69ff",
|
||||
"secondary_selected": "#3c2e69ff",
|
||||
"secondary_disabled": "#7d66d94d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#291f43ff",
|
||||
"ghost_element_hover": "#baa7ff0d",
|
||||
"ghost_element_active": "#baa7ff1a",
|
||||
"ghost_element_selected": "#baa7ff1a",
|
||||
"ghost_element_disabled": "#baa7ff05",
|
||||
"tab_background": "#291f43ff",
|
||||
"tab_foreground": "#baa7ffff",
|
||||
"tab_hover_background": "#baa7ff0d",
|
||||
"tab_active_background": "#14121fff",
|
||||
"tab_active_foreground": "#e2ddfeff",
|
||||
"scrollbar_thumb_background": "#baa7ff1a",
|
||||
"scrollbar_thumb_hover_background": "#baa7ff26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#baa7ff1a",
|
||||
"cursor": "#baa7ffff",
|
||||
"selection": "#baa7ff40"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "catppuccin-frappe",
|
||||
"name": "Catppuccin Frappé",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#232634",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#51576d",
|
||||
"border": "#51576d",
|
||||
"border_variant": "#414559",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#c6d0f500",
|
||||
"border_disabled": "#292c3c",
|
||||
"ring": "#babbf1",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#a5adce",
|
||||
"text_placeholder": "#838ba7",
|
||||
"text_accent": "#8caaee",
|
||||
"text_danger": "#e78284",
|
||||
"text_warning": "#ef9f76",
|
||||
"icon": "#a5adce",
|
||||
"icon_muted": "#838ba7",
|
||||
"icon_accent": "#babbf1",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#babbf1",
|
||||
"element_active": "#99d1db",
|
||||
"element_selected": "#85c1dc",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#c6d0f5",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#51576d",
|
||||
"secondary_active": "#626880",
|
||||
"secondary_selected": "#626880",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#ea999c",
|
||||
"danger_active": "#ef9f76",
|
||||
"danger_selected": "#e5c890",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#ef9f76",
|
||||
"warning_hover": "#e5c890",
|
||||
"warning_active": "#a6d189",
|
||||
"warning_selected": "#81c8be",
|
||||
"warning_disabled": "#ef9f764d",
|
||||
"ghost_element_background": "#c6d0f500",
|
||||
"ghost_element_background_alt": "#292c3c",
|
||||
"ghost_element_hover": "#c6d0f50d",
|
||||
"ghost_element_active": "#c6d0f51a",
|
||||
"ghost_element_selected": "#c6d0f51a",
|
||||
"ghost_element_disabled": "#c6d0f505",
|
||||
"tab_background": "#232634",
|
||||
"tab_foreground": "#a5adce",
|
||||
"tab_hover_background": "#c6d0f50d",
|
||||
"tab_active_background": "#303446",
|
||||
"tab_active_foreground": "#c6d0f5",
|
||||
"scrollbar_thumb_background": "#c6d0f51a",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||
"scrollbar_thumb_border": "#c6d0f500",
|
||||
"scrollbar_track_background": "#c6d0f500",
|
||||
"scrollbar_track_border": "#c6d0f500",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#8caaee",
|
||||
"selection": "#8caaee40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#232634",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#51576d",
|
||||
"border": "#51576d",
|
||||
"border_variant": "#414559",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#c6d0f500",
|
||||
"border_disabled": "#292c3c",
|
||||
"ring": "#babbf1",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#a5adce",
|
||||
"text_placeholder": "#838ba7",
|
||||
"text_accent": "#8caaee",
|
||||
"text_danger": "#e78284",
|
||||
"text_warning": "#ef9f76",
|
||||
"icon": "#a5adce",
|
||||
"icon_muted": "#838ba7",
|
||||
"icon_accent": "#babbf1",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#babbf1",
|
||||
"element_active": "#99d1db",
|
||||
"element_selected": "#85c1dc",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#c6d0f5",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#51576d",
|
||||
"secondary_active": "#626880",
|
||||
"secondary_selected": "#626880",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#ea999c",
|
||||
"danger_active": "#ef9f76",
|
||||
"danger_selected": "#e5c890",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#ef9f76",
|
||||
"warning_hover": "#e5c890",
|
||||
"warning_active": "#a6d189",
|
||||
"warning_selected": "#81c8be",
|
||||
"warning_disabled": "#ef9f764d",
|
||||
"ghost_element_background": "#c6d0f500",
|
||||
"ghost_element_background_alt": "#292c3c",
|
||||
"ghost_element_hover": "#c6d0f50d",
|
||||
"ghost_element_active": "#c6d0f51a",
|
||||
"ghost_element_selected": "#c6d0f51a",
|
||||
"ghost_element_disabled": "#c6d0f505",
|
||||
"tab_background": "#232634",
|
||||
"tab_foreground": "#a5adce",
|
||||
"tab_hover_background": "#c6d0f50d",
|
||||
"tab_active_background": "#303446",
|
||||
"tab_active_foreground": "#c6d0f5",
|
||||
"scrollbar_thumb_background": "#c6d0f51a",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||
"scrollbar_thumb_border": "#c6d0f500",
|
||||
"scrollbar_track_background": "#c6d0f500",
|
||||
"scrollbar_track_border": "#c6d0f500",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#8caaee",
|
||||
"selection": "#8caaee40"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "catppuccin-latte",
|
||||
"name": "Catppuccin Latte",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#dce0e8",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#bcc0cc",
|
||||
"border": "#bcc0cc",
|
||||
"border_variant": "#ccd0da",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#4c4f6900",
|
||||
"border_disabled": "#e6e9ef",
|
||||
"ring": "#7287fd",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#6c6f85",
|
||||
"text_placeholder": "#8c8fa1",
|
||||
"text_accent": "#1e66f5",
|
||||
"text_danger": "#d20f39",
|
||||
"text_warning": "#fe640b",
|
||||
"icon": "#6c6f85",
|
||||
"icon_muted": "#8c8fa1",
|
||||
"icon_accent": "#7287fd",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#7287fd",
|
||||
"element_active": "#04a5e5",
|
||||
"element_selected": "#209fb5",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#4c4f69",
|
||||
"secondary_background": "#ccd0da",
|
||||
"secondary_hover": "#bcc0cc",
|
||||
"secondary_active": "#acb0be",
|
||||
"secondary_selected": "#acb0be",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#e64553",
|
||||
"danger_active": "#fe640b",
|
||||
"danger_selected": "#df8e1d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#eff1f5",
|
||||
"warning_background": "#fe640b",
|
||||
"warning_hover": "#df8e1d",
|
||||
"warning_active": "#40a02b",
|
||||
"warning_selected": "#179299",
|
||||
"warning_disabled": "#fe640b4d",
|
||||
"ghost_element_background": "#4c4f6900",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f690d",
|
||||
"ghost_element_active": "#4c4f691a",
|
||||
"ghost_element_selected": "#4c4f691a",
|
||||
"ghost_element_disabled": "#4c4f6905",
|
||||
"tab_background": "#e6e9ef",
|
||||
"tab_foreground": "#6c6f85",
|
||||
"tab_hover_background": "#4c4f690d",
|
||||
"tab_active_background": "#eff1f5",
|
||||
"tab_active_foreground": "#4c4f69",
|
||||
"scrollbar_thumb_background": "#4c4f691a",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||
"scrollbar_thumb_border": "#4c4f6900",
|
||||
"scrollbar_track_background": "#4c4f6900",
|
||||
"scrollbar_track_border": "#4c4f6900",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#1e66f5",
|
||||
"selection": "#1e66f540"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#dce0e8",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#bcc0cc",
|
||||
"border": "#bcc0cc",
|
||||
"border_variant": "#ccd0da",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#4c4f6900",
|
||||
"border_disabled": "#e6e9ef",
|
||||
"ring": "#7287fd",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#6c6f85",
|
||||
"text_placeholder": "#8c8fa1",
|
||||
"text_accent": "#1e66f5",
|
||||
"text_danger": "#d20f39",
|
||||
"text_warning": "#fe640b",
|
||||
"icon": "#6c6f85",
|
||||
"icon_muted": "#8c8fa1",
|
||||
"icon_accent": "#7287fd",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#7287fd",
|
||||
"element_active": "#04a5e5",
|
||||
"element_selected": "#209fb5",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#4c4f69",
|
||||
"secondary_background": "#ccd0da",
|
||||
"secondary_hover": "#bcc0cc",
|
||||
"secondary_active": "#acb0be",
|
||||
"secondary_selected": "#acb0be",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#e64553",
|
||||
"danger_active": "#fe640b",
|
||||
"danger_selected": "#df8e1d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#eff1f5",
|
||||
"warning_background": "#fe640b",
|
||||
"warning_hover": "#df8e1d",
|
||||
"warning_active": "#40a02b",
|
||||
"warning_selected": "#179299",
|
||||
"warning_disabled": "#fe640b4d",
|
||||
"ghost_element_background": "#4c4f6900",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f690d",
|
||||
"ghost_element_active": "#4c4f691a",
|
||||
"ghost_element_selected": "#4c4f691a",
|
||||
"ghost_element_disabled": "#4c4f6905",
|
||||
"tab_background": "#e6e9ef",
|
||||
"tab_foreground": "#6c6f85",
|
||||
"tab_hover_background": "#4c4f690d",
|
||||
"tab_active_background": "#eff1f5",
|
||||
"tab_active_foreground": "#4c4f69",
|
||||
"scrollbar_thumb_background": "#4c4f691a",
|
||||
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||
"scrollbar_thumb_border": "#4c4f6900",
|
||||
"scrollbar_track_background": "#4c4f6900",
|
||||
"scrollbar_track_border": "#4c4f6900",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#1e66f5",
|
||||
"selection": "#1e66f540"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "catppuccin-macchiato",
|
||||
"name": "Catppuccin Macchiato",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#181926",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#494d64",
|
||||
"border": "#494d64",
|
||||
"border_variant": "#363a4f",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#cad3f500",
|
||||
"border_disabled": "#1e2030",
|
||||
"ring": "#b7bdf8",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#a5adcb",
|
||||
"text_placeholder": "#8087a2",
|
||||
"text_accent": "#8aadf4",
|
||||
"text_danger": "#ed8796",
|
||||
"text_warning": "#f5a97f",
|
||||
"icon": "#a5adcb",
|
||||
"icon_muted": "#8087a2",
|
||||
"icon_accent": "#b7bdf8",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#b7bdf8",
|
||||
"element_active": "#91d7e3",
|
||||
"element_selected": "#7dc4e4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#cad3f5",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#494d64",
|
||||
"secondary_active": "#5b6078",
|
||||
"secondary_selected": "#5b6078",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ee99a0",
|
||||
"danger_active": "#f5a97f",
|
||||
"danger_selected": "#eed49f",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#f5a97f",
|
||||
"warning_hover": "#eed49f",
|
||||
"warning_active": "#a6da95",
|
||||
"warning_selected": "#8bd5ca",
|
||||
"warning_disabled": "#f5a97f4d",
|
||||
"ghost_element_background": "#cad3f500",
|
||||
"ghost_element_background_alt": "#1e2030",
|
||||
"ghost_element_hover": "#cad3f50d",
|
||||
"ghost_element_active": "#cad3f51a",
|
||||
"ghost_element_selected": "#cad3f51a",
|
||||
"ghost_element_disabled": "#cad3f505",
|
||||
"tab_background": "#181926",
|
||||
"tab_foreground": "#a5adcb",
|
||||
"tab_hover_background": "#cad3f50d",
|
||||
"tab_active_background": "#24273a",
|
||||
"tab_active_foreground": "#cad3f5",
|
||||
"scrollbar_thumb_background": "#cad3f51a",
|
||||
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||
"scrollbar_thumb_border": "#cad3f500",
|
||||
"scrollbar_track_background": "#cad3f500",
|
||||
"scrollbar_track_border": "#cad3f500",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#8aadf4",
|
||||
"selection": "#8aadf440"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#181926",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#494d64",
|
||||
"border": "#494d64",
|
||||
"border_variant": "#363a4f",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#cad3f500",
|
||||
"border_disabled": "#1e2030",
|
||||
"ring": "#b7bdf8",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#a5adcb",
|
||||
"text_placeholder": "#8087a2",
|
||||
"text_accent": "#8aadf4",
|
||||
"text_danger": "#ed8796",
|
||||
"text_warning": "#f5a97f",
|
||||
"icon": "#a5adcb",
|
||||
"icon_muted": "#8087a2",
|
||||
"icon_accent": "#b7bdf8",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#b7bdf8",
|
||||
"element_active": "#91d7e3",
|
||||
"element_selected": "#7dc4e4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#cad3f5",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#494d64",
|
||||
"secondary_active": "#5b6078",
|
||||
"secondary_selected": "#5b6078",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ee99a0",
|
||||
"danger_active": "#f5a97f",
|
||||
"danger_selected": "#eed49f",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#f5a97f",
|
||||
"warning_hover": "#eed49f",
|
||||
"warning_active": "#a6da95",
|
||||
"warning_selected": "#8bd5ca",
|
||||
"warning_disabled": "#f5a97f4d",
|
||||
"ghost_element_background": "#cad3f500",
|
||||
"ghost_element_background_alt": "#1e2030",
|
||||
"ghost_element_hover": "#cad3f50d",
|
||||
"ghost_element_active": "#cad3f51a",
|
||||
"ghost_element_selected": "#cad3f51a",
|
||||
"ghost_element_disabled": "#cad3f505",
|
||||
"tab_background": "#181926",
|
||||
"tab_foreground": "#a5adcb",
|
||||
"tab_hover_background": "#cad3f50d",
|
||||
"tab_active_background": "#24273a",
|
||||
"tab_active_foreground": "#cad3f5",
|
||||
"scrollbar_thumb_background": "#cad3f51a",
|
||||
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||
"scrollbar_thumb_border": "#cad3f500",
|
||||
"scrollbar_track_background": "#cad3f500",
|
||||
"scrollbar_track_border": "#cad3f500",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#8aadf4",
|
||||
"selection": "#8aadf440"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "catppuccin-mocha",
|
||||
"name": "Catppuccin Mocha",
|
||||
"author": "Catppuccin Org (ported by Coop)",
|
||||
"url": "https://catppuccin.com",
|
||||
"light": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#11111b",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#45475a",
|
||||
"border": "#45475a",
|
||||
"border_variant": "#313244",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#cdd6f400",
|
||||
"border_disabled": "#181825",
|
||||
"ring": "#b4befe",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#a6adc8",
|
||||
"text_placeholder": "#7f849c",
|
||||
"text_accent": "#89b4fa",
|
||||
"text_danger": "#f38ba8",
|
||||
"text_warning": "#fab387",
|
||||
"icon": "#a6adc8",
|
||||
"icon_muted": "#7f849c",
|
||||
"icon_accent": "#b4befe",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#b4befe",
|
||||
"element_active": "#89dceb",
|
||||
"element_selected": "#74c7ec",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#cdd6f4",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#45475a",
|
||||
"secondary_active": "#585b70",
|
||||
"secondary_selected": "#585b70",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#eba0ac",
|
||||
"danger_active": "#fab387",
|
||||
"danger_selected": "#f9e2af",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#fab387",
|
||||
"warning_hover": "#f9e2af",
|
||||
"warning_active": "#a6e3a1",
|
||||
"warning_selected": "#94e2d5",
|
||||
"warning_disabled": "#fab3874d",
|
||||
"ghost_element_background": "#cdd6f400",
|
||||
"ghost_element_background_alt": "#181825",
|
||||
"ghost_element_hover": "#cdd6f40d",
|
||||
"ghost_element_active": "#cdd6f41a",
|
||||
"ghost_element_selected": "#cdd6f41a",
|
||||
"ghost_element_disabled": "#cdd6f405",
|
||||
"tab_background": "#11111b",
|
||||
"tab_foreground": "#a6adc8",
|
||||
"tab_hover_background": "#cdd6f40d",
|
||||
"tab_active_background": "#1e1e2e",
|
||||
"tab_active_foreground": "#cdd6f4",
|
||||
"scrollbar_thumb_background": "#cdd6f41a",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||
"scrollbar_thumb_border": "#cdd6f400",
|
||||
"scrollbar_track_background": "#cdd6f400",
|
||||
"scrollbar_track_border": "#cdd6f400",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#89b4fa",
|
||||
"selection": "#89b4fa40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#11111b",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#45475a",
|
||||
"border": "#45475a",
|
||||
"border_variant": "#313244",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#cdd6f400",
|
||||
"border_disabled": "#181825",
|
||||
"ring": "#b4befe",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#a6adc8",
|
||||
"text_placeholder": "#7f849c",
|
||||
"text_accent": "#89b4fa",
|
||||
"text_danger": "#f38ba8",
|
||||
"text_warning": "#fab387",
|
||||
"icon": "#a6adc8",
|
||||
"icon_muted": "#7f849c",
|
||||
"icon_accent": "#b4befe",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#b4befe",
|
||||
"element_active": "#89dceb",
|
||||
"element_selected": "#74c7ec",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#cdd6f4",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#45475a",
|
||||
"secondary_active": "#585b70",
|
||||
"secondary_selected": "#585b70",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#eba0ac",
|
||||
"danger_active": "#fab387",
|
||||
"danger_selected": "#f9e2af",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#fab387",
|
||||
"warning_hover": "#f9e2af",
|
||||
"warning_active": "#a6e3a1",
|
||||
"warning_selected": "#94e2d5",
|
||||
"warning_disabled": "#fab3874d",
|
||||
"ghost_element_background": "#cdd6f400",
|
||||
"ghost_element_background_alt": "#181825",
|
||||
"ghost_element_hover": "#cdd6f40d",
|
||||
"ghost_element_active": "#cdd6f41a",
|
||||
"ghost_element_selected": "#cdd6f41a",
|
||||
"ghost_element_disabled": "#cdd6f405",
|
||||
"tab_background": "#11111b",
|
||||
"tab_foreground": "#a6adc8",
|
||||
"tab_hover_background": "#cdd6f40d",
|
||||
"tab_active_background": "#1e1e2e",
|
||||
"tab_active_foreground": "#cdd6f4",
|
||||
"scrollbar_thumb_background": "#cdd6f41a",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||
"scrollbar_thumb_border": "#cdd6f400",
|
||||
"scrollbar_track_background": "#cdd6f400",
|
||||
"scrollbar_track_border": "#cdd6f400",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#89b4fa",
|
||||
"selection": "#89b4fa40"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "flexoki",
|
||||
"name": "Flexoki",
|
||||
"author": "Steph Ango (ported by Coop)",
|
||||
"url": "https://stephango.com/flexoki",
|
||||
"light": {
|
||||
"background": "#FFFCF0",
|
||||
"surface_background": "#F2F0E5",
|
||||
"elevated_surface_background": "#E6E4D9",
|
||||
"panel_background": "#FFFCF0",
|
||||
"overlay": "#100F0F1A",
|
||||
"title_bar": "#E6E4D9",
|
||||
"title_bar_inactive": "#FFFCF0",
|
||||
"window_border": "#CECDC3",
|
||||
"border": "#CECDC3",
|
||||
"border_variant": "#DAD8CE",
|
||||
"border_focused": "#24837B",
|
||||
"border_selected": "#24837B",
|
||||
"border_transparent": "#100F0F00",
|
||||
"border_disabled": "#E6E4D9",
|
||||
"ring": "#3AA99F",
|
||||
"text": "#100F0F",
|
||||
"text_muted": "#6F6E69",
|
||||
"text_placeholder": "#B7B5AC",
|
||||
"text_accent": "#24837B",
|
||||
"text_danger": "#AF3029",
|
||||
"text_warning": "#BC5215",
|
||||
"icon": "#6F6E69",
|
||||
"icon_muted": "#B7B5AC",
|
||||
"icon_accent": "#3AA99F",
|
||||
"element_foreground": "#FFFCF0",
|
||||
"element_background": "#24837B",
|
||||
"element_hover": "#3AA99F",
|
||||
"element_active": "#1C1B1A",
|
||||
"element_selected": "#100F0F",
|
||||
"element_disabled": "#24837B4D",
|
||||
"secondary_foreground": "#100F0F",
|
||||
"secondary_background": "#E6E4D9",
|
||||
"secondary_hover": "#DAD8CE",
|
||||
"secondary_active": "#CECDC3",
|
||||
"secondary_selected": "#CECDC3",
|
||||
"secondary_disabled": "#24837B4D",
|
||||
"danger_foreground": "#FFFCF0",
|
||||
"danger_background": "#AF3029",
|
||||
"danger_hover": "#D14D41",
|
||||
"danger_active": "#1C1B1A",
|
||||
"danger_selected": "#100F0F",
|
||||
"danger_disabled": "#AF30294D",
|
||||
"warning_foreground": "#FFFCF0",
|
||||
"warning_background": "#BC5215",
|
||||
"warning_hover": "#DA702C",
|
||||
"warning_active": "#1C1B1A",
|
||||
"warning_selected": "#100F0F",
|
||||
"warning_disabled": "#BC52154D",
|
||||
"ghost_element_background": "#100F0F00",
|
||||
"ghost_element_background_alt": "#F2F0E5",
|
||||
"ghost_element_hover": "#100F0F0D",
|
||||
"ghost_element_active": "#100F0F1A",
|
||||
"ghost_element_selected": "#100F0F1A",
|
||||
"ghost_element_disabled": "#100F0F05",
|
||||
"tab_background": "#E6E4D9",
|
||||
"tab_foreground": "#6F6E69",
|
||||
"tab_hover_background": "#100F0F0D",
|
||||
"tab_active_background": "#FFFCF0",
|
||||
"tab_active_foreground": "#100F0F",
|
||||
"scrollbar_thumb_background": "#100F0F1A",
|
||||
"scrollbar_thumb_hover_background": "#100F0F26",
|
||||
"scrollbar_thumb_border": "#100F0F00",
|
||||
"scrollbar_track_background": "#100F0F00",
|
||||
"scrollbar_track_border": "#100F0F00",
|
||||
"drop_target_background": "#24837B1A",
|
||||
"cursor": "#24837B",
|
||||
"selection": "#24837B40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#100F0F",
|
||||
"surface_background": "#1C1B1A",
|
||||
"elevated_surface_background": "#282726",
|
||||
"panel_background": "#100F0F",
|
||||
"overlay": "#FFFCF01A",
|
||||
"title_bar": "#282726",
|
||||
"title_bar_inactive": "#100F0F",
|
||||
"window_border": "#403E3C",
|
||||
"border": "#403E3C",
|
||||
"border_variant": "#343331",
|
||||
"border_focused": "#3AA99F",
|
||||
"border_selected": "#3AA99F",
|
||||
"border_transparent": "#100F0F00",
|
||||
"border_disabled": "#282726",
|
||||
"ring": "#24837B",
|
||||
"text": "#CECDC3",
|
||||
"text_muted": "#878580",
|
||||
"text_placeholder": "#575653",
|
||||
"text_accent": "#3AA99F",
|
||||
"text_danger": "#D14D41",
|
||||
"text_warning": "#DA702C",
|
||||
"icon": "#878580",
|
||||
"icon_muted": "#575653",
|
||||
"icon_accent": "#24837B",
|
||||
"element_foreground": "#100F0F",
|
||||
"element_background": "#3AA99F",
|
||||
"element_hover": "#24837B",
|
||||
"element_active": "#CECDC3",
|
||||
"element_selected": "#F2F0E5",
|
||||
"element_disabled": "#3AA99F4D",
|
||||
"secondary_foreground": "#CECDC3",
|
||||
"secondary_background": "#1C1B1A",
|
||||
"secondary_hover": "#282726",
|
||||
"secondary_active": "#343331",
|
||||
"secondary_selected": "#343331",
|
||||
"secondary_disabled": "#3AA99F4D",
|
||||
"danger_foreground": "#100F0F",
|
||||
"danger_background": "#D14D41",
|
||||
"danger_hover": "#AF3029",
|
||||
"danger_active": "#CECDC3",
|
||||
"danger_selected": "#F2F0E5",
|
||||
"danger_disabled": "#D14D414D",
|
||||
"warning_foreground": "#100F0F",
|
||||
"warning_background": "#DA702C",
|
||||
"warning_hover": "#BC5215",
|
||||
"warning_active": "#CECDC3",
|
||||
"warning_selected": "#F2F0E5",
|
||||
"warning_disabled": "#DA702C4D",
|
||||
"ghost_element_background": "#100F0F00",
|
||||
"ghost_element_background_alt": "#1C1B1A",
|
||||
"ghost_element_hover": "#FFFCF00D",
|
||||
"ghost_element_active": "#FFFCF01A",
|
||||
"ghost_element_selected": "#FFFCF01A",
|
||||
"ghost_element_disabled": "#FFFCF005",
|
||||
"tab_background": "#282726",
|
||||
"tab_foreground": "#878580",
|
||||
"tab_hover_background": "#FFFCF00D",
|
||||
"tab_active_background": "#100F0F",
|
||||
"tab_active_foreground": "#CECDC3",
|
||||
"scrollbar_thumb_background": "#FFFCF01A",
|
||||
"scrollbar_thumb_hover_background": "#FFFCF026",
|
||||
"scrollbar_thumb_border": "#100F0F00",
|
||||
"scrollbar_track_background": "#100F0F00",
|
||||
"scrollbar_track_border": "#100F0F00",
|
||||
"drop_target_background": "#3AA99F1A",
|
||||
"cursor": "#3AA99F",
|
||||
"selection": "#3AA99F40"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "forest",
|
||||
"name": "Forest",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"light": {
|
||||
"background": "#fbfefcff",
|
||||
"surface_background": "#f4fbf6ff",
|
||||
"elevated_surface_background": "#e9f6e9ff",
|
||||
"panel_background": "#fbfefcff",
|
||||
"overlay": "#193b2d1a",
|
||||
"title_bar": "#e9f6e9ff",
|
||||
"title_bar_inactive": "#fbfefcff",
|
||||
"window_border": "#c4e8d1ff",
|
||||
"border": "#c4e8d1ff",
|
||||
"border_variant": "#b2ddb5ff",
|
||||
"border_focused": "#30a46cff",
|
||||
"border_selected": "#30a46cff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e0f3e6ff",
|
||||
"ring": "#2b9a66ff",
|
||||
"text": "#193b2dff",
|
||||
"text_muted": "#2f7c57ff",
|
||||
"text_placeholder": "#8eceaaff",
|
||||
"text_accent": "#30a46cff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#2f7c57ff",
|
||||
"icon_muted": "#8eceaaff",
|
||||
"icon_accent": "#2b9a66ff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#30a46cff",
|
||||
"element_hover": "#2b9a66ff",
|
||||
"element_active": "#2a7e3bff",
|
||||
"element_selected": "#218358ff",
|
||||
"element_disabled": "#30a46c4d",
|
||||
"secondary_foreground": "#193b2dff",
|
||||
"secondary_background": "#e9f6e9ff",
|
||||
"secondary_hover": "#daf1dbff",
|
||||
"secondary_active": "#c4e8d1ff",
|
||||
"secondary_selected": "#c4e8d1ff",
|
||||
"secondary_disabled": "#30a46c4d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e9f6e9ff",
|
||||
"ghost_element_hover": "#193b2d0d",
|
||||
"ghost_element_active": "#193b2d1a",
|
||||
"ghost_element_selected": "#193b2d1a",
|
||||
"ghost_element_disabled": "#193b2d05",
|
||||
"tab_background": "#e9f6e9ff",
|
||||
"tab_foreground": "#2f7c57ff",
|
||||
"tab_hover_background": "#193b2d0d",
|
||||
"tab_active_background": "#fbfefcff",
|
||||
"tab_active_foreground": "#193b2dff",
|
||||
"scrollbar_thumb_background": "#193b2d1a",
|
||||
"scrollbar_thumb_hover_background": "#193b2d26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#30a46c1a",
|
||||
"cursor": "#30a46cff",
|
||||
"selection": "#30a46c40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0e1512ff",
|
||||
"surface_background": "#121b17ff",
|
||||
"elevated_surface_background": "#132d21ff",
|
||||
"panel_background": "#0e1512ff",
|
||||
"overlay": "#b1f1cb1a",
|
||||
"title_bar": "#132d21ff",
|
||||
"title_bar_inactive": "#0e1512ff",
|
||||
"window_border": "#20573eff",
|
||||
"border": "#20573eff",
|
||||
"border_variant": "#174933ff",
|
||||
"border_focused": "#33b074ff",
|
||||
"border_selected": "#33b074ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#113b29ff",
|
||||
"ring": "#33b074ff",
|
||||
"text": "#b1f1cbff",
|
||||
"text_muted": "#71d083ff",
|
||||
"text_placeholder": "#2f7c57ff",
|
||||
"text_accent": "#3dd68cff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#71d083ff",
|
||||
"icon_muted": "#2f7c57ff",
|
||||
"icon_accent": "#33b074ff",
|
||||
"element_foreground": "#0e1512ff",
|
||||
"element_background": "#3dd68cff",
|
||||
"element_hover": "#71d083ff",
|
||||
"element_active": "#33b074ff",
|
||||
"element_selected": "#30a46cff",
|
||||
"element_disabled": "#3dd68c4d",
|
||||
"secondary_foreground": "#b1f1cbff",
|
||||
"secondary_background": "#132d21ff",
|
||||
"secondary_hover": "#113b29ff",
|
||||
"secondary_active": "#174933ff",
|
||||
"secondary_selected": "#174933ff",
|
||||
"secondary_disabled": "#3dd68c4d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#132d21ff",
|
||||
"ghost_element_hover": "#b1f1cb0d",
|
||||
"ghost_element_active": "#b1f1cb1a",
|
||||
"ghost_element_selected": "#b1f1cb1a",
|
||||
"ghost_element_disabled": "#b1f1cb05",
|
||||
"tab_background": "#132d21ff",
|
||||
"tab_foreground": "#71d083ff",
|
||||
"tab_hover_background": "#b1f1cb0d",
|
||||
"tab_active_background": "#0e1512ff",
|
||||
"tab_active_foreground": "#b1f1cbff",
|
||||
"scrollbar_thumb_background": "#b1f1cb1a",
|
||||
"scrollbar_thumb_hover_background": "#b1f1cb26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#3dd68c1a",
|
||||
"cursor": "#3dd68cff",
|
||||
"selection": "#3dd68c40"
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"id": "ocean",
|
||||
"name": "Ocean",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/lumehq/coop",
|
||||
"light": {
|
||||
"background": "#fafefeff",
|
||||
"surface_background": "#f2fbfaff",
|
||||
"elevated_surface_background": "#e6f7f7ff",
|
||||
"panel_background": "#fafefeff",
|
||||
"overlay": "#00333f1a",
|
||||
"title_bar": "#e6f7f7ff",
|
||||
"title_bar_inactive": "#fafefeff",
|
||||
"window_border": "#cce5e9ff",
|
||||
"border": "#cce5e9ff",
|
||||
"border_variant": "#b8dde3ff",
|
||||
"border_focused": "#00a2c7ff",
|
||||
"border_selected": "#00a2c7ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e0f0f2ff",
|
||||
"ring": "#0797b9ff",
|
||||
"text": "#0d3c48ff",
|
||||
"text_muted": "#107d98ff",
|
||||
"text_placeholder": "#60b3d7ff",
|
||||
"text_accent": "#00a2c7ff",
|
||||
"text_danger": "#e54d2eff",
|
||||
"text_warning": "#f76b15ff",
|
||||
"icon": "#107d98ff",
|
||||
"icon_muted": "#60b3d7ff",
|
||||
"icon_accent": "#0797b9ff",
|
||||
"element_foreground": "#ffffffff",
|
||||
"element_background": "#00a2c7ff",
|
||||
"element_hover": "#0797b9ff",
|
||||
"element_active": "#12667eff",
|
||||
"element_selected": "#0d4a5cff",
|
||||
"element_disabled": "#00a2c74d",
|
||||
"secondary_foreground": "#0d4a5cff",
|
||||
"secondary_background": "#ddf9f2ff",
|
||||
"secondary_hover": "#c8f4e9ff",
|
||||
"secondary_active": "#b3ecdeff",
|
||||
"secondary_selected": "#b3ecdeff",
|
||||
"secondary_disabled": "#00a2c74d",
|
||||
"danger_foreground": "#ffffffff",
|
||||
"danger_background": "#feebe7ff",
|
||||
"danger_hover": "#ffcdc2ff",
|
||||
"danger_active": "#fdbdafff",
|
||||
"danger_selected": "#fdbdafff",
|
||||
"danger_disabled": "#e54d2e4d",
|
||||
"warning_foreground": "#ffffffff",
|
||||
"warning_background": "#fff7edff",
|
||||
"warning_hover": "#ffd19aff",
|
||||
"warning_active": "#ffc182ff",
|
||||
"warning_selected": "#ffc182ff",
|
||||
"warning_disabled": "#f76b154d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6f7f7ff",
|
||||
"ghost_element_hover": "#00333f0d",
|
||||
"ghost_element_active": "#00333f1a",
|
||||
"ghost_element_selected": "#00333f1a",
|
||||
"ghost_element_disabled": "#00333f05",
|
||||
"tab_background": "#e6f7f7ff",
|
||||
"tab_foreground": "#107d98ff",
|
||||
"tab_hover_background": "#00333f0d",
|
||||
"tab_active_background": "#fafefeff",
|
||||
"tab_active_foreground": "#0d3c48ff",
|
||||
"scrollbar_thumb_background": "#00333f1a",
|
||||
"scrollbar_thumb_hover_background": "#00333f26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#00a2c71a",
|
||||
"cursor": "#00a2c7ff",
|
||||
"selection": "#00a2c740"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0b161aff",
|
||||
"surface_background": "#101b20ff",
|
||||
"elevated_surface_background": "#082c36ff",
|
||||
"panel_background": "#0b161aff",
|
||||
"overlay": "#c2f3ff1a",
|
||||
"title_bar": "#082c36ff",
|
||||
"title_bar_inactive": "#0b161aff",
|
||||
"window_border": "#1b537bff",
|
||||
"border": "#1b537bff",
|
||||
"border_variant": "#154467ff",
|
||||
"border_focused": "#00a2c7ff",
|
||||
"border_selected": "#00a2c7ff",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#112840ff",
|
||||
"ring": "#23afd0ff",
|
||||
"text": "#b6ecf7ff",
|
||||
"text_muted": "#4ccce6ff",
|
||||
"text_placeholder": "#197caeff",
|
||||
"text_accent": "#7ce2feff",
|
||||
"text_danger": "#ff977dff",
|
||||
"text_warning": "#ffa057ff",
|
||||
"icon": "#4ccce6ff",
|
||||
"icon_muted": "#197caeff",
|
||||
"icon_accent": "#23afd0ff",
|
||||
"element_foreground": "#0b161aff",
|
||||
"element_background": "#7ce2feff",
|
||||
"element_hover": "#a8eeffff",
|
||||
"element_active": "#23afd0ff",
|
||||
"element_selected": "#00a2c7ff",
|
||||
"element_disabled": "#7ce2fe4d",
|
||||
"secondary_foreground": "#adf0ddff",
|
||||
"secondary_background": "#0d2d2aff",
|
||||
"secondary_hover": "#023b37ff",
|
||||
"secondary_active": "#084843ff",
|
||||
"secondary_selected": "#084843ff",
|
||||
"secondary_disabled": "#7ce2fe4d",
|
||||
"danger_foreground": "#181111ff",
|
||||
"danger_background": "#391714ff",
|
||||
"danger_hover": "#5e1c16ff",
|
||||
"danger_active": "#6e2920ff",
|
||||
"danger_selected": "#6e2920ff",
|
||||
"danger_disabled": "#ff977d4d",
|
||||
"warning_foreground": "#17120eff",
|
||||
"warning_background": "#331e0bff",
|
||||
"warning_hover": "#562800ff",
|
||||
"warning_active": "#66350cff",
|
||||
"warning_selected": "#66350cff",
|
||||
"warning_disabled": "#ffa0574d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#082c36ff",
|
||||
"ghost_element_hover": "#c2f3ff0d",
|
||||
"ghost_element_active": "#c2f3ff1a",
|
||||
"ghost_element_selected": "#c2f3ff1a",
|
||||
"ghost_element_disabled": "#c2f3ff05",
|
||||
"tab_background": "#082c36ff",
|
||||
"tab_foreground": "#4ccce6ff",
|
||||
"tab_hover_background": "#c2f3ff0d",
|
||||
"tab_active_background": "#0b161aff",
|
||||
"tab_active_foreground": "#b6ecf7ff",
|
||||
"scrollbar_thumb_background": "#c2f3ff1a",
|
||||
"scrollbar_thumb_hover_background": "#c2f3ff26",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#00000000",
|
||||
"drop_target_background": "#7ce2fe1a",
|
||||
"cursor": "#7ce2feff",
|
||||
"selection": "#7ce2fe40"
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,4 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
@@ -7,7 +7,6 @@ use rust_embed::RustEmbed;
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -30,22 +29,3 @@ impl AssetSource for Assets {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,13 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
gpui-updater-pre = "0.2"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
[dev-dependencies]
|
||||
reqwest_client.workspace = true
|
||||
|
||||
@@ -1,563 +1,193 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
Window,
|
||||
};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::fs::File;
|
||||
use smol::io::AsyncReadExt;
|
||||
use smol::process::Command;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
use gpui_updater_pre::{EngineConfig, UpdateStatus, Updater, Verification, Version};
|
||||
|
||||
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||
|
||||
mod source;
|
||||
|
||||
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
||||
const GITEA_REPO_OWNER: &str = "reya";
|
||||
const GITEA_REPO_NAME: &str = "coop";
|
||||
|
||||
/// Delay before the automatic check that runs on startup.
|
||||
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
||||
/// How long a failure stays visible before the status reverts to "Up to date".
|
||||
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||
|
||||
fn get_github_repo_owner() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||
fn uses_managed_updates() -> bool {
|
||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||
std::env::var("FLATPAK_ID").is_ok()
|
||||
// Allow opting out of in-app updates via an explicit environment variable.
|
||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value | value == "snap")
|
||||
}
|
||||
|
||||
fn get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
// Check if app is installed via Flatpak
|
||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
}
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
// Skip auto-update initialization if installed via Flatpak
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
/// Initialize the auto-update system.
|
||||
pub fn init(cx: &mut App) {
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
||||
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||
|
||||
let Some(filter) = asset_filter_for(os, arch) else {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||
log::error!(
|
||||
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
struct InstallerDir(tempfile::TempDir);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
impl InstallerDir {
|
||||
async fn new() -> Result<Self, Error> {
|
||||
Ok(Self(
|
||||
tempfile::Builder::new()
|
||||
.prefix("coop-auto-update")
|
||||
.tempdir()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct InstallerDir(PathBuf);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl InstallerDir {
|
||||
async fn new() -> Result<Self, Error> {
|
||||
let installer_dir = std::env::current_exe()?
|
||||
.parent()
|
||||
.context("No parent dir for Coop.exe")?
|
||||
.join("updates");
|
||||
|
||||
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
||||
smol::fs::remove_dir_all(&installer_dir).await?;
|
||||
}
|
||||
|
||||
smol::fs::create_dir(&installer_dir).await?;
|
||||
|
||||
Ok(Self(installer_dir))
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.as_path()
|
||||
}
|
||||
}
|
||||
|
||||
struct MacOsUnmounter<'a> {
|
||||
mount_path: PathBuf,
|
||||
background_executor: &'a BackgroundExecutor,
|
||||
}
|
||||
|
||||
impl Drop for MacOsUnmounter<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mount_path = std::mem::take(&mut self.mount_path);
|
||||
|
||||
self.background_executor
|
||||
.spawn(async move {
|
||||
let unmount_output = Command::new("hdiutil")
|
||||
.args(["detach", "-force"])
|
||||
.arg(&mount_path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match unmount_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("Successfully unmounted the disk image");
|
||||
}
|
||||
Ok(output) => {
|
||||
log::error!(
|
||||
"Failed to unmount disk image: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Checked { download_url: String },
|
||||
Installing,
|
||||
Updated,
|
||||
Errored { msg: Box<String> },
|
||||
}
|
||||
|
||||
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
||||
fn as_ref(&self) -> &AutoUpdateStatus {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoUpdateStatus {
|
||||
pub fn is_updating(&self) -> bool {
|
||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||
}
|
||||
|
||||
pub fn is_updated(&self) -> bool {
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(download_url: String) -> Self {
|
||||
Self::Checked { download_url }
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
Self::Errored { msg: Box::new(e) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitHubRelease {
|
||||
pub tag_name: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitHubAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
/// Current status of the auto updater
|
||||
pub status: AutoUpdateStatus,
|
||||
|
||||
/// Current version of the application
|
||||
pub version: Version,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
updater: Entity<Updater>,
|
||||
status: UpdateStatus,
|
||||
/// The pending revert of an `Errored` status back to `Idle`, if any.
|
||||
error_revert: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the global auto updater instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
/// Retrieve the global auto updater instance, if one was initialized.
|
||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalAutoUpdater>()
|
||||
.map(|global| global.0.clone())
|
||||
}
|
||||
|
||||
/// 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![];
|
||||
|
||||
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);
|
||||
}
|
||||
}),
|
||||
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
||||
let source = GiteaSource::new(
|
||||
GITEA_API_BASE,
|
||||
GITEA_REPO_OWNER,
|
||||
GITEA_REPO_NAME,
|
||||
filter,
|
||||
cx.http_client(),
|
||||
);
|
||||
let config = EngineConfig::new(version).verification(Verification::Checksum);
|
||||
let updater = cx.new(|cx| Updater::new(source, config, cx));
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.check(cx);
|
||||
cx.observe(&updater, |this, _, cx| this.observe_status(cx))
|
||||
.detach();
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay
|
||||
let entity = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||
entity.update(cx, |this, cx| this.check(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
version,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
updater,
|
||||
status: UpdateStatus::Idle,
|
||||
error_revert: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
fn observe_status(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.updater.read(cx).status().clone();
|
||||
self.status = status.clone();
|
||||
|
||||
self.error_revert = if matches!(status, UpdateStatus::Errored(_)) {
|
||||
Some(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||
this.update(cx, |this, cx| {
|
||||
if matches!(this.status, UpdateStatus::Errored(_)) {
|
||||
this.status = UpdateStatus::Idle;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// The adapter stops at `Available`; continue into the download.
|
||||
if matches!(status, UpdateStatus::Available(_)) {
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
updater.download_and_install(cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
/// Whether the running version is the newest release, so the status line can be hidden.
|
||||
pub fn up_to_date(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/releases/latest",
|
||||
GITHUB_API_URL, repo_owner, repo_name
|
||||
);
|
||||
|
||||
let async_body = AsyncBody::default();
|
||||
let mut body = Vec::new();
|
||||
let mut response = http_client.get(&url, async_body, false).await?;
|
||||
|
||||
// Read the response body into a vector
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
})
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
pub fn staged(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Staged(_))
|
||||
}
|
||||
|
||||
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();
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
/// Check the release host for a newer version, then download and install it.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
|
||||
self.tasks.push(
|
||||
// Install the new release
|
||||
cx.spawn(async move |this, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||
})?;
|
||||
|
||||
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);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
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);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
self.updater.update(cx, |updater, cx| {
|
||||
updater.restart(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
let from = app_dir.context("No app directory found in archive")?;
|
||||
|
||||
// Copy to the current installation directory
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&from)
|
||||
.arg(
|
||||
running_app_path
|
||||
.parent()
|
||||
.context("No parent directory for app")?,
|
||||
)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app from {:?} to {:?}: {:?}",
|
||||
from,
|
||||
running_app_path.parent(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let system_root = std::env::var("SYSTEMROOT");
|
||||
let powershell_path = system_root.as_ref().map_or_else(
|
||||
|_| "powershell.exe".to_string(),
|
||||
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
);
|
||||
|
||||
let mut installer_path = std::ffi::OsString::new();
|
||||
installer_path.push("\"");
|
||||
installer_path.push(&downloaded_installer);
|
||||
installer_path.push("\"");
|
||||
|
||||
let output = Command::new(powershell_path)
|
||||
//.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||
.args(["Start-Process"])
|
||||
.arg(installer_path)
|
||||
.arg("-ArgumentList")
|
||||
.args(["/P", "/R"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to start installer: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::AsyncReadExt;
|
||||
use futures::executor::block_on;
|
||||
use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, RedirectPolicy, Request};
|
||||
use gpui_updater_pre::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||
const RELEASE_PAGE_SIZE: usize = 20;
|
||||
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||
/// Deadline for the complete metadata request, including its body.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Which published artifact belongs to a target platform.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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 })
|
||||
}
|
||||
|
||||
/// A release as returned by the Gitea API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<GiteaAsset>,
|
||||
}
|
||||
|
||||
/// A release asset as returned by the Gitea API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// Reads releases from a Gitea repository's Releases.
|
||||
pub struct GiteaSource {
|
||||
api_base: String,
|
||||
owner: String,
|
||||
repo: String,
|
||||
filter: AssetFilter,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
}
|
||||
|
||||
impl GiteaSource {
|
||||
/// 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,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
filter,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
fn releases_url(&self) -> String {
|
||||
format!(
|
||||
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
||||
self.api_base, self.owner, self.repo
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
|
||||
let request = Request::get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.follow_redirects(RedirectPolicy::FollowAll)
|
||||
.body(AsyncBody::empty())
|
||||
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||
|
||||
let mut response = self
|
||||
.http_client
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(Error::Http(format!("GET {url} -> {status}")));
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
|
||||
let body = self.get_bytes(url).await?;
|
||||
serde_json::from_slice(&body).map_err(|error| Error::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
async fn get_string(&self, url: &str) -> Result<String> {
|
||||
let body = self.get_bytes(url).await?;
|
||||
String::from_utf8(body).map_err(|error| Error::Parse(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateSource for GiteaSource {
|
||||
fn fetch_latest(&self) -> Result<Release> {
|
||||
let releases: Vec<GiteaRelease> = block_on(self.get_json(&self.releases_url()))?;
|
||||
let 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| block_on(self.get_string(&sums.browser_download_url)))
|
||||
.transpose()?
|
||||
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||
|
||||
let notes = release
|
||||
.body
|
||||
.clone()
|
||||
.filter(|body| !body.trim().is_empty())
|
||||
.or_else(|| release.name.clone());
|
||||
|
||||
let mut update = Release::new(
|
||||
parse_tag(&release.tag_name)?,
|
||||
Asset::new(&asset.name, &asset.browser_download_url, asset.size),
|
||||
);
|
||||
if let Some(notes) = notes {
|
||||
update = update.with_notes(notes);
|
||||
}
|
||||
if let Some(sha256) = sha256 {
|
||||
update = update.with_sha256(sha256);
|
||||
}
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui_updater_pre::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,
|
||||
Arc::new(reqwest_client::ReqwestClient::new()),
|
||||
);
|
||||
|
||||
let release = source
|
||||
.fetch_latest()
|
||||
.expect("release lookup should succeed");
|
||||
|
||||
assert!(
|
||||
release.version >= Version::new(1, 0, 0),
|
||||
"unexpected version {}",
|
||||
release.version
|
||||
);
|
||||
assert!(
|
||||
source.filter.matches(&release.asset.name),
|
||||
"unexpected artifact {}",
|
||||
release.asset.name
|
||||
);
|
||||
assert!(
|
||||
release.asset.url.starts_with("https://"),
|
||||
"{} ",
|
||||
release.asset.url
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "browser-signer-proxy"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/nostrdevkit/nostr"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
atomic-destructor = "0.2"
|
||||
event-listener = "5"
|
||||
nostr.workspace = true
|
||||
opaquerr = { version = "0.1", features = ["alloc"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
tracing = { version = "0.1", features = ["std"] }
|
||||
uuid = { version = "1.23", features = ["serde", "v4"] }
|
||||
@@ -0,0 +1,55 @@
|
||||
# browser-signer-proxy
|
||||
|
||||
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
|
||||
|
||||
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
|
||||
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
|
||||
|
||||
## Description
|
||||
|
||||
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
|
||||
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
|
||||
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
|
||||
the browser extension.
|
||||
|
||||
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
|
||||
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use browser_signer_proxy::prelude::*;
|
||||
|
||||
async fn example() -> Result<(), Error> {
|
||||
// Create the proxy with default options (localhost:7400)
|
||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||
|
||||
// Open the proxy URL in a browser
|
||||
webbrowser::open(&proxy.url())?;
|
||||
|
||||
// Start the proxy server
|
||||
proxy.start().await?;
|
||||
|
||||
// Use it as an async Nostr signer
|
||||
let public_key = proxy.get_public_key_async().await?;
|
||||
println!("Connected with public key: {public_key}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Differences from the tokio-based version
|
||||
|
||||
| Feature | tokio (original) | smol (this crate) |
|
||||
|---|---|---|
|
||||
| Async runtime | `tokio` | `smol` |
|
||||
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
|
||||
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
|
||||
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
|
||||
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
|
||||
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
|
||||
| Task spawning | `tokio::spawn` | `smol::spawn` |
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the MIT software license.
|
||||
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coop — Web Signer Proxy</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #F8FF37;
|
||||
--ink: #111111;
|
||||
--ink-soft: #333333;
|
||||
--muted: #666666;
|
||||
--paper: #FFFFFF;
|
||||
--edge: rgba(17, 17, 17, 0.14);
|
||||
--radius-sm: 1rem;
|
||||
--radius-md: 1.5rem;
|
||||
--radius-lg: 2.5rem;
|
||||
--green: #2E8B57;
|
||||
--red: #D32F2F;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--brand);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--paper);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.logo__mark {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: var(--ink);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.2rem;
|
||||
color: var(--brand);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo__text {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 1.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: background 300ms ease, color 300ms ease;
|
||||
}
|
||||
|
||||
.status--checking {
|
||||
background: rgba(17, 17, 17, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status--connected {
|
||||
background: rgba(46, 139, 87, 0.1);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status--error {
|
||||
background: rgba(211, 47, 47, 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status__dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status--checking .status__dot {
|
||||
background: var(--muted);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status--connected .status__dot {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.status--error .status__dot {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--edge);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint strong {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 class="heading">Web Signer</h1>
|
||||
|
||||
<p class="subtitle">
|
||||
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
|
||||
</p>
|
||||
|
||||
<div id="nip07-status" class="status status--checking">
|
||||
<div class="status__dot"></div>
|
||||
<span id="nip07-status-text">Checking extension…</span>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="proxy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
let isPolling = false;
|
||||
|
||||
async function pollForRequests() {
|
||||
if (isPolling) return;
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pending');
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Polled for requests, got:', data);
|
||||
|
||||
// Process any new requests
|
||||
if (data.requests && data.requests.length > 0) {
|
||||
console.log(`Processing ${data.requests.length} requests`);
|
||||
for (const request of data.requests) {
|
||||
await handleNip07Request(request);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
updateStatus('Error: ' + error.message, 'error');
|
||||
}
|
||||
|
||||
isPolling = false;
|
||||
}
|
||||
|
||||
async function handleNip07Request(request) {
|
||||
console.log('Handling request:', request);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('NIP-07 extension not available');
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'get_public_key':
|
||||
console.log('Calling nostr.getPublicKey()');
|
||||
result = await window.nostr.getPublicKey();
|
||||
console.log('Got public key:', result);
|
||||
break;
|
||||
|
||||
case 'sign_event':
|
||||
console.log('Calling nostr.signEvent() with:', request.params);
|
||||
result = await window.nostr.signEvent(request.params);
|
||||
console.log('Got signed event:', result);
|
||||
break;
|
||||
|
||||
case 'nip04_encrypt':
|
||||
console.log('Calling nostr.nip04.encrypt()');
|
||||
result = await window.nostr.nip04.encrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip04_decrypt':
|
||||
console.log('Calling nostr.nip04.decrypt()');
|
||||
result = await window.nostr.nip04.decrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip44_encrypt':
|
||||
console.log('Calling nostr.nip44.encrypt()');
|
||||
result = await window.nostr.nip44.encrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip44_decrypt':
|
||||
console.log('Calling nostr.nip44.decrypt()');
|
||||
result = await window.nostr.nip44.decrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown method: ${request.method}`);
|
||||
}
|
||||
|
||||
// Send response back to server
|
||||
const responsePayload = {
|
||||
id: request.id,
|
||||
result: result,
|
||||
error: null
|
||||
};
|
||||
|
||||
console.log('Sending response:', responsePayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(responsePayload)
|
||||
});
|
||||
|
||||
console.log('Response sent successfully');
|
||||
updateStatus('Request processed successfully', 'connected');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
|
||||
// Send error response back to server
|
||||
const errorPayload = {
|
||||
id: request.id,
|
||||
result: null,
|
||||
error: error.message
|
||||
};
|
||||
|
||||
console.log('Sending error response:', errorPayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(errorPayload)
|
||||
});
|
||||
|
||||
updateStatus('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(message, state) {
|
||||
const container = document.getElementById('nip07-status');
|
||||
const textEl = document.getElementById('nip07-status-text');
|
||||
if (container && textEl) {
|
||||
container.className = 'status status--' + state;
|
||||
textEl.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling when page loads
|
||||
window.addEventListener('load', () => {
|
||||
console.log('NIP-07 Proxy loaded');
|
||||
|
||||
// Check if NIP-07 extension is available
|
||||
if (window.nostr) {
|
||||
console.log('NIP-07 extension detected');
|
||||
updateStatus('Connected — ready', 'connected');
|
||||
} else {
|
||||
console.log('NIP-07 extension not found');
|
||||
updateStatus('No NIP-07 extension found', 'error');
|
||||
}
|
||||
|
||||
// Start polling every 500 ms
|
||||
setInterval(pollForRequests, 500);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||
// Distributed under the MIT software license
|
||||
|
||||
//! Error types for the browser signer proxy.
|
||||
|
||||
opaquerr::define_kind! {
|
||||
/// Nostr browser signer proxy error kind.
|
||||
pub ErrorKind {
|
||||
/// Nostr protocol error.
|
||||
Protocol => "nostr protocol error",
|
||||
/// I/O error.
|
||||
IO => "I/O error",
|
||||
/// JSON error.
|
||||
Json => "JSON error",
|
||||
/// The operation timed out.
|
||||
Timeout => "timeout",
|
||||
/// The operation cannot be completed in the current state.
|
||||
State => "invalid state",
|
||||
/// Anything not covered by the stable categories above.
|
||||
Other => "other error",
|
||||
}
|
||||
}
|
||||
|
||||
opaquerr::define_error! {
|
||||
/// Nostr browser signer proxy error.
|
||||
pub Error(ErrorKind)
|
||||
|
||||
from {
|
||||
nostr::error::Error => ErrorKind::Protocol,
|
||||
std::io::Error => ErrorKind::IO,
|
||||
serde_json::Error => ErrorKind::Json,
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn generic<S>(message: S) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
Self::new(ErrorKind::Other, message.into())
|
||||
}
|
||||
|
||||
pub(crate) fn timeout() -> Self {
|
||||
Self::simple(ErrorKind::Timeout)
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown() -> Self {
|
||||
Self::with_static_message(ErrorKind::State, "server is shutdown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::net::{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,
|
||||
Nip44Encrypt,
|
||||
Nip44Decrypt,
|
||||
}
|
||||
|
||||
impl RequestMethod {
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GetPublicKey => "get_public_key",
|
||||
Self::SignEvent => "sign_event",
|
||||
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 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)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 _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 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::*;
|
||||
@@ -7,19 +7,22 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
nip4e = { path = "../nip4e" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
rand.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
fuzzy-matcher = "0.3.7"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use std::hash::Hash;
|
||||
use std::ops::Range;
|
||||
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||
const NEVENT_PREFIX: &str = "nevent1";
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -19,63 +25,96 @@ pub struct Message {
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<Mention>,
|
||||
/// Custom emoji (NIP-30) declared by this message's `emoji` tags
|
||||
pub emojis: Vec<(String, Url)>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
/// Encrypted file attachment
|
||||
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);
|
||||
let emojis = emojis_from_tags(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,
|
||||
emojis,
|
||||
replies_to,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,35 +144,74 @@ 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();
|
||||
}
|
||||
|
||||
// Media-only messages carry their URLs outside `.content`
|
||||
if self.content.is_empty() && !self.media.is_empty() {
|
||||
return self
|
||||
.media
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.into();
|
||||
}
|
||||
|
||||
let references = nevent_refs(&self.content);
|
||||
if references.is_empty() {
|
||||
return self.content.clone().into();
|
||||
}
|
||||
|
||||
let mut preview = String::with_capacity(self.content.len());
|
||||
let mut last = 0;
|
||||
|
||||
for reference in references {
|
||||
preview.push_str(&self.content[last..reference.range.start]);
|
||||
preview.push_str(&reference.label);
|
||||
last = reference.range.end;
|
||||
}
|
||||
preview.push_str(&self.content[last..]);
|
||||
|
||||
preview.into()
|
||||
}
|
||||
|
||||
/// Image URL of a `:shortcode:` reaction, if the shortcode is declared
|
||||
pub fn reaction_image(content: &str, emojis: &[(String, Url)]) -> Option<Url> {
|
||||
let shortcode = content.strip_prefix(':')?.strip_suffix(':')?;
|
||||
let (_, url) = emojis.iter().find(|(name, _)| name == shortcode)?;
|
||||
Some(url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
pub room: u64,
|
||||
pub gift_wrap: EventId,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
pub fn new(_gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
let room = rumor.uniq_id();
|
||||
|
||||
Self {
|
||||
room,
|
||||
gift_wrap,
|
||||
rumor,
|
||||
}
|
||||
Self { room, rumor }
|
||||
}
|
||||
}
|
||||
|
||||
/// Trash message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FailedMessage {
|
||||
pub(crate) struct FailedMessage {
|
||||
pub raw_event: SharedString,
|
||||
pub reason: SharedString,
|
||||
}
|
||||
|
||||
impl FailedMessage {
|
||||
pub fn new<T>(event: &Event, reason: T) -> Self
|
||||
pub(crate) fn new<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
@@ -151,7 +229,7 @@ pub struct Mention {
|
||||
}
|
||||
|
||||
impl Mention {
|
||||
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
pub(crate) fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||
Self { public_key, range }
|
||||
}
|
||||
}
|
||||
@@ -170,6 +248,61 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `nevent` reference found in a content string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NeventRef {
|
||||
/// Range of the reference, including the optional `nostr:` scheme
|
||||
pub range: Range<usize>,
|
||||
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
||||
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
||||
let mut references = Vec::new();
|
||||
|
||||
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
||||
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
||||
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
||||
match_start - SCHEME_WITH_COLON.len()
|
||||
} else {
|
||||
match_start
|
||||
};
|
||||
|
||||
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
||||
if content[..start].ends_with(char::is_alphanumeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = match_start
|
||||
+ content[match_start..]
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_alphanumeric)
|
||||
.count();
|
||||
|
||||
// The bech32 token excludes the optional `nostr:` scheme
|
||||
let bech32 = &content[match_start..end];
|
||||
if Nip19Event::from_bech32(bech32).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
references.push(NeventRef {
|
||||
range: start..end,
|
||||
label: nevent_label(bech32),
|
||||
});
|
||||
}
|
||||
|
||||
references
|
||||
}
|
||||
|
||||
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
||||
fn nevent_label(bech32: &str) -> String {
|
||||
match bech32.len() {
|
||||
0..=4 => format!("https://njump.me/{bech32}"),
|
||||
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all reply (ids) from the event tags.
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
let mut replies_to = vec![];
|
||||
@@ -188,3 +321,78 @@ fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
|
||||
replies_to
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rumor(kind: Kind, content: &str, tags: Tags) -> UnsignedEvent {
|
||||
let sender = Keys::generate();
|
||||
|
||||
let mut rumor = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender.public_key());
|
||||
rumor.ensure_id();
|
||||
rumor
|
||||
}
|
||||
|
||||
fn emoji_tag(shortcode: &str, url: &str) -> Tag {
|
||||
Tag::parse(vec!["emoji", shortcode, url]).expect("valid emoji tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_parses_emoji_tags() {
|
||||
let tags = Tags::from_list(vec![emoji_tag(
|
||||
"soapbox",
|
||||
"https://example.com/soapbox.png",
|
||||
)]);
|
||||
|
||||
let message = Message::from(&rumor(Kind::PrivateDirectMessage, "hi :soapbox:", tags));
|
||||
|
||||
assert_eq!(message.emojis.len(), 1);
|
||||
assert_eq!(message.emojis[0].0, "soapbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaction_image_matches_declared_shortcode() {
|
||||
let emojis = vec![(
|
||||
String::from("soapbox"),
|
||||
Url::parse("https://example.com/soapbox.png").unwrap(),
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
Message::reaction_image(":soapbox:", &emojis),
|
||||
Some(Url::parse("https://example.com/soapbox.png").unwrap())
|
||||
);
|
||||
assert_eq!(Message::reaction_image("😂", &emojis), None);
|
||||
assert_eq!(Message::reaction_image(":unknown:", &emojis), None);
|
||||
}
|
||||
|
||||
fn valid_nevent() -> String {
|
||||
Nip19Event::new(EventId::from_byte_array([7; 32]))
|
||||
.to_bech32()
|
||||
.expect("valid nevent")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_only_messages_preview_their_url() {
|
||||
let url = "https://example.com/picture.png";
|
||||
let message = Message::from(&rumor(Kind::PrivateDirectMessage, url, Tags::new()));
|
||||
|
||||
assert!(message.content.is_empty());
|
||||
assert_eq!(message.preview().to_string(), url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_compacts_nevent_references() {
|
||||
let nevent = valid_nevent();
|
||||
let message = Message::from(&rumor(
|
||||
Kind::PrivateDirectMessage,
|
||||
&format!("see nostr:{nevent}"),
|
||||
Tags::new(),
|
||||
));
|
||||
|
||||
let label = format!("https://njump.me/nevent1...{}", &nevent[nevent.len() - 4..]);
|
||||
assert_eq!(message.preview().to_string(), format!("see {label}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,54 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use device::DeviceRegistry;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task};
|
||||
use instant::Duration;
|
||||
use itertools::Itertools;
|
||||
use nip4e::DeviceRegistry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use person::{Directory, Person};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::NewMessage;
|
||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
|
||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
|
||||
|
||||
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
/// The receiver the rumor was addressed to.
|
||||
pub receiver: PublicKey,
|
||||
pub gift_wrap_id: Option<EventId>,
|
||||
pub error: Option<SharedString>,
|
||||
pub output: Option<Output<EventId, EventSendStatus>>,
|
||||
/// The per-relay send output, or the error that prevented the send.
|
||||
result: Result<Output<EventId, EventSendStatus>, SharedString>,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
gift_wrap_id: None,
|
||||
error: None,
|
||||
output: None,
|
||||
}
|
||||
/// The error that prevented the send, if any.
|
||||
pub fn error(&self) -> Option<&SharedString> {
|
||||
self.result.as_ref().err()
|
||||
}
|
||||
|
||||
/// Set the gift wrap ID.
|
||||
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
|
||||
self.gift_wrap_id = Some(gift_wrap_id);
|
||||
self
|
||||
/// The per-relay send output, if the gift wrap was dispatched.
|
||||
pub fn output(&self) -> Option<&Output<EventId, EventSendStatus>> {
|
||||
self.result.as_ref().ok()
|
||||
}
|
||||
|
||||
/// Set the output.
|
||||
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
|
||||
self.output = Some(output);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the error message.
|
||||
pub fn error<T>(mut self, error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns true if the send is pending.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.error.is_none()
|
||||
&& self
|
||||
.output
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send was successful.
|
||||
/// Whether at least one relay accepted the event.
|
||||
pub fn success(&self) -> bool {
|
||||
self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
|
||||
self.output()
|
||||
.is_some_and(|output| !output.success.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the send failed.
|
||||
/// Whether no relay accepted the event.
|
||||
pub fn failed(&self) -> bool {
|
||||
self.error.is_some() && self.output.as_ref().is_some_and(|o| !o.failed.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SendStatus {
|
||||
Ok {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
},
|
||||
Failed {
|
||||
id: EventId,
|
||||
relay: RelayUrl,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SendStatus {
|
||||
pub fn ok(id: EventId, relay: RelayUrl) -> Self {
|
||||
Self::Ok { id, relay }
|
||||
}
|
||||
|
||||
pub fn failed(id: EventId, relay: RelayUrl, message: String) -> Self {
|
||||
Self::Failed { id, relay, message }
|
||||
match &self.result {
|
||||
Err(_) => true,
|
||||
Ok(output) => output.success.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +78,7 @@ pub struct Room {
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
pub(crate) subject: Option<SharedString>,
|
||||
|
||||
/// All members of the room
|
||||
pub(super) members: Vec<PublicKey>,
|
||||
@@ -135,6 +88,9 @@ pub struct Room {
|
||||
|
||||
/// Configuration
|
||||
config: RoomConfig,
|
||||
|
||||
/// Person directory, attached when the room is registered
|
||||
directory: Option<Entity<Directory>>,
|
||||
}
|
||||
|
||||
impl Ord for Room {
|
||||
@@ -183,6 +139,7 @@ impl From<&UnsignedEvent> for Room {
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
config: RoomConfig::new(),
|
||||
directory: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,13 +190,16 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
|
||||
pub(crate) fn set_created_at<T>(&mut self, created_at: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<Timestamp>,
|
||||
{
|
||||
self.created_at = created_at.into();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -271,8 +231,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)
|
||||
@@ -280,6 +240,32 @@ impl Room {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// The room's person directory, attached when the room is registered
|
||||
pub fn directory(&self) -> Option<Entity<Directory>> {
|
||||
self.directory.clone()
|
||||
}
|
||||
|
||||
/// Attach the person directory, tracking the room's members
|
||||
pub(crate) fn ensure_directory(&mut self, cx: &mut Context<Self>) {
|
||||
if self.directory.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
let members = self.members.clone();
|
||||
directory.update(cx, |directory, cx| directory.track(members, cx));
|
||||
self.directory = Some(directory);
|
||||
}
|
||||
|
||||
/// The person for a room member
|
||||
fn member(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
let Some(directory) = self.directory.as_ref() else {
|
||||
return Person::new(*public_key, Metadata::default());
|
||||
};
|
||||
|
||||
directory.read(cx).person(public_key, cx)
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(value) = self.subject.clone() {
|
||||
@@ -289,12 +275,21 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar()
|
||||
/// Gets the display picture for the room, if it has one
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if self.is_group() {
|
||||
None
|
||||
} else {
|
||||
SharedString::from("brand/group.png")
|
||||
self.display_member(cx).avatar()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable seed for the room's generated avatar
|
||||
pub fn display_image_seed(&self, cx: &App) -> String {
|
||||
if self.is_group() {
|
||||
self.id.to_string()
|
||||
} else {
|
||||
self.display_member(cx).avatar_seed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,19 +297,16 @@ impl Room {
|
||||
///
|
||||
/// Display member is always different from the current user.
|
||||
pub fn display_member(&self, cx: &App) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.members[0], cx)
|
||||
self.member(&self.members[0], cx)
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
fn merged_name(&self, cx: &App) -> SharedString {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
if self.is_group() {
|
||||
let profiles: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|public_key| persons.read(cx).get(public_key, cx))
|
||||
.map(|public_key| self.member(public_key, cx))
|
||||
.collect();
|
||||
|
||||
let mut name = profiles
|
||||
@@ -335,7 +327,7 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Push a new message to the current room
|
||||
pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
pub(crate) fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let created_at = message.rumor.created_at;
|
||||
let new_message = created_at > self.created_at;
|
||||
|
||||
@@ -347,38 +339,42 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a signal to reload the current room's messages.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomEvent::Reload);
|
||||
}
|
||||
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let members = self.members();
|
||||
let members = self.members().to_vec();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
for public_key in members.into_iter() {
|
||||
let inbox = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
let tasks: Vec<_> = members
|
||||
.into_iter()
|
||||
.map(|public_key| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let inbox = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
|
||||
let announcement = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Custom(10044))
|
||||
.limit(1);
|
||||
let announcement = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Custom(10044))
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to the target
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
client
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.close_on(opts)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for result in futures::future::join_all(tasks).await {
|
||||
result?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -389,12 +385,12 @@ impl Room {
|
||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let conversation_id = self.id.to_string();
|
||||
let room_id = self.id.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
@@ -402,10 +398,6 @@ impl Room {
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||
.filter(|event| {
|
||||
// Only process private direct messages and file messages
|
||||
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
|
||||
})
|
||||
.sorted_by_key(|message| message.created_at)
|
||||
.collect();
|
||||
|
||||
@@ -414,55 +406,36 @@ impl Room {
|
||||
}
|
||||
|
||||
// Construct a rumor event for direct message
|
||||
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
pub fn rumor<S, I>(
|
||||
&self,
|
||||
content: S,
|
||||
replies: I,
|
||||
reaction: bool,
|
||||
cx: &App,
|
||||
) -> Option<UnsignedEvent>
|
||||
where
|
||||
S: Into<String>,
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let kind = Kind::PrivateDirectMessage;
|
||||
let kind = if reaction {
|
||||
Kind::Reaction
|
||||
} else {
|
||||
Kind::PrivateDirectMessage
|
||||
};
|
||||
|
||||
let content: String = content.into();
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.collect();
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
// Add subject tag if present
|
||||
if let Some(value) = self.subject.as_ref() {
|
||||
tags.push(Tag::custom("subject", vec![value.to_string()]));
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
for id in replies.into_iter() {
|
||||
tags.push(Tag::event(id))
|
||||
}
|
||||
|
||||
// Add all receiver tags
|
||||
for member in members.into_iter() {
|
||||
tags.push(
|
||||
Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}
|
||||
.to_tag(),
|
||||
);
|
||||
}
|
||||
|
||||
// Construct a direct message rumor event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(self.member(&sender, cx).emoji_tags(&content));
|
||||
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
@@ -473,7 +446,60 @@ impl Room {
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
// 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> {
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
// Add subject tag if present
|
||||
if let Some(value) = self.subject.as_ref() {
|
||||
tags.push(Tag::custom("subject", vec![value.to_string()]));
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
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 = self.member(public_key, cx);
|
||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_hint: member.messaging_relay_hint(),
|
||||
}));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// Send the rumor to every member's messaging relays.
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let config = self.config.clone();
|
||||
|
||||
@@ -486,106 +512,47 @@ impl Room {
|
||||
let current_user = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get sender's profile
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let sender = persons.read(cx).get(¤t_user, cx);
|
||||
let sender = self.member(¤t_user, cx);
|
||||
|
||||
// Get all members (excluding sender)
|
||||
let members: Vec<Person> = self
|
||||
let receivers: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender.public_key())
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.map(|member| self.member(member, cx))
|
||||
.collect();
|
||||
|
||||
Some(cx.background_spawn(async move {
|
||||
let signer_kind = config.signer_kind();
|
||||
let backup = config.backup();
|
||||
|
||||
let mut sents = 0;
|
||||
let mut reports = Vec::new();
|
||||
// Deliver to every receiver concurrently
|
||||
let sends = receivers.into_iter().map(|receiver| {
|
||||
deliver(
|
||||
&client,
|
||||
signer_kind,
|
||||
receiver,
|
||||
&rumor,
|
||||
encryption_signer.as_ref(),
|
||||
&user_signer,
|
||||
)
|
||||
});
|
||||
|
||||
// Process each member
|
||||
for member in members {
|
||||
let announcement = member.announcement();
|
||||
let public_key = member.public_key();
|
||||
let mut reports: Vec<SendReport> = futures::future::join_all(sends).await;
|
||||
let minimum_success = reports.iter().any(SendReport::success);
|
||||
|
||||
// Handle encryption signer requirements
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if announcement.is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||
Ok(report) => {
|
||||
reports.push(report);
|
||||
sents += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send backup to current user if needed
|
||||
if backup && sents >= 1 {
|
||||
let public_key = sender.public_key();
|
||||
|
||||
// Determine the signer to use
|
||||
let signer = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if sender.announcement().is_some()
|
||||
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||
{
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
// Safe to unwrap due to earlier checks
|
||||
encryption_signer.as_ref().unwrap().clone()
|
||||
}
|
||||
SignerKind::User => user_signer.clone(),
|
||||
};
|
||||
|
||||
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
let report = SendReport::new(public_key).error(error.to_string());
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
// Back up the message to ourselves once at least one receiver got it
|
||||
if config.backup() && minimum_success {
|
||||
reports.push(
|
||||
deliver(
|
||||
&client,
|
||||
signer_kind,
|
||||
sender,
|
||||
&rumor,
|
||||
encryption_signer.as_ref(),
|
||||
&user_signer,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
reports
|
||||
@@ -593,55 +560,151 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to send a gift-wrapped event
|
||||
async fn send_gift_wrap(
|
||||
/// Deliver a rumor to a single receiver.
|
||||
async fn deliver<S>(
|
||||
client: &Client,
|
||||
signer_kind: &SignerKind,
|
||||
receiver: Person,
|
||||
rumor: &UnsignedEvent,
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> SendReport
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
let public_key = receiver.public_key();
|
||||
let announcement = receiver.announcement();
|
||||
|
||||
if signer_kind.encryption() {
|
||||
// Receiver didn't set up a decoupled encryption key
|
||||
if announcement.is_none() {
|
||||
return SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(NO_DEKEY.into()),
|
||||
};
|
||||
}
|
||||
|
||||
// Sender didn't set up a decoupled encryption key
|
||||
if encryption_signer.is_none() {
|
||||
return SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(USER_NO_DEKEY.into()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match send_gift_wrap(
|
||||
client,
|
||||
&receiver,
|
||||
rumor,
|
||||
signer_kind,
|
||||
encryption_signer,
|
||||
user_signer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Ok(output),
|
||||
},
|
||||
Err(error) => SendReport {
|
||||
receiver: public_key,
|
||||
result: Err(error.to_string().into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the gift-wrapped event for a rumor and send it.
|
||||
async fn send_gift_wrap<S>(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", vec!["14"]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
encryption_signer: Option<&UniversalSigner>,
|
||||
user_signer: &S,
|
||||
) -> Result<Output<EventId, EventSendStatus>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
let mut extra_tags = vec![Tag::custom("k", [rumor.kind.to_string()])];
|
||||
|
||||
// Determine the receiver public key based on the config
|
||||
let receiver = match config {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
let receiver_key = match config {
|
||||
SignerKind::Auto => match receiver.announcement().as_ref() {
|
||||
Some(announcement) => {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
receiver.public_key()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||
None => receiver.public_key(),
|
||||
},
|
||||
SignerKind::Encryption => match receiver.announcement().as_ref() {
|
||||
Some(announcement) => {
|
||||
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||
announcement.public_key()
|
||||
} else {
|
||||
return Err(anyhow!("User has no encryption announcement"));
|
||||
}
|
||||
}
|
||||
None => return Err(anyhow!("User has no encryption announcement")),
|
||||
},
|
||||
SignerKind::User => receiver.public_key(),
|
||||
};
|
||||
|
||||
// Construct the gift wrap event
|
||||
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
|
||||
.extra_tags(extra_tags)
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
// Make sure the rumor carries an event ID before it's serialized
|
||||
let mut rumor = rumor.clone();
|
||||
rumor.ensure_id();
|
||||
|
||||
// Send the gift wrap event and collect the report
|
||||
let report = client
|
||||
let rumor_json = rumor.as_json();
|
||||
let has_encryption_announcement = receiver.announcement().is_some();
|
||||
|
||||
let seal: Event = match (encryption_signer, has_encryption_announcement) {
|
||||
(Some(encryption_signer), true) => {
|
||||
let encryption_pubkey = encryption_signer.get_public_key_async().await?;
|
||||
let content = encryption_signer
|
||||
.nip44_encrypt_async(&receiver_key, &rumor_json)
|
||||
.await?;
|
||||
|
||||
EventBuilder::new(Kind::Seal, content)
|
||||
.tags(vec![Tag::custom("n", [encryption_pubkey.to_hex()])])
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize_async(user_signer)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
let content = user_signer
|
||||
.nip44_encrypt_async(&receiver_key, &rumor_json)
|
||||
.await?;
|
||||
|
||||
EventBuilder::new(Kind::Seal, content)
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize_async(user_signer)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let ephemeral_keys = Keys::generate();
|
||||
let content = nip44::encrypt(
|
||||
ephemeral_keys.secret_key(),
|
||||
&receiver_key,
|
||||
seal.as_json(),
|
||||
nip44::Version::default(),
|
||||
)?;
|
||||
extra_tags.push(Tag::public_key(receiver_key));
|
||||
|
||||
let event = EventBuilder::new(Kind::GiftWrap, content)
|
||||
.tags(extra_tags)
|
||||
.custom_created_at(tweaked_timestamp())
|
||||
.finalize(&ephemeral_keys)?;
|
||||
|
||||
// Send to the receiver's NIP-17 relays.
|
||||
client
|
||||
.send_event(&event)
|
||||
.to_nip17()
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
.map(|output| {
|
||||
SendReport::new(receiver)
|
||||
.gift_wrap_id(event.id)
|
||||
.output(output)
|
||||
})?;
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
/// A tweaked timestamp to thwart time-analysis attacks, per NIP-59
|
||||
fn tweaked_timestamp() -> Timestamp {
|
||||
const MAX_TWEAK_SECONDS: u64 = 2 * 24 * 60 * 60;
|
||||
|
||||
let seconds = rand::random_range(0..=MAX_TWEAK_SECONDS);
|
||||
Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(seconds))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::EventExt;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A chat message kept in memory for content search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchMessage {
|
||||
/// ID of the room the message belongs to
|
||||
pub room: u64,
|
||||
/// Author of the message
|
||||
pub author: PublicKey,
|
||||
/// The content of the message
|
||||
pub content: Arc<str>,
|
||||
/// When the message was created
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IndexedMessage {
|
||||
message: SearchMessage,
|
||||
/// Lowercase content so repeated searches don't re-allocate per message
|
||||
normalized: Arc<str>,
|
||||
}
|
||||
|
||||
/// In-memory index of chat message rumors, keyed by room and rumor id.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageSearchIndex(BTreeMap<u64, BTreeMap<EventId, Arc<IndexedMessage>>>);
|
||||
|
||||
impl MessageSearchIndex {
|
||||
/// Index a rumor for search, ignoring reactions and empty content.
|
||||
pub fn insert(&mut self, rumor: &UnsignedEvent) {
|
||||
if !is_chat(rumor.kind) || rumor.content.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(id) = rumor.id else { return };
|
||||
|
||||
let indexed = Arc::new(IndexedMessage {
|
||||
message: SearchMessage {
|
||||
room: rumor.uniq_id(),
|
||||
author: rumor.pubkey,
|
||||
content: rumor.content.as_str().into(),
|
||||
created_at: rumor.created_at,
|
||||
},
|
||||
normalized: rumor.content.to_lowercase().into(),
|
||||
});
|
||||
|
||||
self.0
|
||||
.entry(rumor.uniq_id())
|
||||
.or_default()
|
||||
.entry(id)
|
||||
.or_insert(indexed);
|
||||
}
|
||||
|
||||
/// Find messages whose content contains all of the terms, newest first.
|
||||
pub fn search(&self, terms: &[String], limit: usize) -> Vec<SearchMessage> {
|
||||
let mut matches: Vec<&Arc<IndexedMessage>> = self
|
||||
.0
|
||||
.values()
|
||||
.flat_map(|messages| messages.values())
|
||||
.filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term)))
|
||||
.collect();
|
||||
|
||||
matches.sort_by_key(|indexed| Reverse(indexed.message.created_at));
|
||||
matches.truncate(limit);
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|indexed| indexed.message.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop all indexed messages.
|
||||
pub fn clear(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chat(kind: Kind) -> bool {
|
||||
kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent {
|
||||
let sender = Keys::generate();
|
||||
let peer = Keys::generate().public_key();
|
||||
|
||||
let mut rumor = EventBuilder::new(kind, content)
|
||||
.tag(Tag::public_key(peer))
|
||||
.finalize_unsigned(sender.public_key());
|
||||
rumor.ensure_id();
|
||||
rumor.created_at = Timestamp::from(created_at);
|
||||
rumor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_all_terms_newest_first_and_deduplicates() {
|
||||
let mut index = MessageSearchIndex::default();
|
||||
let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10);
|
||||
let second = rumor(Kind::PrivateDirectMessage, "hello again", 20);
|
||||
let reaction = rumor(Kind::Reaction, "hello world", 30);
|
||||
|
||||
index.insert(&first);
|
||||
index.insert(&first);
|
||||
index.insert(&second);
|
||||
index.insert(&reaction);
|
||||
|
||||
let hits = index.search(&["hello".to_owned()], 10);
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(&*hits[0].content, "hello again");
|
||||
assert_eq!(hits[0].created_at, Timestamp::from(20));
|
||||
|
||||
let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10);
|
||||
assert_eq!(hits.len(), 1);
|
||||
|
||||
let hits = index.search(&["hello".to_owned()], 1);
|
||||
assert_eq!(hits.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_not_indexed() {
|
||||
let mut index = MessageSearchIndex::default();
|
||||
index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10));
|
||||
|
||||
assert!(index.search(&["hello".to_owned()], 10).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,6 @@ nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
regex = "1"
|
||||
|
||||
@@ -6,7 +6,7 @@ use settings::SignerKind;
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub enum Command {
|
||||
Insert(&'static str),
|
||||
Insert(String),
|
||||
ChangeSubject(String),
|
||||
ChangeSigner(SignerKind),
|
||||
ToggleBackup,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chat::FileAttachment;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A file attachment that has been uploaded, but not sent yet.
|
||||
///
|
||||
/// The local `path` is kept around so the composer can preview
|
||||
/// the file without downloading and decrypting it again.
|
||||
pub(crate) struct PendingFile {
|
||||
pub file: FileAttachment,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// State of the encrypted file attachment of a message
|
||||
pub(crate) enum DecryptedFile {
|
||||
Loading,
|
||||
Ready(PathBuf),
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// Result of an upload, either plain or encrypted
|
||||
pub(crate) enum Uploaded {
|
||||
Url(Url),
|
||||
File(FileAttachment, PathBuf),
|
||||
}
|
||||
|
||||
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
||||
pub(crate) fn file_url(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
@@ -1,399 +1,63 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use chat::{Mention, nevent_refs};
|
||||
use gpui::{App, Entity};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use person::Directory;
|
||||
use ui::markdown::InlineReplacement;
|
||||
pub use ui::markdown::RenderedText;
|
||||
|
||||
use 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 theme::ActiveTheme;
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Highlight(HighlightStyle),
|
||||
Mention,
|
||||
}
|
||||
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Highlight(style)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_plain_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
persons,
|
||||
cx,
|
||||
);
|
||||
|
||||
text.truncate(text.trim_end().len());
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let code_background = cx.theme().elevated_surface_background;
|
||||
let color = cx.theme().text_accent;
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone()).with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if url.starts_with("http") {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_plain_text_mut(
|
||||
block: &str,
|
||||
mut mentions: &[Mention],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
persons: &Entity<PersonRegistry>,
|
||||
pub fn rendered_text(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
emojis: &[(String, Url)],
|
||||
persons: &Entity<Directory>,
|
||||
cx: &App,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
) -> RenderedText {
|
||||
let mut replacements = mentions
|
||||
.iter()
|
||||
.map(|mention| {
|
||||
InlineReplacement::new(
|
||||
mention.range.clone(),
|
||||
format!(
|
||||
"@{}",
|
||||
persons.read(cx).person(&mention.public_key, cx).name()
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut bold_depth = 0;
|
||||
let mut italic_depth = 0;
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
replacements.extend(emoji_replacements(content, emojis));
|
||||
replacements.extend(
|
||||
nevent_refs(content)
|
||||
.into_iter()
|
||||
.map(|reference| InlineReplacement::new(reference.range, reference.label)),
|
||||
);
|
||||
replacements.sort_by_key(|replacement| replacement.range.start);
|
||||
|
||||
let mut options = Options::all();
|
||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||
|
||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
// Process text with mention replacements
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
while let Some(mention) = mentions.first() {
|
||||
if !source_range.contains_inclusive(&mention.range) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate positions within the current text
|
||||
let mention_start_in_text = mention.range.start - source_range.start;
|
||||
let mention_end_in_text = mention.range.end - source_range.start;
|
||||
|
||||
// Add text before this mention
|
||||
if mention_start_in_text > last_processed {
|
||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||
process_text_segment(
|
||||
before_mention,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// Process the mention replacement
|
||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||
let replacement_text = format!("@{}", profile.name());
|
||||
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
mentions = &mentions[1..];
|
||||
}
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
if last_processed < t_str.len() {
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
text.push_str(t.as_ref());
|
||||
let is_link = link_url.is_some();
|
||||
|
||||
if let Some(link_url) = link_url.clone() {
|
||||
link_ranges.push(prev_len..text.len());
|
||||
link_urls.push(link_url);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
Tag::Item => {
|
||||
let len = list_stack.len();
|
||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||
*has_content = false;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..len - 1 {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if let Some(number) = list_number {
|
||||
text.push_str(&format!("{}. ", number));
|
||||
*number += 1;
|
||||
*has_content = false;
|
||||
} else {
|
||||
text.push_str("- ");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||
TagEnd::Link => link_url = None,
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RenderedText::new(content, &replacements)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
segment_start: usize,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
link_url: Option<String>,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
if bold_depth > 0 {
|
||||
style.font_weight = Some(FontWeight::BOLD);
|
||||
}
|
||||
if italic_depth > 0 {
|
||||
style.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
if strikethrough_depth > 0 {
|
||||
style.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec<InlineReplacement> {
|
||||
let mut replacements = Vec::new();
|
||||
|
||||
// Add the text
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
for (shortcode, url) in emojis {
|
||||
let pattern = format!(":{shortcode}:");
|
||||
for (start, _) in content.match_indices(&pattern) {
|
||||
let range = start..start + pattern.len();
|
||||
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||
}
|
||||
} else {
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder.links(segment) {
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
// Add non-link text before this link
|
||||
if start > last_link_pos {
|
||||
let non_link_start = segment_start + last_link_pos;
|
||||
let non_link_end = segment_start + start;
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||
}
|
||||
// Skip matches glued to word characters, like the `30` in `12:30:45`
|
||||
if content[..start].ends_with(char::is_alphanumeric)
|
||||
|| content[range.end..].starts_with(char::is_alphanumeric)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
highlights.push((range, Highlight::Highlight(link_style)));
|
||||
|
||||
last_link_pos = end;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last link
|
||||
if last_link_pos < segment.len() {
|
||||
let remaining_start = segment_start + last_link_pos;
|
||||
let remaining_end = segment_start + segment.len();
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||
}
|
||||
replacements.push(InlineReplacement::image(
|
||||
range,
|
||||
shortcode.clone(),
|
||||
url.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
} else {
|
||||
*has_content = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
replacements
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
regex = "1.10"
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::mem::take;
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||
};
|
||||
|
||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
||||
CoopImageCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoopImageCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |id, window| {
|
||||
window.with_element_state(id, |cache, _| {
|
||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
||||
(cache.clone(), cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoopImageCache {
|
||||
max_items: usize,
|
||||
usage_list: VecDeque<u64>,
|
||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||
}
|
||||
|
||||
impl CoopImageCache {
|
||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
log::info!("Creating CoopImageCache");
|
||||
cx.on_release(|this: &mut Self, cx| {
|
||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
log::info!("Dropping image {ix}");
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
ImageSource::Resource(resource).remove_asset(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
CoopImageCache {
|
||||
max_items,
|
||||
usage_list: VecDeque::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for CoopImageCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &Resource,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_idx = self
|
||||
.usage_list
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache has an item usage_list doesn't");
|
||||
|
||||
self.usage_list.remove(current_idx);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
return item.0.get();
|
||||
}
|
||||
|
||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(load_future).shared();
|
||||
|
||||
if self.usage_list.len() >= self.max_items {
|
||||
log::info!("Image cache is full, evicting oldest item");
|
||||
|
||||
if let Some(oldest) = self.usage_list.pop_back() {
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("usage_list has an item cache doesn't");
|
||||
|
||||
if let Some(Ok(image)) = image.0.get() {
|
||||
log::info!("requesting image to be dropped");
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
|
||||
ImageSource::Resource(image.1).remove_asset(cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.cache.insert(
|
||||
hash,
|
||||
(
|
||||
gpui::ImageCacheItem::Loading(task.clone()),
|
||||
resource.clone(),
|
||||
),
|
||||
);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("error loading image into cache: {:?}", err);
|
||||
}
|
||||
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{Context, Task, Window};
|
||||
use instant::Duration;
|
||||
|
||||
pub struct DebouncedDelay<E: 'static> {
|
||||
task: Option<Task<()>>,
|
||||
@@ -11,12 +11,6 @@ pub struct DebouncedDelay<E: 'static> {
|
||||
_phantom_data: PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> DebouncedDelay<E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -64,3 +58,9 @@ impl<E: 'static> DebouncedDelay<E> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::QrCode;
|
||||
use qrcode::render::svg;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
@@ -59,25 +55,3 @@ impl TimestampExt for Timestamp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StringExt {
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> StringExt for T {
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
let svg = code
|
||||
.render()
|
||||
.min_dimensions(256, 256)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#FFFFFF"))
|
||||
.build();
|
||||
|
||||
Some(Arc::new(Image::from_bytes(
|
||||
ImageFormat::Svg,
|
||||
svg.into_bytes(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,53 @@ impl EventExt for UnsignedEvent {
|
||||
public_keys.into_iter().unique().sorted().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
|
||||
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
||||
let mut emojis: Vec<(String, Url)> = Vec::new();
|
||||
|
||||
for tag in tags.iter() {
|
||||
let Ok(Nip30Tag::Emoji {
|
||||
shortcode,
|
||||
image_url,
|
||||
..
|
||||
}) = Nip30Tag::try_from(tag)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !emojis.iter().any(|(existing, _)| *existing == shortcode) {
|
||||
emojis.push((shortcode, image_url));
|
||||
}
|
||||
}
|
||||
|
||||
emojis
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn emojis_from_tags_parses_and_deduplicates() {
|
||||
let tags = Tags::from_list(vec![
|
||||
Tag::parse(vec!["emoji", "soapbox", "https://example.com/soapbox.png"]).unwrap(),
|
||||
Tag::parse(vec!["emoji", "soapbox", "https://example.com/other.png"]).unwrap(),
|
||||
Tag::parse(vec![
|
||||
"emoji",
|
||||
"bad shortcode",
|
||||
"https://example.com/bad.png",
|
||||
])
|
||||
.unwrap(),
|
||||
]);
|
||||
|
||||
let emojis = emojis_from_tags(&tags);
|
||||
|
||||
assert_eq!(emojis.len(), 1);
|
||||
assert_eq!(emojis[0].0, "soapbox");
|
||||
assert_eq!(
|
||||
emojis[0].1,
|
||||
Url::parse("https://example.com/soapbox.png").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
pub use caching::*;
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use media_extractor::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod caching;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod media_extractor;
|
||||
mod parser;
|
||||
mod paths;
|
||||
mod range;
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
use gpui::SharedUri;
|
||||
use regex::Regex;
|
||||
|
||||
/// Extracts media URLs from a string and returns both the extracted URLs
|
||||
/// and the string with media URLs removed
|
||||
pub struct MediaExtractor {
|
||||
/// Extracts media URLs from a string and returns both
|
||||
/// the extracted URLs and the string with media URLs removed
|
||||
struct MediaExtractor {
|
||||
image_regex: Regex,
|
||||
video_regex: Regex,
|
||||
}
|
||||
|
||||
impl MediaExtractor {
|
||||
/// Creates a new MediaExtractor with compiled regex patterns
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
MediaExtractor {
|
||||
// Match common image extensions
|
||||
image_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
// Match common video extensions
|
||||
video_regex: Regex::new(
|
||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
||||
).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all media URLs from a string
|
||||
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
// Extract image URLs
|
||||
@@ -32,30 +27,22 @@ impl MediaExtractor {
|
||||
urls.push(capture.as_str().to_string().into());
|
||||
}
|
||||
|
||||
// Extract video URLs
|
||||
// for capture in self.video_regex.find_iter(text) {
|
||||
// urls.push(capture.as_str().to_string().into());
|
||||
// }
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
/// Removes all media URLs from a string and returns the cleaned text
|
||||
pub fn remove_media_urls(&self, text: &str) -> String {
|
||||
fn remove_media_urls(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove image URLs
|
||||
result = self.image_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove video URLs
|
||||
// result = self.video_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Clean up extra whitespace that might result from removal
|
||||
self.cleanup_text(&result)
|
||||
}
|
||||
|
||||
/// Extracts media URLs and removes them from the string, returning both
|
||||
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||
let urls = self.extract_media_urls(text);
|
||||
let cleaned_text = self.remove_media_urls(text);
|
||||
(urls, cleaned_text)
|
||||
@@ -69,33 +56,6 @@ impl MediaExtractor {
|
||||
let re = Regex::new(r"\s+").unwrap();
|
||||
re.replace_all(text, " ").trim().to_string()
|
||||
}
|
||||
|
||||
/// Validates if a URL is a valid media URL
|
||||
pub fn is_media_url(&self, url: &str) -> bool {
|
||||
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
||||
}
|
||||
|
||||
/// Categorizes extracted URLs into images and videos
|
||||
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
||||
let mut images = Vec::new();
|
||||
let mut videos = Vec::new();
|
||||
|
||||
for url in urls {
|
||||
if self.image_regex.is_match(url) {
|
||||
images.push(url.clone());
|
||||
} else if self.video_regex.is_match(url) {
|
||||
videos.push(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(images, videos)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for one-time extraction and removal
|
||||
@@ -103,15 +63,3 @@ pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_and_remove(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just extracting media URLs
|
||||
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.extract_media_urls(text)
|
||||
}
|
||||
|
||||
/// Convenience function for just removing media URLs
|
||||
pub fn remove_media_urls(text: &str) -> String {
|
||||
let extractor = MediaExtractor::new();
|
||||
extractor.remove_media_urls(text)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,6 @@ struct Match {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NostrParser;
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrParser {
|
||||
/// Create new parser
|
||||
pub const fn new() -> Self {
|
||||
@@ -44,6 +38,12 @@ impl NostrParser {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NostrParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct FindMatches<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns the path to the user's home directory.
|
||||
pub fn home_dir() -> &'static PathBuf {
|
||||
fn home_dir() -> &'static PathBuf {
|
||||
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
||||
}
|
||||
@@ -36,30 +36,3 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
home_dir().join(".config").join("coop")
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the support directory used by Coop.
|
||||
pub fn support_dir() -> &'static PathBuf {
|
||||
static SUPPORT_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
SUPPORT_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
return home_dir().join("Library/Application Support/Coop");
|
||||
}
|
||||
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
return if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
|
||||
flatpak_xdg_data.into()
|
||||
} else {
|
||||
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
|
||||
}
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
return dirs::data_local_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join("coop");
|
||||
}
|
||||
|
||||
config_dir().clone()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
use std::cmp::{self};
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
|
||||
pub trait RangeExt<T> {
|
||||
fn sorted(&self) -> Self;
|
||||
fn to_inclusive(&self) -> RangeInclusive<T>;
|
||||
fn overlaps(&self, other: &Range<T>) -> bool;
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool;
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.start.clone()..=self.end.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start < other.end && other.start < self.end
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start <= other.start && other.end <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
|
||||
fn sorted(&self) -> Self {
|
||||
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
|
||||
}
|
||||
|
||||
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||
self.start() < &other.end && &other.start <= self.end()
|
||||
}
|
||||
|
||||
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||
self.start() <= &other.start && &other.end <= self.end()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "gpui_tokio"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_util.workspace = true
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::future::Future;
|
||||
|
||||
use gpui::{App, AppContext, Global, Task};
|
||||
use gpui_util::defer;
|
||||
use tokio::task::JoinError;
|
||||
|
||||
/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads.
|
||||
pub fn init(cx: &mut App) {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
// Since we now have two executors, let's try to keep our footprint small
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to initialize Tokio");
|
||||
|
||||
let handle = runtime.handle().clone();
|
||||
cx.set_global(GlobalTokio {
|
||||
owned_runtime: Some(runtime),
|
||||
handle,
|
||||
});
|
||||
}
|
||||
|
||||
struct GlobalTokio {
|
||||
owned_runtime: Option<tokio::runtime::Runtime>,
|
||||
handle: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl Global for GlobalTokio {}
|
||||
|
||||
impl Drop for GlobalTokio {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.owned_runtime.take() {
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tokio {}
|
||||
|
||||
impl Tokio {
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn<C, Fut, R>(cx: &C, f: Fut) -> Task<Result<R, JoinError>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = R> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn_result<C, Fut, R>(cx: &C, f: Fut) -> Task<anyhow::Result<R>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = anyhow::Result<R>> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await?;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "nip29"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,287 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use futures::future::join_all;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList, GroupMetadata};
|
||||
use crate::utils::parse_members;
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct Discovery {
|
||||
refs: HashMap<RelayUrl, HashSet<GroupId>>,
|
||||
discovered: Vec<GroupCandidate>,
|
||||
members: HashMap<GroupKey, (Timestamp, usize)>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
/// The discovered groups, sorted by name.
|
||||
pub fn discovered(&self) -> &[GroupCandidate] {
|
||||
&self.discovered
|
||||
}
|
||||
|
||||
/// The member count of a group, when its member list is known.
|
||||
pub fn members(&self, key: &GroupKey) -> Option<usize> {
|
||||
self.members.get(key).map(|(_, members)| *members)
|
||||
}
|
||||
|
||||
/// The (relay, group) references, for asking the relays for metadata.
|
||||
pub(crate) fn refs(&self) -> &HashMap<RelayUrl, HashSet<GroupId>> {
|
||||
&self.refs
|
||||
}
|
||||
|
||||
/// Absorb the references of a group list, skipping the joined groups.
|
||||
pub(crate) fn absorb_list(&mut self, list: &GroupList, joined: &HashSet<(RelayUrl, GroupId)>) {
|
||||
for entry in list.entries() {
|
||||
if joined.contains(&(entry.relay.clone(), entry.id.clone())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.refs
|
||||
.entry(entry.relay.clone())
|
||||
.or_default()
|
||||
.insert(entry.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb the newest group list of every author.
|
||||
pub(crate) fn absorb_lists(
|
||||
&mut self,
|
||||
lists: Vec<Event>,
|
||||
joined: &HashSet<(RelayUrl, GroupId)>,
|
||||
) {
|
||||
let newest: HashMap<PublicKey, Event> =
|
||||
lists.into_iter().fold(HashMap::new(), |mut newest, event| {
|
||||
match newest.get(&event.pubkey) {
|
||||
Some(current) if current.created_at >= event.created_at => newest,
|
||||
_ => {
|
||||
newest.insert(event.pubkey, event);
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for event in newest.into_values() {
|
||||
let Ok(list) = GroupList::parse(&event) else {
|
||||
log::warn!("ignoring a malformed group list from {}", event.pubkey);
|
||||
continue;
|
||||
};
|
||||
|
||||
self.absorb_list(&list, joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a discovered group, keeping the newest metadata.
|
||||
pub(crate) fn upsert(&mut self, candidate: GroupCandidate) {
|
||||
match self.discovered.iter_mut().find(|d| d.key == candidate.key) {
|
||||
Some(existing) if existing.created_at <= candidate.created_at => {
|
||||
*existing = candidate;
|
||||
}
|
||||
Some(_) => return,
|
||||
None => self.discovered.push(candidate),
|
||||
}
|
||||
|
||||
self.discovered.sort_by(|a, b| {
|
||||
a.display_name()
|
||||
.to_lowercase()
|
||||
.cmp(&b.display_name().to_lowercase())
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply a member count, keeping the newest list.
|
||||
pub fn set_members(&mut self, key: GroupKey, created_at: Timestamp, members: usize) {
|
||||
if self
|
||||
.members
|
||||
.get(&key)
|
||||
.is_some_and(|(current, _)| *current > created_at)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.members.insert(key, (created_at, members));
|
||||
}
|
||||
|
||||
/// Seed the discoveries from the cached group metadata.
|
||||
pub(crate) fn absorb_cached_previews(&mut self, previews: Vec<Event>) {
|
||||
let newest: HashMap<GroupId, (Timestamp, GroupMetadata)> =
|
||||
previews
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut newest, event| {
|
||||
let metadata = match GroupMetadata::parse(&event) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
log::warn!("ignoring malformed cached group metadata: {error}");
|
||||
return newest;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(id) = metadata.id().cloned() else {
|
||||
return newest;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some((created_at, _)) if *created_at >= event.created_at => newest,
|
||||
_ => {
|
||||
newest.insert(id, (event.created_at, metadata));
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (relay, ids) in self.refs.clone() {
|
||||
for id in ids {
|
||||
let Some((created_at, metadata)) = newest.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Hidden groups are not discoverable (NIP-29).
|
||||
if metadata.hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.upsert(GroupCandidate {
|
||||
key: GroupKey::new(relay.clone(), id.clone()),
|
||||
created_at: *created_at,
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the member counts from the cached member lists.
|
||||
pub(crate) fn absorb_cached_members(&mut self, members: Vec<Event>) {
|
||||
let newest: HashMap<GroupId, (Timestamp, usize)> =
|
||||
members
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut newest, event| {
|
||||
let Some((id, created_at, count)) = parse_members(&event) else {
|
||||
return newest;
|
||||
};
|
||||
|
||||
match newest.get(&id) {
|
||||
Some((current, _)) if *current >= created_at => newest,
|
||||
_ => {
|
||||
newest.insert(id, (created_at, count));
|
||||
newest
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (relay, ids) in self.refs.clone() {
|
||||
for id in ids {
|
||||
let Some((created_at, count)) = newest.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.set_members(
|
||||
GroupKey::new(relay.clone(), id.clone()),
|
||||
*created_at,
|
||||
*count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the discoverable groups from the local database only.
|
||||
pub async fn cached(client: Client, joined: HashSet<(RelayUrl, GroupId)>) -> Self {
|
||||
let mut discovery = Self::default();
|
||||
|
||||
// The group lists of every cached author.
|
||||
let lists: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::SimpleGroups))
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_lists(lists, &joined);
|
||||
|
||||
let refs = discovery.refs().clone();
|
||||
|
||||
if refs.is_empty() {
|
||||
return discovery;
|
||||
}
|
||||
|
||||
let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect();
|
||||
|
||||
// The cached metadata of the referenced groups.
|
||||
let previews: Vec<Event> = client
|
||||
.database()
|
||||
.query(
|
||||
Filter::new()
|
||||
.kind(Kind::GroupMetadata)
|
||||
.identifiers(ids.clone()),
|
||||
)
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_cached_previews(previews);
|
||||
|
||||
// The cached member lists of the referenced groups.
|
||||
let members: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::GroupMembers).identifiers(ids))
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
discovery.absorb_cached_members(members);
|
||||
|
||||
discovery
|
||||
}
|
||||
|
||||
/// Reconcile the discoverable groups with the relays.
|
||||
pub async fn sync(
|
||||
&mut self,
|
||||
client: Client,
|
||||
me: PublicKey,
|
||||
joined: HashSet<(RelayUrl, GroupId)>,
|
||||
) {
|
||||
let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1);
|
||||
|
||||
let authors: HashSet<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|events| events.into_iter().next())
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||
let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups);
|
||||
client.sync(filter.clone()).with(urls).await.ok();
|
||||
|
||||
let lists: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.map(|events| events.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.absorb_lists(lists, &joined);
|
||||
|
||||
// The pool-wide sweep of the group metadata and members.
|
||||
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||
let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]);
|
||||
client.sync(filter).with(urls).await.ok();
|
||||
|
||||
// The previews cover the relays the lists just referenced.
|
||||
let fetches = self.refs.clone().into_iter().map(|(relay, ids)| {
|
||||
let client = client.clone();
|
||||
|
||||
async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds([Kind::GroupMetadata, Kind::GroupMembers])
|
||||
.identifiers(ids.iter().map(GroupId::as_str));
|
||||
|
||||
let urls = vec![relay];
|
||||
client.sync(filter).with(urls).await.ok();
|
||||
}
|
||||
});
|
||||
|
||||
join_all(fetches).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chat::Message;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::LOCAL_KEYS;
|
||||
use crate::protocol::{
|
||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
||||
TimelineWindow, tag_value,
|
||||
};
|
||||
|
||||
const REPLAY_LIMIT: usize = 100;
|
||||
const PAGE_LIMIT: usize = 50;
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LEAVE_WINDOW_SECS: u64 = 5 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Absorbed {
|
||||
Ignored,
|
||||
Shown { fresh: bool, updated: bool },
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reaction {
|
||||
pub id: EventId,
|
||||
pub emoji: String,
|
||||
pub image: Option<SharedUri>,
|
||||
pub author: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Invite {
|
||||
pub id: EventId,
|
||||
pub code: String,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Row {
|
||||
Message(Box<Message>),
|
||||
Activity(Activity),
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn id(&self) -> EventId {
|
||||
match self {
|
||||
Self::Message(message) => message.id,
|
||||
Self::Activity(activity) => activity.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn author(&self) -> PublicKey {
|
||||
match self {
|
||||
Self::Message(message) => message.author,
|
||||
Self::Activity(activity) => activity.author,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> Timestamp {
|
||||
match self {
|
||||
Self::Message(message) => message.created_at,
|
||||
Self::Activity(activity) => activity.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GroupEvent {
|
||||
Incoming(Event),
|
||||
Sent(Event),
|
||||
Updated,
|
||||
Eose,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub struct Group {
|
||||
key: GroupKey,
|
||||
metadata: Option<GroupMetadata>,
|
||||
members: GroupMembers,
|
||||
admins: GroupAdmins,
|
||||
roles: GroupRoles,
|
||||
pins: GroupPins,
|
||||
membership: Membership,
|
||||
membership_events: Vec<Event>,
|
||||
elsewhere: Option<GroupKey>,
|
||||
state_ids: HashMap<Kind, EventId>,
|
||||
rows: Vec<Row>,
|
||||
index: HashSet<EventId>,
|
||||
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||
/// Active invite codes; revocations arrive as 9005 deletions.
|
||||
invites: Vec<Invite>,
|
||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||
window: TimelineWindow,
|
||||
oldest: Option<Timestamp>,
|
||||
loading: bool,
|
||||
loading_more: bool,
|
||||
active: bool,
|
||||
refused: Option<String>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupEvent> for Group {}
|
||||
|
||||
impl Group {
|
||||
pub(crate) fn new(key: GroupKey) -> Self {
|
||||
Self {
|
||||
key,
|
||||
metadata: None,
|
||||
members: GroupMembers::default(),
|
||||
admins: GroupAdmins::default(),
|
||||
roles: GroupRoles::default(),
|
||||
pins: GroupPins::default(),
|
||||
membership: Membership::Unknown,
|
||||
membership_events: Vec::new(),
|
||||
elsewhere: None,
|
||||
state_ids: HashMap::new(),
|
||||
rows: Vec::new(),
|
||||
index: HashSet::new(),
|
||||
reactions: HashMap::new(),
|
||||
invites: Vec::new(),
|
||||
leave_requests: HashMap::new(),
|
||||
window: TimelineWindow::new(),
|
||||
oldest: None,
|
||||
loading: true,
|
||||
loading_more: false,
|
||||
active: false,
|
||||
refused: None,
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &GroupKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> Option<&GroupMetadata> {
|
||||
self.metadata.as_ref()
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
let name = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.unwrap_or_else(|| self.key.id().as_str());
|
||||
|
||||
SharedString::from(name.to_owned())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.picture.clone())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
pub fn total_members(&self) -> usize {
|
||||
self.members.len()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &GroupMembers {
|
||||
&self.members
|
||||
}
|
||||
|
||||
pub fn admins(&self) -> &GroupAdmins {
|
||||
&self.admins
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &GroupRoles {
|
||||
&self.roles
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &GroupPins {
|
||||
&self.pins
|
||||
}
|
||||
|
||||
pub fn membership(&self) -> &Membership {
|
||||
&self.membership
|
||||
}
|
||||
|
||||
pub fn elsewhere(&self) -> Option<&GroupKey> {
|
||||
self.elsewhere.as_ref()
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> &[Row] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn message(&self, id: EventId) -> Option<&Message> {
|
||||
self.rows.iter().find_map(|row| match row {
|
||||
Row::Message(message) if message.id == id => Some(message.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reactions(&self, id: &EventId) -> &[Reaction] {
|
||||
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn invites(&self) -> &[Invite] {
|
||||
&self.invites
|
||||
}
|
||||
|
||||
pub fn loading_more(&self) -> bool {
|
||||
self.loading_more
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub(crate) fn activate(&mut self) {
|
||||
self.active = true;
|
||||
}
|
||||
|
||||
pub fn oldest(&self) -> Option<Timestamp> {
|
||||
self.oldest
|
||||
}
|
||||
|
||||
pub fn refused(&self) -> Option<&str> {
|
||||
self.refused.as_deref()
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
emojis: Vec<Tag>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(
|
||||
move |id, prev| id.message(content, &replies, &emojis, prev),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn send_reaction(
|
||||
&mut self,
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
emojis: Vec<Tag>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(
|
||||
move |id, prev| id.reaction(target, &emoji, &emojis, prev),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remove_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(
|
||||
&mut self,
|
||||
metadata: &GroupMetadata,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_group(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(GroupId::delete_group, cx)
|
||||
}
|
||||
|
||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||
}
|
||||
|
||||
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
|
||||
}
|
||||
|
||||
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
|
||||
where
|
||||
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
|
||||
};
|
||||
|
||||
let builder = build(self.key.id(), self.window.previous_tag(me));
|
||||
let published = self.publish(builder, cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let event = published.await?;
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(GroupEvent::Sent(event));
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task<Result<Event>> {
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.client();
|
||||
let signer = nostr.signer();
|
||||
let relay = self.key.relay().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).to([relay]).await?;
|
||||
|
||||
if let Some(reason) = output.failed.into_values().next() {
|
||||
bail!("{reason}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context<Self>) {
|
||||
if self.membership != Membership::Member {
|
||||
self.membership = Membership::Pending { since };
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_member(&mut self, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Member;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Refused {
|
||||
reason: Some(reason),
|
||||
};
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_elsewhere(&mut self, elsewhere: Option<GroupKey>, cx: &mut Context<Self>) {
|
||||
if self.elsewhere == elsewhere {
|
||||
return;
|
||||
}
|
||||
self.elsewhere = elsewhere;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = self.key.clone();
|
||||
let id = crate::subscription_id(&key);
|
||||
let relay = key.relay().clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let filters = [
|
||||
key.id().timeline_filter(REPLAY_LIMIT),
|
||||
key.id().membership_filter(me),
|
||||
key.id().moderation_filter(REPLAY_LIMIT),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(key.id().state_filters())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, filters))
|
||||
.with_id(id)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to {} failed: {error}", key.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(until) = self.oldest else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_more = true;
|
||||
cx.notify();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = crate::page_subscription(&self.key);
|
||||
let relay = self.key.relay().clone();
|
||||
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, [filter]))
|
||||
.with_id(id)
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: loading older messages failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading_more = false;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let tags = vec![
|
||||
Tag::identifier(event.id),
|
||||
Tag::custom("r", [cache_tag]),
|
||||
Tag::custom("k", [event.kind.to_string()]),
|
||||
];
|
||||
|
||||
let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&envelope).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task<Result<Vec<Event>>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
if state_only {
|
||||
let kinds = STATE_KINDS
|
||||
.iter()
|
||||
.chain(MEMBERSHIP_KINDS.iter())
|
||||
.map(|kind| kind.to_string());
|
||||
|
||||
filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds);
|
||||
}
|
||||
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|envelope| Event::from_json(&envelope.content).ok())
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate(&mut self, events: Vec<Event>, me: PublicKey, cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
for event in &events {
|
||||
match self.absorb(event, me) {
|
||||
Absorbed::Shown {
|
||||
fresh: true,
|
||||
updated,
|
||||
} => {
|
||||
rendered.push(event.clone());
|
||||
changed |= updated;
|
||||
}
|
||||
Absorbed::Shown {
|
||||
fresh: false,
|
||||
updated,
|
||||
} => changed |= updated,
|
||||
Absorbed::Updated => changed = true,
|
||||
Absorbed::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
for event in rendered {
|
||||
cx.emit(GroupEvent::Incoming(event));
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
if self.retract(id) {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an event and everything attached to it.
|
||||
fn retract(&mut self, id: EventId) -> bool {
|
||||
self.window.remove(id);
|
||||
|
||||
if !self.index.remove(&id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.rows.retain(|row| row.id() != id);
|
||||
self.invites.retain(|invite| invite.id != id);
|
||||
self.reactions.remove(&id);
|
||||
|
||||
for reactions in self.reactions.values_mut() {
|
||||
reactions.retain(|reaction| reaction.id != id);
|
||||
}
|
||||
|
||||
self.reactions.retain(|_, reactions| !reactions.is_empty());
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
cx.emit(GroupEvent::Eose);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
self.refused = Some(reason.clone());
|
||||
cx.emit(GroupEvent::Refused(reason));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||
if STATE_KINDS.contains(&event.kind) {
|
||||
self.window.note(event);
|
||||
|
||||
return if self.absorb_state(event, me) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
// NIP-29: relays must reject events whose
|
||||
// `previous` references are absent from their database.
|
||||
let previous = event.tags.iter().find(|tag| tag.kind() == "previous");
|
||||
if self.window.references_seen(previous) == Some(false) {
|
||||
log::warn!(
|
||||
"nip29: event {} carries previous references not seen on {}",
|
||||
event.id,
|
||||
self.key.relay()
|
||||
);
|
||||
}
|
||||
|
||||
self.track(event);
|
||||
|
||||
let updated = self.absorb_membership(event, me);
|
||||
|
||||
if ACTIVITY_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_activity(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::Reaction {
|
||||
return if self.push_reaction(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::GroupCreateInvite {
|
||||
return if self.push_invite(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::GroupDeleteEvent {
|
||||
let retracted = event
|
||||
.tags
|
||||
.event_ids()
|
||||
.next()
|
||||
.is_some_and(|target| self.retract(target));
|
||||
|
||||
return if retracted {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if RENDER_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_row(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if updated {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_membership(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self
|
||||
.membership_events
|
||||
.iter()
|
||||
.any(|existing| existing.id == event.id)
|
||||
{
|
||||
self.membership_events.push(event.clone());
|
||||
}
|
||||
|
||||
self.refresh_membership(me)
|
||||
}
|
||||
|
||||
fn track(&mut self, event: &Event) {
|
||||
self.window.push(event.clone());
|
||||
|
||||
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
|
||||
self.oldest = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||
match event.kind {
|
||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||
Ok(admins) => {
|
||||
self.admins = admins;
|
||||
self.adopt_roster_membership(me);
|
||||
}
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||
Ok(members) => {
|
||||
self.members = members;
|
||||
self.adopt_roster_membership(me);
|
||||
}
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||
Ok(roles) => self.roles = roles,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::Custom(PIN_LIST) => match GroupPins::parse(event) {
|
||||
Ok(pins) => self.pins = pins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
||||
}
|
||||
|
||||
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
|
||||
if self.membership != Membership::Unknown {
|
||||
return false;
|
||||
}
|
||||
|
||||
let listed = self.admins.contains(&me) || self.members.iter().any(|member| *member == me);
|
||||
|
||||
if !listed {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.membership = Membership::Member;
|
||||
true
|
||||
}
|
||||
|
||||
fn push_row(&mut self, event: &Event) -> bool {
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let message = Message::from(event);
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= message.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Message(Box::new(message)));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_reaction(&mut self, event: &Event) -> bool {
|
||||
let Some(target) = event.tags.event_ids().next() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let emojis = emojis_from_tags(&event.tags);
|
||||
let image = Message::reaction_image(&event.content, &emojis)
|
||||
.map(|url| SharedUri::from(url.to_string()));
|
||||
|
||||
self.reactions.entry(target).or_default().push(Reaction {
|
||||
id: event.id,
|
||||
emoji: event.content.clone(),
|
||||
image,
|
||||
author: event.pubkey,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_invite(&mut self, event: &Event) -> bool {
|
||||
let Some(code) = tag_value(&event.tags, "code") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.invites.push(Invite {
|
||||
id: event.id,
|
||||
code: code.to_owned(),
|
||||
created_at: event.created_at,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_activity(&mut self, event: &Event) -> bool {
|
||||
let activity = match Activity::parse(event) {
|
||||
Ok(activity) => activity,
|
||||
Err(error) => {
|
||||
log::warn!("nip29: ignoring a malformed group activity: {error}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if activity.kind == Kind::GroupPutUser && activity.roles.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupRemoveUser
|
||||
&& self
|
||||
.leave_requests
|
||||
.get(&activity.subject())
|
||||
.is_some_and(|left| {
|
||||
activity.created_at >= *left
|
||||
&& activity.created_at.as_secs().saturating_sub(left.as_secs())
|
||||
<= LEAVE_WINDOW_SECS
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.index.insert(activity.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupLeaveRequest {
|
||||
self.leave_requests
|
||||
.insert(activity.subject(), activity.created_at);
|
||||
}
|
||||
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= activity.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Activity(activity));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn refresh_membership(&mut self, me: PublicKey) -> bool {
|
||||
let derived = Membership::from_events(&self.membership_events, me);
|
||||
|
||||
if derived != Membership::Unknown && derived != self.membership {
|
||||
self.membership = derived;
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::{GroupId, GroupKey};
|
||||
|
||||
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
||||
const FORK_SUBSCRIPTION: &str = "nip29-forks";
|
||||
const GROUP_PREFIX: &str = "grp-";
|
||||
const BROWSE_PREFIX: &str = "brw-";
|
||||
const PAGE_PREFIX: &str = "pg-";
|
||||
|
||||
pub(crate) fn list_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub(crate) fn fork_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(FORK_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relay.hash(&mut hasher);
|
||||
|
||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
pub(crate) fn is_page(id: &SubscriptionId) -> bool {
|
||||
id.as_str().starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_subscription(id: &SubscriptionId) -> bool {
|
||||
let id = id.as_str();
|
||||
id == LIST_SUBSCRIPTION
|
||||
|| id == FORK_SUBSCRIPTION
|
||||
|| id.starts_with(GROUP_PREFIX)
|
||||
|| id.starts_with(BROWSE_PREFIX)
|
||||
|| id.starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> {
|
||||
if event.kind != Kind::GroupMembers {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = GroupId::new(event.tags.identifier()?).ok()?;
|
||||
let members = event.tags.public_keys().collect::<HashSet<_>>().len();
|
||||
|
||||
Some((id, event.created_at, members))
|
||||
}
|
||||
|
||||
const ONSETS: [&str; 34] = [
|
||||
"b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", "br", "cr",
|
||||
"dr", "fr", "gr", "pr", "tr", "bl", "cl", "fl", "gl", "pl", "sl", "sh", "ch", "th", "st", "sk",
|
||||
];
|
||||
const NUCLEI: [&str; 9] = ["a", "e", "i", "o", "u", "ae", "ei", "io", "ou"];
|
||||
const CODAS: [&str; 11] = ["", "", "", "n", "r", "s", "t", "d", "l", "m", "k"];
|
||||
|
||||
/// A short pronounceable id for new groups, seeded from a fresh key.
|
||||
pub(crate) fn readable_id() -> String {
|
||||
let mut picks = SecretKey::generate().secret_bytes().into_iter();
|
||||
|
||||
let mut id = String::with_capacity(18);
|
||||
|
||||
for _ in 0..3 {
|
||||
let onset = ONSETS[usize::from(picks.next().unwrap_or(0)) % ONSETS.len()];
|
||||
let nucleus = NUCLEI[usize::from(picks.next().unwrap_or(0)) % NUCLEI.len()];
|
||||
let coda = CODAS[usize::from(picks.next().unwrap_or(0)) % CODAS.len()];
|
||||
|
||||
id.push_str(onset);
|
||||
id.push_str(nucleus);
|
||||
id.push_str(coda);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_readable_id() {
|
||||
for _ in 0..32 {
|
||||
let id = readable_id();
|
||||
|
||||
assert!((6..=15).contains(&id.len()));
|
||||
assert!(id.chars().all(|character| character.is_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nip29_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
common = { path = "../common" }
|
||||
nip29 = { path = "../nip29" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
state = { path = "../state" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,215 @@
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||
relative,
|
||||
};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
.show_close(true)
|
||||
.title("About Group")
|
||||
.child(body(&group, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let Some(entity) = group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_style(TextStyle::Callout).child(header(group, cx));
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
body = body.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(about.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
let flags = flags(metadata);
|
||||
|
||||
if !flags.is_empty() {
|
||||
body = body.child(section(
|
||||
"Flags",
|
||||
wrap(flags.into_iter().map(|f| chip(f, cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = metadata.parent.as_ref() {
|
||||
body = body.child(section(
|
||||
"Parent",
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !metadata.children.is_empty() {
|
||||
body = body.child(section(
|
||||
"Children",
|
||||
wrap(
|
||||
metadata
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| chip(child.as_str().to_owned(), cx)),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(kinds) = metadata.supported_kinds.as_ref() {
|
||||
let items: Vec<AnyElement> = kinds
|
||||
.iter()
|
||||
.map(|kind| chip(kind.to_string(), cx))
|
||||
.collect();
|
||||
|
||||
body = body.child(section(
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
wrap(items)
|
||||
},
|
||||
cx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let roles = group.roles().roles();
|
||||
if !roles.is_empty() {
|
||||
body = body.child(section(
|
||||
"Roles",
|
||||
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
let pins = group.pins().pins();
|
||||
if !pins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Pinned",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
body.into_any_element()
|
||||
}
|
||||
|
||||
fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
let key = group.key();
|
||||
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::from_source(group.display_image())
|
||||
.seed(key.cache_tag())
|
||||
.large(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Body)
|
||||
.font_semibold()
|
||||
.truncate()
|
||||
.child(group.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.truncate()
|
||||
.child(SharedString::from(format!(
|
||||
"{} · {}",
|
||||
key.id().as_str(),
|
||||
key.relay().as_str()
|
||||
))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_medium()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label),
|
||||
)
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(items)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.text_color(cx.theme().text)
|
||||
.text_style(TextStyle::Caption)
|
||||
.line_height(relative(1.))
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
if metadata.private {
|
||||
flags.push("Private");
|
||||
}
|
||||
if metadata.restricted {
|
||||
flags.push("Restricted");
|
||||
}
|
||||
if metadata.hidden {
|
||||
flags.push("Hidden");
|
||||
}
|
||||
if metadata.closed {
|
||||
flags.push("Closed");
|
||||
}
|
||||
if metadata.livekit {
|
||||
flags.push("LiveKit");
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use anyhow::Result;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use nip29::{Group, Invite};
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::util::{field, report};
|
||||
|
||||
const CODE_LEN: usize = 12;
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Invite code")
|
||||
.default_value(random_code())
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
.show_close(true)
|
||||
.title("Invites")
|
||||
.child(body(group.clone(), input.clone(), cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn random_code() -> String {
|
||||
SecretKey::generate()
|
||||
.to_secret_hex()
|
||||
.chars()
|
||||
.take(CODE_LEN)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
let invites = group
|
||||
.upgrade()
|
||||
.map(|entity| entity.read(cx).invites().to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Anyone with a code can ask to join. Revoking a code stops new joins, it doesn't remove anyone."),
|
||||
)
|
||||
.when(invites.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("No active invites"),
|
||||
)
|
||||
})
|
||||
.children(invites.iter().map(|invite| invite_row(group.clone(), invite, cx)))
|
||||
.child(create_row(group, input, cx))
|
||||
}
|
||||
|
||||
fn invite_row(group: WeakEntity<Group>, invite: &Invite, cx: &App) -> impl IntoElement {
|
||||
let created = div()
|
||||
.flex_shrink_0()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(invite.created_at.to_ago());
|
||||
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.child(SharedString::from(invite.code.clone())),
|
||||
)
|
||||
.child(created),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.child(copy_button(&group, &invite.code))
|
||||
.child(revoke_button(&group, invite.id, &invite.code)),
|
||||
)
|
||||
}
|
||||
|
||||
fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
|
||||
let group = group.clone();
|
||||
let code = code.to_owned();
|
||||
|
||||
Button::new(SharedString::from(format!("invite-copy-{code}")))
|
||||
.label("Copy")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
let Some(link) = group
|
||||
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
window.push_notification(
|
||||
Notification::error(
|
||||
"The relay doesn't publish group details, so no invite link can be made",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
|
||||
let group = group.clone();
|
||||
|
||||
Button::new(SharedString::from(format!("invite-revoke-{code}")))
|
||||
.label("Revoke")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.items_end()
|
||||
.gap_2()
|
||||
.child(field("New code", &input, cx).flex_1())
|
||||
.child(
|
||||
Button::new("invite-create")
|
||||
.label("Create & copy link")
|
||||
.on_click(move |_event, window, cx| {
|
||||
create(group.clone(), input.clone(), window, cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn invite_link(group: &Group, code: &str) -> Option<String> {
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.naddr(group.key().relay(), Some(code)).ok())
|
||||
}
|
||||
|
||||
fn create(group: WeakEntity<Group>, input: Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let code = input.read(cx).value().trim().to_owned();
|
||||
|
||||
if code.is_empty() {
|
||||
window.push_notification(Notification::error("The invite code is empty"), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(link) = group
|
||||
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
window.push_notification(
|
||||
Notification::error(
|
||||
"The relay doesn't publish group details, so no invite link can be made",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
||||
Ok(task) => {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cx.update(|window, cx| {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use chat::Message;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, div, px,
|
||||
};
|
||||
use nip29::{Activity, Group, Reaction};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||
use ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
|
||||
use crate::GroupPanel;
|
||||
|
||||
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
||||
|
||||
pub(crate) struct Actions {
|
||||
pub group: WeakEntity<Group>,
|
||||
pub panel: WeakEntity<GroupPanel>,
|
||||
pub admin: bool,
|
||||
pub pinned: bool,
|
||||
pub persons: Entity<Directory>,
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
reactions: &[Reaction],
|
||||
actions: Actions,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
let persons = actions.persons.clone();
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
|
||||
let reaction_groups = group_reactions(
|
||||
reactions.iter().map(|reaction| {
|
||||
(
|
||||
SharedString::from(reaction.emoji.as_str()),
|
||||
reaction.image.clone(),
|
||||
reaction.author,
|
||||
)
|
||||
}),
|
||||
nostr.read(cx).current_user(),
|
||||
);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(!message.replies_to.is_empty(), |this| {
|
||||
this.children(replies(
|
||||
&message.replies_to,
|
||||
&persons,
|
||||
&actions.group,
|
||||
&actions.panel,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(MessageMedia::new(&message.media))
|
||||
})
|
||||
.when(!reaction_groups.is_empty(), |this| {
|
||||
this.child(ReactionBar::new(reaction_groups))
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
let id = message.id;
|
||||
let admin = actions.admin;
|
||||
let pinned = actions.pinned;
|
||||
let group = actions.group;
|
||||
let panel = actions.panel;
|
||||
|
||||
ContextMenu::new(
|
||||
format!("message-menu-{}", id.to_hex()),
|
||||
row,
|
||||
move |menu, window, cx| {
|
||||
let menu = menu
|
||||
.item({
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.start_reply(id, window, cx);
|
||||
});
|
||||
})
|
||||
})
|
||||
.submenu("React", window, cx, {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
|
||||
});
|
||||
|
||||
if !admin {
|
||||
return menu;
|
||||
}
|
||||
|
||||
menu.separator()
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||
move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_pin(group.clone(), id, window, cx);
|
||||
});
|
||||
},
|
||||
)
|
||||
})
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.delete_message(group.clone(), id, window, cx);
|
||||
});
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn react_menu(
|
||||
menu: PopupMenu,
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
id: EventId,
|
||||
cx: &App,
|
||||
) -> PopupMenu {
|
||||
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
let emojis = panel
|
||||
.upgrade()
|
||||
.and_then(|panel| {
|
||||
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
|
||||
Some(
|
||||
panel
|
||||
.read(cx)
|
||||
.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if emojis.is_empty() {
|
||||
return menu;
|
||||
}
|
||||
|
||||
let menu = menu.separator();
|
||||
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
|
||||
let emoji = format!(":{shortcode}:");
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_activity(
|
||||
ix: usize,
|
||||
activity: &Activity,
|
||||
persons: &Entity<Directory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let subject = persons.read(cx).person(&activity.subject(), cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap(px(theme::SPACE))
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(subject.avatar())
|
||||
.seed(subject.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_semibold()
|
||||
.child(subject.name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(SharedString::from(action(activity))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(activity.created_at.to_ago()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn badge(activity: &Activity, cx: &App) -> AnyElement {
|
||||
let (icon, color) = match activity.kind {
|
||||
Kind::GroupJoinRequest => (IconName::Login, cx.theme().accent),
|
||||
Kind::GroupPutUser => (IconName::Login, cx.theme().accent),
|
||||
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().danger),
|
||||
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().danger),
|
||||
_ => (IconName::Info, cx.theme().text_faint),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.size_8()
|
||||
.child(Icon::new(icon).small().text_color(color))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn action(activity: &Activity) -> String {
|
||||
match activity.kind {
|
||||
Kind::GroupJoinRequest => "joined the group".to_owned(),
|
||||
Kind::GroupLeaveRequest => "left the group".to_owned(),
|
||||
Kind::GroupRemoveUser => "was removed from the group".to_owned(),
|
||||
Kind::GroupPutUser => format!("is now {}", activity.roles.join(", ")),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn replies(
|
||||
targets: &[EventId],
|
||||
persons: &Entity<Directory>,
|
||||
group: &WeakEntity<Group>,
|
||||
panel: &WeakEntity<GroupPanel>,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
let Some(entity) = group.upgrade() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut items = Vec::with_capacity(targets.len());
|
||||
|
||||
for (ix, id) in targets.iter().enumerate() {
|
||||
let context = entity.read_with(cx, |group, cx| {
|
||||
let message = group.message(*id)?;
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
|
||||
Some((author.name(), message.preview()))
|
||||
});
|
||||
|
||||
let (author, preview) = context.unwrap_or_else(|| {
|
||||
(
|
||||
SharedString::from("Message"),
|
||||
SharedString::from("original not loaded"),
|
||||
)
|
||||
});
|
||||
|
||||
let id = *id;
|
||||
let panel = panel.clone();
|
||||
|
||||
items.push(
|
||||
ReplyPreview::new(ix, author, preview)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("nip29: scrolling to message failed: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use nip29::{Group, GroupId, GroupMetadata};
|
||||
use nostr_sdk::prelude::RelayUrl;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::switch::Switch;
|
||||
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::util::field;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Flags {
|
||||
private: bool,
|
||||
restricted: bool,
|
||||
hidden: bool,
|
||||
closed: bool,
|
||||
livekit: bool,
|
||||
}
|
||||
|
||||
impl Flags {
|
||||
fn of(metadata: &GroupMetadata) -> Self {
|
||||
Self {
|
||||
private: metadata.private,
|
||||
restricted: metadata.restricted,
|
||||
hidden: metadata.hidden,
|
||||
closed: metadata.closed,
|
||||
livekit: metadata.livekit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let base = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Group name")
|
||||
.default_value(base.name.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let about = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("About")
|
||||
.default_value(base.about.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let picture = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://example.com/picture.png")
|
||||
.default_value(base.picture.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let parent = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Parent group id, empty for a root group")
|
||||
.default_value(
|
||||
base.parent
|
||||
.as_ref()
|
||||
.map(|id| id.as_str().to_owned())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
|
||||
let children = cx.new(|_cx| base.children.clone());
|
||||
let flags = cx.new(|_cx| Flags::of(&base));
|
||||
let livekit = cx.new(|_cx| None);
|
||||
|
||||
if let Ok(relay) = group.read_with(cx, |group, _cx| group.key().relay().clone()) {
|
||||
let http = cx.http_client();
|
||||
let probe = cx.background_spawn(async move { probe_livekit(http, relay).await });
|
||||
let livekit = livekit.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let supported = probe.await;
|
||||
|
||||
livekit.update(cx, |livekit, cx| {
|
||||
*livekit = Some(supported);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let current = *flags.read(cx);
|
||||
let supported = *livekit.read(cx) != Some(false);
|
||||
|
||||
this.confirm()
|
||||
.title("Edit Group")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Save"),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("About", &about, cx))
|
||||
.child(field("Picture", &picture, cx))
|
||||
.child(field("Parent", &parent, cx))
|
||||
.child(children_section(&children, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.label("Private")
|
||||
.description("Only members can read group messages")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("restricted")
|
||||
.label("Restricted")
|
||||
.description("Only members can post messages")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("hidden")
|
||||
.label("Hidden")
|
||||
.description("Hidden from non-members, not discoverable")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("closed")
|
||||
.label("Closed")
|
||||
.description("Join requests are ignored (invite-only)")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("livekit")
|
||||
.label("LiveKit")
|
||||
.description(if supported {
|
||||
"Announce audio and video support."
|
||||
} else {
|
||||
"The relay doesn't announce LiveKit support."
|
||||
})
|
||||
.checked(current.livekit)
|
||||
.on_click({
|
||||
let flags = flags.clone();
|
||||
let livekit = livekit.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
if *next && *livekit.read(cx) == Some(false) {
|
||||
return;
|
||||
}
|
||||
flags.update(cx, |flags, cx| {
|
||||
flags.livekit = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.on_ok({
|
||||
let group = group.clone();
|
||||
let name = name.clone();
|
||||
let about = about.clone();
|
||||
let picture = picture.clone();
|
||||
let parent = parent.clone();
|
||||
let children = children.clone();
|
||||
let flags = flags.clone();
|
||||
|
||||
move |_event, window, cx| {
|
||||
let mut next = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
next.name = value(&name, cx);
|
||||
next.about = value(&about, cx);
|
||||
next.picture = value(&picture, cx);
|
||||
|
||||
match value(&parent, cx).map(GroupId::new) {
|
||||
Some(Ok(id)) => next.parent = Some(id),
|
||||
Some(Err(error)) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
None => next.parent = None,
|
||||
}
|
||||
|
||||
next.children = children.read(cx).clone();
|
||||
|
||||
// The relay must reject these anyway; catch them early.
|
||||
if let Some(id) = &next.parent {
|
||||
let key = group
|
||||
.read_with(cx, |group, _cx| group.key().id().as_str().to_owned())
|
||||
.ok();
|
||||
|
||||
if key.as_deref() == Some(id.as_str()) {
|
||||
window.push_notification(
|
||||
Notification::error("A group can't be its own parent"),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if next.children.iter().any(|child| child == id) {
|
||||
window.push_notification(
|
||||
Notification::error("The parent is one of this group's children"),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let current = flags.read(cx);
|
||||
next.private = current.private;
|
||||
next.restricted = current.restricted;
|
||||
next.hidden = current.hidden;
|
||||
next.closed = current.closed;
|
||||
next.livekit = current.livekit;
|
||||
|
||||
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
|
||||
Ok(task) => crate::util::report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// NIP-29: relays announce LiveKit support with a 204 at this well-known path.
|
||||
async fn probe_livekit(http: Arc<dyn HttpClient>, relay: RelayUrl) -> bool {
|
||||
let base = relay
|
||||
.as_str()
|
||||
.replacen("wss://", "https://", 1)
|
||||
.replacen("ws://", "http://", 1);
|
||||
let url = format!("{}/.well-known/nip29/livekit", base.trim_end_matches('/'));
|
||||
|
||||
match http.get(&url, AsyncBody::default(), true).await {
|
||||
Ok(response) => response.status() == 204,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
let list = children.read(cx);
|
||||
|
||||
let mut section = v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Children");
|
||||
|
||||
if list.is_empty() {
|
||||
return section
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("No subgroups"),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
for (ix, child) in list.iter().enumerate() {
|
||||
let order = h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.when(ix > 0, |this| {
|
||||
this.child(move_button(children.clone(), ix, ix - 1, "up"))
|
||||
})
|
||||
.when(ix + 1 < list.len(), |this| {
|
||||
this.child(move_button(children.clone(), ix, ix + 1, "down"))
|
||||
});
|
||||
|
||||
section = section.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_color(cx.theme().text)
|
||||
.child(SharedString::from(child.as_str().to_owned())),
|
||||
)
|
||||
.child(order),
|
||||
);
|
||||
}
|
||||
|
||||
section.into_any_element()
|
||||
}
|
||||
|
||||
fn move_button(
|
||||
children: Entity<Vec<GroupId>>,
|
||||
ix: usize,
|
||||
target: usize,
|
||||
direction: &'static str,
|
||||
) -> Button {
|
||||
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
|
||||
.label(if direction == "up" { "Up" } else { "Down" })
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, _window, cx| {
|
||||
children.update(cx, |children, cx| {
|
||||
if target < children.len() {
|
||||
children.swap(ix, target);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn toggle(
|
||||
flags: &Entity<Flags>,
|
||||
select: fn(&mut Flags) -> &mut bool,
|
||||
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
let flags = flags.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
flags.update(cx, |flags, cx| {
|
||||
*select(flags) = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn value(input: &Entity<InputState>, cx: &App) -> Option<String> {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use anyhow::Result;
|
||||
use gpui::{App, Div, Entity, ParentElement, SharedString, Styled, Task, Window};
|
||||
use nip29::{Group, Pin, Row};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
pub(crate) fn display_name(group: &Group) -> SharedString {
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.map_or_else(
|
||||
|| SharedString::from(group.key().id().as_str()),
|
||||
SharedString::from,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn attachment_name(url: &Url) -> SharedString {
|
||||
url.path_segments()
|
||||
.and_then(|mut segments| segments.next_back())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map_or_else(
|
||||
|| SharedString::from(url.as_str()),
|
||||
|name| SharedString::from(name.to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||
let (Some(current), Some(previous)) = (
|
||||
rows.get(index),
|
||||
index.checked_sub(1).and_then(|index| rows.get(index)),
|
||||
) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
match (current, previous) {
|
||||
(Row::Message(current), Row::Message(previous)) => ui::message::opens_run(
|
||||
Some((previous.author, previous.created_at.as_secs())),
|
||||
(current.author, current.created_at.as_secs()),
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
}
|
||||
|
||||
pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString {
|
||||
match pin {
|
||||
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
|
||||
Pin::Event(id) => {
|
||||
let snippet = group
|
||||
.message(*id)
|
||||
.and_then(|message| message.content.lines().next())
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty());
|
||||
|
||||
match snippet {
|
||||
Some(snippet) => SharedString::from(snippet.chars().take(48).collect::<String>()),
|
||||
None => SharedString::from(id.to_hex().chars().take(8).collect::<String>()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a group write and surface any relay error as a notification.
|
||||
pub(crate) fn report(window: &mut Window, cx: &mut App, task: Task<Result<()>>) {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
[package]
|
||||
name = "device"
|
||||
name = "nip4e"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
person = { path = "../person" }
|
||||
ui = { path = "../ui" }
|
||||
@@ -4,19 +4,18 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||
SharedString, Styled, Subscription, Task, Window, div, px, relative,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::Button;
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
@@ -24,8 +23,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
@@ -40,13 +39,13 @@ pub enum DeviceEvent {
|
||||
/// User have not setup encryption key
|
||||
NotSet,
|
||||
/// The device is requesting an encryption key
|
||||
Requesting,
|
||||
Requesting(SharedString),
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
}
|
||||
|
||||
impl DeviceEvent {
|
||||
pub fn error<T>(error: T) -> Self
|
||||
pub(crate) fn error<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
@@ -59,19 +58,16 @@ impl DeviceEvent {
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
/// Whether there is a pending request for encryption key approval
|
||||
pub pending_request: bool,
|
||||
|
||||
/// Whether an announcement has been made for this device
|
||||
pub announcement_existed: Arc<AtomicBool>,
|
||||
|
||||
/// Signer
|
||||
signer: Entity<Option<UniversalSigner>>,
|
||||
|
||||
/// Async tasks
|
||||
/// Whether there is a pending request for encryption key approval
|
||||
pub(crate) pending_request: bool,
|
||||
/// Whether an announcement has been made for this device
|
||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||
/// Person directory for requesters shown in notifications
|
||||
directory: Entity<Directory>,
|
||||
/// Relay notification listener
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscription
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
@@ -89,10 +85,10 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
let nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
||||
|
||||
let signer = cx.new(|_| None);
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -109,26 +105,35 @@ impl DeviceRegistry {
|
||||
subscriptions.push(
|
||||
// Observe the user signer
|
||||
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
|
||||
if event.signer_changed() && nip4e_enabled {
|
||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||
// The old account's state must not leak into the new one
|
||||
this.announcement_existed.store(false, Ordering::Relaxed);
|
||||
this.handle_notifications(cx);
|
||||
this.get_announcement(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| this.handle_notifications(cx))
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
signer,
|
||||
pending_request: false,
|
||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||
directory: Directory::new(cx),
|
||||
notification_listener: None,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -136,7 +141,7 @@ impl DeviceRegistry {
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
let current_user = signer.get_public_key_async().await.ok();
|
||||
@@ -169,18 +174,18 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
match event.kind {
|
||||
Kind::Custom(10044) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_encryption(&event, cx);
|
||||
})?;
|
||||
}
|
||||
// New request event from other device
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
this.update(cx, |this, cx| {
|
||||
this.ask_for_approval(event, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event from the master device
|
||||
@@ -226,6 +231,11 @@ impl DeviceRegistry {
|
||||
let keys = get_keys(&client, &signer).await?;
|
||||
let content = keys.secret_key().to_bech32()?;
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(anyhow!("Not supported"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
smol::fs::write(path, &content).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -238,43 +248,37 @@ impl DeviceRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(current_user)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe(filter)
|
||||
.close_on(opts)
|
||||
.with_id(SubscriptionId::new("nip4e"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if !cx
|
||||
.background_spawn(async move {
|
||||
// Wait for 5 seconds
|
||||
executor.timer(Duration::from_secs(5)).await;
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
// Then check if the msg relays have been found
|
||||
if !announcement_existed.load(Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
// Construct the filter for the device announcement event
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(current_user)
|
||||
.limit(1);
|
||||
|
||||
false
|
||||
})
|
||||
.await
|
||||
{
|
||||
client
|
||||
.subscribe(filter)
|
||||
.close_on(opts)
|
||||
.with_id(SubscriptionId::new("nip4e"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !announcement_existed.load(Ordering::Acquire) {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
@@ -318,13 +322,13 @@ impl DeviceRegistry {
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
||||
.tags(vec![
|
||||
Tag::custom("n", vec![n]),
|
||||
Tag::custom("client", vec![CLIENT_NAME]),
|
||||
])
|
||||
.tag(Tag::custom("n", vec![n]))
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Persist the key locally before announcing it
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
|
||||
// Publish announcement
|
||||
client
|
||||
.send_event(&event)
|
||||
@@ -332,20 +336,21 @@ impl DeviceRegistry {
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await?;
|
||||
|
||||
// Save device keys to the database
|
||||
set_keys(&client, &signer, &secret).await?;
|
||||
|
||||
Ok(keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// Set encryption key from the announcement event
|
||||
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
log::warn!("Ignoring malformed encryption announcement");
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let announcement = Announcement::from(event);
|
||||
let device_pubkey = announcement.public_key();
|
||||
|
||||
// Get encryption key from the database and compare with the announcement
|
||||
@@ -385,11 +390,8 @@ impl DeviceRegistry {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let id = SubscriptionId::new("dekey-requests");
|
||||
|
||||
// Construct a filter for encryption key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4454))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
// Construct a filter for encryption key requests.
|
||||
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(vec![filter]).with_id(id).await?;
|
||||
@@ -399,16 +401,15 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Request encryption keys from other device
|
||||
pub fn request(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let Ok(app_keys) = get_or_init_app_keys(cx) else {
|
||||
return;
|
||||
};
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let app_pubkey = app_keys.public_key();
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
@@ -419,26 +420,27 @@ impl DeviceRegistry {
|
||||
.pubkey(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.first_owned() {
|
||||
// Found an approval event
|
||||
Some(event) => Ok(Some(event)),
|
||||
// No approval event found, construct a request event
|
||||
None => {
|
||||
// Construct an event for device key request
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![
|
||||
Tag::custom("P", vec![app_pubkey]),
|
||||
Tag::custom("client", vec![CLIENT_NAME]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||
// Only reuse the stored approval if its key is still valid,
|
||||
// otherwise it's stale and a fresh request is needed
|
||||
if decrypt_approval(&client, &app_keys, public_key, &event)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Some(event));
|
||||
}
|
||||
}
|
||||
|
||||
// No usable approval event found, construct a request event
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![Tag::custom("P", vec![app_pubkey])])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(None)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -468,20 +470,45 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
cx.emit(DeviceEvent::Requesting);
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let app_keys = match app_keys_task.await {
|
||||
Ok(app_keys) => app_keys,
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::Requesting(authorization_code(
|
||||
&app_keys.public_key(),
|
||||
)));
|
||||
})?;
|
||||
|
||||
// Construct a filter for device key requests
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
// Only approvals addressed to this device's app key matter
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(4455))
|
||||
.author(public_key)
|
||||
.pubkey(app_keys.public_key());
|
||||
|
||||
// Subscribe to the device key approvals on user's write relays
|
||||
client.subscribe(filter).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -489,24 +516,17 @@ impl DeviceRegistry {
|
||||
|
||||
/// Parse the approval event to get encryption key then set it
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let Ok(app_keys) = get_or_init_app_keys(cx) else {
|
||||
return;
|
||||
};
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let approval_id = event.id;
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let payload = event.content.as_str();
|
||||
let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
let app_keys = app_keys_task.await?;
|
||||
let identity = signer.get_public_key_async().await?;
|
||||
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
||||
|
||||
Ok(keys)
|
||||
});
|
||||
@@ -516,6 +536,8 @@ impl DeviceRegistry {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
// Clean up our request and the approval event from relays
|
||||
this.cleanup_sync_events(approval_id, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -528,6 +550,47 @@ impl DeviceRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Delete our key request and the consumed approval event from relays
|
||||
fn cleanup_sync_events(&mut self, approval_id: EventId, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let result: Result<(), Error> = async {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
// Collect our key request events
|
||||
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
|
||||
|
||||
let mut ids: Vec<EventId> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
ids.push(approval_id);
|
||||
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(Tags::from_list(ids.into_iter().map(Tag::event).collect()))
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
client.send_event(&deletion).to_nip65().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to delete sync events: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Approve requests for device keys from other devices
|
||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -573,9 +636,14 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
// Allow future requests to surface again
|
||||
this.set_pending_request(false, cx);
|
||||
})?;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.clear_notification_by_id::<DeviceNotification>(id, cx);
|
||||
})
|
||||
@@ -591,62 +659,95 @@ impl DeviceRegistry {
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
// Ignore if there is already a pending request
|
||||
if self.pending_request {
|
||||
return;
|
||||
}
|
||||
self.set_pending_request(true, cx);
|
||||
|
||||
// Show notification
|
||||
let notification = self.notification(event, cx);
|
||||
window.push_notification(notification, cx);
|
||||
let notification = match self.notification(event, cx) {
|
||||
Ok(notification) => notification,
|
||||
Err(e) => {
|
||||
log::warn!("Ignoring invalid encryption key request: {e}");
|
||||
self.set_pending_request(false, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(window) = cx.windows().first().copied() {
|
||||
if let Err(error) = window.update(cx, |_view, window, cx| {
|
||||
window.push_notification(notification, cx);
|
||||
}) {
|
||||
log::warn!("Failed to show encryption key request: {error}");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to show encryption key request: no open window");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a notification for the encryption request.
|
||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
||||
fn notification(
|
||||
&mut self,
|
||||
event: Event,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<Notification, Error> {
|
||||
const MSG: &str = "You've requested an encryption key from another device. \
|
||||
Approve to allow Coop to share with it.";
|
||||
Approve to allow Coop to share with it. Before approving, \
|
||||
compare the code with the one shown on the requesting \
|
||||
device.";
|
||||
|
||||
let request = Announcement::from(&event);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&request.public_key(), cx);
|
||||
let requester = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Request has no valid requester key")?;
|
||||
|
||||
let code = authorization_code(&requester);
|
||||
let directory = self.directory.clone();
|
||||
directory.update(cx, |directory, cx| directory.track([requester], cx));
|
||||
let code_label = code.clone();
|
||||
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
let key = SharedString::from(event.id.to_hex());
|
||||
|
||||
Notification::new()
|
||||
Ok(Notification::new()
|
||||
.type_id::<DeviceNotification>(key)
|
||||
.autohide(false)
|
||||
.with_kind(NotificationKind::Info)
|
||||
.title("Encryption Key Request")
|
||||
.content(move |_this, _window, cx| {
|
||||
let profile = directory.read(cx).person(&requester, cx);
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("From:")),
|
||||
)
|
||||
@@ -655,12 +756,16 @@ impl DeviceRegistry {
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).xsmall())
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
),
|
||||
),
|
||||
@@ -668,22 +773,22 @@ impl DeviceRegistry {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Client:")),
|
||||
.child(SharedString::from("Code:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(request.client_name()),
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.child(code_label.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -709,43 +814,99 @@ impl DeviceRegistry {
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// Get or create new app keys
|
||||
fn get_or_init_app_keys(cx: &App) -> Result<Keys, Error> {
|
||||
let read = cx.read_credentials(CLIENT_NAME);
|
||||
let stored_keys: Option<Keys> = cx.foreground_executor().block_on(async move {
|
||||
if let Ok(Some((_, secret))) = read.await {
|
||||
SecretKey::from_slice(&secret).map(Keys::new).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
/// The first eight hexadecimal characters of the client public key.
|
||||
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
||||
let hex = public_key.to_hex();
|
||||
let code = hex[..8].to_uppercase();
|
||||
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
|
||||
}
|
||||
|
||||
if let Some(keys) = stored_keys {
|
||||
Ok(keys)
|
||||
} else {
|
||||
/// Get or create new app keys (async, returns a task)
|
||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let read = cx.read_credentials(CLIENT_NAME);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Some((_, secret))) = read.await
|
||||
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
||||
{
|
||||
return Ok(keys);
|
||||
}
|
||||
|
||||
// No stored keys found or invalid — generate new ones
|
||||
let keys = Keys::generate();
|
||||
let user = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||
|
||||
cx.foreground_executor().block_on(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Keyring not available or panic: {e}")
|
||||
}
|
||||
cx.update(|cx| {
|
||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Keyring not available or panic: {e}")
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Ok(keys)
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt and validate an approval event against the current announcement.
|
||||
async fn decrypt_approval(
|
||||
client: &Client,
|
||||
app_keys: &Keys,
|
||||
identity: PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<Keys, Error> {
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Invalid event's tags")?;
|
||||
|
||||
let decrypted = app_keys
|
||||
.nip44_decrypt_async(&master, event.content.as_str())
|
||||
.await?;
|
||||
|
||||
let secret = SecretKey::from_hex(&decrypted)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
// Reject approvals carrying a key that no longer matches the published announcement.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(identity)
|
||||
.limit(1);
|
||||
|
||||
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
|
||||
let announced = announcement
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
.context("Announcement has no valid encryption key")?;
|
||||
|
||||
if keys.public_key() != announced {
|
||||
return Err(anyhow!("Approval key doesn't match the announcement"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Result<(), Error> {
|
||||
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
|
||||
|
||||
@@ -762,7 +923,10 @@ async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Re
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
|
||||
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
@@ -10,8 +10,8 @@ state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::{Person, PersonEvent, PersonRegistry};
|
||||
|
||||
/// Directory of persons tracked by a view
|
||||
#[derive(Debug)]
|
||||
pub struct Directory {
|
||||
persons: HashMap<PublicKey, Person>,
|
||||
/// Keys awaiting their first update, guarded for render-time access
|
||||
pending: RwLock<HashSet<PublicKey>>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
/// Create a directory following the registry's events
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription = cx.subscribe(®istry, |this: &mut Self, _registry, event, cx| {
|
||||
this.handle_event(event.clone(), cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
pending: RwLock::new(HashSet::new()),
|
||||
tasks: SmallVec::new(),
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Load known profiles from the database, request the rest
|
||||
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>) {
|
||||
/// Profiles and emoji lists loaded from the database
|
||||
type Loaded = (Vec<Person>, Vec<(PublicKey, Vec<(String, Url)>)>);
|
||||
|
||||
let missing: Vec<PublicKey> = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
!self.persons.contains_key(key)
|
||||
&& !self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept events that arrive while the database query is running
|
||||
self.with_pending(|pending| {
|
||||
pending.extend(missing.iter().copied());
|
||||
});
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let authors = missing.clone();
|
||||
|
||||
let task: Task<Result<Loaded, Error>> = cx.background_spawn(async move {
|
||||
let mut metadata_events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::Metadata).authors(authors.clone()))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Keep only the newest event per author
|
||||
metadata_events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let persons = metadata_events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut emoji_events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filter::new().kind(Kind::Emojis).authors(authors))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Keep only the newest event per author
|
||||
emoji_events.sort_by_key(|event| Reverse(event.created_at));
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let emojis = emoji_events
|
||||
.into_iter()
|
||||
.filter(|event| seen.insert(event.pubkey))
|
||||
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
|
||||
.collect();
|
||||
|
||||
Ok((persons, emojis))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let loaded = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let remaining = match loaded {
|
||||
Ok((persons, emojis)) => {
|
||||
let found: HashSet<PublicKey> =
|
||||
persons.iter().map(Person::public_key).collect();
|
||||
this.bulk_insert(persons, cx);
|
||||
|
||||
// Emojis apply on top of loaded or already received data
|
||||
let mut updated = false;
|
||||
|
||||
for (public_key, emojis) in emojis {
|
||||
if emojis.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let person = this
|
||||
.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||
person.set_emojis(emojis);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
missing
|
||||
.into_iter()
|
||||
.filter(|key| !found.contains(key))
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load persons from the database: {e}");
|
||||
missing
|
||||
}
|
||||
};
|
||||
|
||||
if !remaining.is_empty() {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
registry.read(cx).fetch(remaining);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// The person for a key, or a placeholder that queues a request
|
||||
pub fn person(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
if let Some(person) = self.persons.get(public_key) {
|
||||
return person.clone();
|
||||
}
|
||||
|
||||
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||
let registry = PersonRegistry::global(cx);
|
||||
registry.read(cx).fetch([*public_key]);
|
||||
}
|
||||
|
||||
Person::new(*public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Merge event data into the tracked person
|
||||
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
|
||||
let public_key = match &event {
|
||||
PersonEvent::Metadata(person) => person.public_key(),
|
||||
PersonEvent::Emojis { public_key, .. }
|
||||
| PersonEvent::Announcement { public_key, .. }
|
||||
| PersonEvent::MessagingRelays { public_key, .. } => *public_key,
|
||||
};
|
||||
|
||||
if !self.persons.contains_key(&public_key)
|
||||
&& !self.with_pending(|pending| pending.remove(&public_key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let person = self
|
||||
.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||
|
||||
match event {
|
||||
PersonEvent::Metadata(updated) => person.set_metadata(updated.metadata()),
|
||||
PersonEvent::Emojis { emojis, .. } => person.set_emojis(emojis),
|
||||
PersonEvent::Announcement { announcement, .. } => {
|
||||
person.set_announcement(announcement);
|
||||
}
|
||||
PersonEvent::MessagingRelays { relays, .. } => person.set_messaging_relays(relays),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Insert loaded persons, keeping data already received from relays
|
||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
let mut inserted = false;
|
||||
|
||||
for person in persons {
|
||||
let public_key = person.public_key();
|
||||
|
||||
if self.persons.contains_key(&public_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.persons.insert(public_key, person);
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
if inserted {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `then` with the pending set, tolerating poisoning: it is only a dedupe cache
|
||||
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
|
||||
let mut pending = match self.pending.write() {
|
||||
Ok(pending) => pending,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,68 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||
|
||||
mod directory;
|
||||
mod person;
|
||||
|
||||
pub use directory::*;
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
|
||||
impl Global for GlobalPersonRegistry {}
|
||||
|
||||
/// A change to a person's data
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PersonEvent {
|
||||
/// Profile metadata
|
||||
Metadata(Box<Person>),
|
||||
/// Custom emoji list (NIP-30)
|
||||
Emojis {
|
||||
public_key: PublicKey,
|
||||
emojis: Vec<(String, Url)>,
|
||||
},
|
||||
/// Profile encryption keys announcement (NIP-4e)
|
||||
Announcement {
|
||||
public_key: PublicKey,
|
||||
announcement: Announcement,
|
||||
},
|
||||
/// Messaging relay list (NIP-65)
|
||||
MessagingRelays {
|
||||
public_key: PublicKey,
|
||||
relays: Vec<RelayUrl>,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Box<Person>),
|
||||
Announcement(Box<Event>),
|
||||
Relays(Box<Event>),
|
||||
Person(Person),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
Emojis(Event),
|
||||
}
|
||||
|
||||
/// Person Registry
|
||||
#[derive(Debug)]
|
||||
pub struct PersonRegistry {
|
||||
/// Collection of all persons (user profiles)
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seens: Rc<RefCell<HashSet<PublicKey>>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
/// Public keys with a metadata request in flight
|
||||
in_flight: RwLock<HashSet<PublicKey>>,
|
||||
sender: flume::Sender<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl PersonRegistry {
|
||||
@@ -57,70 +77,59 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Create a new person registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
|
||||
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Handle nostr notifications
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
let client2 = client.clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_notifications(&client2, &tx).await;
|
||||
}));
|
||||
|
||||
async move {
|
||||
Self::handle_notifications(&client, &tx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
let client3 = client.clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client3, &metadata_rx).await;
|
||||
}));
|
||||
|
||||
tasks.push(
|
||||
// Handle metadata requests
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
Dispatch::Emojis(event) => {
|
||||
this.set_emojis(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
|
||||
async move {
|
||||
Self::handle_requests(&client, &mta_rx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Update GPUI state
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(*person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.get_emojis(cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
seens: Rc::new(RefCell::new(HashSet::new())),
|
||||
sender: mta_tx,
|
||||
in_flight: RwLock::new(HashSet::new()),
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,24 +154,21 @@ impl PersonRegistry {
|
||||
Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
let val = Box::new(person);
|
||||
// Send
|
||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
// Get metadata for all public keys
|
||||
get_metadata(client, public_keys).await.ok();
|
||||
|
||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||
log::warn!("Channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
let val = Box::new(event.into_owned());
|
||||
// Send
|
||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
|
||||
}
|
||||
Kind::Emojis => {
|
||||
_ = tx.send_async(Dispatch::Emojis(event.into_owned())).await;
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
let val = Box::new(event.into_owned());
|
||||
// Send
|
||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||
_ = tx
|
||||
.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -182,130 +188,118 @@ impl PersonRegistry {
|
||||
Ok(Some(public_key)) => {
|
||||
batch.insert(public_key);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20 {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !batch.is_empty() {
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
/// Subscribe to the signed-in user's emoji list
|
||||
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
let persons = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
Person::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let filter = Filter::new().kind(Kind::Emojis).author(public_key).limit(1);
|
||||
|
||||
Ok(persons)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(persons) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.bulk_inserts(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
if let Err(e) = client.subscribe(vec![filter]).close_on(opts).await {
|
||||
log::warn!("Failed to subscribe to emoji list: {e}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
let announcement = Announcement::from(event);
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
log::warn!("Ignoring announcement with missing or invalid key tag");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_announcement(announcement);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person =
|
||||
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
cx.emit(PersonEvent::Announcement {
|
||||
public_key: event.pubkey,
|
||||
announcement,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set messaging relays for a person
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
|
||||
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_messaging_relays(urls);
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
||||
self.insert(person, cx);
|
||||
}
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
cx.emit(PersonEvent::MessagingRelays {
|
||||
public_key: event.pubkey,
|
||||
relays: nip17::extract_relay_list(event).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert batch of persons
|
||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons.into_iter() {
|
||||
let public_key = person.public_key();
|
||||
self.persons
|
||||
.entry(public_key)
|
||||
.or_insert_with(|| cx.new(|_| person));
|
||||
}
|
||||
cx.notify();
|
||||
/// Set custom emoji for a person from their emoji list event
|
||||
fn set_emojis(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
|
||||
}
|
||||
|
||||
/// Insert or update a person
|
||||
pub fn insert(&mut self, person: Person, cx: &mut App) {
|
||||
/// Set custom emoji for a person
|
||||
fn set_emojis_for(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
emojis: Vec<(String, Url)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.emit(PersonEvent::Emojis { public_key, emojis });
|
||||
}
|
||||
|
||||
/// Publish a person's profile metadata to every directory
|
||||
pub fn insert(&mut self, person: Person, cx: &mut Context<Self>) {
|
||||
let public_key = person.public_key();
|
||||
self.release_in_flight(&public_key);
|
||||
|
||||
match self.persons.get(&public_key) {
|
||||
Some(this) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_metadata(person.metadata());
|
||||
cx.notify();
|
||||
});
|
||||
cx.emit(PersonEvent::Metadata(Box::new(person)));
|
||||
}
|
||||
|
||||
/// Queue a batched metadata request for keys without one in flight
|
||||
pub(crate) fn fetch<I>(&self, keys: I)
|
||||
where
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let mut queue = Vec::new();
|
||||
{
|
||||
let mut in_flight = match self.in_flight.write() {
|
||||
Ok(in_flight) => in_flight,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
|
||||
for key in keys {
|
||||
if in_flight.insert(key) {
|
||||
queue.push(key);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.persons.insert(public_key, cx.new(|_| person));
|
||||
}
|
||||
|
||||
for public_key in queue {
|
||||
if let Err(error) = self.sender.send(public_key) {
|
||||
log::warn!("Failed to queue metadata request: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get single person by public key
|
||||
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
if let Some(person) = self.persons.get(public_key) {
|
||||
return person.read(cx).clone();
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
let mut seen = self.seens.borrow_mut();
|
||||
|
||||
if seen.insert(public_key) {
|
||||
let sender = self.sender.clone();
|
||||
|
||||
// Spawn background task to request metadata
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = sender.send_async(public_key).await {
|
||||
log::warn!("Failed to send public key for metadata request: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// Return a temporary profile with default metadata
|
||||
Person::new(public_key, Metadata::default())
|
||||
/// Allow future fetches for a key once its metadata has arrived
|
||||
fn release_in_flight(&self, public_key: &PublicKey) {
|
||||
let mut in_flight = match self.in_flight.write() {
|
||||
Ok(in_flight) => in_flight,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
in_flight.remove(public_key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,20 +5,17 @@ use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::Announcement;
|
||||
|
||||
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||
|
||||
/// Person
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Person {
|
||||
/// Public Key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Metadata (profile)
|
||||
metadata: Metadata,
|
||||
|
||||
/// Custom emoji (NIP-30) from the person's emoji list
|
||||
emojis: Vec<(String, Url)>,
|
||||
/// Dekey (NIP-4e) announcement
|
||||
announcement: Option<Announcement>,
|
||||
|
||||
/// Messaging relays
|
||||
messaging_relays: Vec<RelayUrl>,
|
||||
}
|
||||
@@ -49,37 +46,17 @@ impl Hash for Person {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PublicKey> for Person {
|
||||
fn from(public_key: PublicKey) -> Self {
|
||||
Self::new(public_key, Metadata::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
emojis: vec![],
|
||||
announcement: None,
|
||||
messaging_relays: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build profile encryption keys announcement
|
||||
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||
self.announcement = Some(announcement);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build profile messaging relays
|
||||
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get profile public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
@@ -90,6 +67,30 @@ impl Person {
|
||||
self.metadata.clone()
|
||||
}
|
||||
|
||||
/// Custom emoji (NIP-30) as (shortcode, image url) pairs
|
||||
pub fn emojis(&self) -> &[(String, Url)] {
|
||||
&self.emojis
|
||||
}
|
||||
|
||||
/// Set custom emoji from the person's emoji list
|
||||
pub(crate) fn set_emojis(&mut self, emojis: Vec<(String, Url)>) {
|
||||
self.emojis = emojis;
|
||||
}
|
||||
|
||||
/// An `emoji` tag for every custom emoji referenced as `:shortcode:` in `content`
|
||||
pub fn emoji_tags(&self, content: &str) -> Vec<Tag> {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
for (shortcode, url) in &self.emojis {
|
||||
let pattern = format!(":{shortcode}:");
|
||||
if content.contains(&pattern) {
|
||||
tags.push(Tag::custom("emoji", [shortcode.clone(), url.to_string()]));
|
||||
}
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// Get profile encryption keys announcement
|
||||
pub fn announcement(&self) -> Option<Announcement> {
|
||||
self.announcement.clone()
|
||||
@@ -105,20 +106,18 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
/// Get profile picture, if the profile has one
|
||||
pub fn avatar(&self) -> Option<SharedString> {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
let encoded_picture = urlencoding::encode(picture);
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
);
|
||||
url.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
/// A stable seed for this profile's generated avatar
|
||||
pub fn avatar_seed(&self) -> String {
|
||||
self.public_key().to_hex()
|
||||
}
|
||||
|
||||
/// Get profile name
|
||||
@@ -139,17 +138,17 @@ impl Person {
|
||||
}
|
||||
|
||||
/// Set profile metadata
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||
pub(crate) fn set_metadata(&mut self, metadata: Metadata) {
|
||||
self.metadata = metadata;
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
pub(crate) fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
use theme::{AppExt as _, AppearanceMode};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
@@ -36,32 +40,33 @@ macro_rules! setting_accessors {
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub theme: Option<String>,
|
||||
pub theme_mode: ThemeMode,
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub nip4e: bool,
|
||||
pub auth_mode: AuthMode,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Authentication mode
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AuthMode {
|
||||
#[default]
|
||||
Auto,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Display for AuthMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthMode::Auto => write!(f, "Auto"),
|
||||
AuthMode::Manual => write!(f, "Ask every time"),
|
||||
}
|
||||
}
|
||||
setting_getters! {
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
@@ -74,14 +79,6 @@ pub enum SignerKind {
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn auto(&self) -> bool {
|
||||
matches!(self, SignerKind::Auto)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> bool {
|
||||
matches!(self, SignerKind::User)
|
||||
}
|
||||
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
@@ -123,14 +120,10 @@ impl RoomConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// Theme
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Theme mode
|
||||
pub theme_mode: ThemeMode,
|
||||
#[serde(default, alias = "theme_mode")]
|
||||
pub appearance_mode: AppearanceMode,
|
||||
|
||||
/// Hide user avatars
|
||||
pub hide_avatar: bool,
|
||||
@@ -141,37 +134,46 @@ pub struct Settings {
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
|
||||
/// Authentication mode
|
||||
pub auth_mode: AuthMode,
|
||||
/// Show an image behind chat and group panels
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
|
||||
/// Custom image for the panel background; the built-in one when unset
|
||||
#[serde(default)]
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
|
||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||
pub trusted_relays: Vec<String>,
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Pinned chat rooms, by conversation ID
|
||||
#[serde(default)]
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
|
||||
/// Pinned groups, by cache tag
|
||||
#[serde(default)]
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: None,
|
||||
theme_mode: ThemeMode::default(),
|
||||
appearance_mode: AppearanceMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
auth_mode: AuthMode::default(),
|
||||
panel_background: true,
|
||||
panel_background_path: None,
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
pinned_rooms: vec![],
|
||||
pinned_groups: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Settings> for Settings {
|
||||
fn as_ref(&self) -> &Settings {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalAppSettings(Entity<AppSettings>);
|
||||
|
||||
impl Global for GlobalAppSettings {}
|
||||
@@ -180,7 +182,6 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
/// Settings
|
||||
inner: Entity<Settings>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -196,7 +197,8 @@ impl AppSettings {
|
||||
cx.set_global(GlobalAppSettings(state));
|
||||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let inner = cx.new(|_| Settings::default());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -208,8 +210,8 @@ impl AppSettings {
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -227,7 +229,7 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -239,13 +241,19 @@ impl AppSettings {
|
||||
Err(anyhow!("Not found"))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let settings = task.await.unwrap_or(Settings::default());
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut settings = task.await.unwrap_or(Settings::default());
|
||||
|
||||
// Move settings still pointed at the old default file server over to the new one
|
||||
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
|
||||
}
|
||||
|
||||
// Update settings
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
this.apply_theme(window, cx);
|
||||
this.apply_theme(cx);
|
||||
cx.refresh_windows();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -265,44 +273,10 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set theme
|
||||
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
// Update settings
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = Some(theme.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.apply_theme(window, cx);
|
||||
}
|
||||
|
||||
/// Apply theme
|
||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
||||
let mode = self.inner.read(cx).theme_mode;
|
||||
|
||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||
}
|
||||
/// Apply the persisted appearance mode to the app
|
||||
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
|
||||
let mode = self.inner.read(cx).appearance_mode;
|
||||
cx.set_appearance_mode(mode);
|
||||
}
|
||||
|
||||
/// Check if decoupling encryption key is enabled
|
||||
@@ -310,27 +284,79 @@ impl AppSettings {
|
||||
self.inner.read(cx).nip4e
|
||||
}
|
||||
|
||||
/// Check if the given relay is already authenticated
|
||||
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
|
||||
self.inner
|
||||
.read(cx)
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
/// The image to show behind chat and group panels, when enabled
|
||||
pub fn panel_background_source(cx: &App) -> Option<ImageSource> {
|
||||
if !Self::get_panel_background(cx) {
|
||||
return None;
|
||||
}
|
||||
Some(match Self::get_panel_background_path(cx) {
|
||||
Some(path) => path.into(),
|
||||
None => DEFAULT_BACKGROUND.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a relay to the trusted list
|
||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
/// Check if the chat room is pinned
|
||||
pub fn pinned_room(&self, id: u64, cx: &App) -> bool {
|
||||
self.inner.read(cx).pinned_rooms.contains(&id)
|
||||
}
|
||||
|
||||
/// Pin or unpin the chat room
|
||||
pub fn toggle_pinned_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if !this
|
||||
.trusted_relays
|
||||
.iter()
|
||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||
{
|
||||
this.trusted_relays
|
||||
.push(url.as_str_without_trailing_slash().to_string());
|
||||
cx.notify();
|
||||
if let Some(index) = this.pinned_rooms.iter().position(|pinned| *pinned == id) {
|
||||
this.pinned_rooms.remove(index);
|
||||
} else {
|
||||
this.pinned_rooms.push(id);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if the group is pinned by its cache tag
|
||||
pub fn pinned_group(&self, tag: &str, cx: &App) -> bool {
|
||||
self.inner
|
||||
.read(cx)
|
||||
.pinned_groups
|
||||
.iter()
|
||||
.any(|pinned| pinned == tag)
|
||||
}
|
||||
|
||||
/// Pin or unpin the group by its cache tag
|
||||
pub fn toggle_pinned_group(&mut self, tag: &str, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
if let Some(index) = this.pinned_groups.iter().position(|pinned| pinned == tag) {
|
||||
this.pinned_groups.remove(index);
|
||||
} else {
|
||||
this.pinned_groups.push(tag.to_owned());
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
|
||||
// Every field the persisted format has always required; `partial`
|
||||
// contributes the fields under test.
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
|
||||
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
|
||||
partial
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_theme_mode_key_keeps_its_pinned_choice() {
|
||||
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_appearance_mode_falls_back_to_dark() {
|
||||
let settings = json_with("").unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,24 @@ nostr-blossom.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
aes-gcm.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||
nostr-lmdb.workspace = true
|
||||
smol.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
|
||||
@@ -4,30 +4,52 @@ use anyhow::{Error, anyhow};
|
||||
use gpui::AsyncApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(path).await?;
|
||||
let keys = Keys::generate();
|
||||
use crate::file::sha256_hex;
|
||||
|
||||
// Construct the blossom client
|
||||
let client = BlossomClient::new(server);
|
||||
/// Upload a blob to a blossom server and return its URL
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) async fn upload_blob(
|
||||
server: &Url,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
sha256: &str,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Url, Error> {
|
||||
let client = BlossomClient::new(server.clone());
|
||||
let keys = Keys::generate();
|
||||
let content_type = content_type.to_string();
|
||||
let base = server.clone();
|
||||
let hash = sha256.to_string();
|
||||
|
||||
Tokio::spawn(cx, async move {
|
||||
let blob = client
|
||||
match client
|
||||
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||
.await?;
|
||||
|
||||
Ok(blob.url)
|
||||
.await
|
||||
{
|
||||
Ok(blob) => Ok(blob.url),
|
||||
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
|
||||
Err(e) => Err(anyhow!(e.to_string())),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(&path).await?;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
/// COOP's public key
|
||||
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x";
|
||||
|
||||
/// App ID
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Keyring name
|
||||
pub const MASTER_KEYRING: &str = "Coop Master Key";
|
||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
||||
pub const USER_KEYRING: &str = "Coop Safe Storage";
|
||||
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 2;
|
||||
|
||||
/// Default image cache size
|
||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
@@ -29,24 +25,16 @@ pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
/// Default subscription id for user gift wrap events
|
||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||
|
||||
/// Default vertex relays
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
/// Default search relays
|
||||
pub(crate) const INDEXER_RELAYS: [&str; 2] =
|
||||
["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// Default search relays
|
||||
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
|
||||
|
||||
/// Default search relays
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://relay.primal.net",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://profiles.nostr1.com",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::aead::consts::{U12, U16, U32};
|
||||
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||
use aes_gcm::aes::Aes256;
|
||||
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::http_client::AsyncBody;
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) const ALGORITHM: &str = "aes-gcm";
|
||||
|
||||
pub(crate) const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
const TAG_FILE_TYPE: &str = "file-type";
|
||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||
const TAG_KEY: &str = "decryption-key";
|
||||
const TAG_NONCE: &str = "decryption-nonce";
|
||||
const TAG_SIZE: &str = "size";
|
||||
const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileAttachment {
|
||||
pub url: Url,
|
||||
pub mime: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub sha256: Option<String>,
|
||||
pub original_sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub dim: Option<(u32, u32)>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FileAttachment {
|
||||
pub fn tags(&self) -> Vec<Tag> {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
||||
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
||||
Tag::custom(TAG_KEY, [self.key.clone()]),
|
||||
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
||||
];
|
||||
|
||||
if let Some(sha256) = &self.sha256 {
|
||||
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(original_sha256) = &self.original_sha256 {
|
||||
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(size) = self.size {
|
||||
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
||||
}
|
||||
|
||||
if let Some((width, height)) = self.dim {
|
||||
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
||||
}
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
||||
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
url: Url::parse(content).ok()?,
|
||||
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
||||
key: tag_value(tags, TAG_KEY)?.to_string(),
|
||||
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
||||
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
||||
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
||||
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
||||
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
||||
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.mime.starts_with("image/")
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
if let Some(name) = &self.name {
|
||||
return name.clone().into();
|
||||
}
|
||||
|
||||
match self.size {
|
||||
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
||||
None => self.mime.clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||
let cipher = AesGcm::<Aes256, U16>::new(&key);
|
||||
|
||||
let data = cipher
|
||||
.encrypt(&nonce, data)
|
||||
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
||||
|
||||
Ok(EncryptedFile {
|
||||
data,
|
||||
key: HEXLOWER.encode(key.as_slice()),
|
||||
nonce: HEXLOWER.encode(nonce.as_slice()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||
let key = decode(key, "decryption key")?;
|
||||
let nonce = decode(nonce, "decryption nonce")?;
|
||||
|
||||
if key.len() != 32 {
|
||||
bail!(
|
||||
"Invalid decryption key length: expected 32 bytes, got {}",
|
||||
key.len()
|
||||
);
|
||||
}
|
||||
|
||||
match nonce.len() {
|
||||
12 => Aes256Gcm::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||
len => bail!("Unsupported decryption nonce length: {len} bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
let encrypted = encrypt(&data)?;
|
||||
let sha256 = sha256_hex(&encrypted.data);
|
||||
let original_sha256 = sha256_hex(&data);
|
||||
let size = encrypted.data.len() as u64;
|
||||
let base_url = server.to_string();
|
||||
|
||||
let url = crate::blossom::upload_blob(
|
||||
&server,
|
||||
encrypted.data,
|
||||
"application/octet-stream",
|
||||
&sha256,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
|
||||
if !message.contains("415") {
|
||||
return anyhow!(message);
|
||||
}
|
||||
|
||||
anyhow!(
|
||||
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
|
||||
opaque data, which this file server does not accept. Choose a different file
|
||||
server in the settings."
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FileAttachment {
|
||||
url,
|
||||
mime,
|
||||
key: encrypted.key,
|
||||
nonce: encrypted.nonce,
|
||||
sha256: Some(sha256),
|
||||
original_sha256: Some(original_sha256),
|
||||
size: Some(size),
|
||||
dim: None,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload_encrypted(
|
||||
_server: Url,
|
||||
_path: PathBuf,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
pub(crate) async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let client = cx.update(|app| app.http_client());
|
||||
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Failed to download file: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
response
|
||||
.into_body()
|
||||
.take(MAX_FILE_SIZE as u64 + 1)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
|
||||
if data.len() > MAX_FILE_SIZE {
|
||||
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_sha256
|
||||
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||
{
|
||||
bail!("File hash mismatch");
|
||||
}
|
||||
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment into a temporary file.
|
||||
///
|
||||
/// The same attachment always maps to the same path, so callers can render the
|
||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let name = file
|
||||
.sha256
|
||||
.clone()
|
||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||
|
||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{name}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(
|
||||
&file.url,
|
||||
&file.key,
|
||||
&file.nonce,
|
||||
file.sha256.as_deref(),
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
_file: &FileAttachment,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
|
||||
HEXLOWER
|
||||
.decode(value.to_ascii_lowercase().as_bytes())
|
||||
.map_err(|_| anyhow!("Invalid {label} encoding"))
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_gossip_memory::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -14,20 +17,19 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod file;
|
||||
mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
// rustls uses the `aws_lc_rs` provider by default
|
||||
// This only errors if the default provider has already
|
||||
// been installed. We can ignore this `Result`.
|
||||
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
@@ -37,7 +39,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
|
||||
}
|
||||
|
||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||
@@ -47,27 +49,27 @@ impl Global for GlobalNostrRegistry {}
|
||||
/// Signer event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum StateEvent {
|
||||
/// The state is busy
|
||||
Busy,
|
||||
/// User has no signer
|
||||
NoSigner,
|
||||
/// The signer has changed
|
||||
SignerChanged,
|
||||
/// A stored credential needs a passphrase to unlock
|
||||
PassphraseRequired,
|
||||
/// An error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl StateEvent {
|
||||
pub fn error<T>(error: T) -> Self
|
||||
pub fn signer_changed(&self) -> bool {
|
||||
matches!(self, StateEvent::SignerChanged)
|
||||
}
|
||||
|
||||
pub(crate) 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
|
||||
@@ -75,13 +77,12 @@ impl StateEvent {
|
||||
pub struct NostrRegistry {
|
||||
/// Nostr client
|
||||
client: Client,
|
||||
|
||||
/// Universal signer
|
||||
signer: UniversalSigner,
|
||||
|
||||
/// Current user's public key
|
||||
current_user: Option<PublicKey>,
|
||||
|
||||
/// Whether the stored credential needs a passphrase to unlock
|
||||
passphrase_required: bool,
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -100,7 +101,8 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
@@ -122,21 +124,35 @@ impl NostrRegistry {
|
||||
.gossip(NostrGossipMemory::unbounded())
|
||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.verify_subscriptions(true)
|
||||
.ban_relay_on_mismatch(true)
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(600),
|
||||
})
|
||||
.build();
|
||||
|
||||
// Connect to bootstrap relays after the window is ready
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
this.get_user_credential(cx);
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
} else if let Some(secret) = cli_key {
|
||||
// Use CLI-provided key -- same path as restore_session
|
||||
let keys = Keys::new(secret);
|
||||
this.set_signer(keys, cx);
|
||||
} else {
|
||||
this.restore_session(cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
passphrase_required: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -157,19 +173,21 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// Update the signer
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
||||
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
match new_signer.get_public_key_async().await {
|
||||
Ok(public_key) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.signer.swap_inner(new_signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.passphrase_required = false;
|
||||
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -181,9 +199,9 @@ impl NostrRegistry {
|
||||
}
|
||||
};
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Connect to the bootstrapping relays
|
||||
@@ -220,80 +238,324 @@ impl NostrRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Check the user's credential and set the signer if valid
|
||||
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
|
||||
/// Restore the signer from the stored credential
|
||||
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||
let master_keyring = self.get_master_key(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match user_keyring.await {
|
||||
Ok(Some((_username, secret))) => {
|
||||
let content = String::from_utf8(secret)?;
|
||||
let result = async {
|
||||
match user_keyring.await {
|
||||
Ok(Some((_username, secret))) => {
|
||||
let content = String::from_utf8(secret)?;
|
||||
|
||||
if content.starts_with("nsec1") {
|
||||
let secret_key = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
if content.starts_with("nsec1") {
|
||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
} else if content.starts_with("bunker://") {
|
||||
// The master key used for Nostr Connect is embedded in
|
||||
// the stored URI so reconnects reuse the same local key
|
||||
let (base, keys) = extract_master_key(&content);
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
} else if content.starts_with("bunker://") {
|
||||
let keys = master_keyring.await;
|
||||
let timeout = Duration::from_secs(30);
|
||||
let uri = NostrConnectUri::parse(content)?;
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
cx.notify();
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
})?;
|
||||
} else if content.starts_with("ncryptsec1") {
|
||||
this.update(cx, |this, cx| {
|
||||
this.passphrase_required = true;
|
||||
cx.emit(StateEvent::PassphraseRequired);
|
||||
})?;
|
||||
} else if content == "proxy" {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
this.update(cx, |this, cx| {
|
||||
this.connect_proxy(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok::<(), Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::error(e.to_string()));
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the master key that used for Nostr Connect
|
||||
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
|
||||
let task = cx.read_credentials(MASTER_KEYRING);
|
||||
/// Unlock the stored encrypted key with its passphrase
|
||||
pub fn restore_with_passphrase(
|
||||
&mut self,
|
||||
password: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let password = password.to_owned();
|
||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (keys, new_key) = match task.await {
|
||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
||||
_ => (Keys::generate(), true),
|
||||
},
|
||||
_ => (Keys::generate(), true),
|
||||
};
|
||||
cx.spawn(async move |this, cx| {
|
||||
let content = user_keyring
|
||||
.await?
|
||||
.map(|(_username, secret)| String::from_utf8(secret))
|
||||
.transpose()?
|
||||
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
||||
|
||||
if new_key {
|
||||
let keys_clone = keys.clone();
|
||||
let username = keys_clone.public_key().to_hex();
|
||||
let password = keys_clone.secret_key().to_secret_bytes();
|
||||
|
||||
cx.update(|cx| {
|
||||
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
||||
cx.background_spawn(async move { task.await.ok() }).detach();
|
||||
});
|
||||
if !content.starts_with("ncryptsec1") {
|
||||
return Err(anyhow!("stored credential is not passphrase-encrypted"));
|
||||
}
|
||||
|
||||
keys
|
||||
let decrypt_task = cx.background_spawn(async move {
|
||||
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
||||
let secret = encrypted.decrypt(&password)?;
|
||||
Ok::<_, Error>(Keys::new(secret))
|
||||
});
|
||||
|
||||
let keys = decrypt_task.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password`
|
||||
pub fn import_nsec(
|
||||
&mut self,
|
||||
nsec: &str,
|
||||
password: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let keys = match SecretKey::parse(nsec.trim()) {
|
||||
Ok(secret) => Keys::new(secret),
|
||||
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||
};
|
||||
|
||||
if password.is_empty() {
|
||||
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||
}
|
||||
|
||||
let password = password.to_owned();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let job = cx.background_spawn(async move {
|
||||
let encrypted =
|
||||
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
||||
let ncryptsec = encrypted.to_bech32()?;
|
||||
Ok::<_, Error>((keys, ncryptsec))
|
||||
});
|
||||
|
||||
let (keys, ncryptsec) = job.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let write = cx.update(|cx| {
|
||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
||||
});
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password`
|
||||
pub fn import_ncryptsec(
|
||||
&mut self,
|
||||
ncryptsec: &str,
|
||||
password: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let ncryptsec = ncryptsec.trim().to_owned();
|
||||
|
||||
if password.is_empty() {
|
||||
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||
}
|
||||
|
||||
let password = password.to_owned();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let stored = ncryptsec.clone();
|
||||
let decrypt_task = cx.background_spawn(async move {
|
||||
let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?;
|
||||
let secret = encrypted.decrypt(&password)?;
|
||||
Ok::<_, Error>(Keys::new(secret))
|
||||
});
|
||||
|
||||
let keys = decrypt_task.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let write = cx.update(|cx| {
|
||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
||||
});
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a `bunker://...` URI (NIP-46), connecting to the remote signer
|
||||
pub fn import_bunker(
|
||||
&mut self,
|
||||
uri: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let uri_string = uri.trim().to_owned();
|
||||
|
||||
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||
Ok(uri) => uri,
|
||||
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||
};
|
||||
|
||||
let keys = Keys::generate();
|
||||
let credential = with_master_key(&uri_string, &keys);
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Construct the nostr connect signer
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Verify the bunker responds before persisting the credential
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
})?;
|
||||
|
||||
Ok(public_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sign out and remove the stored credential
|
||||
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
|
||||
self.current_user = None;
|
||||
self.passphrase_required = false;
|
||||
|
||||
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Start the browser proxy
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
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();
|
||||
@@ -398,68 +660,24 @@ impl NostrRegistry {
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a WoT (via Vertex) search for a given query.
|
||||
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||
let client = self.client();
|
||||
let query = query.to_string();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct a vertex request event
|
||||
let event = EventBuilder::new(Kind::Custom(5315), "")
|
||||
.tags(vec![
|
||||
Tag::custom("param", vec!["search", &query]),
|
||||
Tag::custom("param", vec!["limit", "10"]),
|
||||
])
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the event to vertex relays
|
||||
let output = client.send_event(&event).to(WOT_RELAYS).await?;
|
||||
|
||||
// Construct a filter to get the response or error from vertex
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Custom(6315), Kind::Custom(7000)])
|
||||
.event(output.id().to_owned());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = WOT_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
// Stream events from the wot relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
if let Ok(event) = res {
|
||||
match event.kind {
|
||||
Kind::Custom(6315) => {
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||
let pubkeys: Vec<PublicKey> = content
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| item.as_object())
|
||||
.filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str()))
|
||||
.filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok())
|
||||
.collect();
|
||||
|
||||
return Ok(pubkeys);
|
||||
}
|
||||
Kind::Custom(7000) => {
|
||||
return Err(anyhow!("Search error"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No results for query: {query}"))
|
||||
})
|
||||
// Extract the base URI and the master key from a bunker credential with `master=<nsec>`
|
||||
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||
match credential.split_once("master=") {
|
||||
Some((base, nsec)) => {
|
||||
let keys = SecretKey::parse(nsec)
|
||||
.map(Keys::new)
|
||||
.unwrap_or_else(|_| Keys::generate());
|
||||
(base.trim_end_matches(['?', '&']), keys)
|
||||
}
|
||||
None => (credential, Keys::generate()),
|
||||
}
|
||||
}
|
||||
|
||||
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||
format!("{uri}{separator}master={nsec}")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use anyhow::Error;
|
||||
use futures::io::AsyncReadExt;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait NostrAddress {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// Announcement
|
||||
@@ -6,50 +5,22 @@ use nostr_sdk::prelude::*;
|
||||
pub struct Announcement {
|
||||
/// The public key of the device that created this announcement.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// The name of the device that created this announcement.
|
||||
client_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Announcement {
|
||||
fn from(val: &Event) -> Self {
|
||||
let public_key = val
|
||||
impl Announcement {
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
let public_key = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "n")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())
|
||||
.unwrap_or(val.pubkey);
|
||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||
|
||||
let client_name = val
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "client")
|
||||
.and_then(|tag| tag.content())
|
||||
.map(|c| c.to_string());
|
||||
|
||||
Self::new(public_key, client_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
client_name,
|
||||
}
|
||||
Some(Self { public_key })
|
||||
}
|
||||
|
||||
/// Returns the public key of the device that created this announcement.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
/// Returns the client name of the device that created this announcement.
|
||||
pub fn client_name(&self) -> SharedString {
|
||||
self.client_name
|
||||
.as_ref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("Unknown"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,32 +5,9 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use nostr_connect::client::AuthUrlHandler;
|
||||
use nostr_sdk::error::Error as SignerError;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||
|
||||
impl fmt::Display for UniversalSignerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for UniversalSignerError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&*self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSignerError {
|
||||
pub fn new<E>(err: E) -> Self
|
||||
where
|
||||
E: Error + Send + Sync + 'static,
|
||||
{
|
||||
UniversalSignerError(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||
@@ -59,26 +36,46 @@ impl UniversalSigner {
|
||||
{
|
||||
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||
}
|
||||
|
||||
/// Encrypt a payload to the given key with NIP-44.
|
||||
pub async fn nip44_encrypt(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
content: &str,
|
||||
) -> Result<String, SignerError> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
inner.nip44_encrypt_async(public_key, content).await
|
||||
}
|
||||
|
||||
/// Decrypt a NIP-44 payload from the given key.
|
||||
pub async fn nip44_decrypt(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
payload: &str,
|
||||
) -> Result<String, SignerError> {
|
||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||
inner.nip44_decrypt_async(public_key, payload).await
|
||||
}
|
||||
}
|
||||
|
||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -93,22 +90,22 @@ where
|
||||
{
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,11 +113,11 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,25 +125,17 @@ where
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||
.await
|
||||
.map_err(UniversalSignerError::new)
|
||||
.map_err(SignerError::other)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl UniversalSigner {
|
||||
#[allow(dead_code)]
|
||||
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
|
||||
let guard = self.inner.read().expect("RwLock poisoned");
|
||||
f(&**guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
@@ -157,7 +146,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
@@ -169,7 +158,7 @@ impl AsyncSignEvent for UniversalSigner {
|
||||
}
|
||||
|
||||
impl AsyncNip44 for UniversalSigner {
|
||||
type Error = UniversalSignerError;
|
||||
type Error = SignerError;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
@@ -199,7 +188,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
auth_url: Url,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
webbrowser::open(auth_url.as_str()).unwrap();
|
||||
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
schemars.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../LICENSE
|
||||
@@ -1 +0,0 @@
|
||||
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Light/dark switching, adapted from bezel
|
||||
//! (`crates/theme/src/appearance.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors): what the user asked for, what the OS reports, and the
|
||||
//! plumbing that turns a change in either into a repaint.
|
||||
|
||||
use gpui::{App, Global, Subscription, Window};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Theme, ThemeMode};
|
||||
|
||||
/// The user's appearance preference. Serde-serializable so callers can persist
|
||||
/// it wherever their settings live; this crate never touches disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the OS — matches every other native app on the machine,
|
||||
/// including when the OS is set to switch at sunset.
|
||||
System,
|
||||
Light,
|
||||
/// Paint dark regardless of the OS.
|
||||
#[default]
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl AppearanceMode {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
|
||||
|
||||
/// Menu/label text.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state behind the current theme: what the user chose, and what the OS
|
||||
/// last said. Kept separate from [`Theme`] itself so that flipping the OS
|
||||
/// appearance while the user has pinned Light still records the new system
|
||||
/// value (and takes effect the moment they switch back to `System`).
|
||||
pub struct AppearanceState {
|
||||
pub mode: AppearanceMode,
|
||||
pub system: ThemeMode,
|
||||
}
|
||||
|
||||
impl Global for AppearanceState {}
|
||||
|
||||
/// Combine the user's choice with the OS state.
|
||||
pub fn resolve(mode: AppearanceMode, system: ThemeMode) -> ThemeMode {
|
||||
match mode {
|
||||
AppearanceMode::System => system,
|
||||
AppearanceMode::Light => ThemeMode::Light,
|
||||
AppearanceMode::Dark => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the appearance globals and the matching theme. Call once at boot,
|
||||
/// before any window opens, so the first frame is already the right palette
|
||||
/// (installing later produces a visible dark-to-light flash).
|
||||
pub fn init(mode: AppearanceMode, cx: &mut App) {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.set_global(AppearanceState { mode, system });
|
||||
Theme::change(resolve(mode, system), None, cx);
|
||||
}
|
||||
|
||||
/// The mode currently in effect (defaults to `Dark` before [`init`]).
|
||||
pub fn mode(cx: &App) -> AppearanceMode {
|
||||
cx.try_global::<AppearanceState>()
|
||||
.map(|state| state.mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the user's preference and repaint if that changed the palette.
|
||||
/// Persisting the choice is the caller's job.
|
||||
pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode == mode {
|
||||
return;
|
||||
}
|
||||
state.mode = mode;
|
||||
// Coming back to `System`, re-read the OS: a pinned mode holds an
|
||||
// appearance override, so `system` may be stale from before the pin.
|
||||
if mode == AppearanceMode::System {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.global_mut::<AppearanceState>().system = system;
|
||||
}
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
|
||||
/// must outlive the window — callers typically `.detach()` it.
|
||||
///
|
||||
/// The notification is *per window*, but the appearance it reports is a system
|
||||
/// setting, so any one window is enough to learn about the change; re-applying
|
||||
/// is idempotent when several fire.
|
||||
pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
|
||||
// Reconcile against the *window's* appearance before subscribing: `init`
|
||||
// runs before any window exists and can only ask the app, which on some
|
||||
// platforms is not reliably populated that early in launch.
|
||||
record_system(window.appearance().into(), cx);
|
||||
window.observe_window_appearance(move |window, cx| {
|
||||
record_system(window.appearance().into(), cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. `window`, when given,
|
||||
/// is the more authoritative source for what the OS currently reports.
|
||||
pub fn record_system_appearance(window: Option<&Window>, cx: &mut App) {
|
||||
let appearance = match window {
|
||||
Some(window) => window.appearance(),
|
||||
None => cx.window_appearance(),
|
||||
};
|
||||
record_system(ThemeMode::from_window(appearance), cx);
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. Only recorded while the
|
||||
/// user's mode is `System` — a pinned mode ignores the OS anyway, and
|
||||
/// recording an override read back would overwrite what the OS said with what
|
||||
/// we asked for.
|
||||
fn record_system(system: ThemeMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode != AppearanceMode::System || state.system == system {
|
||||
return;
|
||||
}
|
||||
state.system = system;
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Re-resolve the palette and, if it moved, swap the theme and force a full
|
||||
/// repaint. A no-op when the resolved appearance is unchanged — the OS fires
|
||||
/// the appearance notification for other changes too, and repainting every
|
||||
/// window for those would be a visible hitch for nothing.
|
||||
pub fn apply(cx: &mut App) {
|
||||
let Some(state) = cx.try_global::<AppearanceState>() else {
|
||||
return;
|
||||
};
|
||||
let wanted = resolve(state.mode, state.system);
|
||||
let changed = !cx
|
||||
.try_global::<Theme>()
|
||||
.is_some_and(|theme| theme.mode == wanted);
|
||||
if changed {
|
||||
Theme::change(wanted, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
}
|
||||