Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e50cf3aa | ||
|
|
8f272f1fe8 | ||
|
|
6a7bf17e6e | ||
|
|
3290f71fa4 | ||
|
|
f7f1ea7438 | ||
|
|
070c6a7e87 | ||
|
|
963a641f39 | ||
|
|
ff20e51729 |
@@ -152,23 +152,11 @@ jobs:
|
||||
echo "Artifacts structure:"
|
||||
find artifacts -type f -exec ls -la {} \;
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||
# this to verify a download before installing it, so it must be
|
||||
# published alongside every release. Written outside the directory
|
||||
# being hashed so the checksums file never includes itself.
|
||||
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||
cat artifacts/SHA256SUMS
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
server_url: "https://git.reya.info/"
|
||||
server_url: "https://git.reya.su/"
|
||||
repository: "reya/coop"
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
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
|
||||
```
|
||||
Generated
+305
-401
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.2"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
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 = { 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
|
||||
anyhow = "1.0.44"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
@@ -48,9 +43,7 @@ smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
|
||||
@@ -1,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 |
@@ -8,4 +8,12 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
rust-embed.workspace = true
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
futures.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location"] }
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Generates a compile-time manifest of the asset files served on wasm, so
|
||||
//! the web entrypoint can preload them before the first frame.
|
||||
//!
|
||||
//! `WASM_ASSETS` is emitted into `OUT_DIR` and included by
|
||||
//! `src/wasm_assets.rs` on wasm targets. Native builds keep using
|
||||
//! `rust-embed` and ignore it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::{env, fs};
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by cargo");
|
||||
let assets_dir = Path::new(&manifest_dir).join("../../assets");
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for dir in ["icons", "brand"] {
|
||||
let dir_path = assets_dir.join(dir);
|
||||
let entries = fs::read_dir(&dir_path).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"expected asset directory {} to exist: {error}",
|
||||
dir_path.display()
|
||||
)
|
||||
});
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.expect("failed to read asset directory entry");
|
||||
if entry.file_type().is_ok_and(|t| t.is_file()) {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.starts_with('.') {
|
||||
paths.push(format!("{dir}/{name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
|
||||
let manifest = format!(
|
||||
"/// Asset files served by the wasm asset loader. Generated by build.rs.\npub const WASM_ASSETS: &[&str] = &[\n{}\n];\n",
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| format!(" \"{path}\","))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
|
||||
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set by cargo");
|
||||
fs::write(Path::new(&out_dir).join("wasm_assets.rs"), manifest)
|
||||
.expect("failed to write wasm asset manifest");
|
||||
|
||||
// Rerun when the asset files change (adding/removing files updates the
|
||||
// directory mtime).
|
||||
for dir in ["icons", "brand"] {
|
||||
if let Ok(canonical) = assets_dir.join(dir).canonicalize() {
|
||||
println!("cargo:rerun-if-changed={}", canonical.display());
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
+16
-48
@@ -1,51 +1,19 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
//! Application assets for Coop.
|
||||
//!
|
||||
//! ## Platform differences
|
||||
//!
|
||||
//! - **Native (desktop)**: assets are embedded into the binary at compile time
|
||||
//! with `rust-embed`.
|
||||
//! - **WASM (web)**: assets are downloaded on demand from `{endpoint}/assets/{path}`
|
||||
//! and cached in memory. This keeps the WASM bundle size small.
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod native_assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.with_context(|| format!("loading asset at path {path:?}"))
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
mod wasm_assets;
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use native_assets::Assets;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub use wasm_assets::Assets;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
/// Native implementation using `rust-embed`: assets are embedded into the
|
||||
/// binary at compile time.
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
impl Assets {
|
||||
/// Create a new Assets instance. The endpoint parameter is ignored for
|
||||
/// native builds.
|
||||
pub fn new(_endpoint: impl Into<SharedString>) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.with_context(|| format!("loading asset at path {path:?}"))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
// Compile-time manifest of every asset file served on wasm (see build.rs).
|
||||
include!(concat!(env!("OUT_DIR"), "/wasm_assets.rs"));
|
||||
|
||||
/// Path prefixes that the wasm loader serves. Fonts and themes are not
|
||||
/// downloaded on web: the web platform bundles its own fonts, and the theme
|
||||
/// registry falls back to the built-in default theme.
|
||||
const SERVED_PREFIXES: [&str; 2] = ["icons/", "brand/"];
|
||||
|
||||
/// WASM implementation - download assets on demand.
|
||||
///
|
||||
/// Assets are fetched from `{endpoint}/assets/{path}` and cached in memory
|
||||
/// after the first successful download. This keeps the WASM bundle small
|
||||
/// while still providing the full asset set at runtime.
|
||||
pub struct Assets {
|
||||
endpoint: SharedString,
|
||||
cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
|
||||
pending: Arc<RwLock<HashMap<String, bool>>>,
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Create a new Assets instance backed by the given endpoint.
|
||||
///
|
||||
/// Assets are resolved as `{endpoint}/assets/{path}`. An empty endpoint
|
||||
/// resolves against the current page origin (e.g. `/assets/icons/foo.svg`).
|
||||
pub fn new(endpoint: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
pending: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute URL of the given asset path.
|
||||
///
|
||||
/// `reqwest` requires absolute URLs, so a relative endpoint is resolved
|
||||
/// against the current page origin.
|
||||
fn asset_url(&self, path: &str) -> String {
|
||||
let endpoint = if self.endpoint.is_empty() {
|
||||
web_sys::window()
|
||||
.and_then(|window| window.location().origin().ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.endpoint.to_string()
|
||||
};
|
||||
format!("{endpoint}/assets/{path}")
|
||||
}
|
||||
|
||||
/// Download every asset in [`WASM_ASSETS`] into the cache, in parallel,
|
||||
/// before the app starts.
|
||||
///
|
||||
/// Preloading is required for two reasons:
|
||||
/// - Assets loaded through GPUI's [`gpui::Asset`] machinery (e.g. `img()`)
|
||||
/// cache failed loads and never retry them.
|
||||
/// - SVG painting only re-attempts an empty load on the next repaint, so
|
||||
/// an icon would stay invisible until the window happens to redraw.
|
||||
pub async fn preload(&self) {
|
||||
let downloads = WASM_ASSETS.iter().map(|path| async move {
|
||||
let result = reqwest::get(self.asset_url(path)).await;
|
||||
match result {
|
||||
Ok(response) if response.status().is_success() => match response.bytes().await {
|
||||
Ok(bytes) => {
|
||||
if let Ok(mut cache) = self.cache.write() {
|
||||
cache.insert(path.to_string(), bytes.to_vec());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read asset {}: {}", path, e);
|
||||
}
|
||||
},
|
||||
Ok(response) => {
|
||||
log::warn!(
|
||||
"Failed to download asset {}: HTTP {}",
|
||||
path,
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch asset {}: {}", path, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
futures::future::join_all(downloads).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
|
||||
if path.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Only serve paths the web build actually ships.
|
||||
if !SERVED_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| path.starts_with(prefix))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Serve from the in-memory cache when available.
|
||||
if let Ok(cache) = self.cache.read() {
|
||||
if let Some(data) = cache.get(path) {
|
||||
return Ok(Some(Cow::Owned(data.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off a single download per path; concurrent requests for the
|
||||
// same path share it.
|
||||
let is_pending = self
|
||||
.pending
|
||||
.read()
|
||||
.map(|pending| pending.contains_key(path))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_pending {
|
||||
if let Ok(mut pending) = self.pending.write() {
|
||||
pending.insert(path.to_string(), true);
|
||||
}
|
||||
|
||||
let url = self.asset_url(path);
|
||||
let path_clone = path.to_string();
|
||||
let cache = self.cache.clone();
|
||||
let pending = self.pending.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match reqwest::get(&url).await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
match response.bytes().await {
|
||||
Ok(bytes) => {
|
||||
if let Ok(mut cache) = cache.write() {
|
||||
cache.insert(path_clone.clone(), bytes.to_vec());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read asset {}: {}", path_clone, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
log::warn!(
|
||||
"Failed to download asset {}: HTTP {}",
|
||||
path_clone,
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch asset {}: {}", path_clone, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow retrying failed downloads on subsequent requests.
|
||||
if let Ok(mut pending) = pending.write() {
|
||||
pending.remove(&path_clone);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The asset is not available yet. GPUI's SVG atlas does not cache
|
||||
// empty loads, so the next repaint will call `load` again and find
|
||||
// the asset in the cache once the download completes.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn list(&self, _path: &str) -> Result<Vec<SharedString>> {
|
||||
// The asset manifest is not available at runtime on web; embedded
|
||||
// directories are not listed.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,5 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ureq.workspace = true
|
||||
|
||||
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
||||
|
||||
+113
-259
@@ -1,99 +1,62 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||
use instant::Duration;
|
||||
|
||||
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||
|
||||
mod source;
|
||||
|
||||
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
|
||||
|
||||
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
||||
const GITEA_REPO_OWNER: &str = "reya";
|
||||
const GITEA_REPO_NAME: &str = "coop";
|
||||
|
||||
/// Delay before the automatic check that runs on startup.
|
||||
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
||||
/// How long a failure stays visible before the status reverts to "Up to date".
|
||||
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
||||
use instant::{Duration, Instant};
|
||||
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||
|
||||
fn uses_managed_updates() -> bool {
|
||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||
std::env::var("FLATPAK_ID").is_ok()
|
||||
// Allow opting out of in-app updates via an explicit environment variable.
|
||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
||||
fn get_github_repo_owner() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||
}
|
||||
|
||||
fn get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
///
|
||||
/// Skips initialization when running as a Flatpak (updates are handled by the
|
||||
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
|
||||
/// entity and schedules a check for updates after a 2-minute delay.
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||
);
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
return;
|
||||
}
|
||||
|
||||
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||
|
||||
let Some(filter) = asset_filter_for(os, arch) else {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||
log::error!(
|
||||
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
AutoUpdater::set_global(
|
||||
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||
cx,
|
||||
);
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
||||
|
||||
/// The global auto-updater entity.
|
||||
///
|
||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
||||
///
|
||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
||||
pub struct AutoUpdater {
|
||||
/// The blocking engine, driven on the background executor.
|
||||
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||
status: UpdateStatus,
|
||||
/// The newer release found by the last successful check, if any.
|
||||
available: Option<Release>,
|
||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
||||
pub updater: Entity<Updater>,
|
||||
/// Currently running app version.
|
||||
pub version: Version,
|
||||
/// The in-flight check or download, if any.
|
||||
task: Option<Task<()>>,
|
||||
/// Keeps the observer subscription alive.
|
||||
_subscription: Subscription,
|
||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
||||
error_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Whether auto-update is available for this installation.
|
||||
pub fn is_available(cx: &App) -> bool {
|
||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
||||
}
|
||||
|
||||
/// Retrieve the global auto updater instance, if one was initialized.
|
||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalAutoUpdater>()
|
||||
.map(|global| global.0.clone())
|
||||
}
|
||||
|
||||
/// Retrieve the global auto updater instance.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
@@ -103,49 +66,92 @@ impl AutoUpdater {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
fn new(
|
||||
window: &mut Window,
|
||||
version: Version,
|
||||
filter: AssetFilter,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay
|
||||
let repo_owner = get_github_repo_owner();
|
||||
let repo_name = get_github_repo_name();
|
||||
|
||||
let source =
|
||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
||||
"macos" => "macos",
|
||||
"linux" => "linux",
|
||||
_ => "",
|
||||
});
|
||||
|
||||
let updater: Entity<Updater> =
|
||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
||||
|
||||
// When an update becomes available, automatically download and install it.
|
||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
||||
let status = this.updater.read(cx).status().clone();
|
||||
|
||||
if matches!(status, UpdateStatus::Available(_)) {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.download_and_install(cx);
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(status, UpdateStatus::Errored(_)) {
|
||||
this.error_time = Some(Instant::now());
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
this.error_time = None;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
||||
// end of the current frame so the window is fully set up).
|
||||
cx.defer_in(window, |_this, _window, cx| {
|
||||
let duration = Duration::from_secs(120);
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||
cx.background_executor().timer(duration).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Self {
|
||||
engine,
|
||||
status: UpdateStatus::Idle,
|
||||
available: None,
|
||||
updater,
|
||||
version,
|
||||
task: None,
|
||||
_subscription: subscription,
|
||||
error_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether nothing is happening, so the UI can hide the status line.
|
||||
pub fn idle(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Idle)
|
||||
pub fn idle(&self, cx: &App) -> bool {
|
||||
let status = self.updater.read(cx).status();
|
||||
if status == &UpdateStatus::Idle {
|
||||
return true;
|
||||
}
|
||||
if matches!(status, UpdateStatus::Errored(_))
|
||||
&& self
|
||||
.error_time
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
pub fn staged(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Staged(_))
|
||||
}
|
||||
pub fn status(&self, cx: &App) -> SharedString {
|
||||
let status = self.updater.read(cx).status();
|
||||
|
||||
/// 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(),
|
||||
match status {
|
||||
UpdateStatus::Idle => "Up to date".into(),
|
||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||
UpdateStatus::UpToDate => "Up to date".into(),
|
||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||
UpdateStatus::Downloading { downloaded, total } => {
|
||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||
@@ -159,168 +165,16 @@ impl AutoUpdater {
|
||||
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);
|
||||
}
|
||||
UpdateStatus::Errored(msg) => {
|
||||
if self
|
||||
.error_time
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
"Up to date".into()
|
||||
} else {
|
||||
format!("Update failed: {msg}").into()
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Download the available update, verify it, and swap it into place.
|
||||
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||
if self.status.is_busy() {
|
||||
return;
|
||||
}
|
||||
let Some(release) = self.available.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let engine = self.engine.clone();
|
||||
self.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
self.task = Some(cx.spawn(async move |this, cx| {
|
||||
let downloaded = Arc::new(AtomicU64::new(0));
|
||||
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let download_task = {
|
||||
let (engine, release) = (engine.clone(), release.clone());
|
||||
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||
cx.background_executor().spawn(async move {
|
||||
let result = engine.download(&release, |got, expected| {
|
||||
downloaded.store(got, Ordering::Relaxed);
|
||||
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||
});
|
||||
done.store(true, Ordering::Relaxed);
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
loop {
|
||||
let got = downloaded.load(Ordering::Relaxed);
|
||||
let total = total.load(Ordering::Relaxed);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(
|
||||
UpdateStatus::Downloading {
|
||||
downloaded: got,
|
||||
total: (total != 0).then_some(total),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
if done.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(120))
|
||||
.await;
|
||||
}
|
||||
|
||||
let artifact = match download_task.await {
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
log::warn!("Update download failed: {error}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
||||
|
||||
let installed = {
|
||||
let engine = engine.clone();
|
||||
cx.background_executor()
|
||||
.spawn(async move { engine.install(&artifact) })
|
||||
.await
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.task = None;
|
||||
match installed {
|
||||
Ok(installed) => {
|
||||
if let Some(path) = installed.restart_path {
|
||||
cx.set_restart_path(path);
|
||||
}
|
||||
let version = release.version.clone();
|
||||
this.set_status(UpdateStatus::Staged(version), cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Relaunch into the staged update.
|
||||
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.staged() {
|
||||
log::warn!("Ignoring restart request: no update is staged");
|
||||
return;
|
||||
}
|
||||
cx.restart();
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||
self.status = status;
|
||||
|
||||
if errored {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(UpdateStatus::Idle, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+49
-41
@@ -21,7 +21,6 @@ mod room;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
pub use state::FileAttachment;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
@@ -234,7 +233,7 @@ impl ChatRegistry {
|
||||
if event.kind == Kind::InboxRelays {
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
if event.pubkey == current_user {
|
||||
tx.send_async(Signal::InboxReady).await?;
|
||||
tx.send_async(Signal::InboxReady).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,24 +262,24 @@ impl ChatRegistry {
|
||||
|
||||
if rumor.tags.is_empty() {
|
||||
let signal = Signal::error(&event, "Recipient is missing");
|
||||
tx.send_async(signal).await?;
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
|
||||
// Emit message for both new and backlog events
|
||||
let signal = Signal::message(event.id, rumor);
|
||||
tx.send_async(signal).await?;
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = format!("Failed to extract rumor: {e}");
|
||||
let signal = Signal::error(event.as_ref(), reason);
|
||||
tx.send_async(signal).await?;
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id)
|
||||
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
|
||||
{
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
tx.send_async(Signal::Eose).await.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -291,33 +290,41 @@ impl ChatRegistry {
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
Signal::Message(message) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_message(message, cx);
|
||||
})?;
|
||||
// `update_in` (rather than `update`) routes through a
|
||||
// try-borrow: on wasm a task poll that lands while the app
|
||||
// context is borrowed can't panic and kill this consumer
|
||||
// (which would stall all message delivery).
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
// Drain the whole queue in a single update so a burst of
|
||||
// events (e.g. history sync after login) collapses into
|
||||
// one repaint instead of one per message (important on
|
||||
// wasm, where everything runs on the main thread).
|
||||
let mut batch = vec![message];
|
||||
while let Ok(extra) = rx.try_recv() {
|
||||
batch.push(extra);
|
||||
}
|
||||
Signal::InboxReady => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.update(cx, |this, _cx| {
|
||||
this.tracking.store(false, Ordering::Release);
|
||||
})?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
for message in batch {
|
||||
match message {
|
||||
Signal::Message(message) => {
|
||||
this.new_message(message, cx);
|
||||
}
|
||||
Signal::InboxReady => {
|
||||
this.get_messages(cx);
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.tracking.store(false, Ordering::Release);
|
||||
this.get_rooms(cx);
|
||||
}
|
||||
Signal::Error(failed) => {
|
||||
let _ = trash.update(cx, |this, cx| {
|
||||
this.insert(failed);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Signal::Error(failed) => {
|
||||
trash.update(cx, |this, cx| {
|
||||
this.insert(failed);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
};
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -371,7 +378,7 @@ impl ChatRegistry {
|
||||
.is_some();
|
||||
|
||||
if !found {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(ChatEvent::InboxRelayNotFound);
|
||||
})?;
|
||||
}
|
||||
@@ -422,7 +429,7 @@ impl ChatRegistry {
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
@@ -431,6 +438,7 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get all messages for the provided signer
|
||||
/// Reload the chat registry, fetching messages and contact list from relays.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.reset(cx);
|
||||
@@ -630,18 +638,18 @@ impl ChatRegistry {
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.query_chat_rooms(cx);
|
||||
let task = self.get_rooms_task(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(rooms) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.extend_rooms(rooms, cx);
|
||||
this.sort(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_, cx| {
|
||||
this.update_in(cx, |_, _window, cx| {
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
@@ -651,8 +659,8 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Query the chat rooms from the database
|
||||
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
/// Create a task to load rooms from the database
|
||||
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -678,7 +686,7 @@ impl ChatRegistry {
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_K, "14");
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
@@ -720,8 +728,8 @@ impl ChatRegistry {
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
///
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -824,7 +832,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
Tag::identifier(id),
|
||||
Tag::public_key(rumor.pubkey),
|
||||
Tag::custom("r", [room_id]),
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
Tag::custom("k", ["14"]),
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||
|
||||
+39
-82
@@ -4,9 +4,6 @@ use std::ops::Range;
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -24,90 +21,61 @@ pub struct Message {
|
||||
pub mentions: Vec<Mention>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
/// Encrypted file attachment
|
||||
pub file: Option<FileAttachment>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Message {
|
||||
fn from(val: &Event) -> Self {
|
||||
from_parts(
|
||||
val.id,
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
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
|
||||
val.id.unwrap(),
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
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
|
||||
val.rumor.id.unwrap(),
|
||||
val.rumor.pubkey,
|
||||
val.rumor.created_at,
|
||||
val.rumor.kind,
|
||||
&val.rumor.content,
|
||||
&val.rumor.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
id: EventId,
|
||||
author: PublicKey,
|
||||
created_at: Timestamp,
|
||||
kind: Kind,
|
||||
content: &str,
|
||||
tags: &Tags,
|
||||
) -> Message {
|
||||
let file = if kind == KIND_FILE_MESSAGE {
|
||||
FileAttachment::from_tags(content, tags)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_file = file.is_some();
|
||||
|
||||
let replies_to = extract_reply_ids(tags);
|
||||
|
||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||
let mentions = if has_file {
|
||||
Vec::new()
|
||||
} else {
|
||||
extract_mentions(content)
|
||||
};
|
||||
|
||||
let (media, content) = if has_file {
|
||||
(Vec::new(), String::new())
|
||||
} else {
|
||||
extract_and_remove_media_urls(content)
|
||||
};
|
||||
|
||||
Message {
|
||||
id,
|
||||
author,
|
||||
content,
|
||||
media,
|
||||
created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
file,
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
|
||||
+18
-49
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
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 USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||
@@ -439,51 +439,11 @@ impl Room {
|
||||
let content: String = content.into();
|
||||
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 nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
@@ -494,8 +454,8 @@ impl Room {
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
for id in replies {
|
||||
tags.push(Tag::event(*id))
|
||||
for id in replies.into_iter() {
|
||||
tags.push(Tag::event(id))
|
||||
}
|
||||
|
||||
// Add all receiver tags (no intermediate allocation)
|
||||
@@ -507,7 +467,16 @@ impl Room {
|
||||
}));
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -640,7 +609,7 @@ async fn send_gift_wrap(
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
) -> 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];
|
||||
|
||||
// Determine the receiver public key based on the config
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+98
-473
@@ -1,11 +1,10 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
|
||||
pub use actions::*;
|
||||
use anyhow::Error;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||
use common::TimestampExt;
|
||||
use common::{TimestampExt, coop_cache};
|
||||
use futures::lock::Mutex;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
@@ -13,8 +12,8 @@ use gpui::{
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||
retain_all, svg, white,
|
||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
||||
white,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -22,9 +21,7 @@ use person::{Person, PersonRegistry};
|
||||
use regex::Regex;
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{
|
||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||
};
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
@@ -33,13 +30,11 @@ use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::tooltip::Tooltip;
|
||||
use ui::{
|
||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||
h_flex, v_flex,
|
||||
};
|
||||
|
||||
use crate::file::*;
|
||||
use crate::text::RenderedText;
|
||||
|
||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||
@@ -51,7 +46,6 @@ static EMOJI_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
mod text;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
@@ -102,12 +96,6 @@ pub struct ChatPanel {
|
||||
/// Media Attachment
|
||||
attachments: Entity<Vec<Url>>,
|
||||
|
||||
/// Uploaded, encrypted file attachments which are not sent yet
|
||||
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||
|
||||
/// Decrypted attachments of file messages, by message id
|
||||
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||
|
||||
/// Upload state
|
||||
uploading: bool,
|
||||
|
||||
@@ -122,7 +110,6 @@ impl ChatPanel {
|
||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// Define attachments and replies_to entities
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let encrypted_attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
|
||||
@@ -198,8 +185,6 @@ impl ChatPanel {
|
||||
subject_bar,
|
||||
replies_to,
|
||||
attachments,
|
||||
encrypted_attachments,
|
||||
decrypted_files: HashMap::new(),
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
reports_by_id,
|
||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -257,33 +242,44 @@ impl ChatPanel {
|
||||
while let Ok(status) = rx.recv_async().await {
|
||||
{
|
||||
let mut map = reports.write().unwrap();
|
||||
let status_id = match &*status {
|
||||
SendStatus::Ok { id, .. } => *id,
|
||||
SendStatus::Failed { id, .. } => *id,
|
||||
};
|
||||
|
||||
// Find the matching report and update it (exit early on first match)
|
||||
'outer: for reports_list in map.values_mut() {
|
||||
for report in reports_list.iter_mut() {
|
||||
let Some(output) = report.output.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if *output.id() != status_id {
|
||||
continue;
|
||||
}
|
||||
match &*status {
|
||||
SendStatus::Ok { relay, .. } => {
|
||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||
// Drain the whole queue in a single update so bursts of
|
||||
// send statuses collapse into one repaint (important on
|
||||
// wasm, where everything runs on the main thread).
|
||||
let mut statuses = vec![status];
|
||||
while let Ok(extra) = rx.try_recv() {
|
||||
statuses.push(extra);
|
||||
}
|
||||
|
||||
for status in statuses {
|
||||
let status_id = match &*status {
|
||||
SendStatus::Ok { id, .. } => *id,
|
||||
SendStatus::Failed { id, .. } => *id,
|
||||
};
|
||||
|
||||
// Find the matching report and update it (exit early on first match)
|
||||
'outer: for reports_list in map.values_mut() {
|
||||
for report in reports_list.iter_mut() {
|
||||
let Some(output) = report.output.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if *output.id() != status_id {
|
||||
continue;
|
||||
}
|
||||
SendStatus::Failed { relay, message, .. } => {
|
||||
output.failed.insert(relay.clone(), message.clone());
|
||||
match &*status {
|
||||
SendStatus::Ok { relay, .. } => {
|
||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||
}
|
||||
SendStatus::Failed { relay, message, .. } => {
|
||||
output.failed.insert(relay.clone(), message.clone());
|
||||
}
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.update(cx, |_, cx| cx.notify()).ok();
|
||||
this.update_in(cx, |_, _window, cx| cx.notify()).ok();
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
@@ -340,8 +336,10 @@ impl ChatPanel {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let events = get_messages.await?;
|
||||
|
||||
// Update message list
|
||||
this.update(cx, |this, cx| {
|
||||
// Update message list. `update_in` (rather than `update`) routes
|
||||
// through a try-borrow: on wasm a task poll that lands while the
|
||||
// app context is borrowed can't panic and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.insert_messages(&events, cx);
|
||||
})?;
|
||||
|
||||
@@ -385,32 +383,21 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Get the replies to this message
|
||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||
|
||||
// Uploaded files are sent as encrypted file messages
|
||||
let files: Vec<FileAttachment> = self
|
||||
.encrypted_attachments
|
||||
.read(cx)
|
||||
.iter()
|
||||
.map(|pending| pending.file.clone())
|
||||
.collect();
|
||||
|
||||
// Return if there is nothing to send
|
||||
if content.trim().is_empty() && files.is_empty() {
|
||||
// Return if message is empty
|
||||
if content.trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// If replying to exactly one message with only a valid emoji,
|
||||
// send as a reaction instead of a text message
|
||||
if replies.len() == 1
|
||||
&& EMOJI_RE.is_match(&content)
|
||||
&& self.attachments.read(cx).is_empty()
|
||||
&& files.is_empty()
|
||||
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
|
||||
{
|
||||
for reply in &replies {
|
||||
self.send_reaction(&content, reply, window, cx);
|
||||
@@ -419,15 +406,7 @@ impl ChatPanel {
|
||||
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);
|
||||
}
|
||||
self.send_message(&content, replies, false, window, cx);
|
||||
}
|
||||
|
||||
fn send_reaction(
|
||||
@@ -460,60 +439,30 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upgrade room and create rumor + send task in a single read lock
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
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 room = self.room.clone();
|
||||
let content = value.to_string();
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
|
||||
// Upgrade room and create rumor + send task in a single read lock
|
||||
let Some(room_entity) = room.upgrade() else {
|
||||
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
|
||||
if rumor.kind != Kind::Reaction {
|
||||
self.insert_message(&rumor, true, cx);
|
||||
@@ -532,7 +481,10 @@ impl ChatPanel {
|
||||
let mut sent_ids = sent_ids.lock().await;
|
||||
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
// `update_in` (rather than `update`) routes through a try-borrow:
|
||||
// on wasm a poll that lands while the app context is borrowed
|
||||
// can't panic and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.insert_reports(id, outputs, cx);
|
||||
})?;
|
||||
|
||||
@@ -551,10 +503,6 @@ impl ChatPanel {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
self.encrypted_attachments.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
@@ -672,7 +620,7 @@ impl ChatPanel {
|
||||
let Some(message) = self.message(id) else {
|
||||
return;
|
||||
};
|
||||
let content = message.preview().to_string();
|
||||
let content = message.content.to_string();
|
||||
let item = ClipboardItem::new_string(content);
|
||||
|
||||
cx.write_to_clipboard(item);
|
||||
@@ -698,9 +646,6 @@ impl ChatPanel {
|
||||
// Get the user's configured blossom server
|
||||
let server = AppSettings::get_file_server(cx);
|
||||
|
||||
// Encrypt attachments which are not part of a message being written
|
||||
let encrypted = self.input.read(cx).value().trim().is_empty();
|
||||
|
||||
// Ask user for file upload
|
||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
@@ -710,95 +655,36 @@ impl ChatPanel {
|
||||
});
|
||||
|
||||
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_in(cx, |this, window, cx| {
|
||||
this.upload_file(server, path, encrypted, window, cx);
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_uploading(true, 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
|
||||
fn upload_file(
|
||||
&mut self,
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
encrypted: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.set_uploading(true, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = if encrypted {
|
||||
upload_encrypted(server.clone(), path.clone(), cx)
|
||||
.await
|
||||
.map(|file| Uploaded::File(file, path.clone()))
|
||||
} else {
|
||||
upload(server.clone(), path.clone(), cx)
|
||||
.await
|
||||
.map(Uploaded::Url)
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
|
||||
match result {
|
||||
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
||||
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
||||
Err(e) if encrypted => {
|
||||
this.report_encrypted_upload_error(server, path, e, window, cx)
|
||||
}
|
||||
Err(e) => {
|
||||
// Upload via blossom client
|
||||
match upload(server, path, cx).await {
|
||||
Ok(url) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.add_attachment(url, cx);
|
||||
this.set_uploading(false, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Report a failed encrypted upload, offering to retry it without encryption
|
||||
fn report_encrypted_upload_error(
|
||||
&mut self,
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
error: Error,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string())
|
||||
.title("Encrypted upload failed")
|
||||
.action(move |_this, _window, _cx| {
|
||||
let view = view.clone();
|
||||
let server = server.clone();
|
||||
let path = path.clone();
|
||||
|
||||
Button::new("retry-without-encryption")
|
||||
.label("Upload without encryption")
|
||||
.on_click(move |_ev, window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = uploading;
|
||||
cx.notify();
|
||||
@@ -820,88 +706,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 {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(public_key, cx)
|
||||
@@ -1141,16 +945,6 @@ impl ChatPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let file = self.messages.get(ix).and_then(|message| {
|
||||
let file = message.file.clone()?;
|
||||
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
||||
.then_some((message.id, file))
|
||||
});
|
||||
|
||||
if let Some((id, file)) = file {
|
||||
self.load_file(id, file, cx);
|
||||
}
|
||||
|
||||
if let Some(message) = self.messages.get(ix) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let show_author = self.is_group_start(ix);
|
||||
@@ -1158,7 +952,7 @@ impl ChatPanel {
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.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);
|
||||
|
||||
@@ -1238,11 +1032,8 @@ impl ChatPanel {
|
||||
.when(has_replies, |this| {
|
||||
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))
|
||||
.when_some(message.file.as_ref(), |this, file| {
|
||||
this.child(self.render_message_file(&id, file, cx))
|
||||
})
|
||||
.when(has_reactions, |this| {
|
||||
this.child(self.render_reactions(&id, cx))
|
||||
}),
|
||||
@@ -1348,7 +1139,7 @@ impl ChatPanel {
|
||||
.w_full()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(message.preview()),
|
||||
.child(SharedString::from(&message.content)),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click({
|
||||
@@ -1652,7 +1443,7 @@ impl ChatPanel {
|
||||
.size_16()
|
||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||
.rounded(cx.theme().radius)
|
||||
.object_fit(ObjectFit::Cover),
|
||||
.object_fit(ObjectFit::ScaleDown),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -1689,159 +1480,6 @@ impl ChatPanel {
|
||||
items
|
||||
}
|
||||
|
||||
/// Render the encrypted file attachment of a message
|
||||
fn render_message_file(
|
||||
&self,
|
||||
id: &EventId,
|
||||
file: &FileAttachment,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let state = self.decrypted_files.get(id);
|
||||
|
||||
if let Some(path) = state
|
||||
.and_then(|state| match state {
|
||||
DecryptedFile::Ready(path) => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|_| file.is_image())
|
||||
{
|
||||
return div()
|
||||
.child(
|
||||
img(path.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let label = match state {
|
||||
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
|
||||
Some(DecryptedFile::Failed(error)) => error.clone(),
|
||||
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
|
||||
None => SharedString::from("Click to decrypt"),
|
||||
};
|
||||
|
||||
self.render_file_chip(id, file, label, cx)
|
||||
}
|
||||
|
||||
/// Render an encrypted file as a chip which decrypts and opens it on click
|
||||
fn render_file_chip(
|
||||
&self,
|
||||
id: &EventId,
|
||||
file: &FileAttachment,
|
||||
label: SharedString,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("file-{id}")))
|
||||
.self_start()
|
||||
.items_start()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_sm()
|
||||
.child(div().line_height(relative(1.2)).child(file.display_name()))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(label),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
let file = file.clone();
|
||||
let id = *id;
|
||||
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open_file(id, file.clone(), window, cx);
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Render an uploaded, encrypted file which is not sent yet
|
||||
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
|
||||
let file = &pending.file;
|
||||
let label = file.display_name();
|
||||
|
||||
div()
|
||||
.id(SharedString::from(file.url.to_string()))
|
||||
.relative()
|
||||
.w_16()
|
||||
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
|
||||
.map(|this| {
|
||||
if file.is_image() {
|
||||
this.child(
|
||||
img(pending.path.clone())
|
||||
.size_16()
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(cx.theme().radius)
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.size_16()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.child("Preview not available"),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.absolute()
|
||||
.top_neg_1()
|
||||
.right_neg_1()
|
||||
.size_4()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.bg(gpui::green())
|
||||
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
||||
)
|
||||
.on_click({
|
||||
let url = file.url.clone();
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.remove_pending_file(&url, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_pending_file_list(
|
||||
&self,
|
||||
_window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
let mut items = vec![];
|
||||
|
||||
for pending in self.encrypted_attachments.read(cx).iter() {
|
||||
items.push(self.render_pending_file(pending, cx));
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||
if let Some(text) = self.message(id) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
@@ -1890,7 +1528,7 @@ impl ChatPanel {
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(text.preview()),
|
||||
.child(SharedString::from(&text.content)),
|
||||
)
|
||||
} else {
|
||||
div()
|
||||
@@ -2018,13 +1656,8 @@ impl Focusable for ChatPanel {
|
||||
|
||||
impl Render for ChatPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
|
||||
|
||||
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||
|
||||
v_flex()
|
||||
.image_cache(retain_all(self.id.clone()))
|
||||
.image_cache(coop_cache(self.id.clone(), 100))
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.size_full()
|
||||
.when(*self.subject_bar.read(cx), |this| {
|
||||
@@ -2056,8 +1689,10 @@ impl Render for ChatPanel {
|
||||
.map(|this| {
|
||||
if self.messages.is_empty() {
|
||||
this.child(
|
||||
h_flex()
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.child(self.render_announcement(cx)),
|
||||
)
|
||||
@@ -2082,17 +1717,7 @@ impl Render for ChatPanel {
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.children(self.render_attachment_list(window, cx))
|
||||
.children(self.render_pending_file_list(window, cx))
|
||||
.children(self.render_reply_list(window, cx))
|
||||
.when(is_typing && pending_attachments, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_warning)
|
||||
.child(WARNING),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.items_end()
|
||||
|
||||
+64
-153
@@ -1,19 +1,17 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chat::Mention;
|
||||
use common::RangeExt;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
@@ -41,61 +39,25 @@ impl RenderedText {
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
markdown: bool,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
Self::render(content, mentions, markdown, |mention| {
|
||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
||||
})
|
||||
}
|
||||
|
||||
fn render(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_text_mut(
|
||||
render_plain_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
markdown,
|
||||
resolve_mention,
|
||||
persons,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||
let trimmed_len = text.trim_end().len();
|
||||
|
||||
// Retain highlights and link ranges that are within the trimmed text.
|
||||
if trimmed_len < text.len() {
|
||||
highlights.retain_mut(|(range, _)| {
|
||||
range.end = range.end.min(trimmed_len);
|
||||
range.start < range.end
|
||||
});
|
||||
|
||||
let mut ix = 0;
|
||||
|
||||
while ix < link_ranges.len() {
|
||||
let range = &mut link_ranges[ix];
|
||||
range.end = range.end.min(trimmed_len);
|
||||
if range.start < range.end {
|
||||
ix += 1;
|
||||
} else {
|
||||
link_ranges.remove(ix);
|
||||
link_urls.remove(ix);
|
||||
}
|
||||
}
|
||||
|
||||
text.truncate(trimmed_len);
|
||||
}
|
||||
text.truncate(text.trim_end().len());
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
@@ -108,71 +70,55 @@ impl RenderedText {
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let code_background = cx.theme().elevated_surface_background;
|
||||
let color = cx.theme().text_accent;
|
||||
let code_font = if cfg!(target_os = "macos") {
|
||||
"Menlo"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Consolas"
|
||||
} else {
|
||||
"monospace"
|
||||
};
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone())
|
||||
.with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
StyledText::new(self.text.clone()).with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||
|(range, highlight)| match highlight {
|
||||
Highlight::Code | Highlight::InlineCode(_) => {
|
||||
Some((range.clone(), code_font.into()))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)),
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if WEB_URL.is_match(url) {
|
||||
if url.starts_with("http") {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
@@ -182,15 +128,15 @@ impl RenderedText {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_text_mut(
|
||||
fn render_plain_text_mut(
|
||||
block: &str,
|
||||
mut mentions: &[Mention],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
@@ -199,58 +145,34 @@ fn render_text_mut(
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
let mut code_block = false;
|
||||
|
||||
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||
} else {
|
||||
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||
};
|
||||
let mut options = Options::all();
|
||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||
|
||||
for (event, source_range) in events {
|
||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
if code_block {
|
||||
text.push_str(t.as_ref());
|
||||
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process text with mention replacements
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
while let Some(mention) = mentions.first() {
|
||||
if mention.range.start >= source_range.end {
|
||||
if !source_range.contains_inclusive(&mention.range) {
|
||||
break;
|
||||
}
|
||||
|
||||
mentions = &mentions[1..];
|
||||
if mention.range.start < source_range.start
|
||||
|| mention.range.end > source_range.end
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(token) = block.get(mention.range.clone()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mention_start_in_text = last_processed + offset;
|
||||
let mention_end_in_text = mention_start_in_text + token.len();
|
||||
// Calculate positions within the current text
|
||||
let mention_start_in_text = mention.range.start - source_range.start;
|
||||
let mention_end_in_text = mention.range.end - source_range.start;
|
||||
|
||||
// Add text before this mention
|
||||
if mention_start_in_text > last_processed {
|
||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||
process_text_segment(
|
||||
before_mention,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
@@ -263,7 +185,9 @@ fn render_text_mut(
|
||||
}
|
||||
|
||||
// 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();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
@@ -271,6 +195,7 @@ fn render_text_mut(
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
mentions = &mentions[1..];
|
||||
}
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
@@ -278,6 +203,7 @@ fn render_text_mut(
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
prev_len + last_processed,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
@@ -308,14 +234,11 @@ fn render_text_mut(
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
code_block = true;
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => {
|
||||
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||
}
|
||||
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
@@ -341,7 +264,6 @@ fn render_text_mut(
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::CodeBlock => code_block = false,
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
@@ -350,11 +272,6 @@ fn render_text_mut(
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||
Event::Rule => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
text.push_str("────────\n");
|
||||
}
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
@@ -365,6 +282,7 @@ fn render_text_mut(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
segment_start: usize,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
@@ -389,8 +307,7 @@ fn process_text_segment(
|
||||
});
|
||||
}
|
||||
|
||||
// Ranges always refer to the rendered text, including replaced mentions.
|
||||
let segment_start = text.len();
|
||||
// Add the text
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
@@ -413,10 +330,7 @@ fn process_text_segment(
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder
|
||||
.links(segment)
|
||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||
{
|
||||
for link in finder.links(segment) {
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
@@ -461,7 +375,6 @@ fn process_text_segment(
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
@@ -477,11 +390,9 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
@@ -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 display::*;
|
||||
pub use event::*;
|
||||
@@ -6,6 +7,7 @@ pub use parser::*;
|
||||
pub use paths::*;
|
||||
pub use range::*;
|
||||
|
||||
mod caching;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
|
||||
+11
-11
@@ -184,7 +184,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
// New response event from the master device
|
||||
Kind::Custom(4455) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
@@ -272,7 +272,7 @@ impl DeviceRegistry {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
|
||||
@@ -287,13 +287,13 @@ impl DeviceRegistry {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.wait_for_request(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
@@ -358,12 +358,12 @@ impl DeviceRegistry {
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(keys) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
this.wait_for_request(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.request(cx);
|
||||
})?;
|
||||
}
|
||||
@@ -439,17 +439,17 @@ impl DeviceRegistry {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Some(event)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.extract_encryption(event, cx);
|
||||
})?;
|
||||
}
|
||||
Ok(None) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.wait_for_approval(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
@@ -508,12 +508,12 @@ impl DeviceRegistry {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(DeviceEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,4 @@ anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
+65
-35
@@ -3,7 +3,8 @@ use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use futures::FutureExt;
|
||||
use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, Global, Task, Window};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -72,24 +73,40 @@ impl PersonRegistry {
|
||||
}));
|
||||
|
||||
let client3 = client.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client3, &metadata_rx).await;
|
||||
Self::handle_requests(&client3, &metadata_rx, &executor).await;
|
||||
}));
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
// `update_in` (rather than `update`) routes through a
|
||||
// try-borrow: on wasm a task poll that lands while the app
|
||||
// context is borrowed can't panic and kill this consumer
|
||||
// (which would stall the whole metadata pipeline).
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
// Drain the whole queue in a single update so a burst of
|
||||
// events collapses into one repaint instead of one per
|
||||
// event (important on wasm, where everything runs on the
|
||||
// main thread).
|
||||
let mut dispatch = vec![event];
|
||||
while let Ok(extra) = rx.try_recv() {
|
||||
dispatch.push(extra);
|
||||
}
|
||||
|
||||
for event in dispatch {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -156,30 +173,43 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Handle request for metadata
|
||||
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
|
||||
///
|
||||
/// Requests are collected into batches and flushed when the batch is
|
||||
/// full or the timeout expires.
|
||||
///
|
||||
/// Note: `flume::Selector::wait_timeout` is intentionally not used here:
|
||||
/// it relies on `std::time::Instant` and `thread::park_timeout`, which are
|
||||
/// unavailable on `wasm32-unknown-unknown` (the former panics, the latter
|
||||
/// is a no-op that would turn the wait into a busy loop on the main
|
||||
/// thread).
|
||||
async fn handle_requests(
|
||||
client: &Client,
|
||||
rx: &flume::Receiver<PublicKey>,
|
||||
executor: &BackgroundExecutor,
|
||||
) {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
match flume::Selector::new()
|
||||
.recv(rx, |result| result.ok())
|
||||
.wait_timeout(Duration::from_secs(TIMEOUT))
|
||||
// Wait for the next request, or the batch timeout.
|
||||
futures::select! {
|
||||
result = rx.recv_async() => match result {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
// Keep collecting until the batch is full
|
||||
if batch.len() < 20 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(_) => return,
|
||||
},
|
||||
_ = executor.timer(Duration::from_secs(TIMEOUT)).fuse() => {}
|
||||
}
|
||||
|
||||
// Flush the batch
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
Ok(Some(public_key)) => {
|
||||
batch.insert(public_key);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +235,7 @@ impl PersonRegistry {
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(persons) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.bulk_insert(persons, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Person {
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
self.metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
@@ -115,13 +115,13 @@ impl Person {
|
||||
|
||||
/// Get profile name
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref()
|
||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name.trim());
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref()
|
||||
if let Some(name) = self.metadata.name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name.trim());
|
||||
|
||||
@@ -12,9 +12,6 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
@@ -141,7 +138,7 @@ impl Default for Settings {
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
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| {
|
||||
let mut settings = task.await.unwrap_or(Settings::default());
|
||||
|
||||
// Move settings still pointed at the old default file server over to the new one
|
||||
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
|
||||
}
|
||||
let settings = task.await.unwrap_or(Settings::default());
|
||||
|
||||
// Update settings
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
|
||||
@@ -24,10 +24,6 @@ serde_json.workspace = true
|
||||
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
aes-gcm.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
|
||||
+10
-32
@@ -4,52 +4,30 @@ use anyhow::{Error, anyhow};
|
||||
use gpui::AsyncApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::file::sha256_hex;
|
||||
|
||||
/// Upload a blob to a blossom server and return its URL
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) async fn upload_blob(
|
||||
server: &Url,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
sha256: &str,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Url, Error> {
|
||||
let client = BlossomClient::new(server.clone());
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(path).await?;
|
||||
let keys = Keys::generate();
|
||||
let content_type = content_type.to_string();
|
||||
let base = server.clone();
|
||||
let hash = sha256.to_string();
|
||||
|
||||
// Construct the blossom client
|
||||
let client = BlossomClient::new(server);
|
||||
|
||||
Tokio::spawn(cx, async move {
|
||||
match client
|
||||
let blob = client
|
||||
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||
.await
|
||||
{
|
||||
Ok(blob) => Ok(blob.url),
|
||||
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
|
||||
Err(e) => Err(anyhow!(e.to_string())),
|
||||
}
|
||||
.await?;
|
||||
|
||||
Ok(blob.url)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||
let data = smol::fs::read(&path).await?;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
|
||||
@@ -14,6 +14,9 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 2;
|
||||
|
||||
/// Default image cache size
|
||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
+11
-11
@@ -3,8 +3,10 @@ use std::collections::HashMap;
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -17,14 +19,12 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod file;
|
||||
mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
@@ -181,7 +181,7 @@ impl NostrRegistry {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
match new_signer.get_public_key_async().await {
|
||||
Ok(public_key) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.signer.swap_inner(new_signer);
|
||||
this.current_user = Some(public_key);
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
@@ -189,7 +189,7 @@ impl NostrRegistry {
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(StateEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
@@ -226,7 +226,7 @@ impl NostrRegistry {
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.emit(StateEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ impl NostrRegistry {
|
||||
let secret_key = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -263,19 +263,19 @@ impl NostrRegistry {
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
} else if content == "proxy" {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.connect_proxy(cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
this.update_in(cx, |_, _window, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
@@ -352,7 +352,7 @@ impl NostrRegistry {
|
||||
let proxy = proxy.clone();
|
||||
async move |this, cx| {
|
||||
while let Ok(url) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
|
||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
||||
cx.open_url(&url);
|
||||
@@ -374,7 +374,7 @@ impl NostrRegistry {
|
||||
loop {
|
||||
executor.timer(Duration::from_secs(5)).await;
|
||||
if !proxy.is_session_active() {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
_ = this.update_in(cx, |this, _window, cx| {
|
||||
// Only notify if this proxy is still the active signer
|
||||
if this.current_user.is_some() {
|
||||
this.signer.swap_inner(Keys::generate());
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use std::rc::Rc;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||
Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||
Window, div, px, relative, rems, svg,
|
||||
};
|
||||
use instant::Duration;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::icon::IconNamed;
|
||||
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
|
||||
use crate::{Disableable, IconName, Selectable, Sizable, Size, StyledExt as _, v_flex};
|
||||
|
||||
/// A Checkbox element.
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -172,10 +172,16 @@ pub(crate) fn checkbox_check_icon(
|
||||
if !disabled && checked != *toggle_state.read(cx) {
|
||||
let duration = Duration::from_secs_f64(0.25);
|
||||
cx.spawn({
|
||||
let toggle_state = toggle_state.clone();
|
||||
let toggle_state = toggle_state.downgrade();
|
||||
async move |cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
toggle_state.update(cx, |this, _| *this = checked);
|
||||
// `update_in` (rather than `update`) routes through a
|
||||
// try-borrow: on wasm a task poll that lands while
|
||||
// the app context is borrowed can't panic and kill
|
||||
// this task.
|
||||
toggle_state
|
||||
.update_in(cx, |this, _window, _| *this = checked)
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
@@ -46,7 +46,6 @@ pub enum IconName {
|
||||
InboxFill,
|
||||
Link,
|
||||
Loader,
|
||||
Lock,
|
||||
Moon,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
@@ -119,7 +118,6 @@ impl IconNamed for IconName {
|
||||
Self::InboxFill => "icons/inbox-fill.svg",
|
||||
Self::Link => "icons/link.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Lock => "icons/lock.svg",
|
||||
Self::Moon => "icons/moon.svg",
|
||||
Self::Plus => "icons/plus.svg",
|
||||
Self::PlusCircle => "icons/plus-circle.svg",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::{Context, Pixels, Task, px};
|
||||
use instant::Duration;
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
@@ -63,9 +62,11 @@ impl BlinkCursor {
|
||||
let epoch = self.next_epoch();
|
||||
self._task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(INTERVAL).await;
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, cx| this.blink(epoch, cx));
|
||||
}
|
||||
// `update_in` (rather than `update`) routes through a try-borrow:
|
||||
// on wasm a task poll that lands while the app context is
|
||||
// borrowed can't panic and kill this recurring task.
|
||||
this.update_in(cx, |this, _window, cx| this.blink(epoch, cx))
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,12 +86,11 @@ impl BlinkCursor {
|
||||
self._task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(PAUSE_DELAY).await;
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.paused = false;
|
||||
this.blink(epoch, cx);
|
||||
});
|
||||
}
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.paused = false;
|
||||
this.blink(epoch, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
settings = { path = "../settings" }
|
||||
person = { path = "../person" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
@@ -129,7 +129,7 @@ impl ImportIdentity {
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})?;
|
||||
}
|
||||
@@ -172,6 +172,11 @@ impl ImportIdentity {
|
||||
});
|
||||
}
|
||||
|
||||
// The "Connect via Web Extension" button is hidden on wasm (`is_wasm`),
|
||||
// so this stub is never invoked in the browser.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn proxy(&mut self, _cx: &mut Context<Self>) {}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
@@ -193,7 +198,7 @@ impl ImportIdentity {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
|
||||
@@ -83,7 +83,7 @@ impl RestoreEncryption {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Screening {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.followed = result;
|
||||
cx.notify();
|
||||
})
|
||||
@@ -139,7 +139,7 @@ impl Screening {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
})
|
||||
@@ -185,7 +185,7 @@ impl Screening {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.last_active = result;
|
||||
cx.notify();
|
||||
})
|
||||
@@ -208,7 +208,7 @@ impl Screening {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.verified = result;
|
||||
cx.notify();
|
||||
})
|
||||
|
||||
+70
-64
@@ -2,20 +2,21 @@ use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use anyhow::Error;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use common::download_dir;
|
||||
use common::{CoopImageCache, download_dir};
|
||||
use device::{DeviceEvent, DeviceRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
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 person::{PersonRegistry, shorten_pubkey};
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
@@ -64,6 +65,9 @@ pub struct Workspace {
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// App's Image Cache
|
||||
image_cache: Entity<CoopImageCache>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
@@ -79,6 +83,7 @@ impl Workspace {
|
||||
|
||||
let sidebar = cx.new(|cx| Sidebar::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![];
|
||||
|
||||
@@ -229,6 +234,7 @@ impl Workspace {
|
||||
Self {
|
||||
sidebar,
|
||||
dock,
|
||||
image_cache,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -374,11 +380,18 @@ impl Workspace {
|
||||
Command::ImportEncryption => {
|
||||
self.import_encryption(window, cx);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Command::Update => {
|
||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||
}
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
// Auto-update is a desktop-only feature; no-op in the browser.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Command::Update => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,11 +564,12 @@ impl Workspace {
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
let avatar = avatar.clone();
|
||||
let name = name.clone();
|
||||
|
||||
this.min_w(px(256.))
|
||||
let menu = this
|
||||
.min_w(px(256.))
|
||||
.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
@@ -585,27 +599,27 @@ impl Workspace {
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
// Only offer in-app updates when auto-update is
|
||||
// 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(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowSettings),
|
||||
)
|
||||
.separator();
|
||||
|
||||
// Auto-update is a desktop-only feature; there is no updater in the browser.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let menu = menu.menu_with_icon(
|
||||
"Check for Updates",
|
||||
IconName::Device,
|
||||
Box::new(Command::Update),
|
||||
);
|
||||
|
||||
menu.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowSettings),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let auto_updater = AutoUpdater::try_global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -618,35 +632,22 @@ impl Workspace {
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
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()
|
||||
let titlebar = h_flex()
|
||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||
.gap_2()
|
||||
.when_some(updater_status, |this, status| {
|
||||
.gap_2();
|
||||
|
||||
// Auto-update is a desktop-only feature; there is no updater in the browser.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let titlebar = {
|
||||
let updater = AutoUpdater::global(cx);
|
||||
let updater_idle = updater.read(cx).idle(cx);
|
||||
titlebar.when(!updater_idle, |this| {
|
||||
let status = updater.read(cx).status(cx);
|
||||
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));
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
titlebar
|
||||
.when(nip4e_enabled, |this| {
|
||||
this.child(
|
||||
Button::new("key")
|
||||
@@ -783,26 +784,31 @@ impl Render for Workspace {
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
image_cache(self.image_cache.clone())
|
||||
.relative()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
TitleBar::new()
|
||||
.child(self.titlebar_left(cx))
|
||||
.child(self.titlebar_right(cx)),
|
||||
)
|
||||
// Main
|
||||
.child(
|
||||
h_flex()
|
||||
v_flex()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.w(SIDEBAR_WIDTH)
|
||||
.child(self.sidebar.clone()),
|
||||
TitleBar::new()
|
||||
.child(self.titlebar_left(cx))
|
||||
.child(self.titlebar_right(cx)),
|
||||
)
|
||||
.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
|
||||
|
||||
@@ -97,7 +97,7 @@ impl BackupPanel {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_copied(false, cx);
|
||||
})?;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||
Task, TextAlign, Window, div, rems, retain_all,
|
||||
Task, TextAlign, Window, div, rems,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -108,8 +108,10 @@ impl ContactListPanel {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let public_keys = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
// Update state. `update_in` (rather than `update`) routes through
|
||||
// a try-borrow, so on wasm a poll that happens to land while the
|
||||
// app context is borrowed can't panic and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.contacts.extend(public_keys);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -148,8 +150,11 @@ impl ContactListPanel {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
// Clear the error message after a delay. `update_in` (rather than
|
||||
// `update`) routes through a try-borrow, so on wasm a poll that
|
||||
// happens to land while the app context is borrowed can't panic
|
||||
// and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -297,7 +302,6 @@ impl Focusable for ContactListPanel {
|
||||
impl Render for ContactListPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(retain_all("contact-list-panel"))
|
||||
.p_3()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
|
||||
@@ -103,8 +103,10 @@ impl MessagingRelayPanel {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let relays = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
// Update state. `update_in` (rather than `update`) routes through
|
||||
// a try-borrow: on wasm a poll that lands while the app context
|
||||
// is borrowed can't panic and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -148,8 +150,11 @@ impl MessagingRelayPanel {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
// Clear the error message after a delay. `update_in` (rather than
|
||||
// `update`) routes through a try-borrow: on wasm a poll that
|
||||
// lands while the app context is borrowed can't panic and kill
|
||||
// this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Error;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||
Window, div, retain_all,
|
||||
Window, div,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -167,15 +167,13 @@ impl ProfilePanel {
|
||||
});
|
||||
|
||||
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_in(cx, |this, _window, 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
|
||||
match upload(server, path, cx).await {
|
||||
Ok(url) => {
|
||||
@@ -321,7 +319,6 @@ impl Render for ProfilePanel {
|
||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||
|
||||
v_flex()
|
||||
.image_cache(retain_all("profile-panel"))
|
||||
.p_3()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
|
||||
@@ -121,8 +121,10 @@ impl RelayListPanel {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let relays = task.await?;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
// Update state. `update_in` (rather than `update`) routes through
|
||||
// a try-borrow: on wasm a poll that lands while the app context
|
||||
// is borrowed can't panic and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -167,8 +169,11 @@ impl RelayListPanel {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
// Clear the error message after a delay. `update_in` (rather than
|
||||
// `update`) routes through a try-borrow: on wasm a poll that
|
||||
// lands while the app context is borrowed can't panic and kill
|
||||
// this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
@@ -3,19 +3,19 @@ use std::ops::Range;
|
||||
|
||||
use anyhow::Error;
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
use common::{DebouncedDelay, TimestampExt};
|
||||
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
||||
use entry::RoomEntry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||
Window, div, retain_all, uniform_list,
|
||||
Window, div, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry};
|
||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
@@ -178,7 +178,10 @@ impl Sidebar {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
// `update_in` (rather than `update`) routes through a
|
||||
// try-borrow: on wasm a poll that lands while the app
|
||||
// context is borrowed can't panic and kill this task.
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.set_contact_list(contacts, cx);
|
||||
})?;
|
||||
}
|
||||
@@ -521,7 +524,7 @@ impl Render for Sidebar {
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.image_cache(retain_all("sidebar"))
|
||||
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "1.0.2"
|
||||
version = "1.0.0"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
|
||||
@@ -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
|
||||
title: Coop
|
||||
base: core24
|
||||
|
||||
@@ -35,7 +35,3 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
|
||||
snapcraft --destructive-mode --output "$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"
|
||||
|
||||
+61
-79
@@ -29,29 +29,23 @@ fi
|
||||
# Function to update version in a Cargo.toml file
|
||||
update_version() {
|
||||
local file="$1"
|
||||
local tmp="${file}.tmp"
|
||||
local backup="${file}.bak"
|
||||
|
||||
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
|
||||
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
|
||||
# suffix instead of the extended-regex flag, leaving a stray
|
||||
# `Cargo.toml-E` behind and never updating the file.
|
||||
# Writing to a temp file and moving it over works on both implementations.
|
||||
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
|
||||
&& mv "$tmp" "$file"; then
|
||||
# Backup the original file
|
||||
cp "$file" "$backup"
|
||||
|
||||
# More flexible regex that handles various version formats and whitespace
|
||||
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
|
||||
echo "✓ Updated version to $NEW_VERSION in $file"
|
||||
else
|
||||
echo "Error: Failed to update version in $file"
|
||||
# Remove any partial temp file; the original file is untouched
|
||||
rm -f "$tmp"
|
||||
# Restore original backup
|
||||
mv "$backup" "$file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The substitution can silently match nothing (e.g. a `version.workspace` key),
|
||||
# so verify the new version actually landed before moving on.
|
||||
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
|
||||
echo "Error: Version line not found/updated in $file"
|
||||
exit 1
|
||||
fi
|
||||
# Remove the backup file
|
||||
rm -f "$backup"
|
||||
}
|
||||
|
||||
# Update both Cargo.toml files
|
||||
@@ -59,78 +53,66 @@ echo "Updating versions..."
|
||||
update_version "$WORKSPACE_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"
|
||||
|
||||
# When the requested version is already set there is nothing to bump or commit,
|
||||
# so the current commit is tagged as-is.
|
||||
if git diff --quiet -- "$WORKSPACE_CARGO" "$CRATE_CARGO"; then
|
||||
echo "Version is already $NEW_VERSION, tagging the current commit"
|
||||
if git commit -m "$COMMIT_MSG"; then
|
||||
echo "✓ Committed version changes"
|
||||
else
|
||||
# Check git status before committing
|
||||
echo "Checking git status..."
|
||||
# The version files are always modified at this point, so only ask about other changes.
|
||||
if [ -n "$(git status --porcelain -- . ":(exclude,top)$WORKSPACE_CARGO" ":(exclude,top)$CRATE_CARGO")" ]; then
|
||||
echo "Current uncommitted changes:"
|
||||
git status --short
|
||||
echo "Error: Failed to commit version changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
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
|
||||
# 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
|
||||
|
||||
# Create git tag
|
||||
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
|
||||
echo "✓ Created git tag: $TAG_NAME"
|
||||
else
|
||||
|
||||
+1
-16
@@ -15,26 +15,11 @@ if [ "$#" -ne 1 ]; then
|
||||
exit 1
|
||||
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
|
||||
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
||||
|
||||
snap remove coop || true
|
||||
mkdir -p snap
|
||||
rm -rf snap/unpacked
|
||||
unsquashfs -dest snap/unpacked "$snap_file"
|
||||
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
|
||||
snap try --classic snap/unpacked
|
||||
|
||||
@@ -30,6 +30,9 @@ console_error_panic_hook = "0.1"
|
||||
tracing-wasm = "0.2"
|
||||
console_log = "1.0"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Performance", "console", "DomException"] }
|
||||
universal-time = { git = "https://github.com/shadowylab/universal-time" }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
@@ -36,6 +36,11 @@ if [[ "$(uname)" == "Darwin" ]]; then
|
||||
fi
|
||||
|
||||
# Step 1: Build WASM
|
||||
#
|
||||
# Single-threaded build: `+bulk-memory` only. The multithreaded web backend
|
||||
# is disabled in `web/src/lib.rs` (gpui's wasm workers freeze their JS event
|
||||
# loop in `Atomics.wait`, which breaks nostr-sdk's spawn_local-driven client
|
||||
# and the WebSocket transport), so no atomics/shared-memory flags here.
|
||||
echo -e "${GREEN}Step 1: Building WASM...${NC}"
|
||||
cd "$PROJECT_ROOT"
|
||||
export CARGO_TARGET_DIR="$PROJECT_ROOT/target"
|
||||
|
||||
+137
-12
@@ -1,4 +1,8 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use gpui::*;
|
||||
use theme::{Theme, ThemeMode};
|
||||
use ui::Root;
|
||||
use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider};
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -7,22 +11,64 @@ struct CustomTimeProvider;
|
||||
|
||||
impl WallClock for CustomTimeProvider {
|
||||
fn system_time(&self) -> SystemTime {
|
||||
SystemTime::from_unix_duration(instant::Duration::from_secs(0))
|
||||
// Browser wall clock: milliseconds since the Unix epoch.
|
||||
let millis = js_sys::Date::now();
|
||||
SystemTime::from_unix_duration(instant::Duration::from_millis(millis as u64))
|
||||
}
|
||||
}
|
||||
|
||||
impl MonotonicClock for CustomTimeProvider {
|
||||
fn instant(&self) -> Instant {
|
||||
Instant::from_ticks(instant::Duration::from_secs(0))
|
||||
// `performance.now()` is monotonic; fall back to the wall clock if
|
||||
// it's unavailable.
|
||||
let millis = web_sys::window()
|
||||
.and_then(|window| window.performance())
|
||||
.map(|performance| performance.now())
|
||||
.unwrap_or_else(js_sys::Date::now);
|
||||
Instant::from_ticks(instant::Duration::from_millis(millis as u64))
|
||||
}
|
||||
}
|
||||
|
||||
define_time_provider!(CustomTimeProvider);
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run() -> Result<(), JsValue> {
|
||||
console_error_panic_hook::set_once();
|
||||
thread_local! {
|
||||
static APPLICATION: RefCell<Option<ApplicationHandle>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Applies a theme mode and restores the bundled web fonts.
|
||||
///
|
||||
/// `Theme::change` reapplies the theme config, which can carry its own font
|
||||
/// family; host system fonts are unavailable in wasm, so the bundled Inter
|
||||
/// fonts are put back afterwards.
|
||||
fn apply_theme(mode: ThemeMode, cx: &mut App) {
|
||||
Theme::change(mode, None, cx);
|
||||
Theme::global_mut(cx).font_family = "Inter".into();
|
||||
}
|
||||
|
||||
/// Switches the app between light and dark after it is running.
|
||||
///
|
||||
/// The embedding page calls this to keep the app in sync with its own
|
||||
/// appearance.
|
||||
#[cfg(target_family = "wasm")]
|
||||
#[wasm_bindgen]
|
||||
pub fn set_theme(dark: bool) {
|
||||
let mode = if dark {
|
||||
ThemeMode::Dark
|
||||
} else {
|
||||
ThemeMode::Light
|
||||
};
|
||||
APPLICATION.with(|application| {
|
||||
if let Some(handle) = application.borrow().as_ref() {
|
||||
handle.update(|cx| {
|
||||
apply_theme(mode, cx);
|
||||
cx.refresh_windows();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn run() -> Result<(), JsValue> {
|
||||
// Initialize logging to browser console
|
||||
console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger");
|
||||
|
||||
@@ -32,21 +78,92 @@ pub fn run() -> Result<(), JsValue> {
|
||||
#[cfg(target_family = "wasm")]
|
||||
gpui_platform::web_init();
|
||||
|
||||
// Install the panic hook AFTER `web_init` (which sets the default
|
||||
// `console_error_panic_hook`), so ours wins. It prints the entire
|
||||
// JS/wasm stack as a single string: `console_error_panic_hook`'s default
|
||||
// output is an `Error` object whose stack is collapsed in the console,
|
||||
// and on wasm the frames below the panic machinery name the task that
|
||||
// panicked (debug builds keep symbol names) — essential for diagnosing
|
||||
// `RefCell already borrowed`.
|
||||
#[cfg(target_family = "wasm")]
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
// Capture the JS stack (which includes the wasm frames with symbol
|
||||
// names in debug builds) without constructing DOM objects.
|
||||
let stack = js_sys::Reflect::get(&js_sys::Error::new(""), &"stack".into())
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_default();
|
||||
web_sys::console::error_1(
|
||||
&format!("{info}\n\n==== full stack ====\n{stack}\n=====================").into(),
|
||||
);
|
||||
}));
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let app = gpui_platform::application();
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
let app = {
|
||||
let app = gpui_platform::single_threaded_web();
|
||||
// Assets are not embedded in the WASM bundle; they are served from
|
||||
// the `/assets/...` URL prefix (see `web/www/vite.config.js`) and
|
||||
// downloaded by the `assets` crate.
|
||||
let assets = assets::Assets::new("");
|
||||
|
||||
// Temporary fix: intentionally leak the `Rc<AppCell>` to keep the application alive
|
||||
struct WasmApplication(std::rc::Rc<AppCell>);
|
||||
let wasm_app = unsafe { std::mem::transmute::<Application, WasmApplication>(app) };
|
||||
std::mem::forget(wasm_app.0.clone());
|
||||
unsafe { std::mem::transmute::<WasmApplication, Application>(wasm_app) }
|
||||
// Download every icon and brand asset before the first frame: brand
|
||||
// images are loaded through GPUI's image cache, which does not retry
|
||||
// failed loads, and pre-caching the icons lets them render
|
||||
// immediately instead of waiting for a repaint.
|
||||
assets.preload().await;
|
||||
|
||||
// NOTE: the multithreaded web backend (application_with_web_backend)
|
||||
// cannot host this app's backend. gpui's wasm background workers
|
||||
// block on `Atomics.wait` while idle, freezing their JS event loop,
|
||||
// so `spawn_local`-driven tasks (nostr-sdk's client actor, the
|
||||
// WebSocket transport) and fetch promises never make progress on a
|
||||
// worker thread. Everything must run on the main thread.
|
||||
gpui_platform::single_threaded_web().with_assets(assets)
|
||||
};
|
||||
|
||||
app.run(|cx| {
|
||||
let launch = move |cx: &mut App| {
|
||||
// Load the embedded Inter font stack for WASM, where host system
|
||||
// fonts are unavailable. Inter is the app's UI font on Linux; the
|
||||
// wasm build reuses it so the web app matches the desktop look.
|
||||
let inter_regular =
|
||||
Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Regular.ttf").as_slice());
|
||||
let inter_italic =
|
||||
Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Italic.ttf").as_slice());
|
||||
let inter_medium =
|
||||
Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Medium.ttf").as_slice());
|
||||
let inter_medium_italic = Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/Inter/Inter-MediumItalic.ttf").as_slice(),
|
||||
);
|
||||
let inter_semibold =
|
||||
Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-SemiBold.ttf").as_slice());
|
||||
let inter_semibold_italic = Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/Inter/Inter-SemiBoldItalic.ttf").as_slice(),
|
||||
);
|
||||
let inter_bold =
|
||||
Cow::Borrowed(include_bytes!("../../assets/fonts/Inter/Inter-Bold.ttf").as_slice());
|
||||
let inter_bold_italic = Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/Inter/Inter-BoldItalic.ttf").as_slice(),
|
||||
);
|
||||
|
||||
cx.text_system()
|
||||
.add_fonts(vec![
|
||||
inter_regular,
|
||||
inter_italic,
|
||||
inter_medium,
|
||||
inter_medium_italic,
|
||||
inter_semibold,
|
||||
inter_semibold_italic,
|
||||
inter_bold,
|
||||
inter_bold_italic,
|
||||
])
|
||||
.expect("Failed to load fonts");
|
||||
|
||||
// Apply the system appearance before the first frame, so the app
|
||||
// never flashes the default light theme.
|
||||
apply_theme(cx.window_appearance().into(), cx);
|
||||
|
||||
// Open the root window
|
||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||
// Initialize components
|
||||
@@ -78,7 +195,15 @@ pub fn run() -> Result<(), JsValue> {
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
|
||||
cx.activate(true);
|
||||
};
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
APPLICATION.with(|application| {
|
||||
*application.borrow_mut() = Some(app.run_embedded(launch));
|
||||
});
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.run(launch);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ export default defineConfig({
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: path.resolve(__dirname, "../../../assets/icons"),
|
||||
src: path.resolve(__dirname, "../../assets/icons"),
|
||||
dest: "assets",
|
||||
},
|
||||
{
|
||||
src: path.resolve(__dirname, "../../assets/brand"),
|
||||
dest: "assets",
|
||||
},
|
||||
],
|
||||
@@ -19,9 +23,9 @@ export default defineConfig({
|
||||
name: "serve-assets",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(
|
||||
"/coop/assets",
|
||||
"/assets",
|
||||
(req, res, next) => {
|
||||
const assetsPath = path.resolve(__dirname, "../../../assets");
|
||||
const assetsPath = path.resolve(__dirname, "../../assets");
|
||||
const filePath = path.join(
|
||||
assetsPath,
|
||||
req.url.replace("/assets", ""),
|
||||
|
||||
Reference in New Issue
Block a user