Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a34d66c7c | ||
|
|
57c85f5b99 | ||
|
|
bf7080654d | ||
|
|
42983383b0 | ||
|
|
e2b10c173e | ||
|
|
9e33da717b |
@@ -152,11 +152,23 @@ jobs:
|
|||||||
echo "Artifacts structure:"
|
echo "Artifacts structure:"
|
||||||
find artifacts -type f -exec ls -la {} \;
|
find artifacts -type f -exec ls -la {} \;
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: |
|
||||||
|
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||||
|
# this to verify a download before installing it, so it must be
|
||||||
|
# published alongside every release. Written outside the directory
|
||||||
|
# being hashed so the checksums file never includes itself.
|
||||||
|
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||||
|
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||||
|
cat artifacts/SHA256SUMS
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
server_url: "https://git.reya.su/"
|
server_url: "https://git.reya.info/"
|
||||||
repository: "reya/coop"
|
repository: "reya/coop"
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Rust coding guidelines
|
||||||
|
|
||||||
|
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||||
|
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||||
|
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||||
|
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||||
|
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||||
|
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||||
|
- Propagate errors with `?` when the calling function should handle them
|
||||||
|
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||||
|
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||||
|
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||||
|
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||||
|
* Avoid creative additions unless explicitly requested
|
||||||
|
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||||
|
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||||
|
Example:
|
||||||
|
```rust
|
||||||
|
executor.spawn({
|
||||||
|
let task_ran = task_ran.clone();
|
||||||
|
async move {
|
||||||
|
*task_ran.borrow_mut() = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
# Timers in tests
|
||||||
|
|
||||||
|
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||||
|
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||||
|
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||||
|
|
||||||
|
# GPUI
|
||||||
|
|
||||||
|
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||||
|
|
||||||
|
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||||
|
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||||
|
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||||
|
|
||||||
|
## `Window`
|
||||||
|
|
||||||
|
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||||
|
|
||||||
|
* `thing.entity_id()` returns `EntityId`
|
||||||
|
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||||
|
* `thing.read(cx: &App)` returns `&T`.
|
||||||
|
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||||
|
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||||
|
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||||
|
|
||||||
|
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||||
|
|
||||||
|
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||||
|
|
||||||
|
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
All use of entities and UI rendering occurs on a single foreground thread.
|
||||||
|
|
||||||
|
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||||
|
|
||||||
|
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||||
|
|
||||||
|
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||||
|
|
||||||
|
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||||
|
|
||||||
|
* Awaiting the task in some other async context.
|
||||||
|
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||||
|
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||||
|
|
||||||
|
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
struct TextWithBorder(SharedString);
|
||||||
|
|
||||||
|
impl Render for TextWithBorder {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().border_1().child(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||||
|
|
||||||
|
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||||
|
|
||||||
|
The style methods on elements are similar to those used by Tailwind CSS.
|
||||||
|
|
||||||
|
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||||
|
|
||||||
|
## Input events
|
||||||
|
|
||||||
|
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||||
|
|
||||||
|
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||||
|
|
||||||
|
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||||
|
|
||||||
|
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||||
|
|
||||||
|
## Notify
|
||||||
|
|
||||||
|
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||||
|
|
||||||
|
## Entity events
|
||||||
|
|
||||||
|
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||||
|
|
||||||
|
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||||
|
|
||||||
|
# Pull request hygiene
|
||||||
|
|
||||||
|
When an agent opens or updates a pull request, it must:
|
||||||
|
|
||||||
|
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
||||||
|
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||||
|
- Avoid trailing punctuation in PR titles.
|
||||||
|
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
||||||
|
- Include a `Release Notes:` section as the final section in the PR body.
|
||||||
|
- Use one bullet under `Release Notes:`:
|
||||||
|
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||||
|
- `- N/A` for docs-only and other non-user-facing changes.
|
||||||
|
- Format release notes exactly with a blank line after the heading, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Release Notes:
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
```
|
||||||
Generated
+405
-301
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -27,6 +27,11 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
|
# Crypto (NIP-17 encrypted file messages)
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
data-encoding = "2"
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
@@ -45,6 +50,7 @@ webbrowser = "1.0.4"
|
|||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
|
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Use stacker's psm version which may have better WASM support
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
@@ -8,5 +8,8 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
ureq.workspace = true
|
||||||
|
|
||||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||||
|
|||||||
+239
-123
@@ -1,22 +1,30 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
use std::sync::Arc;
|
||||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use instant::{Duration, Instant};
|
|
||||||
|
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);
|
||||||
|
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||||
|
|
||||||
fn get_github_repo_owner() -> String {
|
|
||||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_github_repo_name() -> String {
|
|
||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether updates are managed by an external distribution channel
|
|
||||||
/// (Flatpak/Snap), in which case the in-app updater must not run.
|
|
||||||
fn uses_managed_updates() -> bool {
|
fn uses_managed_updates() -> bool {
|
||||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||||
std::env::var("FLATPAK_ID").is_ok()
|
std::env::var("FLATPAK_ID").is_ok()
|
||||||
@@ -27,50 +35,55 @@ fn uses_managed_updates() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the auto-update system.
|
/// Initialize the auto-update system.
|
||||||
///
|
|
||||||
/// Skips initialization when updates are handled by an external distribution
|
|
||||||
/// channel (Flatpak/Snap). 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) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
if uses_managed_updates() {
|
if uses_managed_updates() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
|
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||||
|
|
||||||
|
let Some(filter) = asset_filter_for(os, arch) else {
|
||||||
|
log::info!(
|
||||||
|
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||||
|
log::error!(
|
||||||
|
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoUpdater::set_global(
|
||||||
|
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
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 {
|
pub struct AutoUpdater {
|
||||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
/// The blocking engine, driven on the background executor.
|
||||||
pub updater: Entity<Updater>,
|
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||||
|
status: UpdateStatus,
|
||||||
|
/// The newer release found by the last successful check, if any.
|
||||||
|
available: Option<Release>,
|
||||||
/// Currently running app version.
|
/// Currently running app version.
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
/// Keeps the observer subscription alive.
|
/// The in-flight check or download, if any.
|
||||||
_subscription: Subscription,
|
task: Option<Task<()>>,
|
||||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
|
||||||
error_time: Option<Instant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Whether auto-update is available for this installation.
|
/// Whether auto-update is available for this installation.
|
||||||
///
|
|
||||||
/// Returns `false` on managed distribution channels (Flatpak/Snap), where
|
|
||||||
/// updates are handled by the channel and no global updater is created.
|
|
||||||
pub fn is_available(cx: &App) -> bool {
|
pub fn is_available(cx: &App) -> bool {
|
||||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
cx.try_global::<GlobalAutoUpdater>().is_some()
|
||||||
}
|
}
|
||||||
@@ -82,12 +95,6 @@ impl AutoUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance.
|
/// Retrieve the global auto updater instance.
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics when auto-update is not available for this installation. Prefer
|
|
||||||
/// [`AutoUpdater::try_global`] when the installation type is not known at
|
|
||||||
/// compile time (e.g. Flatpak/Snap).
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
}
|
}
|
||||||
@@ -96,92 +103,49 @@ impl AutoUpdater {
|
|||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
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));
|
||||||
|
|
||||||
let repo_owner = get_github_repo_owner();
|
// Schedule an auto-check after a 2-minute delay
|
||||||
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| {
|
cx.defer_in(window, |_this, _window, cx| {
|
||||||
let duration = Duration::from_secs(120);
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(duration).await;
|
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
updater,
|
engine,
|
||||||
|
status: UpdateStatus::Idle,
|
||||||
|
available: None,
|
||||||
version,
|
version,
|
||||||
_subscription: subscription,
|
task: None,
|
||||||
error_time: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn idle(&self, cx: &App) -> bool {
|
/// Whether nothing is happening, so the UI can hide the status line.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn idle(&self) -> bool {
|
||||||
if status == &UpdateStatus::Idle {
|
matches!(self.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self, cx: &App) -> SharedString {
|
/// Whether a verified update is installed and waiting for a restart.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn staged(&self) -> bool {
|
||||||
|
matches!(self.status, UpdateStatus::Staged(_))
|
||||||
|
}
|
||||||
|
|
||||||
match status {
|
/// A short, human-readable description of the current status.
|
||||||
UpdateStatus::Idle => "Up to date".into(),
|
pub fn status(&self) -> SharedString {
|
||||||
|
match &self.status {
|
||||||
|
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||||
UpdateStatus::UpToDate => "Up to date".into(),
|
|
||||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||||
UpdateStatus::Downloading { downloaded, total } => {
|
UpdateStatus::Downloading { downloaded, total } => {
|
||||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||||
@@ -195,16 +159,168 @@ impl AutoUpdater {
|
|||||||
UpdateStatus::Staged(version) => {
|
UpdateStatus::Staged(version) => {
|
||||||
format!("Version {version} ready — restart to apply").into()
|
format!("Version {version} ready — restart to apply").into()
|
||||||
}
|
}
|
||||||
UpdateStatus::Errored(msg) => {
|
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
|
||||||
if self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
"Up to date".into()
|
|
||||||
} else {
|
|
||||||
format!("Update failed: {msg}").into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check the release host for a newer version, then download and install it.
|
||||||
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_status(UpdateStatus::Checking, cx);
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_executor()
|
||||||
|
.spawn(async move { engine.check() })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match result {
|
||||||
|
Ok(Some(release)) => {
|
||||||
|
log::info!("Update {} is available", release.version);
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.available = Some(release);
|
||||||
|
this.set_status(UpdateStatus::Available(version), cx);
|
||||||
|
this.download_and_install(cx);
|
||||||
|
}
|
||||||
|
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update check failed: {error}");
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the available update, verify it, and swap it into place.
|
||||||
|
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(release) = self.available.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
self.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: 0,
|
||||||
|
total: None,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let downloaded = Arc::new(AtomicU64::new(0));
|
||||||
|
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let download_task = {
|
||||||
|
let (engine, release) = (engine.clone(), release.clone());
|
||||||
|
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||||
|
cx.background_executor().spawn(async move {
|
||||||
|
let result = engine.download(&release, |got, expected| {
|
||||||
|
downloaded.store(got, Ordering::Relaxed);
|
||||||
|
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let got = downloaded.load(Ordering::Relaxed);
|
||||||
|
let total = total.load(Ordering::Relaxed);
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: got,
|
||||||
|
total: (total != 0).then_some(total),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
if done.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(120))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact = match download_task.await {
|
||||||
|
Ok(artifact) => artifact,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update download failed: {error}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
||||||
|
|
||||||
|
let installed = {
|
||||||
|
let engine = engine.clone();
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { engine.install(&artifact) })
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match installed {
|
||||||
|
Ok(installed) => {
|
||||||
|
if let Some(path) = installed.restart_path {
|
||||||
|
cx.set_restart_path(path);
|
||||||
|
}
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.set_status(UpdateStatus::Staged(version), cx);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relaunch into the staged update.
|
||||||
|
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.staged() {
|
||||||
|
log::warn!("Ignoring restart request: no update is staged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cx.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||||
|
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||||
|
self.status = status;
|
||||||
|
|
||||||
|
if errored {
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(UpdateStatus::Idle, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||||
|
const RELEASE_PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
/// Which published artifact belongs to a target platform.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AssetFilter {
|
||||||
|
extension: &'static str,
|
||||||
|
arch: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetFilter {
|
||||||
|
/// Whether `name` is the installable artifact for this target.
|
||||||
|
fn matches(&self, name: &str) -> bool {
|
||||||
|
let name = name.to_ascii_lowercase();
|
||||||
|
name.ends_with(self.extension) && name.contains(self.arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||||
|
let extension = match os {
|
||||||
|
"macos" => ".dmg",
|
||||||
|
"linux" => ".tar.gz",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let arch = match (os, arch) {
|
||||||
|
// cargo-packager names the disk images `aarch64`/`x64`.
|
||||||
|
("macos", "aarch64") => "aarch64",
|
||||||
|
("macos", "x86_64") => "x64",
|
||||||
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
|
("linux", "aarch64") => "aarch64",
|
||||||
|
("linux", "x86_64") => "x86_64",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(AssetFilter { extension, arch })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads releases from a Gitea repository's Releases.
|
||||||
|
pub struct GiteaSource {
|
||||||
|
api_base: String,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
filter: AssetFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaSource {
|
||||||
|
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
|
||||||
|
/// (e.g. `https://git.reya.info/api/v1`).
|
||||||
|
pub fn new(
|
||||||
|
api_base: impl Into<String>,
|
||||||
|
owner: impl Into<String>,
|
||||||
|
repo: impl Into<String>,
|
||||||
|
filter: AssetFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||||
|
owner: owner.into(),
|
||||||
|
repo: repo.into(),
|
||||||
|
filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn releases_url(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
||||||
|
self.api_base, self.owner, self.repo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSource for GiteaSource {
|
||||||
|
fn fetch_latest(&self) -> Result<Release> {
|
||||||
|
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
||||||
|
let release = newest_published(&releases)
|
||||||
|
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| self.filter.matches(&asset.name))
|
||||||
|
.ok_or(Error::NoMatchingAsset {
|
||||||
|
target_os: std::env::consts::OS,
|
||||||
|
target_arch: std::env::consts::ARCH,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve the published checksum so the engine can reject a truncated or substituted download.
|
||||||
|
let sha256 = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||||
|
.map(|sums| http::get_string(&sums.browser_download_url))
|
||||||
|
.transpose()?
|
||||||
|
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||||
|
|
||||||
|
Ok(Release {
|
||||||
|
version: parse_tag(&release.tag_name)?,
|
||||||
|
notes: release
|
||||||
|
.body
|
||||||
|
.clone()
|
||||||
|
.filter(|body| !body.trim().is_empty())
|
||||||
|
.or_else(|| release.name.clone()),
|
||||||
|
asset: Asset {
|
||||||
|
name: asset.name.clone(),
|
||||||
|
url: asset.browser_download_url.clone(),
|
||||||
|
size: asset.size,
|
||||||
|
},
|
||||||
|
signature: None,
|
||||||
|
signature_url: None,
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.filter(|release| !release.draft && !release.prerelease)
|
||||||
|
.filter_map(|release| {
|
||||||
|
parse_tag(&release.tag_name)
|
||||||
|
.ok()
|
||||||
|
.map(|version| (version, release))
|
||||||
|
})
|
||||||
|
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||||
|
.map(|(_, release)| release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
|
||||||
|
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
||||||
|
sums.lines().find_map(|line| {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let (hash, path) = (parts.next()?, parts.next()?);
|
||||||
|
let path = path.strip_prefix('*').unwrap_or(path);
|
||||||
|
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||||
|
(base == asset_name).then(|| hash.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaRelease {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<GiteaAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release asset as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking HTTP helpers for release metadata.
|
||||||
|
mod http {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui_updater_core::{Error, Result};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use ureq::Agent;
|
||||||
|
use ureq::tls::{RootCerts, TlsConfig};
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn agent() -> Agent {
|
||||||
|
Agent::config_builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.tls_config(
|
||||||
|
TlsConfig::builder()
|
||||||
|
.root_certs(RootCerts::PlatformVerifier)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut response = agent().get(url).call().map_err(|error| match error {
|
||||||
|
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
||||||
|
other => Error::Http(other.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
||||||
|
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_string(url: &str) -> Result<String> {
|
||||||
|
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui_updater_core::Version;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PUBLISHED: &[&str] = &[
|
||||||
|
"Coop_1.0.1_aarch64.dmg",
|
||||||
|
"Coop_1.0.1_x64.dmg",
|
||||||
|
"coop-linux-aarch64.tar.gz",
|
||||||
|
"coop-linux-x86_64.tar.gz",
|
||||||
|
"coop_1.0.1_aarch64.snap",
|
||||||
|
"coop_1.0.1_arm64-setup.exe",
|
||||||
|
"coop_1.0.1_x64-setup.exe",
|
||||||
|
"coop_1.0.1_x86_64.snap",
|
||||||
|
"su.reya.coop_aarch64.flatpak",
|
||||||
|
"su.reya.coop_x86_64.flatpak",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn selected(os: &str, arch: &str) -> Option<&'static str> {
|
||||||
|
let filter = asset_filter_for(os, arch)?;
|
||||||
|
PUBLISHED.iter().copied().find(|name| filter.matches(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
|
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||||
|
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "aarch64"),
|
||||||
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "x86_64"),
|
||||||
|
Some("coop-linux-x86_64.tar.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
|
||||||
|
let macos = asset_filter_for("macos", "aarch64").unwrap();
|
||||||
|
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
|
||||||
|
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
|
||||||
|
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
|
||||||
|
|
||||||
|
let linux = asset_filter_for("linux", "x86_64").unwrap();
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_checksums_by_basename_ignoring_directory_prefix() {
|
||||||
|
let sums = "\
|
||||||
|
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
||||||
|
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
|
||||||
|
789abc SHA256SUMS
|
||||||
|
";
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
|
||||||
|
Some("abcdef")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
|
||||||
|
Some("123456")
|
||||||
|
);
|
||||||
|
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
|
||||||
|
let releases: Vec<GiteaRelease> = serde_json::from_str(
|
||||||
|
r#"[
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.2",
|
||||||
|
"draft": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v2.0.0-rc.1",
|
||||||
|
"prerelease": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "nightly",
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.1",
|
||||||
|
"name": "v1.0.1",
|
||||||
|
"body": "Fixed app panic on flatpak installations",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let newest = newest_published(&releases).unwrap();
|
||||||
|
assert_eq!(newest.tag_name, "v1.0.1");
|
||||||
|
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
|
||||||
|
assert_eq!(newest.assets.len(), 1);
|
||||||
|
assert_eq!(newest.assets[0].size, 26160329);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires network access to the release host"]
|
||||||
|
fn live_release_source_resolves_the_running_platform() {
|
||||||
|
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||||
|
.expect("this platform should be supported");
|
||||||
|
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
||||||
|
|
||||||
|
let release = source
|
||||||
|
.fetch_latest()
|
||||||
|
.expect("release lookup should succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
release.version >= Version::new(1, 0, 0),
|
||||||
|
"unexpected version {}",
|
||||||
|
release.version
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.filter.matches(&release.asset.name),
|
||||||
|
"unexpected artifact {}",
|
||||||
|
release.asset.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
release.asset.url.starts_with("https://"),
|
||||||
|
"{} ",
|
||||||
|
release.asset.url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ mod room;
|
|||||||
|
|
||||||
pub use message::*;
|
pub use message::*;
|
||||||
pub use room::*;
|
pub use room::*;
|
||||||
|
pub use state::FileAttachment;
|
||||||
|
|
||||||
/// A static keypair used only for signing locally-cached rumor events.
|
/// A static keypair used only for signing locally-cached rumor events.
|
||||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||||
@@ -629,7 +630,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Load all rooms from the database.
|
/// Load all rooms from the database.
|
||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.get_rooms_task(cx);
|
let task = self.query_chat_rooms(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
@@ -650,8 +651,8 @@ impl ChatRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to load rooms from the database
|
/// Query the chat rooms from the database
|
||||||
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
@@ -677,7 +678,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::LOWERCASE_K, "14");
|
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
@@ -719,8 +720,8 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Parse a nostr event into a message and push it to the belonging room
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
///
|
///
|
||||||
/// If the room doesn't exist, it will be created.
|
/// - If the room doesn't exist, it will be created.
|
||||||
/// Updates room ordering based on the most recent messages.
|
/// - Updates room ordering based on the most recent messages.
|
||||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
@@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
|||||||
Tag::identifier(id),
|
Tag::identifier(id),
|
||||||
Tag::public_key(rumor.pubkey),
|
Tag::public_key(rumor.pubkey),
|
||||||
Tag::custom("r", [room_id]),
|
Tag::custom("r", [room_id]),
|
||||||
Tag::custom("k", ["14"]),
|
Tag::custom("k", [rumor.kind.to_string()]),
|
||||||
];
|
];
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||||
|
|||||||
+82
-39
@@ -4,6 +4,9 @@ use std::ops::Range;
|
|||||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||||
use gpui::{SharedString, SharedUri};
|
use gpui::{SharedString, SharedUri};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::FileAttachment;
|
||||||
|
|
||||||
|
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||||
|
|
||||||
/// Rendered message.
|
/// Rendered message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -21,61 +24,90 @@ pub struct Message {
|
|||||||
pub mentions: Vec<Mention>,
|
pub mentions: Vec<Mention>,
|
||||||
/// List of event of the message this message is a reply to
|
/// List of event of the message this message is a reply to
|
||||||
pub replies_to: Vec<EventId>,
|
pub replies_to: Vec<EventId>,
|
||||||
|
/// Encrypted file attachment
|
||||||
|
pub file: Option<FileAttachment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Event> for Message {
|
impl From<&Event> for Message {
|
||||||
fn from(val: &Event) -> Self {
|
fn from(val: &Event) -> Self {
|
||||||
let mentions = extract_mentions(&val.content);
|
from_parts(
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
val.id,
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
val.pubkey,
|
||||||
|
val.created_at,
|
||||||
Self {
|
val.kind,
|
||||||
id: val.id,
|
&val.content,
|
||||||
author: val.pubkey,
|
&val.tags,
|
||||||
content: string,
|
)
|
||||||
media,
|
|
||||||
created_at: val.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for Message {
|
impl From<&UnsignedEvent> for Message {
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
let mentions = extract_mentions(&val.content);
|
from_parts(
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
// Event ID must be known
|
||||||
id: val.id.unwrap(),
|
val.id.unwrap(),
|
||||||
author: val.pubkey,
|
val.pubkey,
|
||||||
content: string,
|
val.created_at,
|
||||||
media,
|
val.kind,
|
||||||
created_at: val.created_at,
|
&val.content,
|
||||||
mentions,
|
&val.tags,
|
||||||
replies_to,
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&NewMessage> for Message {
|
impl From<&NewMessage> for Message {
|
||||||
fn from(val: &NewMessage) -> Self {
|
fn from(val: &NewMessage) -> Self {
|
||||||
let mentions = extract_mentions(&val.rumor.content);
|
from_parts(
|
||||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
// Event ID must be known
|
||||||
id: val.rumor.id.unwrap(),
|
val.rumor.id.unwrap(),
|
||||||
author: val.rumor.pubkey,
|
val.rumor.pubkey,
|
||||||
content: string,
|
val.rumor.created_at,
|
||||||
media,
|
val.rumor.kind,
|
||||||
created_at: val.rumor.created_at,
|
&val.rumor.content,
|
||||||
mentions,
|
&val.rumor.tags,
|
||||||
replies_to,
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(
|
||||||
|
id: EventId,
|
||||||
|
author: PublicKey,
|
||||||
|
created_at: Timestamp,
|
||||||
|
kind: Kind,
|
||||||
|
content: &str,
|
||||||
|
tags: &Tags,
|
||||||
|
) -> Message {
|
||||||
|
let file = if kind == KIND_FILE_MESSAGE {
|
||||||
|
FileAttachment::from_tags(content, tags)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let has_file = file.is_some();
|
||||||
|
|
||||||
|
let replies_to = extract_reply_ids(tags);
|
||||||
|
|
||||||
|
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||||
|
let mentions = if has_file {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
extract_mentions(content)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (media, content) = if has_file {
|
||||||
|
(Vec::new(), String::new())
|
||||||
|
} else {
|
||||||
|
extract_and_remove_media_urls(content)
|
||||||
|
};
|
||||||
|
|
||||||
|
Message {
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
content,
|
||||||
|
media,
|
||||||
|
created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
file,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +137,17 @@ impl Hash for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
/// Single-line representation for reply previews, notifications and copy.
|
||||||
|
pub fn preview(&self) -> SharedString {
|
||||||
|
if let Some(file) = &self.file {
|
||||||
|
return format!("[File] {}", file.display_name()).into();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content.clone().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
|
|||||||
+46
-15
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
|
|||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||||
|
|
||||||
use crate::NewMessage;
|
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||||
|
|
||||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||||
@@ -439,12 +439,52 @@ impl Room {
|
|||||||
let content: String = content.into();
|
let content: String = content.into();
|
||||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
// Get current user's public key
|
||||||
let nostr = NostrRegistry::global(cx);
|
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
|
// Get current user's public key
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
let sender = nostr.read(cx).current_user()?;
|
let sender = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
|
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||||
|
tags.extend(file.tags());
|
||||||
|
|
||||||
|
// Construct a file message rumor event
|
||||||
|
// WARNING: never sign and send this event to relays
|
||||||
|
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_unsigned(sender);
|
||||||
|
|
||||||
|
// Ensure that the ID is set
|
||||||
|
event.ensure_id();
|
||||||
|
|
||||||
|
Some(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||||
|
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
// Construct event's tags
|
// Construct event's tags
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
|
|
||||||
@@ -454,8 +494,8 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add all reply tags
|
// Add all reply tags
|
||||||
for id in replies.into_iter() {
|
for id in replies {
|
||||||
tags.push(Tag::event(id))
|
tags.push(Tag::event(*id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all receiver tags (no intermediate allocation)
|
// Add all receiver tags (no intermediate allocation)
|
||||||
@@ -467,16 +507,7 @@ impl Room {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
tags
|
||||||
// WARNING: never sign and send this event to relays
|
|
||||||
let mut event = EventBuilder::new(kind, content)
|
|
||||||
.tags(tags)
|
|
||||||
.finalize_unsigned(sender);
|
|
||||||
|
|
||||||
// Ensure that the ID is set
|
|
||||||
event.ensure_id();
|
|
||||||
|
|
||||||
Some(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the appropriate signer based on signer kind and available keys.
|
/// Select the appropriate signer based on signer kind and available keys.
|
||||||
@@ -609,7 +640,7 @@ async fn send_gift_wrap(
|
|||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
config: &SignerKind,
|
config: &SignerKind,
|
||||||
) -> Result<SendReport, Error> {
|
) -> Result<SendReport, Error> {
|
||||||
let k_tag = Tag::custom("k", vec!["14"]);
|
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||||
let mut extra_tags = vec![k_tag];
|
let mut extra_tags = vec![k_tag];
|
||||||
|
|
||||||
// Determine the receiver public key based on the config
|
// Determine the receiver public key based on the config
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chat::FileAttachment;
|
||||||
|
use gpui::SharedString;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
/// A file attachment that has been uploaded, but not sent yet.
|
||||||
|
///
|
||||||
|
/// The local `path` is kept around so the composer can preview
|
||||||
|
/// the file without downloading and decrypting it again.
|
||||||
|
pub(crate) struct PendingFile {
|
||||||
|
pub file: FileAttachment,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the encrypted file attachment of a message
|
||||||
|
pub(crate) enum DecryptedFile {
|
||||||
|
Loading,
|
||||||
|
Ready(PathBuf),
|
||||||
|
Failed(SharedString),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an upload, either plain or encrypted
|
||||||
|
pub(crate) enum Uploaded {
|
||||||
|
Url(Url),
|
||||||
|
File(FileAttachment, PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
||||||
|
pub(crate) fn file_url(path: &Path) -> String {
|
||||||
|
format!("file://{}", path.display())
|
||||||
|
}
|
||||||
+442
-51
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::Error;
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||||
use common::{TimestampExt, coop_cache};
|
use common::TimestampExt;
|
||||||
use futures::lock::Mutex;
|
use futures::lock::Mutex;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -12,8 +13,8 @@ use gpui::{
|
|||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||||
white,
|
retain_all, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, upload};
|
use state::{
|
||||||
|
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||||
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -30,11 +33,13 @@ use ui::input::{Input, InputEvent, InputState};
|
|||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
|
use ui::tooltip::Tooltip;
|
||||||
use ui::{
|
use ui::{
|
||||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||||
h_flex, v_flex,
|
h_flex, v_flex,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::file::*;
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
@@ -46,6 +51,7 @@ static EMOJI_RE: LazyLock<Regex> =
|
|||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
|
mod file;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
@@ -96,6 +102,12 @@ pub struct ChatPanel {
|
|||||||
/// Media Attachment
|
/// Media Attachment
|
||||||
attachments: Entity<Vec<Url>>,
|
attachments: Entity<Vec<Url>>,
|
||||||
|
|
||||||
|
/// Uploaded, encrypted file attachments which are not sent yet
|
||||||
|
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||||
|
|
||||||
|
/// Decrypted attachments of file messages, by message id
|
||||||
|
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||||
|
|
||||||
/// Upload state
|
/// Upload state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
|
||||||
@@ -110,6 +122,7 @@ impl ChatPanel {
|
|||||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
// Define attachments and replies_to entities
|
// Define attachments and replies_to entities
|
||||||
let attachments = cx.new(|_| vec![]);
|
let attachments = cx.new(|_| vec![]);
|
||||||
|
let encrypted_attachments = cx.new(|_| vec![]);
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
|
||||||
@@ -185,6 +198,8 @@ impl ChatPanel {
|
|||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
attachments,
|
attachments,
|
||||||
|
encrypted_attachments,
|
||||||
|
decrypted_files: HashMap::new(),
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
reports_by_id,
|
reports_by_id,
|
||||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -370,21 +385,32 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Get the message which includes all attachments
|
// Get the message which includes all plain attachments
|
||||||
let content = self.get_input_value(cx);
|
let content = self.get_input_value(cx);
|
||||||
|
|
||||||
// Get the replies to this message
|
// Get the replies to this message
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Return if message is empty
|
// Uploaded files are sent as encrypted file messages
|
||||||
if content.trim().is_empty() {
|
let files: Vec<FileAttachment> = self
|
||||||
|
.encrypted_attachments
|
||||||
|
.read(cx)
|
||||||
|
.iter()
|
||||||
|
.map(|pending| pending.file.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Return if there is nothing to send
|
||||||
|
if content.trim().is_empty() && files.is_empty() {
|
||||||
window.push_notification("Cannot send an empty message", cx);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If replying to exactly one message with only a valid emoji,
|
// If replying to exactly one message with only a valid emoji,
|
||||||
// send as a reaction instead of a text message
|
// send as a reaction instead of a text message
|
||||||
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
|
if replies.len() == 1
|
||||||
|
&& EMOJI_RE.is_match(&content)
|
||||||
|
&& self.attachments.read(cx).is_empty()
|
||||||
|
&& files.is_empty()
|
||||||
{
|
{
|
||||||
for reply in &replies {
|
for reply in &replies {
|
||||||
self.send_reaction(&content, reply, window, cx);
|
self.send_reaction(&content, reply, window, cx);
|
||||||
@@ -393,7 +419,15 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send_message(&content, replies, false, window, cx);
|
// Send the text part, including the plain attachment urls
|
||||||
|
if !content.trim().is_empty() {
|
||||||
|
self.send_message(&content, replies.clone(), false, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send every file as its own encrypted file message
|
||||||
|
for file in files {
|
||||||
|
self.send_file(file, replies.clone(), window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_reaction(
|
fn send_reaction(
|
||||||
@@ -426,29 +460,59 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let room = self.room.clone();
|
|
||||||
let content = value.to_string();
|
|
||||||
let sent_ids = self.sent_ids.clone();
|
|
||||||
|
|
||||||
// Upgrade room and create rumor + send task in a single read lock
|
// Upgrade room and create rumor + send task in a single read lock
|
||||||
let Some(room_entity) = room.upgrade() else {
|
let Some(room) = self.room.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create rumor and send task
|
let outcome = room.read_with(cx, |room, cx| {
|
||||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
let rumor = room.rumor(value, replies, reaction, cx)?;
|
||||||
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
let send_task = room.send(rumor.clone(), cx)?;
|
||||||
|
|
||||||
Some((rumor, send_task))
|
Some((rumor, send_task))
|
||||||
}) {
|
});
|
||||||
Some(pair) => pair,
|
|
||||||
None => {
|
match outcome {
|
||||||
window.push_notification("Failed to create message", cx);
|
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
||||||
return;
|
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 id = rumor.id.expect("rumor must have an id");
|
||||||
|
let sent_ids = self.sent_ids.clone();
|
||||||
|
|
||||||
// Insert optimistic message and clear input
|
// Insert optimistic message and clear input
|
||||||
if rumor.kind != Kind::Reaction {
|
if rumor.kind != Kind::Reaction {
|
||||||
@@ -487,6 +551,10 @@ impl ChatPanel {
|
|||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
this.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
self.replies_to.update(cx, |this, cx| {
|
self.replies_to.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -604,7 +672,7 @@ impl ChatPanel {
|
|||||||
let Some(message) = self.message(id) else {
|
let Some(message) = self.message(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let content = message.content.to_string();
|
let content = message.preview().to_string();
|
||||||
let item = ClipboardItem::new_string(content);
|
let item = ClipboardItem::new_string(content);
|
||||||
|
|
||||||
cx.write_to_clipboard(item);
|
cx.write_to_clipboard(item);
|
||||||
@@ -630,6 +698,9 @@ impl ChatPanel {
|
|||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
|
// Encrypt attachments which are not part of a message being written
|
||||||
|
let encrypted = self.input.read(cx).value().trim().is_empty();
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask user for file upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
@@ -639,36 +710,95 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
this.update(cx, |this, cx| {
|
// Selecting no file means the prompt was cancelled
|
||||||
this.set_uploading(true, cx);
|
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.upload_file(server, path, encrypted, window, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut paths = path.await??.context("Not found")?;
|
Ok(())
|
||||||
let path = paths.pop().context("No path")?;
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Upload via blossom client
|
/// Upload a file, encrypted when the attachment is the whole message
|
||||||
match upload(server, path, cx).await {
|
fn upload_file(
|
||||||
Ok(url) => {
|
&mut self,
|
||||||
this.update_in(cx, |this, _window, cx| {
|
server: Url,
|
||||||
this.add_attachment(url, cx);
|
path: PathBuf,
|
||||||
this.set_uploading(false, cx);
|
encrypted: bool,
|
||||||
})?;
|
window: &mut Window,
|
||||||
}
|
cx: &mut Context<Self>,
|
||||||
Err(e) => {
|
) {
|
||||||
this.update_in(cx, |this, window, cx| {
|
self.set_uploading(true, cx);
|
||||||
this.set_uploading(false, cx);
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = if encrypted {
|
||||||
|
upload_encrypted(server.clone(), path.clone(), cx)
|
||||||
|
.await
|
||||||
|
.map(|file| Uploaded::File(file, path.clone()))
|
||||||
|
} else {
|
||||||
|
upload(server.clone(), path.clone(), cx)
|
||||||
|
.await
|
||||||
|
.map(Uploaded::Url)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_uploading(false, cx);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
||||||
|
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
||||||
|
Err(e) if encrypted => {
|
||||||
|
this.report_encrypted_upload_error(server, path, e, window, cx)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
})?;
|
}
|
||||||
}
|
}
|
||||||
}
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report a failed encrypted upload, offering to retry it without encryption
|
||||||
|
fn report_encrypted_upload_error(
|
||||||
|
&mut self,
|
||||||
|
server: Url,
|
||||||
|
path: PathBuf,
|
||||||
|
error: Error,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string())
|
||||||
|
.title("Encrypted upload failed")
|
||||||
|
.action(move |_this, _window, _cx| {
|
||||||
|
let view = view.clone();
|
||||||
|
let server = server.clone();
|
||||||
|
let path = path.clone();
|
||||||
|
|
||||||
|
Button::new("retry-without-encryption")
|
||||||
|
.label("Upload without encryption")
|
||||||
|
.on_click(move |_ev, window, cx| {
|
||||||
|
view.update(cx, |this, cx| {
|
||||||
|
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||||
self.uploading = uploading;
|
self.uploading = uploading;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -690,6 +820,88 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
this.push(PendingFile { file, path });
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
|
||||||
|
this.remove(ix);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt the attachment of a file message for preview
|
||||||
|
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
|
||||||
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = download_and_decrypt_to_file(&file, cx).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(path) => {
|
||||||
|
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt the attachment of a file message and open it with the OS
|
||||||
|
fn open_file(
|
||||||
|
&mut self,
|
||||||
|
id: EventId,
|
||||||
|
file: FileAttachment,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
match self.decrypted_files.get(&id) {
|
||||||
|
Some(DecryptedFile::Ready(path)) => {
|
||||||
|
cx.open_url(&file_url(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(DecryptedFile::Loading) => return,
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = download_and_decrypt_to_file(&file, cx).await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(path) => {
|
||||||
|
cx.open_url(&file_url(&path));
|
||||||
|
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
persons.read(cx).get(public_key, cx)
|
persons.read(cx).get(public_key, cx)
|
||||||
@@ -929,6 +1141,16 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
let file = self.messages.get(ix).and_then(|message| {
|
||||||
|
let file = message.file.clone()?;
|
||||||
|
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
||||||
|
.then_some((message.id, file))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((id, file)) = file {
|
||||||
|
self.load_file(id, file, cx);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(message) = self.messages.get(ix) {
|
if let Some(message) = self.messages.get(ix) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let show_author = self.is_group_start(ix);
|
let show_author = self.is_group_start(ix);
|
||||||
@@ -936,7 +1158,7 @@ impl ChatPanel {
|
|||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
RenderedText::new(&message.content, &message.mentions, &persons, cx)
|
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
@@ -1016,8 +1238,11 @@ impl ChatPanel {
|
|||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.child(rendered_text)
|
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||||
.child(self.render_media(&message.media, cx))
|
.child(self.render_media(&message.media, cx))
|
||||||
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
|
})
|
||||||
.when(has_reactions, |this| {
|
.when(has_reactions, |this| {
|
||||||
this.child(self.render_reactions(&id, cx))
|
this.child(self.render_reactions(&id, cx))
|
||||||
}),
|
}),
|
||||||
@@ -1123,7 +1348,7 @@ impl ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(SharedString::from(&message.content)),
|
.child(message.preview()),
|
||||||
)
|
)
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1427,7 +1652,7 @@ impl ChatPanel {
|
|||||||
.size_16()
|
.size_16()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.object_fit(ObjectFit::ScaleDown),
|
.object_fit(ObjectFit::Cover),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1464,6 +1689,159 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the encrypted file attachment of a message
|
||||||
|
fn render_message_file(
|
||||||
|
&self,
|
||||||
|
id: &EventId,
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let state = self.decrypted_files.get(id);
|
||||||
|
|
||||||
|
if let Some(path) = state
|
||||||
|
.and_then(|state| match state {
|
||||||
|
DecryptedFile::Ready(path) => Some(path),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|_| file.is_image())
|
||||||
|
{
|
||||||
|
return div()
|
||||||
|
.child(
|
||||||
|
img(path.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.h(px(250.))
|
||||||
|
.object_fit(ObjectFit::Cover)
|
||||||
|
.rounded(cx.theme().radius),
|
||||||
|
)
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = match state {
|
||||||
|
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
|
||||||
|
Some(DecryptedFile::Failed(error)) => error.clone(),
|
||||||
|
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
|
||||||
|
None => SharedString::from("Click to decrypt"),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.render_file_chip(id, file, label, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an encrypted file as a chip which decrypts and opens it on click
|
||||||
|
fn render_file_chip(
|
||||||
|
&self,
|
||||||
|
id: &EventId,
|
||||||
|
file: &FileAttachment,
|
||||||
|
label: SharedString,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
h_flex()
|
||||||
|
.id(SharedString::from(format!("file-{id}")))
|
||||||
|
.self_start()
|
||||||
|
.items_start()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_2()
|
||||||
|
.p_2()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_sm()
|
||||||
|
.child(div().line_height(relative(1.2)).child(file.display_name()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.on_click({
|
||||||
|
let file = file.clone();
|
||||||
|
let id = *id;
|
||||||
|
|
||||||
|
cx.listener(move |this, _, window, cx| {
|
||||||
|
this.open_file(id, file.clone(), window, cx);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an uploaded, encrypted file which is not sent yet
|
||||||
|
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
let file = &pending.file;
|
||||||
|
let label = file.display_name();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(file.url.to_string()))
|
||||||
|
.relative()
|
||||||
|
.w_16()
|
||||||
|
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
|
||||||
|
.map(|this| {
|
||||||
|
if file.is_image() {
|
||||||
|
this.child(
|
||||||
|
img(pending.path.clone())
|
||||||
|
.size_16()
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.object_fit(ObjectFit::Cover),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.size_16()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.child("Preview not available"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.absolute()
|
||||||
|
.top_neg_1()
|
||||||
|
.right_neg_1()
|
||||||
|
.size_4()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded_full()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.bg(gpui::green())
|
||||||
|
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
||||||
|
)
|
||||||
|
.on_click({
|
||||||
|
let url = file.url.clone();
|
||||||
|
cx.listener(move |this, _, _, cx| {
|
||||||
|
this.remove_pending_file(&url, cx);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pending_file_list(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
|
for pending in self.encrypted_attachments.read(cx).iter() {
|
||||||
|
items.push(self.render_pending_file(pending, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
if let Some(text) = self.message(id) {
|
if let Some(text) = self.message(id) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -1512,7 +1890,7 @@ impl ChatPanel {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(SharedString::from(&text.content)),
|
.child(text.preview()),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
div()
|
div()
|
||||||
@@ -1640,8 +2018,13 @@ impl Focusable for ChatPanel {
|
|||||||
|
|
||||||
impl Render for ChatPanel {
|
impl Render for ChatPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
|
||||||
|
|
||||||
|
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
||||||
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache(self.id.clone(), 100))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
@@ -1673,10 +2056,8 @@ impl Render for ChatPanel {
|
|||||||
.map(|this| {
|
.map(|this| {
|
||||||
if self.messages.is_empty() {
|
if self.messages.is_empty() {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_end()
|
.justify_end()
|
||||||
.child(self.render_announcement(cx)),
|
.child(self.render_announcement(cx)),
|
||||||
)
|
)
|
||||||
@@ -1701,7 +2082,17 @@ impl Render for ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.children(self.render_attachment_list(window, cx))
|
.children(self.render_attachment_list(window, cx))
|
||||||
|
.children(self.render_pending_file_list(window, cx))
|
||||||
.children(self.render_reply_list(window, cx))
|
.children(self.render_reply_list(window, cx))
|
||||||
|
.when(is_typing && pending_attachments, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.px_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_warning)
|
||||||
|
.child(WARNING),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_end()
|
.items_end()
|
||||||
|
|||||||
+153
-64
@@ -1,17 +1,19 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use chat::Mention;
|
use chat::Mention;
|
||||||
use common::RangeExt;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
|
use regex::Regex;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||||
|
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||||
|
|
||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Code,
|
Code,
|
||||||
@@ -39,25 +41,61 @@ impl RenderedText {
|
|||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[Mention],
|
mentions: &[Mention],
|
||||||
persons: &Entity<PersonRegistry>,
|
persons: &Entity<PersonRegistry>,
|
||||||
|
markdown: bool,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
|
) -> Self {
|
||||||
|
Self::render(content, mentions, markdown, |mention| {
|
||||||
|
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
content: &str,
|
||||||
|
mentions: &[Mention],
|
||||||
|
markdown: bool,
|
||||||
|
resolve_mention: impl Fn(&Mention) -> String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
let mut link_ranges = Vec::new();
|
let mut link_ranges = Vec::new();
|
||||||
let mut link_urls = Vec::new();
|
let mut link_urls = Vec::new();
|
||||||
|
|
||||||
render_plain_text_mut(
|
render_text_mut(
|
||||||
content,
|
content,
|
||||||
mentions,
|
mentions,
|
||||||
&mut text,
|
&mut text,
|
||||||
&mut highlights,
|
&mut highlights,
|
||||||
&mut link_ranges,
|
&mut link_ranges,
|
||||||
&mut link_urls,
|
&mut link_urls,
|
||||||
persons,
|
markdown,
|
||||||
cx,
|
resolve_mention,
|
||||||
);
|
);
|
||||||
|
|
||||||
text.truncate(text.trim_end().len());
|
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||||
|
let trimmed_len = text.trim_end().len();
|
||||||
|
|
||||||
|
// Retain highlights and link ranges that are within the trimmed text.
|
||||||
|
if trimmed_len < text.len() {
|
||||||
|
highlights.retain_mut(|(range, _)| {
|
||||||
|
range.end = range.end.min(trimmed_len);
|
||||||
|
range.start < range.end
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut ix = 0;
|
||||||
|
|
||||||
|
while ix < link_ranges.len() {
|
||||||
|
let range = &mut link_ranges[ix];
|
||||||
|
range.end = range.end.min(trimmed_len);
|
||||||
|
if range.start < range.end {
|
||||||
|
ix += 1;
|
||||||
|
} else {
|
||||||
|
link_ranges.remove(ix);
|
||||||
|
link_urls.remove(ix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.truncate(trimmed_len);
|
||||||
|
}
|
||||||
|
|
||||||
RenderedText {
|
RenderedText {
|
||||||
text: SharedString::from(text),
|
text: SharedString::from(text),
|
||||||
@@ -70,55 +108,71 @@ impl RenderedText {
|
|||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let code_background = cx.theme().elevated_surface_background;
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
let color = cx.theme().text_accent;
|
let color = cx.theme().text_accent;
|
||||||
|
let code_font = if cfg!(target_os = "macos") {
|
||||||
|
"Menlo"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"Consolas"
|
||||||
|
} else {
|
||||||
|
"monospace"
|
||||||
|
};
|
||||||
|
|
||||||
InteractiveText::new(
|
InteractiveText::new(
|
||||||
id,
|
id,
|
||||||
StyledText::new(self.text.clone()).with_default_highlights(
|
StyledText::new(self.text.clone())
|
||||||
&window.text_style(),
|
.with_default_highlights(
|
||||||
self.highlights.iter().map(|(range, highlight)| {
|
&window.text_style(),
|
||||||
(
|
self.highlights.iter().map(|(range, highlight)| {
|
||||||
range.clone(),
|
(
|
||||||
match highlight {
|
range.clone(),
|
||||||
Highlight::Code => HighlightStyle {
|
match highlight {
|
||||||
background_color: Some(code_background),
|
Highlight::Code => HighlightStyle {
|
||||||
..Default::default()
|
background_color: Some(code_background),
|
||||||
},
|
..Default::default()
|
||||||
Highlight::InlineCode(link) => {
|
},
|
||||||
if *link {
|
Highlight::InlineCode(link) => {
|
||||||
HighlightStyle {
|
if *link {
|
||||||
background_color: Some(code_background),
|
HighlightStyle {
|
||||||
underline: Some(UnderlineStyle {
|
background_color: Some(code_background),
|
||||||
thickness: 1.0.into(),
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}
|
||||||
..Default::default()
|
} else {
|
||||||
}
|
HighlightStyle {
|
||||||
} else {
|
background_color: Some(code_background),
|
||||||
HighlightStyle {
|
..Default::default()
|
||||||
background_color: Some(code_background),
|
}
|
||||||
..Default::default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Highlight::Mention => HighlightStyle {
|
||||||
Highlight::Mention => HighlightStyle {
|
color: Some(color),
|
||||||
color: Some(color),
|
underline: Some(UnderlineStyle {
|
||||||
underline: Some(UnderlineStyle {
|
thickness: 1.0.into(),
|
||||||
thickness: 1.0.into(),
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
},
|
||||||
..Default::default()
|
Highlight::Highlight(highlight) => *highlight,
|
||||||
},
|
},
|
||||||
Highlight::Highlight(highlight) => *highlight,
|
)
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
}),
|
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||||
),
|
|(range, highlight)| match highlight {
|
||||||
|
Highlight::Code | Highlight::InlineCode(_) => {
|
||||||
|
Some((range.clone(), code_font.into()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.on_click(self.link_ranges.clone(), {
|
.on_click(self.link_ranges.clone(), {
|
||||||
let link_urls = self.link_urls.clone();
|
let link_urls = self.link_urls.clone();
|
||||||
move |ix, _, cx| {
|
move |ix, _, cx| {
|
||||||
let url = &link_urls[ix];
|
let url = &link_urls[ix];
|
||||||
if url.starts_with("http") {
|
if WEB_URL.is_match(url) {
|
||||||
cx.open_url(url);
|
cx.open_url(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,15 +182,15 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_plain_text_mut(
|
fn render_text_mut(
|
||||||
block: &str,
|
block: &str,
|
||||||
mut mentions: &[Mention],
|
mut mentions: &[Mention],
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
persons: &Entity<PersonRegistry>,
|
markdown: bool,
|
||||||
cx: &App,
|
resolve_mention: impl Fn(&Mention) -> String,
|
||||||
) {
|
) {
|
||||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||||
|
|
||||||
@@ -145,34 +199,58 @@ fn render_plain_text_mut(
|
|||||||
let mut strikethrough_depth = 0;
|
let mut strikethrough_depth = 0;
|
||||||
let mut link_url = None;
|
let mut link_url = None;
|
||||||
let mut list_stack = Vec::new();
|
let mut list_stack = Vec::new();
|
||||||
|
let mut code_block = false;
|
||||||
|
|
||||||
let mut options = Options::all();
|
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||||
|
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||||
|
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||||
|
} else {
|
||||||
|
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||||
|
};
|
||||||
|
|
||||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
for (event, source_range) in events {
|
||||||
let prev_len = text.len();
|
let prev_len = text.len();
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::Text(t) => {
|
Event::Text(t) => {
|
||||||
// Process text with mention replacements
|
if code_block {
|
||||||
|
text.push_str(t.as_ref());
|
||||||
|
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let t_str = t.as_ref();
|
let t_str = t.as_ref();
|
||||||
let mut last_processed = 0;
|
let mut last_processed = 0;
|
||||||
|
|
||||||
while let Some(mention) = mentions.first() {
|
while let Some(mention) = mentions.first() {
|
||||||
if !source_range.contains_inclusive(&mention.range) {
|
if mention.range.start >= source_range.end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate positions within the current text
|
mentions = &mentions[1..];
|
||||||
let mention_start_in_text = mention.range.start - source_range.start;
|
if mention.range.start < source_range.start
|
||||||
let mention_end_in_text = mention.range.end - source_range.start;
|
|| mention.range.end > source_range.end
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(token) = block.get(mention.range.clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mention_start_in_text = last_processed + offset;
|
||||||
|
let mention_end_in_text = mention_start_in_text + token.len();
|
||||||
|
|
||||||
// Add text before this mention
|
// Add text before this mention
|
||||||
if mention_start_in_text > last_processed {
|
if mention_start_in_text > last_processed {
|
||||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||||
process_text_segment(
|
process_text_segment(
|
||||||
before_mention,
|
before_mention,
|
||||||
prev_len + last_processed,
|
|
||||||
bold_depth,
|
bold_depth,
|
||||||
italic_depth,
|
italic_depth,
|
||||||
strikethrough_depth,
|
strikethrough_depth,
|
||||||
@@ -185,9 +263,7 @@ fn render_plain_text_mut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process the mention replacement
|
// Process the mention replacement
|
||||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
let replacement_text = resolve_mention(mention);
|
||||||
let replacement_text = format!("@{}", profile.name());
|
|
||||||
|
|
||||||
let replacement_start = text.len();
|
let replacement_start = text.len();
|
||||||
text.push_str(&replacement_text);
|
text.push_str(&replacement_text);
|
||||||
let replacement_end = text.len();
|
let replacement_end = text.len();
|
||||||
@@ -195,7 +271,6 @@ fn render_plain_text_mut(
|
|||||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||||
|
|
||||||
last_processed = mention_end_in_text;
|
last_processed = mention_end_in_text;
|
||||||
mentions = &mentions[1..];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last mention
|
// Add any remaining text after the last mention
|
||||||
@@ -203,7 +278,6 @@ fn render_plain_text_mut(
|
|||||||
let remaining_text = &t_str[last_processed..];
|
let remaining_text = &t_str[last_processed..];
|
||||||
process_text_segment(
|
process_text_segment(
|
||||||
remaining_text,
|
remaining_text,
|
||||||
prev_len + last_processed,
|
|
||||||
bold_depth,
|
bold_depth,
|
||||||
italic_depth,
|
italic_depth,
|
||||||
strikethrough_depth,
|
strikethrough_depth,
|
||||||
@@ -234,11 +308,14 @@ fn render_plain_text_mut(
|
|||||||
}
|
}
|
||||||
Tag::CodeBlock(_kind) => {
|
Tag::CodeBlock(_kind) => {
|
||||||
new_paragraph(text, &mut list_stack);
|
new_paragraph(text, &mut list_stack);
|
||||||
|
code_block = true;
|
||||||
}
|
}
|
||||||
Tag::Emphasis => italic_depth += 1,
|
Tag::Emphasis => italic_depth += 1,
|
||||||
Tag::Strong => bold_depth += 1,
|
Tag::Strong => bold_depth += 1,
|
||||||
Tag::Strikethrough => strikethrough_depth += 1,
|
Tag::Strikethrough => strikethrough_depth += 1,
|
||||||
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
Tag::Link { dest_url, .. } => {
|
||||||
|
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||||
|
}
|
||||||
Tag::List(number) => {
|
Tag::List(number) => {
|
||||||
list_stack.push((number, false));
|
list_stack.push((number, false));
|
||||||
}
|
}
|
||||||
@@ -264,6 +341,7 @@ fn render_plain_text_mut(
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Event::End(tag) => match tag {
|
Event::End(tag) => match tag {
|
||||||
|
TagEnd::CodeBlock => code_block = false,
|
||||||
TagEnd::Heading(_) => bold_depth -= 1,
|
TagEnd::Heading(_) => bold_depth -= 1,
|
||||||
TagEnd::Emphasis => italic_depth -= 1,
|
TagEnd::Emphasis => italic_depth -= 1,
|
||||||
TagEnd::Strong => bold_depth -= 1,
|
TagEnd::Strong => bold_depth -= 1,
|
||||||
@@ -272,6 +350,11 @@ fn render_plain_text_mut(
|
|||||||
TagEnd::List(_) => drop(list_stack.pop()),
|
TagEnd::List(_) => drop(list_stack.pop()),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||||
|
Event::Rule => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
text.push_str("────────\n");
|
||||||
|
}
|
||||||
Event::HardBreak => text.push('\n'),
|
Event::HardBreak => text.push('\n'),
|
||||||
Event::SoftBreak => text.push('\n'),
|
Event::SoftBreak => text.push('\n'),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -282,7 +365,6 @@ fn render_plain_text_mut(
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn process_text_segment(
|
fn process_text_segment(
|
||||||
segment: &str,
|
segment: &str,
|
||||||
segment_start: usize,
|
|
||||||
bold_depth: i32,
|
bold_depth: i32,
|
||||||
italic_depth: i32,
|
italic_depth: i32,
|
||||||
strikethrough_depth: i32,
|
strikethrough_depth: i32,
|
||||||
@@ -307,7 +389,8 @@ fn process_text_segment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the text
|
// Ranges always refer to the rendered text, including replaced mentions.
|
||||||
|
let segment_start = text.len();
|
||||||
text.push_str(segment);
|
text.push_str(segment);
|
||||||
let text_end = text.len();
|
let text_end = text.len();
|
||||||
|
|
||||||
@@ -330,7 +413,10 @@ fn process_text_segment(
|
|||||||
finder.kinds(&[linkify::LinkKind::Url]);
|
finder.kinds(&[linkify::LinkKind::Url]);
|
||||||
let mut last_link_pos = 0;
|
let mut last_link_pos = 0;
|
||||||
|
|
||||||
for link in finder.links(segment) {
|
for link in finder
|
||||||
|
.links(segment)
|
||||||
|
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||||
|
{
|
||||||
let start = link.start();
|
let start = link.start();
|
||||||
let end = link.end();
|
let end = link.end();
|
||||||
|
|
||||||
@@ -375,6 +461,7 @@ fn process_text_segment(
|
|||||||
|
|
||||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||||
let mut is_subsequent_paragraph_of_list = false;
|
let mut is_subsequent_paragraph_of_list = false;
|
||||||
|
|
||||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||||
if *has_content {
|
if *has_content {
|
||||||
is_subsequent_paragraph_of_list = true;
|
is_subsequent_paragraph_of_list = true;
|
||||||
@@ -390,9 +477,11 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
|||||||
}
|
}
|
||||||
text.push('\n');
|
text.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||||
text.push_str(" ");
|
text.push_str(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_subsequent_paragraph_of_list {
|
if is_subsequent_paragraph_of_list {
|
||||||
text.push_str(" ");
|
text.push_str(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::mem::take;
|
|
||||||
|
|
||||||
use futures::FutureExt;
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
|
||||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
|
||||||
CoopImageCacheProvider {
|
|
||||||
id: id.into(),
|
|
||||||
max_items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCacheProvider {
|
|
||||||
id: ElementId,
|
|
||||||
max_items: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
|
||||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
|
||||||
window
|
|
||||||
.with_global_id(self.id.clone(), |id, window| {
|
|
||||||
window.with_element_state(id, |cache, _| {
|
|
||||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
|
||||||
(cache.clone(), cache)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCache {
|
|
||||||
max_items: usize,
|
|
||||||
usage_list: VecDeque<u64>,
|
|
||||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CoopImageCache {
|
|
||||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| {
|
|
||||||
log::info!("Creating CoopImageCache");
|
|
||||||
cx.on_release(|this: &mut Self, cx| {
|
|
||||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
|
||||||
if let Some(Ok(image)) = image.get() {
|
|
||||||
log::info!("Dropping image {ix}");
|
|
||||||
cx.drop_image(image, None);
|
|
||||||
}
|
|
||||||
ImageSource::Resource(resource).remove_asset(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
CoopImageCache {
|
|
||||||
max_items,
|
|
||||||
usage_list: VecDeque::with_capacity(max_items),
|
|
||||||
cache: HashMap::with_capacity(max_items),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCache for CoopImageCache {
|
|
||||||
fn load(
|
|
||||||
&mut self,
|
|
||||||
resource: &Resource,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::App,
|
|
||||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
|
||||||
let hash = hash(resource);
|
|
||||||
|
|
||||||
if let Some(item) = self.cache.get_mut(&hash) {
|
|
||||||
let current_idx = self
|
|
||||||
.usage_list
|
|
||||||
.iter()
|
|
||||||
.position(|item| *item == hash)
|
|
||||||
.expect("cache has an item usage_list doesn't");
|
|
||||||
|
|
||||||
self.usage_list.remove(current_idx);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
return item.0.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
|
||||||
let task = cx.background_executor().spawn(load_future).shared();
|
|
||||||
|
|
||||||
if self.usage_list.len() >= self.max_items {
|
|
||||||
log::info!("Image cache is full, evicting oldest item");
|
|
||||||
|
|
||||||
if let Some(oldest) = self.usage_list.pop_back() {
|
|
||||||
let mut image = self
|
|
||||||
.cache
|
|
||||||
.remove(&oldest)
|
|
||||||
.expect("usage_list has an item cache doesn't");
|
|
||||||
|
|
||||||
if let Some(Ok(image)) = image.0.get() {
|
|
||||||
log::info!("requesting image to be dropped");
|
|
||||||
cx.drop_image(image, Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageSource::Resource(image.1).remove_asset(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache.insert(
|
|
||||||
hash,
|
|
||||||
(
|
|
||||||
gpui::ImageCacheItem::Loading(task.clone()),
|
|
||||||
resource.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
let entity = window.current_view();
|
|
||||||
|
|
||||||
window
|
|
||||||
.spawn(cx, async move |cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
log::error!("error loading image into cache: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.on_next_frame(move |_, cx| {
|
|
||||||
cx.notify(entity);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub use caching::*;
|
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
@@ -7,7 +6,6 @@ pub use parser::*;
|
|||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
pub use range::*;
|
pub use range::*;
|
||||||
|
|
||||||
mod caching;
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
|||||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
|
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||||
|
|
||||||
macro_rules! setting_accessors {
|
macro_rules! setting_accessors {
|
||||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
@@ -138,7 +141,7 @@ impl Default for Settings {
|
|||||||
screening: true,
|
screening: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
trusted_relays: vec![],
|
trusted_relays: vec![],
|
||||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +220,12 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let settings = task.await.unwrap_or(Settings::default());
|
let mut settings = task.await.unwrap_or(Settings::default());
|
||||||
|
|
||||||
|
// Move settings still pointed at the old default file server over to the new one
|
||||||
|
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||||
|
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// Update settings
|
// Update settings
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
|
||||||
|
aes-gcm.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
|
|||||||
+32
-10
@@ -4,30 +4,52 @@ use anyhow::{Error, anyhow};
|
|||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
use crate::file::sha256_hex;
|
||||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
|
||||||
let data = smol::fs::read(path).await?;
|
|
||||||
let keys = Keys::generate();
|
|
||||||
|
|
||||||
// Construct the blossom client
|
/// Upload a blob to a blossom server and return its URL
|
||||||
let client = BlossomClient::new(server);
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub(crate) async fn upload_blob(
|
||||||
|
server: &Url,
|
||||||
|
data: Vec<u8>,
|
||||||
|
content_type: &str,
|
||||||
|
sha256: &str,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<Url, Error> {
|
||||||
|
let client = BlossomClient::new(server.clone());
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let content_type = content_type.to_string();
|
||||||
|
let base = server.clone();
|
||||||
|
let hash = sha256.to_string();
|
||||||
|
|
||||||
Tokio::spawn(cx, async move {
|
Tokio::spawn(cx, async move {
|
||||||
let blob = client
|
match client
|
||||||
.upload_blob(data, Some(content_type), None, Some(&keys))
|
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
Ok(blob.url)
|
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
|
.await
|
||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
|
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||||
|
let data = smol::fs::read(&path).await?;
|
||||||
|
let sha256 = sha256_hex(&data);
|
||||||
|
|
||||||
|
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
Err(anyhow!("File upload not supported on web"))
|
Err(anyhow!("File upload not supported on web"))
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
|||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
/// Default image cache size
|
|
||||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use aes_gcm::aead::consts::{U12, U16, U32};
|
||||||
|
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||||
|
use aes_gcm::aes::Aes256;
|
||||||
|
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
||||||
|
use anyhow::{Error, anyhow, bail};
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use futures::AsyncReadExt;
|
||||||
|
use gpui::http_client::AsyncBody;
|
||||||
|
use gpui::{AsyncApp, SharedString};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use mime_guess::from_path;
|
||||||
|
use nostr::nips::nip94::Sha256Hash;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub const ALGORITHM: &str = "aes-gcm";
|
||||||
|
|
||||||
|
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
const TAG_SHA256: &str = "x";
|
||||||
|
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||||
|
const TAG_FILE_TYPE: &str = "file-type";
|
||||||
|
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||||
|
const TAG_KEY: &str = "decryption-key";
|
||||||
|
const TAG_NONCE: &str = "decryption-nonce";
|
||||||
|
const TAG_SIZE: &str = "size";
|
||||||
|
const TAG_DIM: &str = "dim";
|
||||||
|
const TAG_ALT: &str = "alt";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EncryptedFile {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub key: String,
|
||||||
|
pub nonce: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FileAttachment {
|
||||||
|
pub url: Url,
|
||||||
|
pub mime: String,
|
||||||
|
pub key: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
pub original_sha256: Option<String>,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub dim: Option<(u32, u32)>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileAttachment {
|
||||||
|
pub fn tags(&self) -> Vec<Tag> {
|
||||||
|
let mut tags = vec![
|
||||||
|
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
||||||
|
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
||||||
|
Tag::custom(TAG_KEY, [self.key.clone()]),
|
||||||
|
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(sha256) = &self.sha256 {
|
||||||
|
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(original_sha256) = &self.original_sha256 {
|
||||||
|
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(size) = self.size {
|
||||||
|
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((width, height)) = self.dim {
|
||||||
|
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
||||||
|
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
url: Url::parse(content).ok()?,
|
||||||
|
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
||||||
|
key: tag_value(tags, TAG_KEY)?.to_string(),
|
||||||
|
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
||||||
|
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
||||||
|
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
||||||
|
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
||||||
|
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
||||||
|
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_image(&self) -> bool {
|
||||||
|
self.mime.starts_with("image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> SharedString {
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
return name.clone().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.size {
|
||||||
|
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
||||||
|
None => self.mime.clone().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||||
|
let key = Aes256Gcm::generate_key(OsRng);
|
||||||
|
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||||
|
let cipher = AesGcm::<Aes256, U16>::new(&key);
|
||||||
|
|
||||||
|
let data = cipher
|
||||||
|
.encrypt(&nonce, data)
|
||||||
|
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
||||||
|
|
||||||
|
Ok(EncryptedFile {
|
||||||
|
data,
|
||||||
|
key: HEXLOWER.encode(key.as_slice()),
|
||||||
|
nonce: HEXLOWER.encode(nonce.as_slice()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||||
|
let key = decode(key, "decryption key")?;
|
||||||
|
let nonce = decode(nonce, "decryption nonce")?;
|
||||||
|
|
||||||
|
if key.len() != 32 {
|
||||||
|
bail!(
|
||||||
|
"Invalid decryption key length: expected 32 bytes, got {}",
|
||||||
|
key.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match nonce.len() {
|
||||||
|
12 => Aes256Gcm::new_from_slice(&key)
|
||||||
|
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||||
|
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
||||||
|
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||||
|
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
|
||||||
|
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||||
|
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
|
||||||
|
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||||
|
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
|
||||||
|
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||||
|
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
|
||||||
|
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||||
|
len => bail!("Unsupported decryption nonce length: {len} bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||||
|
|
||||||
|
Sha256Hash::from_byte_array(hash).to_hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn upload_encrypted(
|
||||||
|
server: Url,
|
||||||
|
path: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<FileAttachment, Error> {
|
||||||
|
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned());
|
||||||
|
let data = smol::fs::read(&path).await?;
|
||||||
|
|
||||||
|
let encrypted = encrypt(&data)?;
|
||||||
|
let sha256 = sha256_hex(&encrypted.data);
|
||||||
|
let original_sha256 = sha256_hex(&data);
|
||||||
|
let size = encrypted.data.len() as u64;
|
||||||
|
let base_url = server.to_string();
|
||||||
|
|
||||||
|
let url = crate::blossom::upload_blob(
|
||||||
|
&server,
|
||||||
|
encrypted.data,
|
||||||
|
"application/octet-stream",
|
||||||
|
&sha256,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let message = e.to_string();
|
||||||
|
|
||||||
|
if !message.contains("415") {
|
||||||
|
return anyhow!(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow!(
|
||||||
|
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
|
||||||
|
opaque data, which this file server does not accept. Choose a different file
|
||||||
|
server in the settings."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(FileAttachment {
|
||||||
|
url,
|
||||||
|
mime,
|
||||||
|
key: encrypted.key,
|
||||||
|
nonce: encrypted.nonce,
|
||||||
|
sha256: Some(sha256),
|
||||||
|
original_sha256: Some(original_sha256),
|
||||||
|
size: Some(size),
|
||||||
|
dim: None,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn upload_encrypted(
|
||||||
|
_server: Url,
|
||||||
|
_path: PathBuf,
|
||||||
|
_cx: &AsyncApp,
|
||||||
|
) -> Result<FileAttachment, Error> {
|
||||||
|
Err(anyhow!("File upload not supported on web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_and_decrypt(
|
||||||
|
url: &Url,
|
||||||
|
key: &str,
|
||||||
|
nonce: &str,
|
||||||
|
expected_sha256: Option<&str>,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<Vec<u8>, Error> {
|
||||||
|
let client = cx.update(|app| app.http_client());
|
||||||
|
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
bail!("Failed to download file: HTTP {}", response.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
response
|
||||||
|
.into_body()
|
||||||
|
.take(MAX_FILE_SIZE as u64 + 1)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if data.len() > MAX_FILE_SIZE {
|
||||||
|
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected) = expected_sha256
|
||||||
|
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||||
|
{
|
||||||
|
bail!("File hash mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(&data, key, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt a file attachment into a temporary file.
|
||||||
|
///
|
||||||
|
/// The same attachment always maps to the same path, so callers can render the
|
||||||
|
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn download_and_decrypt_to_file(
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<PathBuf, Error> {
|
||||||
|
let name = file
|
||||||
|
.sha256
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||||
|
|
||||||
|
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||||
|
.and_then(|extensions| extensions.first())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("bin");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join("coop-files")
|
||||||
|
.join(format!("{name}.{extension}"));
|
||||||
|
|
||||||
|
if smol::fs::metadata(&path).await.is_ok() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = download_and_decrypt(
|
||||||
|
&file.url,
|
||||||
|
&file.key,
|
||||||
|
&file.nonce,
|
||||||
|
file.sha256.as_deref(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(parent) = path.parent() else {
|
||||||
|
bail!("Invalid file path");
|
||||||
|
};
|
||||||
|
smol::fs::create_dir_all(parent).await?;
|
||||||
|
|
||||||
|
// Write under a temporary name first, so an interrupted download is never reused
|
||||||
|
let partial = path.with_extension("download");
|
||||||
|
smol::fs::write(&partial, data).await?;
|
||||||
|
smol::fs::rename(&partial, &path).await?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn download_and_decrypt_to_file(
|
||||||
|
_file: &FileAttachment,
|
||||||
|
_cx: &AsyncApp,
|
||||||
|
) -> Result<PathBuf, Error> {
|
||||||
|
Err(anyhow!("File download not supported on web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
|
tags.iter()
|
||||||
|
.find(|tag| tag.kind() == name)
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||||
|
let (width, height) = value.split_once('x')?;
|
||||||
|
|
||||||
|
Some((width.parse().ok()?, height.parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
|
||||||
|
HEXLOWER
|
||||||
|
.decode(value.to_ascii_lowercase().as_bytes())
|
||||||
|
.map_err(|_| anyhow!("Invalid {label} encoding"))
|
||||||
|
}
|
||||||
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
|
|||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
mod file;
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod nip4e;
|
mod nip4e;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
pub use blossom::*;
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
|
pub use file::*;
|
||||||
pub use nip4e::*;
|
pub use nip4e::*;
|
||||||
pub use nip05::*;
|
pub use nip05::*;
|
||||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub enum IconName {
|
|||||||
InboxFill,
|
InboxFill,
|
||||||
Link,
|
Link,
|
||||||
Loader,
|
Loader,
|
||||||
|
Lock,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
|
|||||||
Self::InboxFill => "icons/inbox-fill.svg",
|
Self::InboxFill => "icons/inbox-fill.svg",
|
||||||
Self::Link => "icons/link.svg",
|
Self::Link => "icons/link.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
|
Self::Lock => "icons/lock.svg",
|
||||||
Self::Moon => "icons/moon.svg",
|
Self::Moon => "icons/moon.svg",
|
||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
Self::PlusCircle => "icons/plus-circle.svg",
|
Self::PlusCircle => "icons/plus-circle.svg",
|
||||||
|
|||||||
+40
-40
@@ -4,18 +4,18 @@ use ::settings::AppSettings;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::{CoopImageCache, download_dir};
|
use common::download_dir;
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
use person::{PersonRegistry, shorten_pubkey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -64,9 +64,6 @@ pub struct Workspace {
|
|||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// App's Image Cache
|
|
||||||
image_cache: Entity<CoopImageCache>,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
@@ -82,7 +79,6 @@ impl Workspace {
|
|||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -233,7 +229,6 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
sidebar,
|
sidebar,
|
||||||
dock,
|
dock,
|
||||||
image_cache,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -380,14 +375,8 @@ impl Workspace {
|
|||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
Command::Update => {
|
||||||
// No-op on managed distribution channels (Flatpak/Snap) where
|
|
||||||
// the in-app updater is never initialized.
|
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
auto_updater.update(cx, |this, cx| {
|
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +605,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let auto_updater = AutoUpdater::try_global(cx);
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -628,20 +618,35 @@ impl Workspace {
|
|||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
let announcement = profile.announcement();
|
let announcement = profile.announcement();
|
||||||
|
|
||||||
// Update status is only shown when auto-update is available. On
|
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
||||||
// managed distribution channels (Flatpak/Snap) no updater exists, so
|
|
||||||
// nothing is rendered.
|
|
||||||
let updater_status = AutoUpdater::try_global(cx).and_then(|updater| {
|
|
||||||
let updater = updater.read(cx);
|
let updater = updater.read(cx);
|
||||||
(!updater.idle(cx)).then(|| updater.status(cx))
|
(!updater.idle()).then(|| updater.status())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let staged_update = auto_updater
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|updater| updater.read(cx).staged());
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(updater_status, |this, status| {
|
.when_some(updater_status, |this, status| {
|
||||||
this.child(div().text_xs().italic().child(status))
|
this.child(div().text_xs().italic().child(status))
|
||||||
})
|
})
|
||||||
|
.when(staged_update, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("restart-to-update")
|
||||||
|
.label("Restart to Update")
|
||||||
|
.tooltip("Quit and relaunch into the installed update")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(cx.listener(|_this, _event, _window, cx| {
|
||||||
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
|
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(nip4e_enabled, |this| {
|
.when(nip4e_enabled, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
@@ -778,31 +783,26 @@ impl Render for Workspace {
|
|||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
image_cache(self.image_cache.clone())
|
v_flex()
|
||||||
.relative()
|
|
||||||
.size_full()
|
.size_full()
|
||||||
|
// Title Bar
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
TitleBar::new()
|
||||||
|
.child(self.titlebar_left(cx))
|
||||||
|
.child(self.titlebar_right(cx)),
|
||||||
|
)
|
||||||
|
// Main
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
// Title Bar
|
|
||||||
.child(
|
.child(
|
||||||
TitleBar::new()
|
div()
|
||||||
.child(self.titlebar_left(cx))
|
.flex_shrink_0()
|
||||||
.child(self.titlebar_right(cx)),
|
.h_full()
|
||||||
|
.w(SIDEBAR_WIDTH)
|
||||||
|
.child(self.sidebar.clone()),
|
||||||
)
|
)
|
||||||
// Main
|
.child(self.dock.clone()),
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.h_full()
|
|
||||||
.w(SIDEBAR_WIDTH)
|
|
||||||
.child(self.sidebar.clone()),
|
|
||||||
)
|
|
||||||
.child(self.dock.clone()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems,
|
Task, TextAlign, Window, div, rems, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -297,6 +297,7 @@ impl Focusable for ContactListPanel {
|
|||||||
impl Render for ContactListPanel {
|
impl Render for ContactListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("contact-list-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::Error;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Window, div,
|
Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -167,13 +167,15 @@ impl ProfilePanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
// Selecting no file means the prompt was cancelled
|
||||||
|
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_uploading(true, cx);
|
this.set_uploading(true, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut paths = path.await??.context("Not found")?;
|
|
||||||
let path = paths.pop().context("No path")?;
|
|
||||||
|
|
||||||
// Upload via blossom client
|
// Upload via blossom client
|
||||||
match upload(server, path, cx).await {
|
match upload(server, path, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
@@ -319,6 +321,7 @@ impl Render for ProfilePanel {
|
|||||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("profile-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
use common::{DebouncedDelay, TimestampExt};
|
||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||||
Window, div, uniform_list,
|
Window, div, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
@@ -521,7 +521,7 @@ impl Render for Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
+63
-51
@@ -59,66 +59,78 @@ echo "Updating versions..."
|
|||||||
update_version "$WORKSPACE_CARGO"
|
update_version "$WORKSPACE_CARGO"
|
||||||
update_version "$CRATE_CARGO"
|
update_version "$CRATE_CARGO"
|
||||||
|
|
||||||
# Check git status before committing
|
|
||||||
echo "Checking git status..."
|
|
||||||
if git status --porcelain | grep -q .; then
|
|
||||||
echo "Current uncommitted changes:"
|
|
||||||
git status --short
|
|
||||||
|
|
||||||
# Ask user if they want to commit all changes or just version files
|
|
||||||
echo ""
|
|
||||||
echo "Do you want to:"
|
|
||||||
echo "1) Commit all current changes (including the version updates)"
|
|
||||||
echo "2) Commit only the version file changes"
|
|
||||||
echo "3) Abort the release"
|
|
||||||
read -p "Enter choice (1/2/3): " choice
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1)
|
|
||||||
echo "Committing all changes..."
|
|
||||||
git add .
|
|
||||||
;;
|
|
||||||
2)
|
|
||||||
echo "Committing only version file changes..."
|
|
||||||
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
|
||||||
;;
|
|
||||||
3)
|
|
||||||
echo "Release aborted by user"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Invalid choice. Release aborted."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# Only version files were modified, add them specifically
|
|
||||||
echo "Only version files were modified, adding them for commit..."
|
|
||||||
git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit the changes
|
|
||||||
COMMIT_MSG="chore: release version $NEW_VERSION"
|
COMMIT_MSG="chore: release version $NEW_VERSION"
|
||||||
|
|
||||||
if git commit -m "$COMMIT_MSG"; then
|
# When the requested version is already set there is nothing to bump or commit,
|
||||||
echo "✓ Committed version changes"
|
# 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"
|
||||||
else
|
else
|
||||||
echo "Error: Failed to commit version changes"
|
# Check git status before committing
|
||||||
exit 1
|
echo "Checking git status..."
|
||||||
fi
|
# 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
|
||||||
|
|
||||||
# Push version changes to origin
|
# Ask user if they want to commit all changes or just version files
|
||||||
echo "Pushing version changes to origin..."
|
echo ""
|
||||||
if git push origin master; then
|
echo "Do you want to:"
|
||||||
echo "✓ Successfully pushed version changes to origin"
|
echo "1) Commit all current changes (including the version updates)"
|
||||||
else
|
echo "2) Commit only the version file changes"
|
||||||
echo "Error: Failed to push version changes to origin"
|
echo "3) Abort the release"
|
||||||
exit 1
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create git tag
|
# Create git tag
|
||||||
TAG_NAME="v$NEW_VERSION"
|
TAG_NAME="v$NEW_VERSION"
|
||||||
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||||
|
echo "Error: tag $TAG_NAME already exists"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
||||||
echo "✓ Created git tag: $TAG_NAME"
|
echo "✓ Created git tag: $TAG_NAME"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user