Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01d2fbef3 | ||
|
|
c3d677ca81 |
@@ -152,23 +152,11 @@ jobs:
|
|||||||
echo "Artifacts structure:"
|
echo "Artifacts structure:"
|
||||||
find artifacts -type f -exec ls -la {} \;
|
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
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
server_url: "https://git.reya.info/"
|
server_url: "https://git.reya.su/"
|
||||||
repository: "reya/coop"
|
repository: "reya/coop"
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
# Rust coding guidelines
|
|
||||||
|
|
||||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
|
||||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
|
||||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
|
||||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
|
||||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
|
||||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
|
||||||
- Propagate errors with `?` when the calling function should handle them
|
|
||||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
|
||||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
|
||||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
|
||||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
|
||||||
* Avoid creative additions unless explicitly requested
|
|
||||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
|
||||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
|
||||||
Example:
|
|
||||||
```rust
|
|
||||||
executor.spawn({
|
|
||||||
let task_ran = task_ran.clone();
|
|
||||||
async move {
|
|
||||||
*task_ran.borrow_mut() = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
# Timers in tests
|
|
||||||
|
|
||||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
|
||||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
|
||||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
|
||||||
|
|
||||||
# GPUI
|
|
||||||
|
|
||||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
|
||||||
|
|
||||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
|
||||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
|
||||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
|
||||||
|
|
||||||
## `Window`
|
|
||||||
|
|
||||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
|
||||||
|
|
||||||
## Entities
|
|
||||||
|
|
||||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
|
||||||
|
|
||||||
* `thing.entity_id()` returns `EntityId`
|
|
||||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
|
||||||
* `thing.read(cx: &App)` returns `&T`.
|
|
||||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
|
||||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
|
||||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
|
||||||
|
|
||||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
|
||||||
|
|
||||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
|
||||||
|
|
||||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
|
||||||
|
|
||||||
## Concurrency
|
|
||||||
|
|
||||||
All use of entities and UI rendering occurs on a single foreground thread.
|
|
||||||
|
|
||||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
|
||||||
|
|
||||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
|
||||||
|
|
||||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
|
||||||
|
|
||||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
|
||||||
|
|
||||||
* Awaiting the task in some other async context.
|
|
||||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
|
||||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
|
||||||
|
|
||||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
|
||||||
|
|
||||||
## Elements
|
|
||||||
|
|
||||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
struct TextWithBorder(SharedString);
|
|
||||||
|
|
||||||
impl Render for TextWithBorder {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div().border_1().child(self.0.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
|
||||||
|
|
||||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
|
||||||
|
|
||||||
The style methods on elements are similar to those used by Tailwind CSS.
|
|
||||||
|
|
||||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
|
||||||
|
|
||||||
## Input events
|
|
||||||
|
|
||||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
|
||||||
|
|
||||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
|
||||||
|
|
||||||
## Actions
|
|
||||||
|
|
||||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
|
||||||
|
|
||||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
|
||||||
|
|
||||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
|
||||||
|
|
||||||
## Notify
|
|
||||||
|
|
||||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
|
||||||
|
|
||||||
## Entity events
|
|
||||||
|
|
||||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
|
||||||
|
|
||||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
|
||||||
|
|
||||||
# Pull request hygiene
|
|
||||||
|
|
||||||
When an agent opens or updates a pull request, it must:
|
|
||||||
|
|
||||||
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
|
||||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
|
||||||
- Avoid trailing punctuation in PR titles.
|
|
||||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
|
||||||
- Include a `Release Notes:` section as the final section in the PR body.
|
|
||||||
- Use one bullet under `Release Notes:`:
|
|
||||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
|
||||||
- `- N/A` for docs-only and other non-user-facing changes.
|
|
||||||
- Format release notes exactly with a blank line after the heading, for example:
|
|
||||||
|
|
||||||
```
|
|
||||||
Release Notes:
|
|
||||||
|
|
||||||
- N/A
|
|
||||||
```
|
|
||||||
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.2"
|
version = "1.0.0-beta5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -27,11 +27,6 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
# Crypto (NIP-17 encrypted file messages)
|
|
||||||
aes-gcm = "0.10"
|
|
||||||
sha2 = "0.10"
|
|
||||||
data-encoding = "2"
|
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
@@ -47,10 +42,9 @@ schemars = "1"
|
|||||||
smallvec = "1.14.0"
|
smallvec = "1.14.0"
|
||||||
smol = "2"
|
smol = "2"
|
||||||
webbrowser = "1.0.4"
|
webbrowser = "1.0.4"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Use stacker's psm version which may have better WASM support
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
|||||||
@@ -1,5 +1,125 @@
|
|||||||
|

|
||||||
|
|
||||||
|
<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.
|
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
|
### License
|
||||||
|
|
||||||
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 626 B |
@@ -2,7 +2,7 @@
|
|||||||
"id": "aurora",
|
"id": "aurora",
|
||||||
"name": "Aurora",
|
"name": "Aurora",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://coopchat.xyz",
|
"url": "https://github.com/lumehq/coop",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#fdfcfeff",
|
"background": "#fdfcfeff",
|
||||||
"surface_background": "#f8f8ffff",
|
"surface_background": "#f8f8ffff",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "forest",
|
"id": "forest",
|
||||||
"name": "Forest",
|
"name": "Forest",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://coopchat.xyz",
|
"url": "https://github.com/lumehq/coop",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#fbfefcff",
|
"background": "#fbfefcff",
|
||||||
"surface_background": "#f4fbf6ff",
|
"surface_background": "#f4fbf6ff",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "ocean",
|
"id": "ocean",
|
||||||
"name": "Ocean",
|
"name": "Ocean",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://coopchat.xyz",
|
"url": "https://github.com/lumehq/coop",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#fafefeff",
|
"background": "#fafefeff",
|
||||||
"surface_background": "#f2fbfaff",
|
"surface_background": "#f2fbfaff",
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
smallvec.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
ureq.workspace = true
|
|
||||||
|
|
||||||
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
semver = "1.0.27"
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
smol.workspace = true
|
||||||
|
|||||||
@@ -1,326 +1,563 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
|
use semver::Version;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use smol::fs::File;
|
||||||
|
use smol::io::AsyncReadExt;
|
||||||
|
use smol::process::Command;
|
||||||
|
|
||||||
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||||
|
|
||||||
mod source;
|
|
||||||
|
|
||||||
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
|
|
||||||
|
|
||||||
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
|
||||||
const GITEA_REPO_OWNER: &str = "reya";
|
|
||||||
const GITEA_REPO_NAME: &str = "coop";
|
|
||||||
|
|
||||||
/// Delay before the automatic check that runs on startup.
|
|
||||||
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
|
||||||
/// How long a failure stays visible before the status reverts to "Up to date".
|
|
||||||
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
|
||||||
|
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
|
||||||
|
|
||||||
fn uses_managed_updates() -> bool {
|
fn get_github_repo_owner() -> String {
|
||||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||||
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()
|
fn get_github_repo_name() -> String {
|
||||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the auto-update system.
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
if uses_managed_updates() {
|
// Skip auto-update initialization if installed via Flatpak
|
||||||
log::info!(
|
if is_flatpak_installation() {
|
||||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
||||||
|
|
||||||
let Some(filter) = asset_filter_for(os, arch) else {
|
|
||||||
log::info!(
|
|
||||||
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
|
||||||
log::error!(
|
|
||||||
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
AutoUpdater::set_global(
|
|
||||||
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
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 {
|
pub struct AutoUpdater {
|
||||||
/// The blocking engine, driven on the background executor.
|
/// Current status of the auto updater
|
||||||
engine: Arc<UpdateEngine<GiteaSource>>,
|
pub status: AutoUpdateStatus,
|
||||||
status: UpdateStatus,
|
|
||||||
/// The newer release found by the last successful check, if any.
|
/// Current version of the application
|
||||||
available: Option<Release>,
|
|
||||||
/// Currently running app version.
|
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
/// The in-flight check or download, if any.
|
|
||||||
task: Option<Task<()>>,
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Whether auto-update is available for this installation.
|
/// Retrieve the global auto updater instance
|
||||||
pub fn is_available(cx: &App) -> bool {
|
|
||||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance, if one was initialized.
|
|
||||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
|
||||||
cx.try_global::<GlobalAutoUpdater>()
|
|
||||||
.map(|global| global.0.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance.
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the global auto updater instance
|
||||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
window: &mut Window,
|
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||||
version: Version,
|
let mut subscriptions = smallvec![];
|
||||||
filter: AssetFilter,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Self {
|
|
||||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
|
||||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
|
||||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
|
||||||
|
|
||||||
// Schedule an auto-check after a 2-minute delay
|
subscriptions.push(
|
||||||
cx.defer_in(window, |_this, _window, cx| {
|
// Observe the status
|
||||||
cx.spawn(async move |this, cx| {
|
cx.observe_self(|this, cx| {
|
||||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
||||||
this.update(cx, |this, cx| this.check(cx)).ok();
|
this.download_and_install(&download_url, cx);
|
||||||
})
|
}
|
||||||
.detach();
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run at the end of current cycle
|
||||||
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
|
this.check(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
engine,
|
status: AutoUpdateStatus::Idle,
|
||||||
status: UpdateStatus::Idle,
|
|
||||||
available: None,
|
|
||||||
version,
|
version,
|
||||||
task: None,
|
tasks: vec![],
|
||||||
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether nothing is happening, so the UI can hide the status line.
|
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||||
pub fn idle(&self) -> bool {
|
|
||||||
matches!(self.status, UpdateStatus::Idle)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a verified update is installed and waiting for a restart.
|
|
||||||
pub fn staged(&self) -> bool {
|
|
||||||
matches!(self.status, UpdateStatus::Staged(_))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check the release host for a newer version, then download and install it.
|
|
||||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.status.is_busy() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.set_status(UpdateStatus::Checking, cx);
|
|
||||||
|
|
||||||
let engine = self.engine.clone();
|
|
||||||
|
|
||||||
self.task = Some(cx.spawn(async move |this, cx| {
|
|
||||||
let result = cx
|
|
||||||
.background_executor()
|
|
||||||
.spawn(async move { engine.check() })
|
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.task = None;
|
|
||||||
match result {
|
|
||||||
Ok(Some(release)) => {
|
|
||||||
log::info!("Update {} is available", release.version);
|
|
||||||
let version = release.version.clone();
|
|
||||||
this.available = Some(release);
|
|
||||||
this.set_status(UpdateStatus::Available(version), cx);
|
|
||||||
this.download_and_install(cx);
|
|
||||||
}
|
|
||||||
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("Update check failed: {error}");
|
|
||||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download the available update, verify it, and swap it into place.
|
|
||||||
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.status.is_busy() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(release) = self.available.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let engine = self.engine.clone();
|
|
||||||
self.set_status(
|
|
||||||
UpdateStatus::Downloading {
|
|
||||||
downloaded: 0,
|
|
||||||
total: None,
|
|
||||||
},
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.task = Some(cx.spawn(async move |this, cx| {
|
|
||||||
let downloaded = Arc::new(AtomicU64::new(0));
|
|
||||||
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
|
||||||
let done = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let download_task = {
|
|
||||||
let (engine, release) = (engine.clone(), release.clone());
|
|
||||||
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
|
||||||
cx.background_executor().spawn(async move {
|
|
||||||
let result = engine.download(&release, |got, expected| {
|
|
||||||
downloaded.store(got, Ordering::Relaxed);
|
|
||||||
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
|
||||||
});
|
|
||||||
done.store(true, Ordering::Relaxed);
|
|
||||||
result
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let got = downloaded.load(Ordering::Relaxed);
|
|
||||||
let total = total.load(Ordering::Relaxed);
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(
|
|
||||||
UpdateStatus::Downloading {
|
|
||||||
downloaded: got,
|
|
||||||
total: (total != 0).then_some(total),
|
|
||||||
},
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
if done.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cx.background_executor()
|
|
||||||
.timer(Duration::from_millis(120))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let artifact = match download_task.await {
|
|
||||||
Ok(artifact) => artifact,
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("Update download failed: {error}");
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.task = None;
|
|
||||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
|
||||||
|
|
||||||
let installed = {
|
|
||||||
let engine = engine.clone();
|
|
||||||
cx.background_executor()
|
|
||||||
.spawn(async move { engine.install(&artifact) })
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.task = None;
|
|
||||||
match installed {
|
|
||||||
Ok(installed) => {
|
|
||||||
if let Some(path) = installed.restart_path {
|
|
||||||
cx.set_restart_path(path);
|
|
||||||
}
|
|
||||||
let version = release.version.clone();
|
|
||||||
this.set_status(UpdateStatus::Staged(version), cx);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relaunch into the staged update.
|
|
||||||
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if !self.staged() {
|
|
||||||
log::warn!("Ignoring restart request: no update is staged");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cx.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
|
||||||
let errored = matches!(status, UpdateStatus::Errored(_));
|
|
||||||
self.status = status;
|
self.status = status;
|
||||||
|
|
||||||
if errored {
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(UpdateStatus::Idle, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
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(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
|
||||||
|
let http_client = cx.http_client();
|
||||||
|
let download_url = download_url.to_string();
|
||||||
|
|
||||||
|
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||||
|
let installer_dir = InstallerDir::new().await?;
|
||||||
|
let target_path = Self::target_path(&installer_dir).await?;
|
||||||
|
|
||||||
|
// Download the release
|
||||||
|
download(&download_url, &target_path, http_client).await?;
|
||||||
|
|
||||||
|
Ok((installer_dir, target_path))
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(
|
||||||
|
// Install the new release
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
|
||||||
const RELEASE_PAGE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// Which published artifact belongs to a target platform.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct AssetFilter {
|
|
||||||
extension: &'static str,
|
|
||||||
arch: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AssetFilter {
|
|
||||||
/// Whether `name` is the installable artifact for this target.
|
|
||||||
fn matches(&self, name: &str) -> bool {
|
|
||||||
let name = name.to_ascii_lowercase();
|
|
||||||
name.ends_with(self.extension) && name.contains(self.arch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
|
||||||
let extension = match os {
|
|
||||||
"macos" => ".dmg",
|
|
||||||
"linux" => ".tar.gz",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let arch = match (os, arch) {
|
|
||||||
// cargo-packager names the disk images `aarch64`/`x64`.
|
|
||||||
("macos", "aarch64") => "aarch64",
|
|
||||||
("macos", "x86_64") => "x64",
|
|
||||||
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
|
||||||
("linux", "aarch64") => "aarch64",
|
|
||||||
("linux", "x86_64") => "x86_64",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(AssetFilter { extension, arch })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads releases from a Gitea repository's Releases.
|
|
||||||
pub struct GiteaSource {
|
|
||||||
api_base: String,
|
|
||||||
owner: String,
|
|
||||||
repo: String,
|
|
||||||
filter: AssetFilter,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GiteaSource {
|
|
||||||
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
|
|
||||||
/// (e.g. `https://git.reya.info/api/v1`).
|
|
||||||
pub fn new(
|
|
||||||
api_base: impl Into<String>,
|
|
||||||
owner: impl Into<String>,
|
|
||||||
repo: impl Into<String>,
|
|
||||||
filter: AssetFilter,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
|
||||||
owner: owner.into(),
|
|
||||||
repo: repo.into(),
|
|
||||||
filter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn releases_url(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
|
||||||
self.api_base, self.owner, self.repo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpdateSource for GiteaSource {
|
|
||||||
fn fetch_latest(&self) -> Result<Release> {
|
|
||||||
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
|
||||||
let release = newest_published(&releases)
|
|
||||||
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
|
||||||
|
|
||||||
let asset = release
|
|
||||||
.assets
|
|
||||||
.iter()
|
|
||||||
.find(|asset| self.filter.matches(&asset.name))
|
|
||||||
.ok_or(Error::NoMatchingAsset {
|
|
||||||
target_os: std::env::consts::OS,
|
|
||||||
target_arch: std::env::consts::ARCH,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Resolve the published checksum so the engine can reject a truncated or substituted download.
|
|
||||||
let sha256 = release
|
|
||||||
.assets
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
|
||||||
.map(|sums| http::get_string(&sums.browser_download_url))
|
|
||||||
.transpose()?
|
|
||||||
.and_then(|sums| sha256_for(&sums, &asset.name));
|
|
||||||
|
|
||||||
Ok(Release {
|
|
||||||
version: parse_tag(&release.tag_name)?,
|
|
||||||
notes: release
|
|
||||||
.body
|
|
||||||
.clone()
|
|
||||||
.filter(|body| !body.trim().is_empty())
|
|
||||||
.or_else(|| release.name.clone()),
|
|
||||||
asset: Asset {
|
|
||||||
name: asset.name.clone(),
|
|
||||||
url: asset.browser_download_url.clone(),
|
|
||||||
size: asset.size,
|
|
||||||
},
|
|
||||||
signature: None,
|
|
||||||
signature_url: None,
|
|
||||||
sha256,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
|
|
||||||
releases
|
|
||||||
.iter()
|
|
||||||
.filter(|release| !release.draft && !release.prerelease)
|
|
||||||
.filter_map(|release| {
|
|
||||||
parse_tag(&release.tag_name)
|
|
||||||
.ok()
|
|
||||||
.map(|version| (version, release))
|
|
||||||
})
|
|
||||||
.max_by(|(left, _), (right, _)| left.cmp(right))
|
|
||||||
.map(|(_, release)| release)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
|
|
||||||
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
|
||||||
sums.lines().find_map(|line| {
|
|
||||||
let mut parts = line.split_whitespace();
|
|
||||||
let (hash, path) = (parts.next()?, parts.next()?);
|
|
||||||
let path = path.strip_prefix('*').unwrap_or(path);
|
|
||||||
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
|
||||||
(base == asset_name).then(|| hash.to_ascii_lowercase())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A release as returned by the Gitea API.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GiteaRelease {
|
|
||||||
tag_name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
body: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
draft: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
prerelease: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
assets: Vec<GiteaAsset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A release asset as returned by the Gitea API.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GiteaAsset {
|
|
||||||
name: String,
|
|
||||||
browser_download_url: String,
|
|
||||||
#[serde(default)]
|
|
||||||
size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocking HTTP helpers for release metadata.
|
|
||||||
mod http {
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use gpui_updater_core::{Error, Result};
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use ureq::Agent;
|
|
||||||
use ureq::tls::{RootCerts, TlsConfig};
|
|
||||||
|
|
||||||
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
|
||||||
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
fn agent() -> Agent {
|
|
||||||
Agent::config_builder()
|
|
||||||
.user_agent(USER_AGENT)
|
|
||||||
.tls_config(
|
|
||||||
TlsConfig::builder()
|
|
||||||
.root_certs(RootCerts::PlatformVerifier)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
|
||||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
|
||||||
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
|
||||||
.build()
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
|
||||||
let mut response = agent().get(url).call().map_err(|error| match error {
|
|
||||||
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
|
||||||
other => Error::Http(other.to_string()),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
response
|
|
||||||
.body_mut()
|
|
||||||
.read_to_vec()
|
|
||||||
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
|
||||||
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn get_string(url: &str) -> Result<String> {
|
|
||||||
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use gpui_updater_core::Version;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const PUBLISHED: &[&str] = &[
|
|
||||||
"Coop_1.0.1_aarch64.dmg",
|
|
||||||
"Coop_1.0.1_x64.dmg",
|
|
||||||
"coop-linux-aarch64.tar.gz",
|
|
||||||
"coop-linux-x86_64.tar.gz",
|
|
||||||
"coop_1.0.1_aarch64.snap",
|
|
||||||
"coop_1.0.1_arm64-setup.exe",
|
|
||||||
"coop_1.0.1_x64-setup.exe",
|
|
||||||
"coop_1.0.1_x86_64.snap",
|
|
||||||
"su.reya.coop_aarch64.flatpak",
|
|
||||||
"su.reya.coop_x86_64.flatpak",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn selected(os: &str, arch: &str) -> Option<&'static str> {
|
|
||||||
let filter = asset_filter_for(os, arch)?;
|
|
||||||
PUBLISHED.iter().copied().find(|name| filter.matches(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn picks_the_artifact_matching_os_and_architecture() {
|
|
||||||
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
|
||||||
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
|
||||||
assert_eq!(
|
|
||||||
selected("linux", "aarch64"),
|
|
||||||
Some("coop-linux-aarch64.tar.gz")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
selected("linux", "x86_64"),
|
|
||||||
Some("coop-linux-x86_64.tar.gz")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn has_no_target_for_windows_or_unknown_platforms() {
|
|
||||||
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
|
||||||
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
|
||||||
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
|
|
||||||
let macos = asset_filter_for("macos", "aarch64").unwrap();
|
|
||||||
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
|
|
||||||
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
|
|
||||||
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
|
|
||||||
|
|
||||||
let linux = asset_filter_for("linux", "x86_64").unwrap();
|
|
||||||
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
|
|
||||||
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_checksums_by_basename_ignoring_directory_prefix() {
|
|
||||||
let sums = "\
|
|
||||||
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
|
||||||
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
|
|
||||||
789abc SHA256SUMS
|
|
||||||
";
|
|
||||||
assert_eq!(
|
|
||||||
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
|
|
||||||
Some("abcdef")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
|
|
||||||
Some("123456")
|
|
||||||
);
|
|
||||||
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
|
|
||||||
let releases: Vec<GiteaRelease> = serde_json::from_str(
|
|
||||||
r#"[
|
|
||||||
{
|
|
||||||
"tag_name": "v1.0.2",
|
|
||||||
"draft": true,
|
|
||||||
"assets": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag_name": "v2.0.0-rc.1",
|
|
||||||
"prerelease": true,
|
|
||||||
"assets": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag_name": "nightly",
|
|
||||||
"assets": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag_name": "v1.0.0",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "coop-linux-x86_64.tar.gz",
|
|
||||||
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
|
|
||||||
"size": 26160329
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag_name": "v1.0.1",
|
|
||||||
"name": "v1.0.1",
|
|
||||||
"body": "Fixed app panic on flatpak installations",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "coop-linux-x86_64.tar.gz",
|
|
||||||
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
|
|
||||||
"size": 26160329
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let newest = newest_published(&releases).unwrap();
|
|
||||||
assert_eq!(newest.tag_name, "v1.0.1");
|
|
||||||
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
|
|
||||||
assert_eq!(newest.assets.len(), 1);
|
|
||||||
assert_eq!(newest.assets[0].size, 26160329);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires network access to the release host"]
|
|
||||||
fn live_release_source_resolves_the_running_platform() {
|
|
||||||
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
|
||||||
.expect("this platform should be supported");
|
|
||||||
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
|
||||||
|
|
||||||
let release = source
|
|
||||||
.fetch_latest()
|
|
||||||
.expect("release lookup should succeed");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
release.version >= Version::new(1, 0, 0),
|
|
||||||
"unexpected version {}",
|
|
||||||
release.version
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
source.filter.matches(&release.asset.name),
|
|
||||||
"unexpected artifact {}",
|
|
||||||
release.asset.name
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
release.asset.url.starts_with("https://"),
|
|
||||||
"{} ",
|
|
||||||
release.asset.url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[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"] }
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,764 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
|
||||||
use event_listener::Event as ShutdownEvent;
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use smol::channel;
|
|
||||||
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use smol::lock::Mutex;
|
|
||||||
use smol::net::{TcpListener, TcpStream};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
mod error;
|
|
||||||
pub mod prelude;
|
|
||||||
|
|
||||||
pub use self::error::Error;
|
|
||||||
|
|
||||||
const DEFAULT_HTML: &str = include_str!("../index.html");
|
|
||||||
const JS: &str = include_str!("../proxy.js");
|
|
||||||
|
|
||||||
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct Message {
|
|
||||||
id: Uuid,
|
|
||||||
error: Option<String>,
|
|
||||||
result: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
fn into_result(self) -> Result<Value, String> {
|
|
||||||
if let Some(error) = self.error {
|
|
||||||
Err(error)
|
|
||||||
} else {
|
|
||||||
Ok(self.result.unwrap_or(Value::Null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum RequestMethod {
|
|
||||||
GetPublicKey,
|
|
||||||
SignEvent,
|
|
||||||
Nip04Encrypt,
|
|
||||||
Nip04Decrypt,
|
|
||||||
Nip44Encrypt,
|
|
||||||
Nip44Decrypt,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestMethod {
|
|
||||||
fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::GetPublicKey => "get_public_key",
|
|
||||||
Self::SignEvent => "sign_event",
|
|
||||||
Self::Nip04Encrypt => "nip04_encrypt",
|
|
||||||
Self::Nip04Decrypt => "nip04_decrypt",
|
|
||||||
Self::Nip44Encrypt => "nip44_encrypt",
|
|
||||||
Self::Nip44Decrypt => "nip44_decrypt",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for RequestMethod {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
struct RequestData {
|
|
||||||
id: Uuid,
|
|
||||||
method: RequestMethod,
|
|
||||||
params: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestData {
|
|
||||||
#[inline]
|
|
||||||
fn new(method: RequestMethod, params: Value) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
method,
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Requests<'a> {
|
|
||||||
requests: &'a [RequestData],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Requests<'a> {
|
|
||||||
#[inline]
|
|
||||||
fn new(requests: &'a [RequestData]) -> Self {
|
|
||||||
Self { requests }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.requests.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Params for NIP-04 and NIP-44 encryption/decryption
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct CryptoParams<'a> {
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> CryptoParams<'a> {
|
|
||||||
#[inline]
|
|
||||||
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key,
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ProxyState {
|
|
||||||
/// Requests waiting to be picked up by browser
|
|
||||||
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
|
||||||
/// Map of request ID to response sender
|
|
||||||
pub pending_responses: Mutex<PendingResponseMap>,
|
|
||||||
/// Last time the client asked for the pending requests
|
|
||||||
pub last_pending_request: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration options for [`BrowserSignerProxy`].
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BrowserSignerProxyOptions {
|
|
||||||
/// Request timeout for the signer extension. Default is 30 seconds.
|
|
||||||
pub timeout: Duration,
|
|
||||||
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
|
||||||
pub addr: SocketAddr,
|
|
||||||
/// Custom HTML page.
|
|
||||||
// NOTE: not `Option` to move it between threads without reference counter
|
|
||||||
pub custom_html: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct InnerBrowserSignerProxy {
|
|
||||||
/// Configuration options for the proxy
|
|
||||||
options: BrowserSignerProxyOptions,
|
|
||||||
/// Internal state of the proxy including request queues
|
|
||||||
state: Arc<ProxyState>,
|
|
||||||
/// Notification trigger for graceful shutdown
|
|
||||||
shutdown: Arc<ShutdownEvent>,
|
|
||||||
/// Flag to indicate if the server is shutdown
|
|
||||||
is_shutdown: Arc<AtomicBool>,
|
|
||||||
/// Flag indicating if the server is started
|
|
||||||
is_started: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
|
||||||
fn on_destroy(&self) {
|
|
||||||
self.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InnerBrowserSignerProxy {
|
|
||||||
#[inline]
|
|
||||||
fn is_shutdown(&self) -> bool {
|
|
||||||
self.is_shutdown.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown(&self) {
|
|
||||||
// Mark the server as shutdown
|
|
||||||
self.is_shutdown.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Notify all waiters that the proxy is shutting down
|
|
||||||
self.shutdown.notify(usize::MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nostr Browser Signer Proxy
|
|
||||||
///
|
|
||||||
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BrowserSignerProxy {
|
|
||||||
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BrowserSignerProxyOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
timeout: Duration::from_secs(30),
|
|
||||||
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
|
||||||
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
|
||||||
custom_html: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSignerProxyOptions {
|
|
||||||
/// Sets the timeout duration.
|
|
||||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
|
||||||
self.timeout = timeout;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the IP address.
|
|
||||||
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
|
||||||
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the port number.
|
|
||||||
pub const fn port(mut self, new_port: u16) -> Self {
|
|
||||||
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets a custom html page.
|
|
||||||
///
|
|
||||||
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
|
||||||
/// which will handle communication with the server and update the element
|
|
||||||
/// with id `nip07-proxy-status` with the status.
|
|
||||||
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
|
||||||
self.custom_html = custom_html;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSignerProxy {
|
|
||||||
/// Construct a new browser signer proxy
|
|
||||||
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
|
||||||
let state = ProxyState {
|
|
||||||
outgoing_requests: Mutex::new(Vec::new()),
|
|
||||||
pending_responses: Mutex::new(HashMap::new()),
|
|
||||||
last_pending_request: Arc::new(AtomicU64::new(0)),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
|
||||||
options,
|
|
||||||
state: Arc::new(state),
|
|
||||||
shutdown: Arc::new(ShutdownEvent::new()),
|
|
||||||
is_shutdown: Arc::new(AtomicBool::new(false)),
|
|
||||||
is_started: Arc::new(AtomicBool::new(false)),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates whether the server is currently running.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_started(&self) -> bool {
|
|
||||||
self.inner.is_started.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if there is an open browser tab ready to respond to requests by
|
|
||||||
/// verifying the time since the last pending request.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_session_active(&self) -> bool {
|
|
||||||
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the signer proxy webpage URL
|
|
||||||
#[inline]
|
|
||||||
pub fn url(&self) -> String {
|
|
||||||
format!("http://{}", self.inner.options.addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the proxy server.
|
|
||||||
///
|
|
||||||
/// If this is not called explicitly, the server will be automatically
|
|
||||||
/// started on the first interaction with the signer.
|
|
||||||
pub async fn start(&self) -> Result<(), Error> {
|
|
||||||
// Ensure is not shutdown
|
|
||||||
if self.inner.is_shutdown() {
|
|
||||||
return Err(Error::shutdown());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark the proxy as started and check if was already started
|
|
||||||
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Immediately return if already started
|
|
||||||
if is_started {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
|
|
||||||
Ok(listener) => listener,
|
|
||||||
Err(e) => {
|
|
||||||
// Undo the started flag if binding fails
|
|
||||||
self.inner.is_started.store(false, Ordering::SeqCst);
|
|
||||||
return Err(Error::from(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let addr: SocketAddr = self.inner.options.addr;
|
|
||||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
|
||||||
let custom_html: &'static str = self.inner.options.custom_html;
|
|
||||||
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
|
||||||
|
|
||||||
smol::spawn(async move {
|
|
||||||
tracing::info!("Starting proxy server on {addr}");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Race between accepting a new connection and shutdown signal
|
|
||||||
let shutdown_listener = shutdown.listen();
|
|
||||||
|
|
||||||
enum AcceptEvent {
|
|
||||||
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = smol::future::or(
|
|
||||||
async { AcceptEvent::Connection(listener.accept().await) },
|
|
||||||
async {
|
|
||||||
shutdown_listener.await;
|
|
||||||
AcceptEvent::Shutdown
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match event {
|
|
||||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
|
||||||
let state: Arc<ProxyState> = state.clone();
|
|
||||||
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
|
||||||
|
|
||||||
smol::spawn(async move {
|
|
||||||
let shutdown_listener = shutdown.listen();
|
|
||||||
|
|
||||||
smol::future::or(
|
|
||||||
async {
|
|
||||||
handle_connection(stream, state, custom_html).await;
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
shutdown_listener.await;
|
|
||||||
tracing::debug!(
|
|
||||||
"Closing connection, proxy server is shutting down."
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
AcceptEvent::Connection(Err(e)) => {
|
|
||||||
tracing::error!("Failed to accept connection: {e}");
|
|
||||||
}
|
|
||||||
AcceptEvent::Shutdown => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Proxy server shut down.");
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
|
|
||||||
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
|
||||||
pending_responses.insert(id, tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn store_outgoing_request(&self, request: RequestData) {
|
|
||||||
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
|
||||||
outgoing_requests.push(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
|
||||||
where
|
|
||||||
T: DeserializeOwned,
|
|
||||||
{
|
|
||||||
// Start the proxy if not already started
|
|
||||||
self.start().await?;
|
|
||||||
|
|
||||||
// Construct the request
|
|
||||||
let request: RequestData = RequestData::new(method, params);
|
|
||||||
|
|
||||||
// Create a bounded channel of size 1 as a oneshot replacement
|
|
||||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
|
||||||
|
|
||||||
// Store the response sender
|
|
||||||
self.store_pending_response(request.id, tx).await;
|
|
||||||
|
|
||||||
// Add to outgoing requests queue
|
|
||||||
self.store_outgoing_request(request).await;
|
|
||||||
|
|
||||||
// Wait for response with timeout
|
|
||||||
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
|
||||||
Ok(Err(error)) => Err(Error::generic(error)),
|
|
||||||
Err(TimeoutError) => Err(Error::timeout()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
|
||||||
self.request(RequestMethod::GetPublicKey, json!({})).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
|
||||||
let event: Event = self
|
|
||||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
|
||||||
.await?;
|
|
||||||
event.verify()?;
|
|
||||||
Ok(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncGetPublicKey for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn get_public_key_async(
|
|
||||||
&self,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
|
||||||
Box::pin(async move { self._get_public_key().await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncSignEvent for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn sign_event_async(
|
|
||||||
&self,
|
|
||||||
unsigned: UnsignedEvent,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
|
||||||
Box::pin(async move { self._sign_event(unsigned).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncNip04 for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn nip04_encrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip04_decrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
encrypted_content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncNip44 for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip44_decrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
payload: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Handle a single HTTP connection.
|
|
||||||
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
|
|
||||||
// Read the request line
|
|
||||||
let mut request_line = String::new();
|
|
||||||
if reader.read_line(&mut request_line).await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let request_line = request_line.trim_end().to_string();
|
|
||||||
|
|
||||||
// Parse method, path, and HTTP version from request line
|
|
||||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
|
||||||
if parts.len() < 2 {
|
|
||||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let method = parts[0].to_uppercase();
|
|
||||||
let path = parts[1].to_string();
|
|
||||||
|
|
||||||
// Read headers until empty line
|
|
||||||
let mut headers = Vec::new();
|
|
||||||
let mut content_length: usize = 0;
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
if reader.read_line(&mut line).await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let line = line.trim_end().to_string();
|
|
||||||
if line.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(value) = line.strip_prefix("content-length:") {
|
|
||||||
content_length = value.trim().parse().unwrap_or(0);
|
|
||||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
|
||||||
content_length = value.trim().parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
headers.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
match (method.as_str(), path.as_str()) {
|
|
||||||
// Serve the HTML proxy page
|
|
||||||
("GET", "/") => {
|
|
||||||
let html = if custom_html.is_empty() {
|
|
||||||
DEFAULT_HTML
|
|
||||||
} else {
|
|
||||||
custom_html
|
|
||||||
};
|
|
||||||
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
|
||||||
}
|
|
||||||
// Serve the JS proxy script
|
|
||||||
("GET", "/proxy.js") => {
|
|
||||||
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
|
|
||||||
}
|
|
||||||
// Browser polls this endpoint to get pending requests
|
|
||||||
("GET", "/api/pending") => {
|
|
||||||
state
|
|
||||||
.last_pending_request
|
|
||||||
.store(current_time(), Ordering::SeqCst);
|
|
||||||
|
|
||||||
let mut outgoing = state.outgoing_requests.lock().await;
|
|
||||||
|
|
||||||
let requests = Requests::new(&outgoing);
|
|
||||||
let json = match serde_json::to_string(&requests) {
|
|
||||||
Ok(j) => j,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to serialize pending requests: {e}");
|
|
||||||
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!("Sending {} pending requests to browser", requests.len());
|
|
||||||
|
|
||||||
// Clear the outgoing requests after sending them
|
|
||||||
outgoing.clear();
|
|
||||||
|
|
||||||
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
|
||||||
}
|
|
||||||
// Receive response from browser extension
|
|
||||||
("POST", "/api/response") => {
|
|
||||||
let mut body_bytes = vec![0u8; content_length];
|
|
||||||
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
|
|
||||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let message: Message = match serde_json::from_slice(&body_bytes) {
|
|
||||||
Ok(json) => json,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to parse response body: {e}");
|
|
||||||
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!("Received response from browser: {message:?}");
|
|
||||||
|
|
||||||
let id: Uuid = message.id;
|
|
||||||
let mut pending = state.pending_responses.lock().await;
|
|
||||||
|
|
||||||
match pending.remove(&id) {
|
|
||||||
Some(sender) => {
|
|
||||||
// Use try_send since we already hold the lock
|
|
||||||
let _ = sender.try_send(message.into_result());
|
|
||||||
tracing::info!("Forwarded response for request {id}");
|
|
||||||
}
|
|
||||||
None => tracing::warn!("No pending request found for {id}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
|
||||||
}
|
|
||||||
// CORS preflight
|
|
||||||
("OPTIONS", _) => {
|
|
||||||
let response = "HTTP/1.1 200 OK\r\n\
|
|
||||||
Access-Control-Allow-Origin: *\r\n\
|
|
||||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
|
||||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
|
||||||
Content-Length: 0\r\n\
|
|
||||||
Connection: close\r\n\
|
|
||||||
\r\n";
|
|
||||||
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
|
||||||
let _ = reader.get_mut().flush().await;
|
|
||||||
}
|
|
||||||
// 404 - not found
|
|
||||||
_ => {
|
|
||||||
send_response(&mut reader, 404, "Not Found", "", "").await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write an HTTP response to the stream.
|
|
||||||
async fn send_response(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
content_type: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
|
|
||||||
if !content_type.is_empty() {
|
|
||||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a response with CORS headers and JSON content type.
|
|
||||||
async fn send_response_cors_json(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
response.push_str("Content-Type: application/json\r\n");
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a response with CORS headers.
|
|
||||||
async fn send_response_cors(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
content_type: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
|
|
||||||
if !content_type.is_empty() {
|
|
||||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// An error indicating that an operation timed out.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct TimeoutError;
|
|
||||||
|
|
||||||
/// Races a channel receive against a duration.
|
|
||||||
///
|
|
||||||
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
|
||||||
/// elapses first or the channel is closed.
|
|
||||||
async fn race_timeout<T>(
|
|
||||||
duration: Duration,
|
|
||||||
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
|
||||||
) -> Result<T, TimeoutError> {
|
|
||||||
enum Event<T> {
|
|
||||||
Value(T),
|
|
||||||
ChannelClosed,
|
|
||||||
Timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = smol::future::or(
|
|
||||||
async {
|
|
||||||
match recv.await {
|
|
||||||
Ok(value) => Event::Value(value),
|
|
||||||
Err(_) => Event::ChannelClosed,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
smol::Timer::after(duration).await;
|
|
||||||
Event::Timeout
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::Value(value) => Ok(value),
|
|
||||||
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Utility ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
|
||||||
/// time is before the epoch, returns 0.
|
|
||||||
#[inline]
|
|
||||||
fn current_time() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// 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::*;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ mod room;
|
|||||||
|
|
||||||
pub use message::*;
|
pub use message::*;
|
||||||
pub use room::*;
|
pub use room::*;
|
||||||
pub use state::FileAttachment;
|
|
||||||
|
|
||||||
/// A static keypair used only for signing locally-cached rumor events.
|
/// A static keypair used only for signing locally-cached rumor events.
|
||||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||||
@@ -81,9 +80,6 @@ pub struct ChatRegistry {
|
|||||||
/// Chat rooms
|
/// Chat rooms
|
||||||
rooms: Vec<Entity<Room>>,
|
rooms: Vec<Entity<Room>>,
|
||||||
|
|
||||||
/// O(1) room lookup by room ID
|
|
||||||
room_index: HashMap<u64, Entity<Room>>,
|
|
||||||
|
|
||||||
/// Events that failed to unwrap for any reason
|
/// Events that failed to unwrap for any reason
|
||||||
trash: Entity<BTreeSet<FailedMessage>>,
|
trash: Entity<BTreeSet<FailedMessage>>,
|
||||||
|
|
||||||
@@ -174,7 +170,6 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
rooms: vec![],
|
rooms: vec![],
|
||||||
room_index: HashMap::new(),
|
|
||||||
trash: cx.new(|_| BTreeSet::default()),
|
trash: cx.new(|_| BTreeSet::default()),
|
||||||
seen: Arc::new(RwLock::new(HashMap::default())),
|
seen: Arc::new(RwLock::new(HashMap::default())),
|
||||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||||
@@ -366,8 +361,7 @@ impl ChatRegistry {
|
|||||||
.query(filter)
|
.query(filter)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.first_owned()
|
||||||
.next()
|
|
||||||
.is_some();
|
.is_some();
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
@@ -399,8 +393,7 @@ impl ChatRegistry {
|
|||||||
.database()
|
.database()
|
||||||
.query(filter)
|
.query(filter)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.first_owned()
|
||||||
.next()
|
|
||||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||||
|
|
||||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||||
@@ -443,9 +436,12 @@ impl ChatRegistry {
|
|||||||
self.tracking.load(Ordering::Acquire)
|
self.tracking.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a weak reference to a room by its ID
|
/// Get a weak reference to a room by its ID.
|
||||||
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
|
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||||
self.room_index.get(id).map(|room| room.downgrade())
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.find(|this| &this.read(cx).id == id)
|
||||||
|
.map(|this| this.downgrade())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all rooms based on the filter.
|
/// Get all rooms based on the filter.
|
||||||
@@ -515,11 +511,7 @@ impl ChatRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let room: Room = room.into().organize(&public_key);
|
let room: Room = room.into().organize(&public_key);
|
||||||
let room_id = room.id;
|
self.rooms.insert(0, cx.new(|_| room));
|
||||||
let entity = cx.new(|_| room);
|
|
||||||
|
|
||||||
self.room_index.insert(room_id, entity.clone());
|
|
||||||
self.rooms.insert(0, entity);
|
|
||||||
|
|
||||||
cx.emit(ChatEvent::Ping);
|
cx.emit(ChatEvent::Ping);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -532,11 +524,9 @@ impl ChatRegistry {
|
|||||||
// Get the room's ID.
|
// Get the room's ID.
|
||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
// If the room is new, add it to the registry and index.
|
// If the room is new, add it to the registry.
|
||||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
let entity = room.to_owned();
|
self.rooms.insert(0, room.to_owned());
|
||||||
e.insert(entity.clone());
|
|
||||||
self.rooms.insert(0, entity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit the open room event deferred to avoid re-entrant reads
|
// Emit the open room event deferred to avoid re-entrant reads
|
||||||
@@ -547,23 +537,17 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Close a room.
|
/// Close a room.
|
||||||
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.room_index.contains_key(&id) {
|
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
self.room_index.remove(&id);
|
|
||||||
self.rooms.retain(|r| r.read(cx).id != id);
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
cx.defer_in(window, move |_this, _window, cx| {
|
||||||
cx.emit(ChatEvent::CloseRoom(id));
|
cx.emit(ChatEvent::CloseRoom(id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort rooms by their created at. Only notifies if order changed.
|
/// Sort rooms by their created at.
|
||||||
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||||
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
|
||||||
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
||||||
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
cx.notify();
|
||||||
if before != after {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finding rooms based on a query.
|
/// Finding rooms based on a query.
|
||||||
@@ -590,7 +574,6 @@ impl ChatRegistry {
|
|||||||
/// Reset the registry.
|
/// Reset the registry.
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.rooms.clear();
|
self.rooms.clear();
|
||||||
self.room_index.clear();
|
|
||||||
self.trash.update(cx, |this, cx| {
|
self.trash.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -618,9 +601,7 @@ impl ChatRegistry {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let new_room_id = new_room.id;
|
let new_room_id = new_room.id;
|
||||||
let entity = cx.new(|_| new_room);
|
self.rooms.push(cx.new(|_| new_room));
|
||||||
self.room_index.insert(new_room_id, entity.clone());
|
|
||||||
self.rooms.push(entity);
|
|
||||||
|
|
||||||
let new_index = self.rooms.len();
|
let new_index = self.rooms.len();
|
||||||
room_map.insert(new_room_id, new_index);
|
room_map.insert(new_room_id, new_index);
|
||||||
@@ -630,7 +611,13 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Load all rooms from the database.
|
/// Load all rooms from the database.
|
||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.query_chat_rooms(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let task = self.get_rooms_from_database(public_key, cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
@@ -651,65 +638,62 @@ impl ChatRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query the chat rooms from the database
|
/// Create a task to load rooms from the database
|
||||||
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
fn get_rooms_from_database(
|
||||||
|
&self,
|
||||||
|
public_key: PublicKey,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<HashSet<Room>, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let contacts = client
|
||||||
|
|
||||||
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let contacts: HashSet<PublicKey> = client
|
|
||||||
.database()
|
.database()
|
||||||
.query(filter)
|
.contacts_public_keys(public_key)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().collect())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Query all cached rumor events (works with both old and new cache formats)
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
let mut rooms: HashSet<Room> = HashSet::new();
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
|
||||||
for raw in events.into_iter() {
|
for raw in events.into_iter() {
|
||||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||||
&& rumor.tags.public_keys().next().is_some()
|
&& rumor.tags.public_keys().peekable().peek().is_some()
|
||||||
{
|
{
|
||||||
if rumor.pubkey != public_key
|
|
||||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rooms = HashSet::with_capacity(grouped.len());
|
for (_id, mut messages) in grouped.into_iter() {
|
||||||
|
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||||
|
|
||||||
for (_id, messages) in grouped.into_iter() {
|
// Always use the latest message
|
||||||
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
|
let Some(latest) = messages.first() else {
|
||||||
let room = Room::from(latest).organize(&public_key);
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct the room from the latest message.
|
||||||
|
//
|
||||||
|
// Call `.organize` to ensure the current user is at the end of the list.
|
||||||
|
let mut room = Room::from(latest).organize(&public_key);
|
||||||
|
|
||||||
|
// Check if the user has responded to the room
|
||||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||||
|
|
||||||
|
// Check if public keys are from the user's contacts
|
||||||
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
||||||
|
|
||||||
let room = if user_sent || is_contact {
|
// Set the room's kind based on status
|
||||||
room.kind(RoomKind::Ongoing)
|
if user_sent || is_contact {
|
||||||
} else {
|
room = room.kind(RoomKind::Ongoing);
|
||||||
room
|
}
|
||||||
};
|
|
||||||
|
|
||||||
rooms.insert(room);
|
rooms.insert(room);
|
||||||
}
|
}
|
||||||
@@ -720,8 +704,8 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Parse a nostr event into a message and push it to the belonging room
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
///
|
///
|
||||||
/// - If the room doesn't exist, it will be created.
|
/// If the room doesn't exist, it will be created.
|
||||||
/// - Updates room ordering based on the most recent messages.
|
/// Updates room ordering based on the most recent messages.
|
||||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
@@ -729,7 +713,7 @@ impl ChatRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.room_index.get(&message.room).cloned() {
|
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||||
Some(room) => {
|
Some(room) => {
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
||||||
@@ -824,7 +808,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
|||||||
Tag::identifier(id),
|
Tag::identifier(id),
|
||||||
Tag::public_key(rumor.pubkey),
|
Tag::public_key(rumor.pubkey),
|
||||||
Tag::custom("r", [room_id]),
|
Tag::custom("r", [room_id]),
|
||||||
Tag::custom("k", [rumor.kind.to_string()]),
|
Tag::custom("k", ["14"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||||
@@ -841,7 +825,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
|||||||
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||||
let filter = Filter::new().identifier(gift_wrap).limit(1);
|
let filter = Filter::new().identifier(gift_wrap).limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Event is not cached yet."))
|
Err(anyhow!("Event is not cached yet."))
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ use std::ops::Range;
|
|||||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||||
use gpui::{SharedString, SharedUri};
|
use gpui::{SharedString, SharedUri};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::FileAttachment;
|
|
||||||
|
|
||||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
|
||||||
|
|
||||||
/// Rendered message.
|
/// Rendered message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -24,90 +21,61 @@ pub struct Message {
|
|||||||
pub mentions: Vec<Mention>,
|
pub mentions: Vec<Mention>,
|
||||||
/// List of event of the message this message is a reply to
|
/// List of event of the message this message is a reply to
|
||||||
pub replies_to: Vec<EventId>,
|
pub replies_to: Vec<EventId>,
|
||||||
/// Encrypted file attachment
|
|
||||||
pub file: Option<FileAttachment>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Event> for Message {
|
impl From<&Event> for Message {
|
||||||
fn from(val: &Event) -> Self {
|
fn from(val: &Event) -> Self {
|
||||||
from_parts(
|
let mentions = extract_mentions(&val.content);
|
||||||
val.id,
|
let replies_to = extract_reply_ids(&val.tags);
|
||||||
val.pubkey,
|
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||||
val.created_at,
|
|
||||||
val.kind,
|
Self {
|
||||||
&val.content,
|
id: val.id,
|
||||||
&val.tags,
|
author: val.pubkey,
|
||||||
)
|
content: string,
|
||||||
|
media,
|
||||||
|
created_at: val.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for Message {
|
impl From<&UnsignedEvent> for Message {
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
from_parts(
|
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 {
|
||||||
// Event ID must be known
|
// Event ID must be known
|
||||||
val.id.unwrap(),
|
id: val.id.unwrap(),
|
||||||
val.pubkey,
|
author: val.pubkey,
|
||||||
val.created_at,
|
content: string,
|
||||||
val.kind,
|
media,
|
||||||
&val.content,
|
created_at: val.created_at,
|
||||||
&val.tags,
|
mentions,
|
||||||
)
|
replies_to,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&NewMessage> for Message {
|
impl From<&NewMessage> for Message {
|
||||||
fn from(val: &NewMessage) -> Self {
|
fn from(val: &NewMessage) -> Self {
|
||||||
from_parts(
|
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 {
|
||||||
// Event ID must be known
|
// Event ID must be known
|
||||||
val.rumor.id.unwrap(),
|
id: val.rumor.id.unwrap(),
|
||||||
val.rumor.pubkey,
|
author: val.rumor.pubkey,
|
||||||
val.rumor.created_at,
|
content: string,
|
||||||
val.rumor.kind,
|
media,
|
||||||
&val.rumor.content,
|
created_at: val.rumor.created_at,
|
||||||
&val.rumor.tags,
|
mentions,
|
||||||
)
|
replies_to,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(
|
|
||||||
id: EventId,
|
|
||||||
author: PublicKey,
|
|
||||||
created_at: Timestamp,
|
|
||||||
kind: Kind,
|
|
||||||
content: &str,
|
|
||||||
tags: &Tags,
|
|
||||||
) -> Message {
|
|
||||||
let file = if kind == KIND_FILE_MESSAGE {
|
|
||||||
FileAttachment::from_tags(content, tags)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let has_file = file.is_some();
|
|
||||||
|
|
||||||
let replies_to = extract_reply_ids(tags);
|
|
||||||
|
|
||||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
|
||||||
let mentions = if has_file {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
extract_mentions(content)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (media, content) = if has_file {
|
|
||||||
(Vec::new(), String::new())
|
|
||||||
} else {
|
|
||||||
extract_and_remove_media_urls(content)
|
|
||||||
};
|
|
||||||
|
|
||||||
Message {
|
|
||||||
id,
|
|
||||||
author,
|
|
||||||
content,
|
|
||||||
media,
|
|
||||||
created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
file,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,17 +105,6 @@ impl Hash for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
|
||||||
/// Single-line representation for reply previews, notifications and copy.
|
|
||||||
pub fn preview(&self) -> SharedString {
|
|
||||||
if let Some(file) = &self.file {
|
|
||||||
return format!("[File] {}", file.display_name()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.content.clone().into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
|
|||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||||
|
|
||||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
use crate::NewMessage;
|
||||||
|
|
||||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
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 or it's not available.";
|
||||||
@@ -271,8 +271,8 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the members of the room
|
/// Returns the members of the room
|
||||||
pub fn members(&self) -> &[PublicKey] {
|
pub fn members(&self) -> Vec<PublicKey> {
|
||||||
&self.members
|
self.members.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the room has more than two members (group)
|
/// Checks if the room has more than two members (group)
|
||||||
@@ -356,7 +356,7 @@ impl Room {
|
|||||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let members = self.members().to_vec();
|
let members = self.members();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
@@ -403,7 +403,7 @@ impl Room {
|
|||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
||||||
|
|
||||||
let messages = client
|
let messages = client
|
||||||
.database()
|
.database()
|
||||||
@@ -439,51 +439,11 @@ impl Room {
|
|||||||
let content: String = content.into();
|
let content: String = content.into();
|
||||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
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()?;
|
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
|
||||||
// WARNING: never sign and send this event to relays
|
|
||||||
let mut event = EventBuilder::new(kind, content)
|
|
||||||
.tags(self.conversation_tags(&replies, sender, cx))
|
|
||||||
.finalize_unsigned(sender);
|
|
||||||
|
|
||||||
// Ensure that the ID is set
|
|
||||||
event.ensure_id();
|
|
||||||
|
|
||||||
Some(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
|
|
||||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = EventId>,
|
|
||||||
{
|
|
||||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
|
||||||
|
|
||||||
// Get current user's public key
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let sender = nostr.read(cx).current_user()?;
|
|
||||||
|
|
||||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
|
||||||
tags.extend(file.tags());
|
|
||||||
|
|
||||||
// Construct a file message rumor event
|
|
||||||
// WARNING: never sign and send this event to relays
|
|
||||||
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
|
||||||
.tags(tags)
|
|
||||||
.finalize_unsigned(sender);
|
|
||||||
|
|
||||||
// Ensure that the ID is set
|
|
||||||
event.ensure_id();
|
|
||||||
|
|
||||||
Some(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
|
||||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
// Get current user's public key
|
||||||
|
let sender = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
// Construct event's tags
|
// Construct event's tags
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
@@ -494,20 +454,32 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add all reply tags
|
// Add all reply tags
|
||||||
for id in replies {
|
for id in replies.into_iter() {
|
||||||
tags.push(Tag::event(*id))
|
tags.push(Tag::event(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all receiver tags (no intermediate allocation)
|
// Add all receiver tags (no intermediate allocation)
|
||||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||||
let member = persons.read(cx).get(public_key, cx);
|
let member = persons.read(cx).get(public_key, cx);
|
||||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
tags.push(
|
||||||
public_key: member.public_key(),
|
Nip01Tag::PublicKey {
|
||||||
relay_hint: member.messaging_relay_hint(),
|
public_key: member.public_key(),
|
||||||
}));
|
relay_hint: member.messaging_relay_hint(),
|
||||||
|
}
|
||||||
|
.to_tag(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
tags
|
// Construct a direct message rumor event
|
||||||
|
// WARNING: never sign and send this event to relays
|
||||||
|
let mut event = EventBuilder::new(kind, content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_unsigned(sender);
|
||||||
|
|
||||||
|
// Ensure that the ID is set
|
||||||
|
event.ensure_id();
|
||||||
|
|
||||||
|
Some(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the appropriate signer based on signer kind and available keys.
|
/// Select the appropriate signer based on signer kind and available keys.
|
||||||
@@ -640,7 +612,7 @@ async fn send_gift_wrap(
|
|||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
config: &SignerKind,
|
config: &SignerKind,
|
||||||
) -> Result<SendReport, Error> {
|
) -> Result<SendReport, Error> {
|
||||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
let k_tag = Tag::custom("k", vec!["14"]);
|
||||||
let mut extra_tags = vec![k_tag];
|
let mut extra_tags = vec![k_tag];
|
||||||
|
|
||||||
// Determine the receiver public key based on the config
|
// Determine the receiver public key based on the config
|
||||||
|
|||||||
@@ -25,5 +25,4 @@ serde.workspace = true
|
|||||||
|
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
pulldown-cmark = "0.13.1"
|
pulldown-cmark = "0.13.1"
|
||||||
regex = "1"
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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,30 +1,25 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::sync::{Arc, RwLock};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||||
use common::TimestampExt;
|
use common::{TimestampExt, coop_cache};
|
||||||
use futures::lock::Mutex;
|
use futures::lock::Mutex;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
img, list, px, red, relative, svg, white,
|
||||||
retain_all, svg, white,
|
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use regex::Regex;
|
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{
|
use state::{NostrRegistry, upload};
|
||||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
|
||||||
};
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -33,25 +28,17 @@ use ui::input::{Input, InputEvent, InputState};
|
|||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::tooltip::Tooltip;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||||
h_flex, v_flex,
|
h_flex, v_flex,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::file::*;
|
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||||
|
|
||||||
/// Regex matching strings that consist entirely of emoji characters,
|
|
||||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
|
||||||
static EMOJI_RE: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod file;
|
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
@@ -72,9 +59,6 @@ pub struct ChatPanel {
|
|||||||
/// All messages (sorted by created_at)
|
/// All messages (sorted by created_at)
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
|
|
||||||
/// O(1) message lookup by EventId
|
|
||||||
message_index: HashMap<EventId, usize>,
|
|
||||||
|
|
||||||
/// All reactions
|
/// All reactions
|
||||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
||||||
|
|
||||||
@@ -102,12 +86,6 @@ pub struct ChatPanel {
|
|||||||
/// Media Attachment
|
/// Media Attachment
|
||||||
attachments: Entity<Vec<Url>>,
|
attachments: Entity<Vec<Url>>,
|
||||||
|
|
||||||
/// Uploaded, encrypted file attachments which are not sent yet
|
|
||||||
encrypted_attachments: Entity<Vec<PendingFile>>,
|
|
||||||
|
|
||||||
/// Decrypted attachments of file messages, by message id
|
|
||||||
decrypted_files: HashMap<EventId, DecryptedFile>,
|
|
||||||
|
|
||||||
/// Upload state
|
/// Upload state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
|
||||||
@@ -122,7 +100,6 @@ impl ChatPanel {
|
|||||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
// Define attachments and replies_to entities
|
// Define attachments and replies_to entities
|
||||||
let attachments = cx.new(|_| vec![]);
|
let attachments = cx.new(|_| vec![]);
|
||||||
let encrypted_attachments = cx.new(|_| vec![]);
|
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
|
||||||
@@ -189,7 +166,6 @@ impl ChatPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
message_index: HashMap::new(),
|
|
||||||
reactions: BTreeMap::new(),
|
reactions: BTreeMap::new(),
|
||||||
room,
|
room,
|
||||||
list_state,
|
list_state,
|
||||||
@@ -198,8 +174,6 @@ impl ChatPanel {
|
|||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
attachments,
|
attachments,
|
||||||
encrypted_attachments,
|
|
||||||
decrypted_files: HashMap::new(),
|
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
reports_by_id,
|
reports_by_id,
|
||||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -257,29 +231,23 @@ impl ChatPanel {
|
|||||||
while let Ok(status) = rx.recv_async().await {
|
while let Ok(status) = rx.recv_async().await {
|
||||||
{
|
{
|
||||||
let mut map = reports.write().unwrap();
|
let mut map = reports.write().unwrap();
|
||||||
let status_id = match &*status {
|
for reports in map.values_mut() {
|
||||||
SendStatus::Ok { id, .. } => *id,
|
for report in reports.iter_mut() {
|
||||||
SendStatus::Failed { id, .. } => *id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find the matching report and update it (exit early on first match)
|
|
||||||
'outer: for reports_list in map.values_mut() {
|
|
||||||
for report in reports_list.iter_mut() {
|
|
||||||
let Some(output) = report.output.as_mut() else {
|
let Some(output) = report.output.as_mut() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if *output.id() != status_id {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match &*status {
|
match &*status {
|
||||||
SendStatus::Ok { relay, .. } => {
|
SendStatus::Ok { id, relay } => {
|
||||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
if output.id() == id {
|
||||||
|
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SendStatus::Failed { relay, message, .. } => {
|
SendStatus::Failed { id, relay, message } => {
|
||||||
output.failed.insert(relay.clone(), message.clone());
|
if output.id() == id {
|
||||||
|
output.failed.insert(relay.clone(), message.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break 'outer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,44 +259,30 @@ impl ChatPanel {
|
|||||||
|
|
||||||
/// Subscribe to room events
|
/// Subscribe to room events
|
||||||
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(room) = self.room.upgrade() else {
|
if let Some(room) = self.room.upgrade() {
|
||||||
return;
|
self.subscriptions.push(cx.subscribe_in(
|
||||||
};
|
&room,
|
||||||
|
window,
|
||||||
self.subscriptions.push(cx.subscribe_in(
|
move |this, _room, event, window, cx| {
|
||||||
&room,
|
match event {
|
||||||
window,
|
RoomEvent::Incoming(message) => {
|
||||||
move |this, _room, event, window, cx| {
|
if message.rumor.kind == Kind::Reaction {
|
||||||
match event {
|
this.insert_reaction(&message.rumor, cx);
|
||||||
RoomEvent::Incoming(message) => {
|
} else {
|
||||||
if message.rumor.kind == Kind::Reaction {
|
this.insert_message(message, false, cx);
|
||||||
this.insert_reaction(&message.rumor, cx);
|
|
||||||
} else {
|
|
||||||
this.insert_message(message, false, cx);
|
|
||||||
|
|
||||||
if !window.is_window_active() {
|
|
||||||
cx.show_system_notification(SystemNotification {
|
|
||||||
tag: "message".into(),
|
|
||||||
title: "New Message".into(),
|
|
||||||
body: "You have a new message.".into(),
|
|
||||||
actions: vec![SystemNotificationAction {
|
|
||||||
id: "open".into(),
|
|
||||||
label: "Open".into(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
RoomEvent::Reload => {
|
||||||
RoomEvent::Reload => {
|
// Defer to avoid re-entrant read on Room while
|
||||||
// Defer to avoid re-entrant read on Room while
|
// emit_refresh holds a write lock (via refresh_rooms).
|
||||||
// emit_refresh holds a write lock (via refresh_rooms).
|
cx.defer_in(window, |this, window, cx| {
|
||||||
cx.defer_in(window, |this, window, cx| {
|
this.get_messages(window, cx);
|
||||||
this.get_messages(window, cx);
|
});
|
||||||
});
|
}
|
||||||
}
|
};
|
||||||
};
|
},
|
||||||
},
|
));
|
||||||
));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all messages belonging to this room
|
/// Load all messages belonging to this room
|
||||||
@@ -385,49 +339,19 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Get the message which includes all plain attachments
|
// Get the message which includes all attachments
|
||||||
let content = self.get_input_value(cx);
|
let content = self.get_input_value(cx);
|
||||||
|
|
||||||
// Get the replies to this message
|
// Get the replies to this message
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Uploaded files are sent as encrypted file messages
|
// Return if message is empty
|
||||||
let files: Vec<FileAttachment> = self
|
if content.trim().is_empty() {
|
||||||
.encrypted_attachments
|
|
||||||
.read(cx)
|
|
||||||
.iter()
|
|
||||||
.map(|pending| pending.file.clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Return if there is nothing to send
|
|
||||||
if content.trim().is_empty() && files.is_empty() {
|
|
||||||
window.push_notification("Cannot send an empty message", cx);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If replying to exactly one message with only a valid emoji,
|
self.send_message(&content, replies, false, window, cx);
|
||||||
// send as a reaction instead of a text message
|
|
||||||
if replies.len() == 1
|
|
||||||
&& EMOJI_RE.is_match(&content)
|
|
||||||
&& self.attachments.read(cx).is_empty()
|
|
||||||
&& files.is_empty()
|
|
||||||
{
|
|
||||||
for reply in &replies {
|
|
||||||
self.send_reaction(&content, reply, window, cx);
|
|
||||||
}
|
|
||||||
self.clear(window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the text part, including the plain attachment urls
|
|
||||||
if !content.trim().is_empty() {
|
|
||||||
self.send_message(&content, replies.clone(), false, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send every file as its own encrypted file message
|
|
||||||
for file in files {
|
|
||||||
self.send_file(file, replies.clone(), window, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_reaction(
|
fn send_reaction(
|
||||||
@@ -460,60 +384,30 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade room and create rumor + send task in a single read lock
|
let room = self.room.clone();
|
||||||
let Some(room) = self.room.upgrade() else {
|
let content = value.to_string();
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = room.read_with(cx, |room, cx| {
|
|
||||||
let rumor = room.rumor(value, replies, reaction, cx)?;
|
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
|
||||||
|
|
||||||
Some((rumor, send_task))
|
|
||||||
});
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
|
||||||
None => window.push_notification("Failed to create message", cx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
|
|
||||||
fn send_file(
|
|
||||||
&mut self,
|
|
||||||
file: FileAttachment,
|
|
||||||
replies: Vec<EventId>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let Some(room) = self.room.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = room.read_with(cx, |room, cx| {
|
|
||||||
let rumor = room.file_rumor(file, replies, cx)?;
|
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
|
||||||
|
|
||||||
Some((rumor, send_task))
|
|
||||||
});
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
|
||||||
None => window.push_notification("Failed to create message", cx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a rumor optimistically and track the send reports of its gift wraps
|
|
||||||
fn dispatch(
|
|
||||||
&mut self,
|
|
||||||
rumor: UnsignedEvent,
|
|
||||||
send_task: Task<Vec<SendReport>>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let id = rumor.id.expect("rumor must have an id");
|
|
||||||
let sent_ids = self.sent_ids.clone();
|
let sent_ids = self.sent_ids.clone();
|
||||||
|
|
||||||
|
// Upgrade room and create rumor + send task in a single read lock
|
||||||
|
let Some(room_entity) = room.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create rumor and send task
|
||||||
|
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
||||||
|
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
||||||
|
let send_task = room.send(rumor.clone(), cx)?;
|
||||||
|
Some((rumor, send_task))
|
||||||
|
}) {
|
||||||
|
Some(pair) => pair,
|
||||||
|
None => {
|
||||||
|
window.push_notification("Failed to create message", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = rumor.id.expect("rumor must have an id");
|
||||||
|
|
||||||
// Insert optimistic message and clear input
|
// Insert optimistic message and clear input
|
||||||
if rumor.kind != Kind::Reaction {
|
if rumor.kind != Kind::Reaction {
|
||||||
self.insert_message(&rumor, true, cx);
|
self.insert_message(&rumor, true, cx);
|
||||||
@@ -551,10 +445,6 @@ impl ChatPanel {
|
|||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
self.encrypted_attachments.update(cx, |this, cx| {
|
|
||||||
this.clear();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
self.replies_to.update(cx, |this, cx| {
|
self.replies_to.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -582,10 +472,6 @@ impl ChatPanel {
|
|||||||
|
|
||||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
if let Err(pos) = self.messages.binary_search(&msg) {
|
||||||
self.messages.insert(pos, msg);
|
self.messages.insert(pos, msg);
|
||||||
// Rebuild message index after insertion (indices from pos to end shift)
|
|
||||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
|
||||||
self.message_index.insert(message.id, i);
|
|
||||||
}
|
|
||||||
self.list_state.splice(old_len..old_len, 1);
|
self.list_state.splice(old_len..old_len, 1);
|
||||||
|
|
||||||
if scroll {
|
if scroll {
|
||||||
@@ -628,25 +514,22 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reports
|
/// Check if a message has any reports
|
||||||
fn has_reports(&self, id: &EventId) -> bool {
|
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
|
||||||
self.reports_by_id.read().unwrap().contains_key(id)
|
self.reports_by_id.read().unwrap().get(id).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone reports for a message (used for modal display, not called during render)
|
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
|
||||||
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
|
|
||||||
self.reports_by_id.read().unwrap().get(id).cloned()
|
self.reports_by_id.read().unwrap().get(id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a message by its ID (O(1) lookup)
|
/// Get a message by its ID
|
||||||
fn message(&self, id: &EventId) -> Option<&Message> {
|
fn message(&self, id: &EventId) -> Option<&Message> {
|
||||||
self.message_index
|
self.messages.iter().find(|msg| &msg.id == id)
|
||||||
.get(id)
|
|
||||||
.and_then(|&ix| self.messages.get(ix))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reaction by its target ID (returns reference, no allocation)
|
/// Get a reaction by its target ID
|
||||||
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> {
|
||||||
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
self.reactions.get(id).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reactions
|
/// Check if a message has any reactions
|
||||||
@@ -672,7 +555,7 @@ impl ChatPanel {
|
|||||||
let Some(message) = self.message(id) else {
|
let Some(message) = self.message(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let content = message.preview().to_string();
|
let content = message.content.to_string();
|
||||||
let item = ClipboardItem::new_string(content);
|
let item = ClipboardItem::new_string(content);
|
||||||
|
|
||||||
cx.write_to_clipboard(item);
|
cx.write_to_clipboard(item);
|
||||||
@@ -698,9 +581,6 @@ impl ChatPanel {
|
|||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
// Encrypt attachments which are not part of a message being written
|
|
||||||
let encrypted = self.input.read(cx).value().trim().is_empty();
|
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask user for file upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
@@ -710,95 +590,36 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Selecting no file means the prompt was cancelled
|
this.update(cx, |this, cx| {
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
this.set_uploading(true, cx);
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.upload_file(server, path, encrypted, window, cx);
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
let mut paths = path.await??.context("Not found")?;
|
||||||
}));
|
let path = paths.pop().context("No path")?;
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload a file, encrypted when the attachment is the whole message
|
// Upload via blossom client
|
||||||
fn upload_file(
|
match upload(server, path, cx).await {
|
||||||
&mut self,
|
Ok(url) => {
|
||||||
server: Url,
|
this.update_in(cx, |this, _window, cx| {
|
||||||
path: PathBuf,
|
this.add_attachment(url, cx);
|
||||||
encrypted: bool,
|
this.set_uploading(false, cx);
|
||||||
window: &mut Window,
|
})?;
|
||||||
cx: &mut Context<Self>,
|
}
|
||||||
) {
|
Err(e) => {
|
||||||
self.set_uploading(true, cx);
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_uploading(false, cx);
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = if encrypted {
|
|
||||||
upload_encrypted(server.clone(), path.clone(), cx)
|
|
||||||
.await
|
|
||||||
.map(|file| Uploaded::File(file, path.clone()))
|
|
||||||
} else {
|
|
||||||
upload(server.clone(), path.clone(), cx)
|
|
||||||
.await
|
|
||||||
.map(Uploaded::Url)
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_uploading(false, cx);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
|
||||||
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
|
||||||
Err(e) if encrypted => {
|
|
||||||
this.report_encrypted_upload_error(server, path, e, window, cx)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
}
|
})?;
|
||||||
}
|
}
|
||||||
})?;
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report a failed encrypted upload, offering to retry it without encryption
|
|
||||||
fn report_encrypted_upload_error(
|
|
||||||
&mut self,
|
|
||||||
server: Url,
|
|
||||||
path: PathBuf,
|
|
||||||
error: Error,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let view = cx.entity().downgrade();
|
|
||||||
|
|
||||||
window.push_notification(
|
|
||||||
Notification::error(error.to_string())
|
|
||||||
.title("Encrypted upload failed")
|
|
||||||
.action(move |_this, _window, _cx| {
|
|
||||||
let view = view.clone();
|
|
||||||
let server = server.clone();
|
|
||||||
let path = path.clone();
|
|
||||||
|
|
||||||
Button::new("retry-without-encryption")
|
|
||||||
.label("Upload without encryption")
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
view.update(cx, |this, cx| {
|
|
||||||
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||||
self.uploading = uploading;
|
self.uploading = uploading;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -820,88 +641,6 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
|
||||||
self.encrypted_attachments.update(cx, |this, cx| {
|
|
||||||
this.push(PendingFile { file, path });
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
|
|
||||||
self.encrypted_attachments.update(cx, |this, cx| {
|
|
||||||
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
|
|
||||||
this.remove(ix);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download and decrypt the attachment of a file message for preview
|
|
||||||
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
|
|
||||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(path) => {
|
|
||||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.decrypted_files
|
|
||||||
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt the attachment of a file message and open it with the OS
|
|
||||||
fn open_file(
|
|
||||||
&mut self,
|
|
||||||
id: EventId,
|
|
||||||
file: FileAttachment,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
match self.decrypted_files.get(&id) {
|
|
||||||
Some(DecryptedFile::Ready(path)) => {
|
|
||||||
cx.open_url(&file_url(path));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Some(DecryptedFile::Loading) => return,
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(path) => {
|
|
||||||
cx.open_url(&file_url(&path));
|
|
||||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.decrypted_files
|
|
||||||
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
persons.read(cx).get(public_key, cx)
|
persons.read(cx).get(public_key, cx)
|
||||||
@@ -1141,16 +880,6 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let file = self.messages.get(ix).and_then(|message| {
|
|
||||||
let file = message.file.clone()?;
|
|
||||||
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
|
||||||
.then_some((message.id, file))
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some((id, file)) = file {
|
|
||||||
self.load_file(id, file, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(message) = self.messages.get(ix) {
|
if let Some(message) = self.messages.get(ix) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let show_author = self.is_group_start(ix);
|
let show_author = self.is_group_start(ix);
|
||||||
@@ -1158,7 +887,7 @@ impl ChatPanel {
|
|||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
|
RenderedText::new(&message.content, &message.mentions, &persons, cx)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
@@ -1183,7 +912,7 @@ impl ChatPanel {
|
|||||||
let replies = message.replies_to.as_slice();
|
let replies = message.replies_to.as_slice();
|
||||||
let has_replies = !replies.is_empty();
|
let has_replies = !replies.is_empty();
|
||||||
let has_reactions = self.has_reaction(&id);
|
let has_reactions = self.has_reaction(&id);
|
||||||
let has_reports = self.has_reports(&id);
|
let has_reports = self.has_reports(&id, cx);
|
||||||
|
|
||||||
// Hide avatar setting
|
// Hide avatar setting
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
@@ -1238,11 +967,8 @@ impl ChatPanel {
|
|||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
.child(rendered_text)
|
||||||
.child(self.render_media(&message.media, cx))
|
.child(self.render_media(&message.media, cx))
|
||||||
.when_some(message.file.as_ref(), |this, file| {
|
|
||||||
this.child(self.render_message_file(&id, file, cx))
|
|
||||||
})
|
|
||||||
.when(has_reactions, |this| {
|
.when(has_reactions, |this| {
|
||||||
this.child(self.render_reactions(&id, cx))
|
this.child(self.render_reactions(&id, cx))
|
||||||
}),
|
}),
|
||||||
@@ -1348,7 +1074,7 @@ impl ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(message.preview()),
|
.child(SharedString::from(&message.content)),
|
||||||
)
|
)
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1369,7 +1095,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
// Group reactions by emoji and collect authors for each
|
// Group reactions by emoji and collect authors for each
|
||||||
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
||||||
for (emoji, author) in reactions {
|
for (emoji, author) in &reactions {
|
||||||
grouped.entry(emoji.clone()).or_default().push(*author);
|
grouped.entry(emoji.clone()).or_default().push(*author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,7 +1127,7 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||||
let reports = self.sent_reports(id);
|
let reports = self.sent_reports(id, cx);
|
||||||
|
|
||||||
let pending = reports
|
let pending = reports
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1652,7 +1378,7 @@ impl ChatPanel {
|
|||||||
.size_16()
|
.size_16()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.object_fit(ObjectFit::Cover),
|
.object_fit(ObjectFit::ScaleDown),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1689,159 +1415,6 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the encrypted file attachment of a message
|
|
||||||
fn render_message_file(
|
|
||||||
&self,
|
|
||||||
id: &EventId,
|
|
||||||
file: &FileAttachment,
|
|
||||||
cx: &Context<Self>,
|
|
||||||
) -> AnyElement {
|
|
||||||
let state = self.decrypted_files.get(id);
|
|
||||||
|
|
||||||
if let Some(path) = state
|
|
||||||
.and_then(|state| match state {
|
|
||||||
DecryptedFile::Ready(path) => Some(path),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.filter(|_| file.is_image())
|
|
||||||
{
|
|
||||||
return div()
|
|
||||||
.child(
|
|
||||||
img(path.clone())
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.h(px(250.))
|
|
||||||
.object_fit(ObjectFit::Cover)
|
|
||||||
.rounded(cx.theme().radius),
|
|
||||||
)
|
|
||||||
.into_any_element();
|
|
||||||
}
|
|
||||||
|
|
||||||
let label = match state {
|
|
||||||
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
|
|
||||||
Some(DecryptedFile::Failed(error)) => error.clone(),
|
|
||||||
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
|
|
||||||
None => SharedString::from("Click to decrypt"),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.render_file_chip(id, file, label, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render an encrypted file as a chip which decrypts and opens it on click
|
|
||||||
fn render_file_chip(
|
|
||||||
&self,
|
|
||||||
id: &EventId,
|
|
||||||
file: &FileAttachment,
|
|
||||||
label: SharedString,
|
|
||||||
cx: &Context<Self>,
|
|
||||||
) -> AnyElement {
|
|
||||||
h_flex()
|
|
||||||
.id(SharedString::from(format!("file-{id}")))
|
|
||||||
.self_start()
|
|
||||||
.items_start()
|
|
||||||
.min_w_0()
|
|
||||||
.gap_2()
|
|
||||||
.p_2()
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.min_w_0()
|
|
||||||
.overflow_hidden()
|
|
||||||
.text_sm()
|
|
||||||
.child(div().line_height(relative(1.2)).child(file.display_name()))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(label),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.on_click({
|
|
||||||
let file = file.clone();
|
|
||||||
let id = *id;
|
|
||||||
|
|
||||||
cx.listener(move |this, _, window, cx| {
|
|
||||||
this.open_file(id, file.clone(), window, cx);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render an uploaded, encrypted file which is not sent yet
|
|
||||||
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
let file = &pending.file;
|
|
||||||
let label = file.display_name();
|
|
||||||
|
|
||||||
div()
|
|
||||||
.id(SharedString::from(file.url.to_string()))
|
|
||||||
.relative()
|
|
||||||
.w_16()
|
|
||||||
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
|
|
||||||
.map(|this| {
|
|
||||||
if file.is_image() {
|
|
||||||
this.child(
|
|
||||||
img(pending.path.clone())
|
|
||||||
.size_16()
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.object_fit(ObjectFit::Cover),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.size_16()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.child("Preview not available"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.absolute()
|
|
||||||
.top_neg_1()
|
|
||||||
.right_neg_1()
|
|
||||||
.size_4()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.rounded_full()
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.bg(gpui::green())
|
|
||||||
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
|
||||||
)
|
|
||||||
.on_click({
|
|
||||||
let url = file.url.clone();
|
|
||||||
cx.listener(move |this, _, _, cx| {
|
|
||||||
this.remove_pending_file(&url, cx);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_pending_file_list(
|
|
||||||
&self,
|
|
||||||
_window: &Window,
|
|
||||||
cx: &Context<Self>,
|
|
||||||
) -> impl IntoIterator<Item = impl IntoElement> {
|
|
||||||
let mut items = vec![];
|
|
||||||
|
|
||||||
for pending in self.encrypted_attachments.read(cx).iter() {
|
|
||||||
items.push(self.render_pending_file(pending, cx));
|
|
||||||
}
|
|
||||||
|
|
||||||
items
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
if let Some(text) = self.message(id) {
|
if let Some(text) = self.message(id) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -1890,7 +1463,7 @@ impl ChatPanel {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(text.preview()),
|
.child(SharedString::from(&text.content)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
div()
|
div()
|
||||||
@@ -2018,13 +1591,8 @@ impl Focusable for ChatPanel {
|
|||||||
|
|
||||||
impl Render for ChatPanel {
|
impl Render for ChatPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
|
|
||||||
|
|
||||||
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
|
||||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all(self.id.clone()))
|
.image_cache(coop_cache(self.id.clone(), 100))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
@@ -2056,8 +1624,10 @@ impl Render for ChatPanel {
|
|||||||
.map(|this| {
|
.map(|this| {
|
||||||
if self.messages.is_empty() {
|
if self.messages.is_empty() {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
.justify_end()
|
.justify_end()
|
||||||
.child(self.render_announcement(cx)),
|
.child(self.render_announcement(cx)),
|
||||||
)
|
)
|
||||||
@@ -2082,17 +1652,7 @@ impl Render for ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.children(self.render_attachment_list(window, cx))
|
.children(self.render_attachment_list(window, cx))
|
||||||
.children(self.render_pending_file_list(window, cx))
|
|
||||||
.children(self.render_reply_list(window, cx))
|
.children(self.render_reply_list(window, cx))
|
||||||
.when(is_typing && pending_attachments, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.px_1()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_warning)
|
|
||||||
.child(WARNING),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_end()
|
.items_end()
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chat::Mention;
|
use chat::Mention;
|
||||||
|
use common::RangeExt;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use regex::Regex;
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
|
||||||
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
|
||||||
|
|
||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Code,
|
Code,
|
||||||
@@ -41,61 +39,25 @@ impl RenderedText {
|
|||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[Mention],
|
mentions: &[Mention],
|
||||||
persons: &Entity<PersonRegistry>,
|
persons: &Entity<PersonRegistry>,
|
||||||
markdown: bool,
|
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Self {
|
|
||||||
Self::render(content, mentions, markdown, |mention| {
|
|
||||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(
|
|
||||||
content: &str,
|
|
||||||
mentions: &[Mention],
|
|
||||||
markdown: bool,
|
|
||||||
resolve_mention: impl Fn(&Mention) -> String,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
let mut link_ranges = Vec::new();
|
let mut link_ranges = Vec::new();
|
||||||
let mut link_urls = Vec::new();
|
let mut link_urls = Vec::new();
|
||||||
|
|
||||||
render_text_mut(
|
render_plain_text_mut(
|
||||||
content,
|
content,
|
||||||
mentions,
|
mentions,
|
||||||
&mut text,
|
&mut text,
|
||||||
&mut highlights,
|
&mut highlights,
|
||||||
&mut link_ranges,
|
&mut link_ranges,
|
||||||
&mut link_urls,
|
&mut link_urls,
|
||||||
markdown,
|
persons,
|
||||||
resolve_mention,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trim trailing whitespace and adjust highlight and link ranges.
|
text.truncate(text.trim_end().len());
|
||||||
let trimmed_len = text.trim_end().len();
|
|
||||||
|
|
||||||
// Retain highlights and link ranges that are within the trimmed text.
|
|
||||||
if trimmed_len < text.len() {
|
|
||||||
highlights.retain_mut(|(range, _)| {
|
|
||||||
range.end = range.end.min(trimmed_len);
|
|
||||||
range.start < range.end
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut ix = 0;
|
|
||||||
|
|
||||||
while ix < link_ranges.len() {
|
|
||||||
let range = &mut link_ranges[ix];
|
|
||||||
range.end = range.end.min(trimmed_len);
|
|
||||||
if range.start < range.end {
|
|
||||||
ix += 1;
|
|
||||||
} else {
|
|
||||||
link_ranges.remove(ix);
|
|
||||||
link_urls.remove(ix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text.truncate(trimmed_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
RenderedText {
|
RenderedText {
|
||||||
text: SharedString::from(text),
|
text: SharedString::from(text),
|
||||||
@@ -108,71 +70,55 @@ impl RenderedText {
|
|||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let code_background = cx.theme().elevated_surface_background;
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
let color = cx.theme().text_accent;
|
let color = cx.theme().text_accent;
|
||||||
let code_font = if cfg!(target_os = "macos") {
|
|
||||||
"Menlo"
|
|
||||||
} else if cfg!(target_os = "windows") {
|
|
||||||
"Consolas"
|
|
||||||
} else {
|
|
||||||
"monospace"
|
|
||||||
};
|
|
||||||
|
|
||||||
InteractiveText::new(
|
InteractiveText::new(
|
||||||
id,
|
id,
|
||||||
StyledText::new(self.text.clone())
|
StyledText::new(self.text.clone()).with_default_highlights(
|
||||||
.with_default_highlights(
|
&window.text_style(),
|
||||||
&window.text_style(),
|
self.highlights.iter().map(|(range, highlight)| {
|
||||||
self.highlights.iter().map(|(range, highlight)| {
|
(
|
||||||
(
|
range.clone(),
|
||||||
range.clone(),
|
match highlight {
|
||||||
match highlight {
|
Highlight::Code => HighlightStyle {
|
||||||
Highlight::Code => HighlightStyle {
|
background_color: Some(code_background),
|
||||||
background_color: Some(code_background),
|
..Default::default()
|
||||||
..Default::default()
|
},
|
||||||
},
|
Highlight::InlineCode(link) => {
|
||||||
Highlight::InlineCode(link) => {
|
if *link {
|
||||||
if *link {
|
HighlightStyle {
|
||||||
HighlightStyle {
|
background_color: Some(code_background),
|
||||||
background_color: Some(code_background),
|
underline: Some(UnderlineStyle {
|
||||||
underline: Some(UnderlineStyle {
|
thickness: 1.0.into(),
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}),
|
||||||
} else {
|
..Default::default()
|
||||||
HighlightStyle {
|
}
|
||||||
background_color: Some(code_background),
|
} else {
|
||||||
..Default::default()
|
HighlightStyle {
|
||||||
}
|
background_color: Some(code_background),
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Highlight::Mention => HighlightStyle {
|
}
|
||||||
color: Some(color),
|
Highlight::Mention => HighlightStyle {
|
||||||
underline: Some(UnderlineStyle {
|
color: Some(color),
|
||||||
thickness: 1.0.into(),
|
underline: Some(UnderlineStyle {
|
||||||
..Default::default()
|
thickness: 1.0.into(),
|
||||||
}),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
}),
|
||||||
Highlight::Highlight(highlight) => *highlight,
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
Highlight::Highlight(highlight) => *highlight,
|
||||||
}),
|
},
|
||||||
)
|
)
|
||||||
.with_font_family_overrides(self.highlights.iter().filter_map(
|
}),
|
||||||
|(range, highlight)| match highlight {
|
),
|
||||||
Highlight::Code | Highlight::InlineCode(_) => {
|
|
||||||
Some((range.clone(), code_font.into()))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
.on_click(self.link_ranges.clone(), {
|
.on_click(self.link_ranges.clone(), {
|
||||||
let link_urls = self.link_urls.clone();
|
let link_urls = self.link_urls.clone();
|
||||||
move |ix, _, cx| {
|
move |ix, _, cx| {
|
||||||
let url = &link_urls[ix];
|
let url = &link_urls[ix];
|
||||||
if WEB_URL.is_match(url) {
|
if url.starts_with("http") {
|
||||||
cx.open_url(url);
|
cx.open_url(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,15 +128,15 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_text_mut(
|
fn render_plain_text_mut(
|
||||||
block: &str,
|
block: &str,
|
||||||
mut mentions: &[Mention],
|
mut mentions: &[Mention],
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
markdown: bool,
|
persons: &Entity<PersonRegistry>,
|
||||||
resolve_mention: impl Fn(&Mention) -> String,
|
cx: &App,
|
||||||
) {
|
) {
|
||||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||||
|
|
||||||
@@ -199,58 +145,34 @@ fn render_text_mut(
|
|||||||
let mut strikethrough_depth = 0;
|
let mut strikethrough_depth = 0;
|
||||||
let mut link_url = None;
|
let mut link_url = None;
|
||||||
let mut list_stack = Vec::new();
|
let mut list_stack = Vec::new();
|
||||||
let mut code_block = false;
|
|
||||||
|
|
||||||
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
let mut options = Options::all();
|
||||||
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||||
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
|
||||||
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
|
||||||
} else {
|
|
||||||
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
|
||||||
};
|
|
||||||
|
|
||||||
for (event, source_range) in events {
|
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||||
let prev_len = text.len();
|
let prev_len = text.len();
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::Text(t) => {
|
Event::Text(t) => {
|
||||||
if code_block {
|
// Process text with mention replacements
|
||||||
text.push_str(t.as_ref());
|
|
||||||
highlights.push((prev_len..text.len(), Highlight::Code));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let t_str = t.as_ref();
|
let t_str = t.as_ref();
|
||||||
let mut last_processed = 0;
|
let mut last_processed = 0;
|
||||||
|
|
||||||
while let Some(mention) = mentions.first() {
|
while let Some(mention) = mentions.first() {
|
||||||
if mention.range.start >= source_range.end {
|
if !source_range.contains_inclusive(&mention.range) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
mentions = &mentions[1..];
|
// Calculate positions within the current text
|
||||||
if mention.range.start < source_range.start
|
let mention_start_in_text = mention.range.start - source_range.start;
|
||||||
|| mention.range.end > source_range.end
|
let mention_end_in_text = mention.range.end - source_range.start;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(token) = block.get(mention.range.clone()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(offset) = t_str[last_processed..].find(token) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mention_start_in_text = last_processed + offset;
|
|
||||||
let mention_end_in_text = mention_start_in_text + token.len();
|
|
||||||
|
|
||||||
// Add text before this mention
|
// Add text before this mention
|
||||||
if mention_start_in_text > last_processed {
|
if mention_start_in_text > last_processed {
|
||||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||||
process_text_segment(
|
process_text_segment(
|
||||||
before_mention,
|
before_mention,
|
||||||
|
prev_len + last_processed,
|
||||||
bold_depth,
|
bold_depth,
|
||||||
italic_depth,
|
italic_depth,
|
||||||
strikethrough_depth,
|
strikethrough_depth,
|
||||||
@@ -263,7 +185,9 @@ fn render_text_mut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process the mention replacement
|
// Process the mention replacement
|
||||||
let replacement_text = resolve_mention(mention);
|
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||||
|
let replacement_text = format!("@{}", profile.name());
|
||||||
|
|
||||||
let replacement_start = text.len();
|
let replacement_start = text.len();
|
||||||
text.push_str(&replacement_text);
|
text.push_str(&replacement_text);
|
||||||
let replacement_end = text.len();
|
let replacement_end = text.len();
|
||||||
@@ -271,6 +195,7 @@ fn render_text_mut(
|
|||||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||||
|
|
||||||
last_processed = mention_end_in_text;
|
last_processed = mention_end_in_text;
|
||||||
|
mentions = &mentions[1..];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last mention
|
// Add any remaining text after the last mention
|
||||||
@@ -278,6 +203,7 @@ fn render_text_mut(
|
|||||||
let remaining_text = &t_str[last_processed..];
|
let remaining_text = &t_str[last_processed..];
|
||||||
process_text_segment(
|
process_text_segment(
|
||||||
remaining_text,
|
remaining_text,
|
||||||
|
prev_len + last_processed,
|
||||||
bold_depth,
|
bold_depth,
|
||||||
italic_depth,
|
italic_depth,
|
||||||
strikethrough_depth,
|
strikethrough_depth,
|
||||||
@@ -308,14 +234,11 @@ fn render_text_mut(
|
|||||||
}
|
}
|
||||||
Tag::CodeBlock(_kind) => {
|
Tag::CodeBlock(_kind) => {
|
||||||
new_paragraph(text, &mut list_stack);
|
new_paragraph(text, &mut list_stack);
|
||||||
code_block = true;
|
|
||||||
}
|
}
|
||||||
Tag::Emphasis => italic_depth += 1,
|
Tag::Emphasis => italic_depth += 1,
|
||||||
Tag::Strong => bold_depth += 1,
|
Tag::Strong => bold_depth += 1,
|
||||||
Tag::Strikethrough => strikethrough_depth += 1,
|
Tag::Strikethrough => strikethrough_depth += 1,
|
||||||
Tag::Link { dest_url, .. } => {
|
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||||
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
|
||||||
}
|
|
||||||
Tag::List(number) => {
|
Tag::List(number) => {
|
||||||
list_stack.push((number, false));
|
list_stack.push((number, false));
|
||||||
}
|
}
|
||||||
@@ -341,7 +264,6 @@ fn render_text_mut(
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Event::End(tag) => match tag {
|
Event::End(tag) => match tag {
|
||||||
TagEnd::CodeBlock => code_block = false,
|
|
||||||
TagEnd::Heading(_) => bold_depth -= 1,
|
TagEnd::Heading(_) => bold_depth -= 1,
|
||||||
TagEnd::Emphasis => italic_depth -= 1,
|
TagEnd::Emphasis => italic_depth -= 1,
|
||||||
TagEnd::Strong => bold_depth -= 1,
|
TagEnd::Strong => bold_depth -= 1,
|
||||||
@@ -350,11 +272,6 @@ fn render_text_mut(
|
|||||||
TagEnd::List(_) => drop(list_stack.pop()),
|
TagEnd::List(_) => drop(list_stack.pop()),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
|
||||||
Event::Rule => {
|
|
||||||
new_paragraph(text, &mut list_stack);
|
|
||||||
text.push_str("────────\n");
|
|
||||||
}
|
|
||||||
Event::HardBreak => text.push('\n'),
|
Event::HardBreak => text.push('\n'),
|
||||||
Event::SoftBreak => text.push('\n'),
|
Event::SoftBreak => text.push('\n'),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -365,6 +282,7 @@ fn render_text_mut(
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn process_text_segment(
|
fn process_text_segment(
|
||||||
segment: &str,
|
segment: &str,
|
||||||
|
segment_start: usize,
|
||||||
bold_depth: i32,
|
bold_depth: i32,
|
||||||
italic_depth: i32,
|
italic_depth: i32,
|
||||||
strikethrough_depth: i32,
|
strikethrough_depth: i32,
|
||||||
@@ -389,8 +307,7 @@ fn process_text_segment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ranges always refer to the rendered text, including replaced mentions.
|
// Add the text
|
||||||
let segment_start = text.len();
|
|
||||||
text.push_str(segment);
|
text.push_str(segment);
|
||||||
let text_end = text.len();
|
let text_end = text.len();
|
||||||
|
|
||||||
@@ -413,10 +330,7 @@ fn process_text_segment(
|
|||||||
finder.kinds(&[linkify::LinkKind::Url]);
|
finder.kinds(&[linkify::LinkKind::Url]);
|
||||||
let mut last_link_pos = 0;
|
let mut last_link_pos = 0;
|
||||||
|
|
||||||
for link in finder
|
for link in finder.links(segment) {
|
||||||
.links(segment)
|
|
||||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
|
||||||
{
|
|
||||||
let start = link.start();
|
let start = link.start();
|
||||||
let end = link.end();
|
let end = link.end();
|
||||||
|
|
||||||
@@ -461,7 +375,6 @@ fn process_text_segment(
|
|||||||
|
|
||||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||||
let mut is_subsequent_paragraph_of_list = false;
|
let mut is_subsequent_paragraph_of_list = false;
|
||||||
|
|
||||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||||
if *has_content {
|
if *has_content {
|
||||||
is_subsequent_paragraph_of_list = true;
|
is_subsequent_paragraph_of_list = true;
|
||||||
@@ -477,11 +390,9 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
|||||||
}
|
}
|
||||||
text.push('\n');
|
text.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||||
text.push_str(" ");
|
text.push_str(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_subsequent_paragraph_of_list {
|
if is_subsequent_paragraph_of_list {
|
||||||
text.push_str(" ");
|
text.push_str(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
pub use caching::*;
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
@@ -6,6 +7,7 @@ pub use parser::*;
|
|||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
pub use range::*;
|
pub use range::*;
|
||||||
|
|
||||||
|
mod caching;
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ use std::path::PathBuf;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -414,7 +414,7 @@ impl DeviceRegistry {
|
|||||||
.pubkey(app_pubkey)
|
.pubkey(app_pubkey)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
match client.database().query(filter).await?.into_iter().next() {
|
match client.database().query(filter).await?.first_owned() {
|
||||||
// Found an approval event
|
// Found an approval event
|
||||||
Some(event) => Ok(Some(event)),
|
Some(event) => Ok(Some(event)),
|
||||||
// No approval event found, construct a request event
|
// No approval event found, construct a request event
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ anyhow.workspace = true
|
|||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use gpui::SharedString;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::Announcement;
|
use state::Announcement;
|
||||||
|
|
||||||
|
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||||
|
|
||||||
/// Person
|
/// Person
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
@@ -109,7 +111,13 @@ impl Person {
|
|||||||
.picture
|
.picture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|picture| !picture.is_empty())
|
.filter(|picture| !picture.is_empty())
|
||||||
.map(|picture| picture.into())
|
.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())
|
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
|||||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
|
||||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
|
||||||
|
|
||||||
macro_rules! setting_accessors {
|
macro_rules! setting_accessors {
|
||||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
@@ -141,7 +138,7 @@ impl Default for Settings {
|
|||||||
screening: true,
|
screening: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
trusted_relays: vec![],
|
trusted_relays: vec![],
|
||||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,12 +217,7 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let mut settings = task.await.unwrap_or(Settings::default());
|
let 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
|
// Update settings
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
|||||||
@@ -24,15 +24,10 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
|
||||||
aes-gcm.workspace = true
|
|
||||||
sha2.workspace = true
|
|
||||||
data-encoding.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
|
||||||
nostr-lmdb.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
gpui_tokio.workspace = true
|
gpui_tokio.workspace = true
|
||||||
|
|||||||
@@ -4,52 +4,30 @@ use anyhow::{Error, anyhow};
|
|||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::file::sha256_hex;
|
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();
|
||||||
/// Upload a blob to a blossom server and return its URL
|
let data = smol::fs::read(path).await?;
|
||||||
#[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 keys = Keys::generate();
|
||||||
let content_type = content_type.to_string();
|
|
||||||
let base = server.clone();
|
// Construct the blossom client
|
||||||
let hash = sha256.to_string();
|
let client = BlossomClient::new(server);
|
||||||
|
|
||||||
Tokio::spawn(cx, async move {
|
Tokio::spawn(cx, async move {
|
||||||
match client
|
let blob = client
|
||||||
.upload_blob(data, Some(content_type), None, Some(&keys))
|
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
Ok(blob) => Ok(blob.url),
|
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
|
.await
|
||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
Err(anyhow!("File upload not supported on web"))
|
Err(anyhow!("File upload not supported on web"))
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
|||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
|
/// Default image cache size
|
||||||
|
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use aes_gcm::aead::consts::{U12, U16, U32};
|
|
||||||
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
|
||||||
use aes_gcm::aes::Aes256;
|
|
||||||
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
|
||||||
use anyhow::{Error, anyhow, bail};
|
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
use futures::AsyncReadExt;
|
|
||||||
use gpui::http_client::AsyncBody;
|
|
||||||
use gpui::{AsyncApp, SharedString};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use mime_guess::from_path;
|
|
||||||
use nostr::nips::nip94::Sha256Hash;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
pub const ALGORITHM: &str = "aes-gcm";
|
|
||||||
|
|
||||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
|
||||||
|
|
||||||
const TAG_SHA256: &str = "x";
|
|
||||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
|
||||||
const TAG_FILE_TYPE: &str = "file-type";
|
|
||||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
|
||||||
const TAG_KEY: &str = "decryption-key";
|
|
||||||
const TAG_NONCE: &str = "decryption-nonce";
|
|
||||||
const TAG_SIZE: &str = "size";
|
|
||||||
const TAG_DIM: &str = "dim";
|
|
||||||
const TAG_ALT: &str = "alt";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct EncryptedFile {
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub key: String,
|
|
||||||
pub nonce: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct FileAttachment {
|
|
||||||
pub url: Url,
|
|
||||||
pub mime: String,
|
|
||||||
pub key: String,
|
|
||||||
pub nonce: String,
|
|
||||||
pub sha256: Option<String>,
|
|
||||||
pub original_sha256: Option<String>,
|
|
||||||
pub size: Option<u64>,
|
|
||||||
pub dim: Option<(u32, u32)>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileAttachment {
|
|
||||||
pub fn tags(&self) -> Vec<Tag> {
|
|
||||||
let mut tags = vec![
|
|
||||||
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
|
||||||
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
|
||||||
Tag::custom(TAG_KEY, [self.key.clone()]),
|
|
||||||
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Some(sha256) = &self.sha256 {
|
|
||||||
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(original_sha256) = &self.original_sha256 {
|
|
||||||
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(size) = self.size {
|
|
||||||
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((width, height)) = self.dim {
|
|
||||||
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = &self.name {
|
|
||||||
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
tags
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
|
||||||
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
url: Url::parse(content).ok()?,
|
|
||||||
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
|
||||||
key: tag_value(tags, TAG_KEY)?.to_string(),
|
|
||||||
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
|
||||||
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
|
||||||
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
|
||||||
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
|
||||||
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
|
||||||
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_image(&self) -> bool {
|
|
||||||
self.mime.starts_with("image/")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn display_name(&self) -> SharedString {
|
|
||||||
if let Some(name) = &self.name {
|
|
||||||
return name.clone().into();
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.size {
|
|
||||||
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
|
||||||
None => self.mime.clone().into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
|
||||||
let key = Aes256Gcm::generate_key(OsRng);
|
|
||||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
|
||||||
let cipher = AesGcm::<Aes256, U16>::new(&key);
|
|
||||||
|
|
||||||
let data = cipher
|
|
||||||
.encrypt(&nonce, data)
|
|
||||||
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
|
||||||
|
|
||||||
Ok(EncryptedFile {
|
|
||||||
data,
|
|
||||||
key: HEXLOWER.encode(key.as_slice()),
|
|
||||||
nonce: HEXLOWER.encode(nonce.as_slice()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
|
||||||
let key = decode(key, "decryption key")?;
|
|
||||||
let nonce = decode(nonce, "decryption nonce")?;
|
|
||||||
|
|
||||||
if key.len() != 32 {
|
|
||||||
bail!(
|
|
||||||
"Invalid decryption key length: expected 32 bytes, got {}",
|
|
||||||
key.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
match nonce.len() {
|
|
||||||
12 => Aes256Gcm::new_from_slice(&key)
|
|
||||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
|
||||||
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
|
||||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
|
||||||
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
|
|
||||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
|
||||||
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
|
|
||||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
|
||||||
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
|
|
||||||
.map_err(|_| anyhow!("Invalid decryption key"))?
|
|
||||||
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
|
|
||||||
.map_err(|_| anyhow!("Failed to decrypt file")),
|
|
||||||
len => bail!("Unsupported decryption nonce length: {len} bytes"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sha256_hex(data: &[u8]) -> String {
|
|
||||||
let hash: [u8; 32] = Sha256::digest(data).into();
|
|
||||||
|
|
||||||
Sha256Hash::from_byte_array(hash).to_hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub async fn upload_encrypted(
|
|
||||||
server: Url,
|
|
||||||
path: PathBuf,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<FileAttachment, Error> {
|
|
||||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().into_owned());
|
|
||||||
let data = smol::fs::read(&path).await?;
|
|
||||||
|
|
||||||
let encrypted = encrypt(&data)?;
|
|
||||||
let sha256 = sha256_hex(&encrypted.data);
|
|
||||||
let original_sha256 = sha256_hex(&data);
|
|
||||||
let size = encrypted.data.len() as u64;
|
|
||||||
let base_url = server.to_string();
|
|
||||||
|
|
||||||
let url = crate::blossom::upload_blob(
|
|
||||||
&server,
|
|
||||||
encrypted.data,
|
|
||||||
"application/octet-stream",
|
|
||||||
&sha256,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
let message = e.to_string();
|
|
||||||
|
|
||||||
if !message.contains("415") {
|
|
||||||
return anyhow!(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
anyhow!(
|
|
||||||
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
|
|
||||||
opaque data, which this file server does not accept. Choose a different file
|
|
||||||
server in the settings."
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(FileAttachment {
|
|
||||||
url,
|
|
||||||
mime,
|
|
||||||
key: encrypted.key,
|
|
||||||
nonce: encrypted.nonce,
|
|
||||||
sha256: Some(sha256),
|
|
||||||
original_sha256: Some(original_sha256),
|
|
||||||
size: Some(size),
|
|
||||||
dim: None,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub async fn upload_encrypted(
|
|
||||||
_server: Url,
|
|
||||||
_path: PathBuf,
|
|
||||||
_cx: &AsyncApp,
|
|
||||||
) -> Result<FileAttachment, Error> {
|
|
||||||
Err(anyhow!("File upload not supported on web"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn download_and_decrypt(
|
|
||||||
url: &Url,
|
|
||||||
key: &str,
|
|
||||||
nonce: &str,
|
|
||||||
expected_sha256: Option<&str>,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<Vec<u8>, Error> {
|
|
||||||
let client = cx.update(|app| app.http_client());
|
|
||||||
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
bail!("Failed to download file: HTTP {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut data = Vec::new();
|
|
||||||
response
|
|
||||||
.into_body()
|
|
||||||
.take(MAX_FILE_SIZE as u64 + 1)
|
|
||||||
.read_to_end(&mut data)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if data.len() > MAX_FILE_SIZE {
|
|
||||||
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expected) = expected_sha256
|
|
||||||
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
|
||||||
{
|
|
||||||
bail!("File hash mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypt(&data, key, nonce)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download and decrypt a file attachment into a temporary file.
|
|
||||||
///
|
|
||||||
/// The same attachment always maps to the same path, so callers can render the
|
|
||||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub async fn download_and_decrypt_to_file(
|
|
||||||
file: &FileAttachment,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<PathBuf, Error> {
|
|
||||||
let name = file
|
|
||||||
.sha256
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
|
||||||
|
|
||||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
|
||||||
.and_then(|extensions| extensions.first())
|
|
||||||
.copied()
|
|
||||||
.unwrap_or("bin");
|
|
||||||
|
|
||||||
let path = std::env::temp_dir()
|
|
||||||
.join("coop-files")
|
|
||||||
.join(format!("{name}.{extension}"));
|
|
||||||
|
|
||||||
if smol::fs::metadata(&path).await.is_ok() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = download_and_decrypt(
|
|
||||||
&file.url,
|
|
||||||
&file.key,
|
|
||||||
&file.nonce,
|
|
||||||
file.sha256.as_deref(),
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(parent) = path.parent() else {
|
|
||||||
bail!("Invalid file path");
|
|
||||||
};
|
|
||||||
smol::fs::create_dir_all(parent).await?;
|
|
||||||
|
|
||||||
// Write under a temporary name first, so an interrupted download is never reused
|
|
||||||
let partial = path.with_extension("download");
|
|
||||||
smol::fs::write(&partial, data).await?;
|
|
||||||
smol::fs::rename(&partial, &path).await?;
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub async fn download_and_decrypt_to_file(
|
|
||||||
_file: &FileAttachment,
|
|
||||||
_cx: &AsyncApp,
|
|
||||||
) -> Result<PathBuf, Error> {
|
|
||||||
Err(anyhow!("File download not supported on web"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
|
||||||
tags.iter()
|
|
||||||
.find(|tag| tag.kind() == name)
|
|
||||||
.and_then(|tag| tag.content())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
|
||||||
let (width, height) = value.split_once('x')?;
|
|
||||||
|
|
||||||
Some((width.parse().ok()?, height.parse().ok()?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
|
|
||||||
HEXLOWER
|
|
||||||
.decode(value.to_ascii_lowercase().as_bytes())
|
|
||||||
.map_err(|_| anyhow!("Invalid {label} encoding"))
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use browser_signer_proxy::prelude::*;
|
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||||
use gpui_tokio::Tokio;
|
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
@@ -17,19 +14,17 @@ use nostr_sdk::prelude::*;
|
|||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod file;
|
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod nip4e;
|
mod nip4e;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
pub use blossom::*;
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
pub use file::*;
|
|
||||||
pub use nip4e::*;
|
pub use nip4e::*;
|
||||||
pub use nip05::*;
|
pub use nip05::*;
|
||||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
// rustls uses the `aws_lc_rs` provider by default
|
// rustls uses the `aws_lc_rs` provider by default
|
||||||
// This only errors if the default provider has already
|
// This only errors if the default provider has already
|
||||||
// been installed. We can ignore this `Result`.
|
// been installed. We can ignore this `Result`.
|
||||||
@@ -42,7 +37,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
gpui_tokio::init(cx);
|
gpui_tokio::init(cx);
|
||||||
|
|
||||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
|
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||||
@@ -63,16 +58,16 @@ pub enum StateEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StateEvent {
|
impl StateEvent {
|
||||||
pub fn signer_changed(&self) -> bool {
|
|
||||||
matches!(self, StateEvent::SignerChanged)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error<T>(error: T) -> Self
|
pub fn error<T>(error: T) -> Self
|
||||||
where
|
where
|
||||||
T: Into<String>,
|
T: Into<String>,
|
||||||
{
|
{
|
||||||
Self::Error(error.into())
|
Self::Error(error.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn signer_changed(&self) -> bool {
|
||||||
|
matches!(self, StateEvent::SignerChanged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nostr Registry
|
/// Nostr Registry
|
||||||
@@ -105,7 +100,7 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new nostr instance
|
/// Create a new nostr instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
@@ -138,10 +133,6 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
} else if let Some(secret) = cli_key {
|
|
||||||
// Use CLI-provided key -- same path as get_user_credential
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
this.get_user_credential(cx);
|
||||||
}
|
}
|
||||||
@@ -267,11 +258,6 @@ impl NostrRegistry {
|
|||||||
this.set_signer(signer, cx);
|
this.set_signer(signer, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
} else if content == "proxy" {
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.connect_proxy(cx);
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -317,81 +303,6 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
|
||||||
let (tx, rx) = flume::bounded::<String>(1);
|
|
||||||
|
|
||||||
self.tasks.push(Tokio::spawn_result(cx, {
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
async move {
|
|
||||||
// Start the proxy and get the web url
|
|
||||||
proxy.start().await?;
|
|
||||||
// Notify GPUI
|
|
||||||
let url = proxy.url();
|
|
||||||
tx.send(url).ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.tasks.push(Tokio::spawn_result(cx, {
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
async move {
|
|
||||||
loop {
|
|
||||||
if proxy.is_session_active() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
smol::Timer::after(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn({
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
async move |this, cx| {
|
|
||||||
while let Ok(url) = rx.recv_async().await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
|
|
||||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
|
||||||
cx.open_url(&url);
|
|
||||||
this.set_signer(proxy.clone(), cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Monitor the session, if the browser disconnects, notify user to reconnect
|
|
||||||
self.tasks.push(cx.spawn({
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
let executor = cx.background_executor().clone();
|
|
||||||
async move |this, cx| {
|
|
||||||
// Wait for the signer to be confirmed (timeout is 30s)
|
|
||||||
executor.timer(Duration::from_secs(30)).await;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
executor.timer(Duration::from_secs(5)).await;
|
|
||||||
if !proxy.is_session_active() {
|
|
||||||
_ = this.update(cx, |this, cx| {
|
|
||||||
// Only notify if this proxy is still the active signer
|
|
||||||
if this.current_user.is_some() {
|
|
||||||
this.signer.swap_inner(Keys::generate());
|
|
||||||
this.current_user = None;
|
|
||||||
cx.emit(StateEvent::NoSigner);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the public key of a NIP-05 address
|
/// Get the public key of a NIP-05 address
|
||||||
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
|
|||||||
id: "coop".into(),
|
id: "coop".into(),
|
||||||
name: "Coop Default Theme".into(),
|
name: "Coop Default Theme".into(),
|
||||||
author: "Coop".into(),
|
author: "Coop".into(),
|
||||||
url: "https://github.com/reyakov/coop".into(),
|
url: "https://github.com/lumehq/coop".into(),
|
||||||
light: ThemeColors::light(),
|
light: ThemeColors::light(),
|
||||||
dark: ThemeColors::dark(),
|
dark: ThemeColors::dark(),
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ mod tests {
|
|||||||
"id": "test-theme",
|
"id": "test-theme",
|
||||||
"name": "Test Theme",
|
"name": "Test Theme",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://github.com/reyakov/coop",
|
"url": "https://github.com/lumehq/coop",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#ffffff",
|
"background": "#ffffff",
|
||||||
"surface_background": "#fafafa",
|
"surface_background": "#fafafa",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||||
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
|
||||||
Window, div, img, px,
|
px,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
|||||||
/// ```
|
/// ```
|
||||||
/// use ui::{Avatar};
|
/// use ui::{Avatar};
|
||||||
///
|
///
|
||||||
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
|
/// Avatar::new("path/to/image.png")
|
||||||
|
/// .grayscale(true)
|
||||||
|
/// .border_color(gpui::red());
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
@@ -128,7 +130,7 @@ impl RenderOnce for Avatar {
|
|||||||
self.image
|
self.image
|
||||||
.size(image_size)
|
.size(image_size)
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.object_fit(ObjectFit::Cover)
|
.object_fit(gpui::ObjectFit::Fill)
|
||||||
.bg(cx.theme().ghost_element_background)
|
.bg(cx.theme().ghost_element_background)
|
||||||
.with_fallback(move || {
|
.with_fallback(move || {
|
||||||
img("brand/avatar.png")
|
img("brand/avatar.png")
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl Render for DragPanel {
|
|||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().text)
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||||
@@ -312,7 +312,6 @@ impl TabPanel {
|
|||||||
|
|
||||||
cx.emit(PanelEvent::ZoomOut);
|
cx.emit(PanelEvent::ZoomOut);
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detach_panel(
|
fn detach_panel(
|
||||||
@@ -322,22 +321,10 @@ impl TabPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let panel_view = panel.view();
|
let panel_view = panel.view();
|
||||||
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
|
|
||||||
self.panels.retain(|p| p.view() != panel_view);
|
self.panels.retain(|p| p.view() != panel_view);
|
||||||
|
|
||||||
if self.active_ix >= self.panels.len() {
|
if self.active_ix >= self.panels.len() {
|
||||||
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
|
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
|
||||||
} else if let Some(removed_ix) = removed_ix {
|
|
||||||
if removed_ix < self.active_ix {
|
|
||||||
self.active_ix = self.active_ix.saturating_sub(1);
|
|
||||||
} else if removed_ix == self.active_ix {
|
|
||||||
// The active panel was removed and another panel shifted into
|
|
||||||
// its position. Activate the new panel at the same index.
|
|
||||||
if let Some(new_active) = self.panels.get(self.active_ix) {
|
|
||||||
new_active.set_active(true, cx);
|
|
||||||
}
|
|
||||||
self.focus_active_panel(window, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +613,7 @@ impl TabPanel {
|
|||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.child(panel.title(cx)),
|
.child(panel.title(cx)),
|
||||||
)
|
)
|
||||||
.when(state.draggable, |this| {
|
.when(state.draggable, |this| {
|
||||||
@@ -700,8 +687,8 @@ impl TabPanel {
|
|||||||
.on_click(cx.listener({
|
.on_click(cx.listener({
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
move |view, _ev, window, cx| {
|
move |view, _ev, window, cx| {
|
||||||
cx.stop_propagation();
|
|
||||||
view.remove_panel(&panel, window, cx);
|
view.remove_panel(&panel, window, cx);
|
||||||
|
view.set_active_ix(ix, window, cx);
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ pub enum IconName {
|
|||||||
InboxFill,
|
InboxFill,
|
||||||
Link,
|
Link,
|
||||||
Loader,
|
Loader,
|
||||||
Lock,
|
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
@@ -119,7 +118,6 @@ impl IconNamed for IconName {
|
|||||||
Self::InboxFill => "icons/inbox-fill.svg",
|
Self::InboxFill => "icons/inbox-fill.svg",
|
||||||
Self::Link => "icons/link.svg",
|
Self::Link => "icons/link.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
Self::Lock => "icons/lock.svg",
|
|
||||||
Self::Moon => "icons/moon.svg",
|
Self::Moon => "icons/moon.svg",
|
||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
Self::PlusCircle => "icons/plus-circle.svg",
|
Self::PlusCircle => "icons/plus-circle.svg",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -6,7 +7,6 @@ use gpui::{
|
|||||||
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
||||||
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
|
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::{Cancel, Confirm};
|
use crate::actions::{Cancel, Confirm};
|
||||||
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
|
|||||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||||
let x = bounds.center().x - self.width / 2.;
|
let x = bounds.center().x - self.width / 2.;
|
||||||
|
|
||||||
let mut padding_right = px(16.);
|
let mut padding_right = px(8.);
|
||||||
let mut padding_left = px(16.);
|
let mut padding_left = px(8.);
|
||||||
|
|
||||||
if let Some(pl) = self.style.padding.left {
|
if let Some(pl) = self.style.padding.left {
|
||||||
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
||||||
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
|
|||||||
.when_some(self.max_width, |this, w| this.max_w(w))
|
.when_some(self.max_width, |this, w| this.max_w(w))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.px_4()
|
.px_2()
|
||||||
.h_8()
|
.h_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
|
|||||||
|
|
||||||
/// Returns a `Div` as divider.
|
/// Returns a `Div` as divider.
|
||||||
pub fn divider(cx: &App) -> Div {
|
pub fn divider(cx: &App) -> Div {
|
||||||
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
|
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! font_weight {
|
macro_rules! font_weight {
|
||||||
|
|||||||
@@ -183,10 +183,12 @@ impl RenderOnce for Tab {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.relative()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.text_color(fg)
|
.text_color(fg)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.when(!self.selected && !self.disabled, |this| {
|
||||||
|
this.hover(|this| this.text_color(cx.theme().secondary_foreground))
|
||||||
|
})
|
||||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -220,21 +222,5 @@ impl RenderOnce for Tab {
|
|||||||
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.bottom_0()
|
|
||||||
.left_0()
|
|
||||||
.right_0()
|
|
||||||
.h_0p5()
|
|
||||||
.when(self.selected && !self.disabled, |this| {
|
|
||||||
this.bg(cx.theme().element_active)
|
|
||||||
})
|
|
||||||
.when(!self.selected && !self.disabled, |this| {
|
|
||||||
this.invisible().group_hover("", |this| {
|
|
||||||
this.visible().bg(cx.theme().secondary_background)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ chat = { path = "../chat" }
|
|||||||
chat_ui = { path = "../chat_ui" }
|
chat_ui = { path = "../chat_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
auto_update = { path = "../auto_update" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
|||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
|
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportIdentity {
|
||||||
@@ -164,14 +164,6 @@ impl ImportIdentity {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn proxy(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.connect_proxy(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.loading = status;
|
self.loading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -207,13 +199,10 @@ impl ImportIdentity {
|
|||||||
|
|
||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
||||||
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
||||||
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
|
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -238,20 +227,13 @@ impl Render for ImportIdentity {
|
|||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(bunker_warning, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_warning)
|
|
||||||
.child(div().child(BUNKER_WARN)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(key_warning, |this| {
|
.when(key_warning, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_warning)
|
.text_color(cx.theme().text_warning)
|
||||||
.child(div().child(KEY_WARN)),
|
.child(div().font_semibold().child("Warning"))
|
||||||
|
.child(div().child(MSG)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -266,19 +248,6 @@ impl Render for ImportIdentity {
|
|||||||
this.login(window, cx);
|
this.login(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(divider(cx))
|
|
||||||
.when(!is_wasm, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("proxy")
|
|
||||||
.label("Connect via Web Extension (Experimental)")
|
|
||||||
.ghost_alt()
|
|
||||||
.loading(self.loading)
|
|
||||||
.disabled(self.loading)
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.proxy(cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||||
@@ -84,20 +84,8 @@ impl Screening {
|
|||||||
|
|
||||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||||
// Check if user is in contact list
|
// Check if user is in contact list
|
||||||
let filter = Filter::new()
|
let contacts = client.database().contacts_public_keys(current_user).await;
|
||||||
.author(current_user)
|
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let followed = client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().any(|k| k == public_key))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
Ok(followed)
|
Ok(followed)
|
||||||
});
|
});
|
||||||
@@ -240,10 +228,11 @@ impl Screening {
|
|||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let tag = Tag::from(Nip56Tag::PublicKey {
|
let tag = Nip56Tag::PublicKey {
|
||||||
public_key,
|
public_key,
|
||||||
report: Report::Impersonation,
|
report: Report::Impersonation,
|
||||||
});
|
}
|
||||||
|
.to_tag();
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::Reporting, "")
|
let event = EventBuilder::new(Kind::Reporting, "")
|
||||||
.tag(tag)
|
.tag(tag)
|
||||||
@@ -274,7 +263,7 @@ impl Screening {
|
|||||||
let contacts = contacts.clone();
|
let contacts = contacts.clone();
|
||||||
let total = contacts.len();
|
let total = contacts.len();
|
||||||
|
|
||||||
this.title("Mutual contacts").child(
|
this.title(SharedString::from("Mutual contacts")).child(
|
||||||
v_flex().gap_1().pb_2().child(
|
v_flex().gap_1().pb_2().child(
|
||||||
uniform_list("contacts", total, move |range, _window, cx| {
|
uniform_list("contacts", total, move |range, _window, cx| {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -353,7 +342,7 @@ impl Render for Screening {
|
|||||||
.h_7()
|
.h_7()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().surface_background)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.truncate()
|
.truncate()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
@@ -366,8 +355,7 @@ impl Render for Screening {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
Button::new("njump")
|
Button::new("njump")
|
||||||
.icon(IconName::Link)
|
.label("View on njump.me")
|
||||||
.label("njump.me")
|
|
||||||
.secondary()
|
.secondary()
|
||||||
.small()
|
.small()
|
||||||
.rounded()
|
.rounded()
|
||||||
@@ -398,18 +386,21 @@ impl Render for Screening {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.child(status_badge(Some(self.followed), cx))
|
.child(status_badge(Some(self.followed), cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex().text_sm().child("Contact").child(
|
v_flex()
|
||||||
div()
|
.text_sm()
|
||||||
.line_clamp(1)
|
.child(SharedString::from("Contact"))
|
||||||
.text_color(cx.theme().text_muted)
|
.child(
|
||||||
.child({
|
div()
|
||||||
if self.followed {
|
.line_clamp(1)
|
||||||
SharedString::from(CONTACT)
|
.text_color(cx.theme().text_muted)
|
||||||
} else {
|
.child({
|
||||||
SharedString::from(NOT_CONTACT)
|
if self.followed {
|
||||||
}
|
SharedString::from(CONTACT)
|
||||||
}),
|
} else {
|
||||||
),
|
SharedString::from(NOT_CONTACT)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -424,7 +415,7 @@ impl Render for Screening {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.child("Activity on Public Relays")
|
.child(SharedString::from("Activity on Public Relays"))
|
||||||
.child(
|
.child(
|
||||||
Button::new("active")
|
Button::new("active")
|
||||||
.icon(IconName::Info)
|
.icon(IconName::Info)
|
||||||
@@ -493,8 +484,25 @@ impl Render for Screening {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.child(status_badge(Some(mutuals > 0), cx))
|
.child(status_badge(Some(mutuals > 0), cx))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
v_flex()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(SharedString::from("Mutual contacts"))
|
||||||
|
.child(
|
||||||
|
Button::new("mutuals")
|
||||||
|
.icon(IconName::Info)
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.rounded()
|
||||||
|
.on_click(cx.listener(
|
||||||
|
move |this, _, window, cx| {
|
||||||
|
this.mutual_contacts(window, cx);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
@@ -506,17 +514,6 @@ impl Render for Screening {
|
|||||||
SharedString::from(NO_MUTUAL)
|
SharedString::from(NO_MUTUAL)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("mutuals")
|
|
||||||
.icon(IconName::Info)
|
|
||||||
.xsmall()
|
|
||||||
.ghost()
|
|
||||||
.rounded()
|
|
||||||
.disabled(mutuals == 0)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.mutual_contacts(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,20 +2,19 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use ::settings::AppSettings;
|
use ::settings::AppSettings;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use auto_update::AutoUpdater;
|
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::download_dir;
|
use common::{CoopImageCache, download_dir};
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
use person::{PersonRegistry, shorten_pubkey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, StateEvent};
|
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -45,12 +44,13 @@ struct MsgRelayNotification;
|
|||||||
#[action(namespace = workspace, no_json)]
|
#[action(namespace = workspace, no_json)]
|
||||||
enum Command {
|
enum Command {
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
Update,
|
|
||||||
RefreshMessagingRelays,
|
RefreshMessagingRelays,
|
||||||
BackupEncryption,
|
BackupEncryption,
|
||||||
ImportEncryption,
|
ImportEncryption,
|
||||||
RefreshEncryption,
|
RefreshEncryption,
|
||||||
ResetEncryption,
|
ResetEncryption,
|
||||||
|
|
||||||
ShowRelayList,
|
ShowRelayList,
|
||||||
ShowMessaging,
|
ShowMessaging,
|
||||||
ShowProfile,
|
ShowProfile,
|
||||||
@@ -64,6 +64,9 @@ pub struct Workspace {
|
|||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
|
/// App's Image Cache
|
||||||
|
image_cache: Entity<CoopImageCache>,
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ impl Workspace {
|
|||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
|
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -229,6 +233,7 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
sidebar,
|
sidebar,
|
||||||
dock,
|
dock,
|
||||||
|
image_cache,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -374,11 +379,6 @@ impl Workspace {
|
|||||||
Command::ImportEncryption => {
|
Command::ImportEncryption => {
|
||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
|
||||||
auto_updater.update(cx, |this, cx| this.check(cx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ impl Workspace {
|
|||||||
.caret()
|
.caret()
|
||||||
.compact()
|
.compact()
|
||||||
.transparent()
|
.transparent()
|
||||||
.dropdown_menu(move |this, _window, cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
let avatar = avatar.clone();
|
let avatar = avatar.clone();
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
|
|
||||||
@@ -585,15 +585,7 @@ impl Workspace {
|
|||||||
IconName::Sun,
|
IconName::Sun,
|
||||||
Box::new(Command::ToggleTheme),
|
Box::new(Command::ToggleTheme),
|
||||||
)
|
)
|
||||||
// Only offer in-app updates when auto-update is
|
.separator()
|
||||||
// enabled (managed channels update themselves).
|
|
||||||
.when(AutoUpdater::is_available(cx), |this| {
|
|
||||||
this.separator().menu_with_icon(
|
|
||||||
"Check for Updates",
|
|
||||||
IconName::Device,
|
|
||||||
Box::new(Command::Update),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
"Settings",
|
"Settings",
|
||||||
IconName::Settings,
|
IconName::Settings,
|
||||||
@@ -605,7 +597,6 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let auto_updater = AutoUpdater::try_global(cx);
|
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -618,35 +609,9 @@ impl Workspace {
|
|||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
let announcement = profile.announcement();
|
let announcement = profile.announcement();
|
||||||
|
|
||||||
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
|
||||||
let updater = updater.read(cx);
|
|
||||||
(!updater.idle()).then(|| updater.status())
|
|
||||||
});
|
|
||||||
|
|
||||||
let staged_update = auto_updater
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|updater| updater.read(cx).staged());
|
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(updater_status, |this, status| {
|
|
||||||
this.child(div().text_xs().italic().child(status))
|
|
||||||
})
|
|
||||||
.when(staged_update, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("restart-to-update")
|
|
||||||
.label("Restart to Update")
|
|
||||||
.tooltip("Quit and relaunch into the installed update")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.on_click(cx.listener(|_this, _event, _window, cx| {
|
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
|
||||||
auto_updater.update(cx, |this, cx| this.restart(cx));
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(nip4e_enabled, |this| {
|
.when(nip4e_enabled, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
@@ -783,26 +748,31 @@ impl Render for Workspace {
|
|||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
image_cache(self.image_cache.clone())
|
||||||
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
// Title Bar
|
|
||||||
.child(
|
.child(
|
||||||
TitleBar::new()
|
v_flex()
|
||||||
.child(self.titlebar_left(cx))
|
|
||||||
.child(self.titlebar_right(cx)),
|
|
||||||
)
|
|
||||||
// Main
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
.size_full()
|
||||||
|
// Title Bar
|
||||||
.child(
|
.child(
|
||||||
div()
|
TitleBar::new()
|
||||||
.flex_shrink_0()
|
.child(self.titlebar_left(cx))
|
||||||
.h_full()
|
.child(self.titlebar_right(cx)),
|
||||||
.w(SIDEBAR_WIDTH)
|
|
||||||
.child(self.sidebar.clone()),
|
|
||||||
)
|
)
|
||||||
.child(self.dock.clone()),
|
// Main
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.size_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.h_full()
|
||||||
|
.w(SIDEBAR_WIDTH)
|
||||||
|
.child(self.sidebar.clone()),
|
||||||
|
)
|
||||||
|
.child(self.dock.clone()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems, retain_all,
|
Task, TextAlign, Window, div, rems,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -88,20 +88,7 @@ impl ContactListPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let contact_list: HashSet<PublicKey> = client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(contact_list)
|
Ok(contact_list)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +284,6 @@ impl Focusable for ContactListPanel {
|
|||||||
impl Render for ContactListPanel {
|
impl Render for ContactListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all("contact-list-panel"))
|
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ impl MessagingRelayPanel {
|
|||||||
.author(public_key)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(nip17::extract_relay_list(&event).collect())
|
Ok(nip17::extract_relay_list(&event).collect())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
@@ -177,7 +177,7 @@ impl MessagingRelayPanel {
|
|||||||
let tags: Vec<Tag> = self
|
let tags: Vec<Tag> = self
|
||||||
.relays
|
.relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned())))
|
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Set updating state
|
// Set updating state
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Window, div, retain_all,
|
Window, div,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -167,15 +167,13 @@ impl ProfilePanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
// Selecting no file means the prompt was cancelled
|
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_uploading(true, cx);
|
this.set_uploading(true, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let mut paths = path.await??.context("Not found")?;
|
||||||
|
let path = paths.pop().context("No path")?;
|
||||||
|
|
||||||
// Upload via blossom client
|
// Upload via blossom client
|
||||||
match upload(server, path, cx).await {
|
match upload(server, path, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
@@ -321,7 +319,6 @@ impl Render for ProfilePanel {
|
|||||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all("profile-panel"))
|
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ impl RelayListPanel {
|
|||||||
.author(public_key)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(nip65::extract_relay_list(&event).collect())
|
Ok(nip65::extract_relay_list(&event).collect())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ impl RenderOnce for RoomEntry {
|
|||||||
)
|
)
|
||||||
.on_cancel(move |_event, window, cx| {
|
.on_cancel(move |_event, window, cx| {
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||||
true
|
// Prevent closing the modal on click
|
||||||
|
// modal will be automatically closed after closing panel
|
||||||
|
false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt};
|
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||||
Window, div, retain_all, uniform_list,
|
Window, div, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry};
|
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
@@ -158,20 +158,7 @@ impl Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let contacts: HashSet<PublicKey> = client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(contacts)
|
Ok(contacts)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,14 +354,6 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
self.new_requests = false;
|
self.new_requests = false;
|
||||||
|
|
||||||
// Reset search state when switching to inbox/requests
|
|
||||||
self.reset(window, cx);
|
|
||||||
|
|
||||||
// Clear the find input value
|
|
||||||
self.find_input.update(cx, |this, cx| {
|
|
||||||
this.set_value("", window, cx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list_items(
|
fn render_list_items(
|
||||||
@@ -521,7 +500,7 @@ impl Render for Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all("sidebar"))
|
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "1.0.2"
|
version = "1.0.0-beta5"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
@@ -48,4 +48,3 @@ reqwest_client.workspace = true
|
|||||||
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
nostr-sdk.workspace = true
|
|
||||||
|
|||||||
@@ -35,13 +35,13 @@
|
|||||||
<content_attribute id="social-audio">intense</content_attribute>
|
<content_attribute id="social-audio">intense</content_attribute>
|
||||||
</content_rating>
|
</content_rating>
|
||||||
|
|
||||||
<url type="homepage">https://coopchat.xyz</url>
|
<url type="homepage">https://reya.su/coop</url>
|
||||||
<url type="bugtracker">https://github.com/reyakov/coop/issues</url>
|
<url type="bugtracker">https://github.com/lumehq/coop/issues</url>
|
||||||
<url type="faq">https://github.com/reyakov/coop</url>
|
<url type="faq">https://github.com/lumehq/coop</url>
|
||||||
<url type="help">https://github.com/reyakov/coop/issues</url>
|
<url type="help">https://github.com/lumehq/coop/issues</url>
|
||||||
<url type="contact">reyakov@proton.me</url>
|
<url type="contact">https://reya.su/</url>
|
||||||
<url type="vcs-browser">https://github.com/reykov/coop</url>
|
<url type="vcs-browser">https://github.com/lumehq/coop</url>
|
||||||
<url type="contribute">https://github.com/reyakov/coop/blob/main/CONTRIBUTING.md</url>
|
<url type="contribute">https://github.com/lumehq/coop/blob/main/CONTRIBUTING.md</url>
|
||||||
|
|
||||||
<supports>
|
<supports>
|
||||||
<internet>yes</internet>
|
<internet>yes</internet>
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# Snaps built by snapcraft without Snap Store credentials are unsigned and
|
|
||||||
# cannot be installed without bypassing signature checks. Use:
|
|
||||||
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
|
|
||||||
# For signed installs (`snap install coop`), publish via the Snap Store.
|
|
||||||
name: coop
|
name: coop
|
||||||
title: Coop
|
title: Coop
|
||||||
base: core24
|
base: core24
|
||||||
@@ -14,10 +10,10 @@ description: |
|
|||||||
grade: stable
|
grade: stable
|
||||||
confinement: classic
|
confinement: classic
|
||||||
compression: lzo
|
compression: lzo
|
||||||
website: https://reya.info/coop
|
website: https://reya.su/coop
|
||||||
source-code: https://git.reya.info/reya/coop
|
source-code: https://github.com/lumehq/coop
|
||||||
issues: https://github.com/reyakov/coop/issues
|
issues: https://github.com/lumehq/coop/issues
|
||||||
contact: https://coopchat.xyz
|
contact: https://reya.su
|
||||||
|
|
||||||
parts:
|
parts:
|
||||||
coop:
|
coop:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use gpui::{
|
|||||||
actions, point, px, size,
|
actions, point, px, size,
|
||||||
};
|
};
|
||||||
use gpui_platform::application;
|
use gpui_platform::application;
|
||||||
use nostr_sdk::prelude::SecretKey;
|
|
||||||
use state::{APP_ID, CLIENT_NAME};
|
use state::{APP_ID, CLIENT_NAME};
|
||||||
use ui::Root;
|
use ui::Root;
|
||||||
|
|
||||||
@@ -17,14 +16,6 @@ fn main() {
|
|||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
// Parse CLI arguments for --sec <nsec1>
|
|
||||||
let cli_key = parse_cli_key();
|
|
||||||
if let Err(ref e) = cli_key {
|
|
||||||
eprintln!("Failed to parse --sec argument: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
let cli_key = cli_key.unwrap();
|
|
||||||
|
|
||||||
// Run application
|
// Run application
|
||||||
application()
|
application()
|
||||||
.with_assets(Assets)
|
.with_assets(Assets)
|
||||||
@@ -33,9 +24,6 @@ fn main() {
|
|||||||
// Load embedded fonts in assets/fonts
|
// Load embedded fonts in assets/fonts
|
||||||
load_embedded_fonts(cx);
|
load_embedded_fonts(cx);
|
||||||
|
|
||||||
// Set app identity
|
|
||||||
cx.set_app_identity(APP_ID, CLIENT_NAME);
|
|
||||||
|
|
||||||
// Register the `quit` function
|
// Register the `quit` function
|
||||||
cx.on_action(quit);
|
cx.on_action(quit);
|
||||||
|
|
||||||
@@ -84,7 +72,7 @@ fn main() {
|
|||||||
settings::init(window, cx);
|
settings::init(window, cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(window, cx, cli_key);
|
state::init(window, cx);
|
||||||
|
|
||||||
// Initialize person registry
|
// Initialize person registry
|
||||||
person::init(window, cx);
|
person::init(window, cx);
|
||||||
@@ -134,25 +122,6 @@ fn load_embedded_fonts(cx: &App) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_cli_key() -> Result<Option<SecretKey>, String> {
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
|
||||||
let mut i = 0;
|
|
||||||
while i < args.len() {
|
|
||||||
if args[i] == "--sec" {
|
|
||||||
if i + 1 < args.len() {
|
|
||||||
let nsec = &args[i + 1];
|
|
||||||
return SecretKey::parse(nsec)
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|e| format!("Invalid nsec key '{nsec}': {e}"));
|
|
||||||
} else {
|
|
||||||
return Err("--sec requires a value (nsec1...)".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quit(_ev: &Quit, cx: &mut App) {
|
fn quit(_ev: &Quit, cx: &mut App) {
|
||||||
log::info!("Gracefully quitting the application . . .");
|
log::info!("Gracefully quitting the application . . .");
|
||||||
cx.quit();
|
cx.quit();
|
||||||
|
|||||||
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 651 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 696 KiB |
|
After Width: | Height: | Size: 506 KiB |
@@ -55,8 +55,8 @@ flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
|
|||||||
|
|
||||||
Ensure you have:
|
Ensure you have:
|
||||||
- [ ] Committed all changes
|
- [ ] Committed all changes
|
||||||
- [ ] Tagged the release: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
- [ ] Tagged the release: `git tag -a v1.0.0-beta2 -m "Release v1.0.0-beta2"`
|
||||||
- [ ] Pushed the tag: `git push origin v1.0.0`
|
- [ ] Pushed the tag: `git push origin v1.0.0-beta2`
|
||||||
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
|
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
|
||||||
|
|
||||||
### 2. Fork and Submit
|
### 2. Fork and Submit
|
||||||
@@ -101,8 +101,8 @@ git push origin su.reya.coop
|
|||||||
To release a new version:
|
To release a new version:
|
||||||
|
|
||||||
1. Update version in workspace `Cargo.toml`
|
1. Update version in workspace `Cargo.toml`
|
||||||
2. Tag the new release: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
2. Tag the new release: `git tag -a v1.0.0-beta3 -m "Release v1.0.0-beta3"`
|
||||||
3. Push the tag: `git push origin v1.0.0`
|
3. Push the tag: `git push origin v1.0.0-beta3`
|
||||||
4. Run `./script/prepare-flathub.sh` to regenerate
|
4. Run `./script/prepare-flathub.sh` to regenerate
|
||||||
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
|
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
|
||||||
6. Update the manifest with new commit/tag and hashes
|
6. Update the manifest with new commit/tag and hashes
|
||||||
|
|||||||
@@ -35,7 +35,3 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
|
|||||||
snapcraft --destructive-mode --output "$SNAP_NAME"
|
snapcraft --destructive-mode --output "$SNAP_NAME"
|
||||||
|
|
||||||
echo "Created snap package: $SNAP_NAME"
|
echo "Created snap package: $SNAP_NAME"
|
||||||
echo ""
|
|
||||||
echo "This snap is unsigned (built without Snap Store credentials)."
|
|
||||||
echo "To install it locally, use:"
|
|
||||||
echo " sudo snap install --dangerous ./$SNAP_NAME"
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ cd "$(dirname "$0")/.."
|
|||||||
# Configuration
|
# Configuration
|
||||||
APP_ID="su.reya.coop"
|
APP_ID="su.reya.coop"
|
||||||
APP_NAME="Coop"
|
APP_NAME="Coop"
|
||||||
REPO_URL="https://git.reya.info/reya/coop"
|
REPO_URL="https://git.reya.su/reya/coop"
|
||||||
BRANDING_LIGHT="#FFE629"
|
BRANDING_LIGHT="#FFE629"
|
||||||
BRANDING_DARK="#FFE629"
|
BRANDING_DARK="#FFE629"
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ modules:
|
|||||||
sources:
|
sources:
|
||||||
# Main source code - specific commit
|
# Main source code - specific commit
|
||||||
- type: git
|
- type: git
|
||||||
url: https://git.reya.info/reya/coop.git
|
url: https://git.reya.su/reya/coop.git
|
||||||
commit: "@COMMIT@"
|
commit: "@COMMIT@"
|
||||||
tag: "v@VERSION@"
|
tag: "v@VERSION@"
|
||||||
|
|
||||||
|
|||||||
@@ -29,29 +29,23 @@ fi
|
|||||||
# Function to update version in a Cargo.toml file
|
# Function to update version in a Cargo.toml file
|
||||||
update_version() {
|
update_version() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
local tmp="${file}.tmp"
|
local backup="${file}.bak"
|
||||||
|
|
||||||
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
|
# Backup the original file
|
||||||
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
|
cp "$file" "$backup"
|
||||||
# suffix instead of the extended-regex flag, leaving a stray
|
|
||||||
# `Cargo.toml-E` behind and never updating the file.
|
# More flexible regex that handles various version formats and whitespace
|
||||||
# Writing to a temp file and moving it over works on both implementations.
|
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
|
||||||
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
|
|
||||||
&& mv "$tmp" "$file"; then
|
|
||||||
echo "✓ Updated version to $NEW_VERSION in $file"
|
echo "✓ Updated version to $NEW_VERSION in $file"
|
||||||
else
|
else
|
||||||
echo "Error: Failed to update version in $file"
|
echo "Error: Failed to update version in $file"
|
||||||
# Remove any partial temp file; the original file is untouched
|
# Restore original backup
|
||||||
rm -f "$tmp"
|
mv "$backup" "$file"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The substitution can silently match nothing (e.g. a `version.workspace` key),
|
# Remove the backup file
|
||||||
# so verify the new version actually landed before moving on.
|
rm -f "$backup"
|
||||||
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
|
|
||||||
echo "Error: Version line not found/updated in $file"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update both Cargo.toml files
|
# Update both Cargo.toml files
|
||||||
@@ -59,78 +53,66 @@ echo "Updating versions..."
|
|||||||
update_version "$WORKSPACE_CARGO"
|
update_version "$WORKSPACE_CARGO"
|
||||||
update_version "$CRATE_CARGO"
|
update_version "$CRATE_CARGO"
|
||||||
|
|
||||||
|
# Check git status before committing
|
||||||
|
echo "Checking git status..."
|
||||||
|
if git status --porcelain | grep -q .; then
|
||||||
|
echo "Current uncommitted changes:"
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
# Ask user if they want to commit all changes or just version files
|
||||||
|
echo ""
|
||||||
|
echo "Do you want to:"
|
||||||
|
echo "1) Commit all current changes (including the version updates)"
|
||||||
|
echo "2) Commit only the version file changes"
|
||||||
|
echo "3) Abort the release"
|
||||||
|
read -p "Enter choice (1/2/3): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo "Committing all changes..."
|
||||||
|
git add .
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "Committing only version file changes..."
|
||||||
|
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "Release aborted by user"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Release aborted."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
# Only version files were modified, add them specifically
|
||||||
|
echo "Only version files were modified, adding them for commit..."
|
||||||
|
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Commit the changes
|
||||||
COMMIT_MSG="chore: release version $NEW_VERSION"
|
COMMIT_MSG="chore: release version $NEW_VERSION"
|
||||||
|
|
||||||
# When the requested version is already set there is nothing to bump or commit,
|
if git commit -m "$COMMIT_MSG"; then
|
||||||
# so the current commit is tagged as-is.
|
echo "✓ Committed version changes"
|
||||||
if git diff --quiet -- "$WORKSPACE_CARGO" "$CRATE_CARGO"; then
|
|
||||||
echo "Version is already $NEW_VERSION, tagging the current commit"
|
|
||||||
else
|
else
|
||||||
# Check git status before committing
|
echo "Error: Failed to commit version changes"
|
||||||
echo "Checking git status..."
|
exit 1
|
||||||
# The version files are always modified at this point, so only ask about other changes.
|
fi
|
||||||
if [ -n "$(git status --porcelain -- . ":(exclude,top)$WORKSPACE_CARGO" ":(exclude,top)$CRATE_CARGO")" ]; then
|
|
||||||
echo "Current uncommitted changes:"
|
|
||||||
git status --short
|
|
||||||
|
|
||||||
# Ask user if they want to commit all changes or just version files
|
# Push version changes to origin
|
||||||
echo ""
|
echo "Pushing version changes to origin..."
|
||||||
echo "Do you want to:"
|
if git push origin master; then
|
||||||
echo "1) Commit all current changes (including the version updates)"
|
echo "✓ Successfully pushed version changes to origin"
|
||||||
echo "2) Commit only the version file changes"
|
else
|
||||||
echo "3) Abort the release"
|
echo "Error: Failed to push version changes to origin"
|
||||||
read -p "Enter choice (1/2/3): " choice
|
exit 1
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1)
|
|
||||||
echo "Committing all changes..."
|
|
||||||
git add .
|
|
||||||
;;
|
|
||||||
2)
|
|
||||||
echo "Committing only version file changes..."
|
|
||||||
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
|
||||||
;;
|
|
||||||
3)
|
|
||||||
echo "Release aborted by user"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Invalid choice. Release aborted."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# Only version files were modified, add them specifically
|
|
||||||
echo "Only version files were modified, adding them for commit..."
|
|
||||||
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit the changes
|
|
||||||
if git commit -m "$COMMIT_MSG"; then
|
|
||||||
echo "✓ Committed version changes"
|
|
||||||
else
|
|
||||||
echo "Error: Failed to commit version changes"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Push version changes to origin
|
|
||||||
echo "Pushing version changes to origin..."
|
|
||||||
if git push origin master; then
|
|
||||||
echo "✓ Successfully pushed version changes to origin"
|
|
||||||
else
|
|
||||||
echo "Error: Failed to push version changes to origin"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create git tag
|
# Create git tag
|
||||||
TAG_NAME="v$NEW_VERSION"
|
TAG_NAME="v$NEW_VERSION"
|
||||||
|
|
||||||
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
|
||||||
echo "Error: tag $TAG_NAME already exists"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
||||||
echo "✓ Created git tag: $TAG_NAME"
|
echo "✓ Created git tag: $TAG_NAME"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -15,26 +15,11 @@ if [ "$#" -ne 1 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get system architecture (same mapping as script/bundle-snap)
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64) ARCH_SUFFIX="x86_64" ;;
|
|
||||||
aarch64) ARCH_SUFFIX="aarch64" ;;
|
|
||||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
|
|
||||||
if [ ! -f "$snap_file" ]; then
|
|
||||||
echo "Snap file not found: $snap_file"
|
|
||||||
echo "Build it first with: script/bundle-snap $1"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rerun as root
|
# Rerun as root
|
||||||
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
||||||
|
|
||||||
snap remove coop || true
|
snap remove coop || true
|
||||||
mkdir -p snap
|
mkdir -p snap
|
||||||
rm -rf snap/unpacked
|
rm -rf snap/unpacked
|
||||||
unsquashfs -dest snap/unpacked "$snap_file"
|
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
|
||||||
snap try --classic snap/unpacked
|
snap try --classic snap/unpacked
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ pub fn run() -> Result<(), JsValue> {
|
|||||||
settings::init(window, cx);
|
settings::init(window, cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(window, cx, None);
|
state::init(window, cx);
|
||||||
|
|
||||||
// Initialize person registry
|
// Initialize person registry
|
||||||
person::init(window, cx);
|
person::init(window, cx);
|
||||||
|
|||||||