Compare commits
13
Commits
3212951ed4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d548e5c6 | ||
|
|
690dc37fc5 | ||
|
|
2f2661ecd2 | ||
|
|
d045a7ef3a | ||
|
|
23d7017f25 | ||
|
|
af2a276b1b | ||
|
|
69ca199a7d | ||
|
|
83a440d893 | ||
|
|
77b2a5f3cd | ||
|
|
b0121f8df2 | ||
|
|
bfa34959cf | ||
|
|
4d0e2f5050 | ||
|
|
0824b1825b |
@@ -20,9 +20,6 @@ jobs:
|
|||||||
- platform: windows-arm64
|
- platform: windows-arm64
|
||||||
os: windows-11-arm
|
os: windows-11-arm
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
- platform: macos-x64
|
|
||||||
os: macos-15-intel
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- platform: macos-arm64
|
- platform: macos-arm64
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
|
|||||||
@@ -30,3 +30,26 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --verbose
|
run: cargo test --verbose
|
||||||
|
|
||||||
|
check-wasm:
|
||||||
|
name: Check web target
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust nightly
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: nightly-2026-10-31
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
components: llvm-tools-preview
|
||||||
|
|
||||||
|
- name: Install wasm C toolchain
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y clang llvm
|
||||||
|
|
||||||
|
- name: Check web target
|
||||||
|
env:
|
||||||
|
CC_wasm32_unknown_unknown: clang
|
||||||
|
AR_wasm32_unknown_unknown: llvm-ar
|
||||||
|
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ dist/
|
|||||||
vendor/
|
vendor/
|
||||||
wasm/
|
wasm/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
|||||||
Generated
+1545
-265
File diff suppressed because it is too large
Load Diff
+17
-15
@@ -4,27 +4,26 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.0.0-alpha2"
|
version = "2.0.0-alpha3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
gpui = { package = "gpui-pre", version = "0.3.8" }
|
gpui = { package = "gpui-pre", version = "0.3.10" }
|
||||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
|
||||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
|
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
|
||||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
|
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
|
||||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
|
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
|
||||||
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
|
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
|
||||||
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
|
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
|
||||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
|
||||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||||
gpui-base = "0.7.1"
|
|
||||||
gpui-kit = "0.7.3"
|
|
||||||
|
|
||||||
# Native OS theme reading; the connector is written against gpui-component 0.7.x
|
gpui-base = "0.8.1"
|
||||||
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
|
gpui-kit = "0.8.1"
|
||||||
native-theme = { version = "0.6.1", features = ["watch"] }
|
|
||||||
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
native-theme = { version = "0.7", features = ["native", "watch"] }
|
||||||
|
native-theme-gpui = { version = "0.7", default-features = false, features = ["system-fonts"] }
|
||||||
|
|
||||||
# Nostr
|
# Nostr
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
@@ -32,7 +31,9 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-browser-signer = { 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-sqlite = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
||||||
|
|
||||||
# Crypto (NIP-17 encrypted file messages)
|
# Crypto (NIP-17 encrypted file messages)
|
||||||
@@ -49,6 +50,7 @@ rand = { version = "0.10", default-features = false, features = [ "std", "sys_rn
|
|||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
|
tts = { git = "https://github.com/ndarilek/tts-rs" }
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
itertools = "0.13.0"
|
itertools = "0.13.0"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12.2126 10.7642L7.07919 15.8829C6.51216 16.4483 6.51216 17.365 7.07919 17.9304C7.64621 18.4958 8.56554 18.4958 9.13257 17.9304L19.3995 7.69303C19.9664 7.12769 20.25 6.38673 20.25 5.64575C20.25 4.90465 19.9665 4.16353 19.3995 3.59809C18.8324 3.03271 18.0893 2.75001 17.3461 2.75C16.6029 2.74999 15.8597 3.03269 15.2927 3.59809L5.02581 13.8354C4.17522 14.6836 3.74995 15.7952 3.75 16.9068C3.75005 18.0183 4.17532 19.1298 5.02581 19.9779C5.87635 20.826 6.99113 21.25 8.10591 21.25C9.22067 21.25 10.3354 20.8259 11.1859 19.9779L16.3194 14.8592" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 726 B |
@@ -9,3 +9,8 @@ gpui.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
gpui-kit.workspace = true
|
gpui-kit.workspace = true
|
||||||
|
|
||||||
|
# On wasm there is no filesystem to read from, so rust-embed's debug-mode
|
||||||
|
# disk loading returns nothing; embed the assets even in debug builds.
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
rust-embed = { workspace = true, features = ["debug-embed"] }
|
||||||
|
|||||||
@@ -20,12 +20,39 @@ impl AssetSource for CombinedAssets {
|
|||||||
if let Ok(Some(data)) = Assets.load(path) {
|
if let Ok(Some(data)) = Assets.load(path) {
|
||||||
return Ok(Some(data));
|
return Ok(Some(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// The kit bundle fetches icons over HTTP on the web; a single
|
||||||
|
// instance keeps its download cache alive across loads.
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
|
||||||
|
KIT_ASSETS
|
||||||
|
.get_or_init(gpui_kit::assets::Assets::default)
|
||||||
|
.load(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
gpui_kit::assets::Assets.load(path)
|
gpui_kit::assets::Assets.load(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||||
let mut paths = Assets.list(path).unwrap_or_default();
|
let mut paths = Assets.list(path).unwrap_or_default();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
|
||||||
|
paths.extend(
|
||||||
|
KIT_ASSETS
|
||||||
|
.get_or_init(gpui_kit::assets::Assets::default)
|
||||||
|
.list(path)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
paths.extend(gpui_kit::assets::Assets.list(path)?);
|
paths.extend(gpui_kit::assets::Assets.list(path)?);
|
||||||
|
|
||||||
Ok(paths)
|
Ok(paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
use gpui::AssetSource;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn icons_resolve_from_the_vendored_folder() {
|
|
||||||
// Icons coop's code and the kit components draw, served by the vendored
|
|
||||||
// folder (signed's set, filled with the kit bundle where coop needs more).
|
|
||||||
let vendored = [
|
|
||||||
"icons/search.svg",
|
|
||||||
"icons/close.svg",
|
|
||||||
"icons/panel-left.svg",
|
|
||||||
"icons/window-minimize.svg",
|
|
||||||
"icons/settings-2.svg",
|
|
||||||
"icons/x.svg",
|
|
||||||
"icons/compass.svg",
|
|
||||||
"icons/radio-tower.svg",
|
|
||||||
];
|
|
||||||
for path in vendored {
|
|
||||||
let own = assets::Assets
|
|
||||||
.load(path)
|
|
||||||
.unwrap_or_else(|e| panic!("{path} errored in own embed: {e}"));
|
|
||||||
assert!(own.is_some(), "own embed is missing {path}");
|
|
||||||
|
|
||||||
let combined = assets::CombinedAssets.load(path).unwrap();
|
|
||||||
assert!(combined.is_some(), "CombinedAssets fails to serve {path}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coop's own files still load through the combined source.
|
|
||||||
let own = [
|
|
||||||
"brand/coop.svg",
|
|
||||||
"brand/bg1.jpg",
|
|
||||||
"fonts/Inter/Inter-Medium.ttf",
|
|
||||||
];
|
|
||||||
for path in own {
|
|
||||||
assert!(
|
|
||||||
matches!(assets::CombinedAssets.load(path), Ok(Some(_))),
|
|
||||||
"CombinedAssets fails to serve coop asset {path}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
gpui-updater-pre = "0.2"
|
gpui-updater-pre = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let arch = match (os, arch) {
|
let arch = match (os, arch) {
|
||||||
// cargo-packager names the disk images `aarch64`/`x64`.
|
// cargo-packager names the disk images `aarch64`.
|
||||||
("macos", "aarch64") => "aarch64",
|
("macos", "aarch64") => "aarch64",
|
||||||
("macos", "x86_64") => "x64",
|
|
||||||
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
("linux", "aarch64") => "aarch64",
|
("linux", "aarch64") => "aarch64",
|
||||||
("linux", "x86_64") => "x86_64",
|
("linux", "x86_64") => "x86_64",
|
||||||
@@ -243,7 +242,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn picks_the_artifact_matching_os_and_architecture() {
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
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("macos", "x86_64"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
selected("linux", "aarch64"),
|
selected("linux", "aarch64"),
|
||||||
Some("coop-linux-aarch64.tar.gz")
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
@@ -258,6 +257,7 @@ mod tests {
|
|||||||
fn has_no_target_for_windows_or_unknown_platforms() {
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "x86_64"), None);
|
||||||
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+180
-66
@@ -11,12 +11,12 @@ use gpui::{
|
|||||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||||
WeakEntity,
|
WeakEntity,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::{Duration, Instant};
|
||||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
mod room;
|
mod room;
|
||||||
@@ -29,7 +29,10 @@ pub use state::FileAttachment;
|
|||||||
|
|
||||||
use crate::search::MessageSearchIndex;
|
use crate::search::MessageSearchIndex;
|
||||||
|
|
||||||
/// A static keypair used only for signing locally-cached rumor events.
|
/// Upper bound on signals applied to the UI in a single update
|
||||||
|
const MAX_SIGNAL_BATCH: usize = 256;
|
||||||
|
|
||||||
|
/// Keypair used only for identifying locally-cached rumor events.
|
||||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
@@ -44,6 +47,7 @@ impl Global for GlobalChatRegistry {}
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum ChatEvent {
|
pub enum ChatEvent {
|
||||||
Ping,
|
Ping,
|
||||||
|
Loaded,
|
||||||
InboxRelayNotFound,
|
InboxRelayNotFound,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -74,6 +78,13 @@ impl Signal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which signer to attempt when unwrapping a gift wrap
|
||||||
|
enum UnwrapRoute {
|
||||||
|
User,
|
||||||
|
Device,
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
/// Chat Registry
|
/// Chat Registry
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ChatRegistry {
|
pub struct ChatRegistry {
|
||||||
@@ -216,32 +227,34 @@ impl ChatRegistry {
|
|||||||
let tx = self.signal_tx.clone();
|
let tx = self.signal_tx.clone();
|
||||||
let rx = self.signal_rx.clone();
|
let rx = self.signal_rx.clone();
|
||||||
|
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
|
||||||
self.notification_listener = Some(cx.background_spawn(async move {
|
self.notification_listener = Some(cx.background_spawn(async move {
|
||||||
let mut notifications = client.notifications();
|
|
||||||
let mut processed_events = HashSet::new();
|
|
||||||
const MAX_PROCESSED: usize = 10_000;
|
const MAX_PROCESSED: usize = 10_000;
|
||||||
|
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
let mut user_key = signer.get_public_key_async().await.ok();
|
||||||
|
let device_key = match device_signer.as_ref() {
|
||||||
|
Some(device) => device.get_public_key_async().await.ok(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_yield = Instant::now();
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
|
if last_yield.elapsed() >= Duration::from_millis(8) {
|
||||||
|
executor.timer(Duration::from_millis(0)).await;
|
||||||
|
last_yield = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
let ClientNotification::Message { message, relay_url } = notification else {
|
let ClientNotification::Message { message, relay_url } = notification else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
match *message {
|
match *message {
|
||||||
RelayMessage::Event {
|
RelayMessage::Event { event, .. } => {
|
||||||
subscription_id,
|
|
||||||
event,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
|
|
||||||
let device_sub = subscription_id.as_str() != sub_id2.as_str();
|
|
||||||
|
|
||||||
// Concord wraps are also kind 1059.
|
|
||||||
//
|
|
||||||
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
|
|
||||||
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune the dedup set before it grows unbounded
|
// Prune the dedup set before it grows unbounded
|
||||||
if processed_events.len() >= MAX_PROCESSED {
|
if processed_events.len() >= MAX_PROCESSED {
|
||||||
processed_events.clear();
|
processed_events.clear();
|
||||||
@@ -253,8 +266,10 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
// Handle msg relays event to determine when the app is ready to subscribe
|
// Handle msg relays event to determine when the app is ready to subscribe
|
||||||
if event.kind == Kind::InboxRelays {
|
if event.kind == Kind::InboxRelays {
|
||||||
let current_user = signer.get_public_key_async().await?;
|
if user_key.is_none() {
|
||||||
if event.pubkey == current_user {
|
user_key = signer.get_public_key_async().await.ok();
|
||||||
|
}
|
||||||
|
if Some(event.pubkey) == user_key {
|
||||||
tx.send_async(Signal::InboxReady).await?;
|
tx.send_async(Signal::InboxReady).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,15 +285,39 @@ impl ChatRegistry {
|
|||||||
seen.entry(event.id).or_default().insert(relay_url);
|
seen.entry(event.id).or_default().insert(relay_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route to the signer matching the recipient tag
|
||||||
|
// so a wrap is never decrypted twice.
|
||||||
|
let recipient = event.tags.public_keys().next();
|
||||||
|
let route = match recipient {
|
||||||
|
Some(key) if Some(key) == device_key => UnwrapRoute::Device,
|
||||||
|
Some(key) if Some(key) == user_key => UnwrapRoute::User,
|
||||||
|
Some(_) if user_key.is_some() && device_key.is_some() => continue,
|
||||||
|
_ => UnwrapRoute::Both,
|
||||||
|
};
|
||||||
|
|
||||||
// Extract the rumor from the gift wrap event
|
// Extract the rumor from the gift wrap event
|
||||||
match extract_rumor(
|
let result = match route {
|
||||||
|
UnwrapRoute::User => {
|
||||||
|
extract_rumor(&client, &signer, None, event.as_ref()).await
|
||||||
|
}
|
||||||
|
UnwrapRoute::Device => match device_signer.as_ref() {
|
||||||
|
Some(device) => {
|
||||||
|
extract_rumor(&client, device, None, event.as_ref()).await
|
||||||
|
}
|
||||||
|
None => continue,
|
||||||
|
},
|
||||||
|
UnwrapRoute::Both => {
|
||||||
|
extract_rumor(
|
||||||
&client,
|
&client,
|
||||||
&signer,
|
&signer,
|
||||||
device_signer.as_ref(),
|
device_signer.as_ref(),
|
||||||
event.as_ref(),
|
event.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
Ok(rumor) => {
|
Ok(rumor) => {
|
||||||
let Some(rumor_id) = rumor.id else {
|
let Some(rumor_id) = rumor.id else {
|
||||||
log::error!("Rumor missing id after ensure_id");
|
log::error!("Rumor missing id after ensure_id");
|
||||||
@@ -317,35 +356,57 @@ impl ChatRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let tracking = self.tracking.clone();
|
||||||
|
|
||||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||||
while let Ok(message) = rx.recv_async().await {
|
while let Ok(first) = rx.recv_async().await {
|
||||||
match message {
|
// Batch whatever has already arrived so the UI updates once per burst
|
||||||
Signal::Message(message) => {
|
let mut messages = Vec::new();
|
||||||
this.update(cx, |this, cx| {
|
let mut errors = Vec::new();
|
||||||
this.new_message(message, cx);
|
let mut inbox_ready = false;
|
||||||
})?;
|
let mut eose = false;
|
||||||
|
|
||||||
|
let mut pending = Some(first);
|
||||||
|
while let Some(signal) = pending.take() {
|
||||||
|
match signal {
|
||||||
|
Signal::Message(message) => messages.push(message),
|
||||||
|
Signal::InboxReady => inbox_ready = true,
|
||||||
|
Signal::Eose => eose = true,
|
||||||
|
Signal::Error(failed) => errors.push(failed),
|
||||||
}
|
}
|
||||||
Signal::InboxReady => {
|
|
||||||
|
if messages.len() + errors.len() >= MAX_SIGNAL_BATCH {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = rx.try_recv().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if inbox_ready {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.get_messages(cx);
|
this.get_messages(cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Signal::Eose => {
|
|
||||||
this.update(cx, |this, _cx| {
|
|
||||||
this.tracking.store(false, Ordering::Release);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
|
if !messages.is_empty() {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.get_rooms(cx);
|
this.new_messages(messages, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Signal::Error(failed) => {
|
|
||||||
trash.update(cx, |this, cx| {
|
if !errors.is_empty() {
|
||||||
this.insert(failed);
|
trash.update(cx, |trash, cx| {
|
||||||
|
trash.extend(errors);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if eose && tracking.swap(false, Ordering::AcqRel) {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
cx.emit(ChatEvent::Loaded);
|
||||||
|
this.get_rooms(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -669,6 +730,7 @@ impl ChatRegistry {
|
|||||||
self.rooms.clear();
|
self.rooms.clear();
|
||||||
self.room_index.clear();
|
self.room_index.clear();
|
||||||
self.search.write().unwrap().clear();
|
self.search.write().unwrap().clear();
|
||||||
|
self.tracking.store(true, Ordering::Release);
|
||||||
self.trash.update(cx, |this, cx| {
|
self.trash.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -748,6 +810,7 @@ impl ChatRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let search = self.search.clone();
|
let search = self.search.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
@@ -775,7 +838,15 @@ impl ChatRegistry {
|
|||||||
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();
|
||||||
|
|
||||||
|
// Keep the single-threaded web event loop responsive during large rescans
|
||||||
|
let mut last_yield = Instant::now();
|
||||||
|
|
||||||
for raw in events.into_iter() {
|
for raw in events.into_iter() {
|
||||||
|
if last_yield.elapsed() >= Duration::from_millis(8) {
|
||||||
|
executor.timer(Duration::from_millis(0)).await;
|
||||||
|
last_yield = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||||
&& rumor.tags.public_keys().next().is_some()
|
&& rumor.tags.public_keys().next().is_some()
|
||||||
{
|
{
|
||||||
@@ -811,12 +882,17 @@ impl ChatRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a nostr event into a message and push it to the belonging room
|
/// Parse nostr events into messages and push them to the belonging rooms
|
||||||
///
|
///
|
||||||
/// - If the room doesn't exist, it will be created.
|
/// - If a room doesn't exist, it will be created.
|
||||||
/// - Updates room ordering based on the most recent messages.
|
/// - Room ordering is updated once after the whole batch.
|
||||||
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
pub(crate) fn new_messages(&mut self, messages: Vec<NewMessage>, cx: &mut Context<Self>) {
|
||||||
self.search.write().unwrap().insert(&message.rumor);
|
{
|
||||||
|
let mut search = self.search.write().unwrap();
|
||||||
|
for message in &messages {
|
||||||
|
search.insert(&message.rumor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
@@ -824,17 +900,18 @@ impl ChatRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
match self.room_index.get(&message.room).cloned() {
|
match self.room_index.get(&message.room).cloned() {
|
||||||
Some(room) => {
|
Some(room) => {
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
if this.kind == RoomKind::Request
|
if this.kind == RoomKind::Request
|
||||||
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
|
&& (message.rumor.pubkey == public_key
|
||||||
|
|| this.auto_trusted(public_key, cx))
|
||||||
{
|
{
|
||||||
this.set_ongoing(cx);
|
this.set_ongoing(cx);
|
||||||
}
|
}
|
||||||
this.push_message(message, cx);
|
this.push_message(message, cx);
|
||||||
});
|
});
|
||||||
self.sort(cx);
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Push the new room to the front of the list
|
// Push the new room to the front of the list
|
||||||
@@ -842,19 +919,23 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.sort(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
|
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
|
self.new_messages(vec![message], cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unwraps a gift-wrapped event and processes its contents.
|
/// Unwraps a gift-wrapped event and processes its contents.
|
||||||
async fn extract_rumor<S, D>(
|
async fn extract_rumor(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
signer: &S,
|
signer: &UniversalSigner,
|
||||||
device_signer: Option<&D>,
|
device_signer: Option<&UniversalSigner>,
|
||||||
gift_wrap: &Event,
|
gift_wrap: &Event,
|
||||||
) -> Result<UnsignedEvent, Error>
|
) -> Result<UnsignedEvent, Error> {
|
||||||
where
|
|
||||||
S: AsyncNip44 + ?Sized,
|
|
||||||
D: AsyncNip44 + ?Sized,
|
|
||||||
{
|
|
||||||
// Try to get cached rumor first
|
// Try to get cached rumor first
|
||||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||||
return Ok(rumor);
|
return Ok(rumor);
|
||||||
@@ -866,7 +947,11 @@ where
|
|||||||
let Some(device_signer) = device_signer else {
|
let Some(device_signer) = device_signer else {
|
||||||
return Err(error);
|
return Err(error);
|
||||||
};
|
};
|
||||||
try_unwrap_with(device_signer, gift_wrap).await?
|
match try_unwrap_with(device_signer, gift_wrap).await {
|
||||||
|
Ok(unwrapped) => unwrapped,
|
||||||
|
// The wrap may be sealed to a key retired by a rotation
|
||||||
|
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut rumor = unwrapped.rumor;
|
let mut rumor = unwrapped.rumor;
|
||||||
@@ -888,10 +973,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||||
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
|
async fn try_unwrap_with(
|
||||||
where
|
signer: &UniversalSigner,
|
||||||
S: AsyncNip44 + ?Sized,
|
gift_wrap: &Event,
|
||||||
{
|
) -> Result<UnwrappedGift, Error> {
|
||||||
// Get the sealed event
|
// Get the sealed event
|
||||||
let seal = signer
|
let seal = signer
|
||||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||||
@@ -922,21 +1007,50 @@ where
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unwrap with an encryption key retired by a rotation; peers may still seal
|
||||||
|
/// to the previous key until their announcement cache refreshes
|
||||||
|
async fn try_unwrap_archived(
|
||||||
|
client: &Client,
|
||||||
|
signer: &UniversalSigner,
|
||||||
|
gift_wrap: &Event,
|
||||||
|
) -> Result<UnwrappedGift, Error> {
|
||||||
|
for keys in DeviceRegistry::archived_keys(client, signer).await? {
|
||||||
|
let archived = UniversalSigner::new(keys);
|
||||||
|
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
|
||||||
|
return Ok(unwrapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"No archived encryption key could unwrap the gift wrap"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Stores an unwrapped event in local database with reference to original
|
/// Stores an unwrapped event in local database with reference to original
|
||||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||||
let room_id = rumor.uniq_id().to_string();
|
let room_id = rumor.uniq_id().to_string();
|
||||||
|
|
||||||
let tags = vec![
|
let tags = Tags::from_list(vec![
|
||||||
Tag::identifier(id),
|
Tag::identifier(id),
|
||||||
Tag::public_key(rumor.pubkey),
|
Tag::public_key(rumor.pubkey),
|
||||||
Tag::custom("r", [room_id]),
|
Tag::custom("r", [room_id]),
|
||||||
Tag::custom("k", [rumor.kind.to_string()]),
|
Tag::custom("k", [rumor.kind.to_string()]),
|
||||||
];
|
]);
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
let public_key = LOCAL_KEYS.public_key();
|
||||||
.tags(tags)
|
let created_at = Timestamp::now();
|
||||||
.finalize_async(&*LOCAL_KEYS)
|
let kind = Kind::ApplicationSpecificData;
|
||||||
.await?;
|
let content = rumor.as_json();
|
||||||
|
|
||||||
|
let event = Event::new(
|
||||||
|
EventId::compute(&public_key, &created_at, &kind, &tags, &content),
|
||||||
|
public_key,
|
||||||
|
created_at,
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
Signature::from_byte_array([0u8; 64]),
|
||||||
|
);
|
||||||
|
|
||||||
client.database().save_event(&event).await?;
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 yet. Set one up in the settings to message them.";
|
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
|
||||||
|
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
|
||||||
|
|
||||||
/// The outcome of delivering an encrypted rumor to a single receiver.
|
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -553,6 +554,7 @@ impl Room {
|
|||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
|
|
||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
|
let resyncing = device.read(cx).is_resyncing();
|
||||||
let encryption_signer = device.read(cx).signer(cx);
|
let encryption_signer = device.read(cx).signer(cx);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -574,6 +576,28 @@ impl Room {
|
|||||||
Some(cx.background_spawn(async move {
|
Some(cx.background_spawn(async move {
|
||||||
let signer_kind = config.signer_kind();
|
let signer_kind = config.signer_kind();
|
||||||
|
|
||||||
|
// Block sends while the local encryption key is stale against the
|
||||||
|
// current announcement; the replacement is being synced from
|
||||||
|
// another device
|
||||||
|
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
|
||||||
|
let mut reports: Vec<SendReport> = receivers
|
||||||
|
.into_iter()
|
||||||
|
.map(|receiver| SendReport {
|
||||||
|
receiver: receiver.public_key(),
|
||||||
|
result: Err(KEY_STALE.into()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if config.backup() {
|
||||||
|
reports.push(SendReport {
|
||||||
|
receiver: sender.public_key(),
|
||||||
|
result: Err(KEY_STALE.into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
// Deliver to every receiver concurrently
|
// Deliver to every receiver concurrently
|
||||||
let sends = receivers.into_iter().map(|receiver| {
|
let sends = receivers.into_iter().map(|receiver| {
|
||||||
deliver(
|
deliver(
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
gpui-kit.workspace = true
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gpui::Action;
|
use gpui::{Action, actions};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::SignerKind;
|
use settings::SignerKind;
|
||||||
@@ -15,3 +15,6 @@ pub enum Command {
|
|||||||
Njump(PublicKey),
|
Njump(PublicKey),
|
||||||
Trace(EventId),
|
Trace(EventId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start or stop dictation in the focused composer
|
||||||
|
actions!(chat, [ToggleDictation]);
|
||||||
|
|||||||
+48
-10
@@ -1,32 +1,70 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use anyhow::Error;
|
||||||
|
|
||||||
use chat::FileAttachment;
|
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::{FileAttachment, LocalFile, PickedFile};
|
||||||
|
|
||||||
/// A file attachment that has been uploaded, but not sent yet.
|
/// A file attachment that has been uploaded, but not sent yet.
|
||||||
///
|
///
|
||||||
/// The local `path` is kept around so the composer can preview
|
/// The `local` handle is kept around so the composer can preview
|
||||||
/// the file without downloading and decrypting it again.
|
/// the file without downloading and decrypting it again.
|
||||||
pub(crate) struct PendingFile {
|
pub(crate) struct PendingFile {
|
||||||
pub file: FileAttachment,
|
pub file: FileAttachment,
|
||||||
pub path: PathBuf,
|
pub local: LocalFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of the encrypted file attachment of a message
|
/// State of the encrypted file attachment of a message
|
||||||
pub(crate) enum DecryptedFile {
|
pub(crate) enum DecryptedFile {
|
||||||
Loading,
|
Loading,
|
||||||
Ready(PathBuf),
|
Ready(LocalFile),
|
||||||
Failed(SharedString),
|
Failed(SharedString),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of an upload, either plain or encrypted
|
/// Result of an upload, either plain or encrypted
|
||||||
pub(crate) enum Uploaded {
|
pub(crate) enum Uploaded {
|
||||||
Url(Url),
|
Url(Url),
|
||||||
File(FileAttachment, PathBuf),
|
File(Box<UploadedFile>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
/// An encrypted upload and the handle to preview it locally
|
||||||
pub(crate) fn file_url(path: &Path) -> String {
|
pub(crate) struct UploadedFile {
|
||||||
format!("file://{}", path.display())
|
pub file: FileAttachment,
|
||||||
|
pub local: LocalFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Uploaded {
|
||||||
|
/// Build the upload result for a picked file, encrypted when the
|
||||||
|
/// attachment is the whole message
|
||||||
|
pub(crate) async fn from_pick(
|
||||||
|
encrypted: bool,
|
||||||
|
server: Url,
|
||||||
|
picked: PickedFile,
|
||||||
|
cx: &gpui::AsyncApp,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
if encrypted {
|
||||||
|
let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?;
|
||||||
|
|
||||||
|
Ok(Self::File(Box::new(UploadedFile { file, local })))
|
||||||
|
} else {
|
||||||
|
state::upload_file(server, picked, cx).await.map(Self::Url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pieces of an encrypted upload, split so the local handle survives the
|
||||||
|
/// picked file being consumed by the upload
|
||||||
|
struct UploadedFileParts {
|
||||||
|
file: FileAttachment,
|
||||||
|
local: LocalFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_encrypted(
|
||||||
|
server: Url,
|
||||||
|
picked: PickedFile,
|
||||||
|
cx: &gpui::AsyncApp,
|
||||||
|
) -> Result<UploadedFileParts, Error> {
|
||||||
|
let local = picked.local.clone();
|
||||||
|
|
||||||
|
state::upload_encrypted_file(server, picked, cx)
|
||||||
|
.await
|
||||||
|
.map(|file| UploadedFileParts { file, local })
|
||||||
}
|
}
|
||||||
|
|||||||
+245
-104
@@ -1,6 +1,6 @@
|
|||||||
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};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
@@ -10,10 +10,9 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
Window, div, img, list, px, red, relative, retain_all, svg, white,
|
||||||
retain_all, svg, white,
|
|
||||||
};
|
};
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
@@ -22,6 +21,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, Textar
|
|||||||
use gpui_kit::component::menu::DropdownMenu;
|
use gpui_kit::component::menu::DropdownMenu;
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
use gpui_kit::component::scroll::Scrollbar;
|
||||||
|
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||||
use gpui_kit::component::tooltip::Tooltip;
|
use gpui_kit::component::tooltip::Tooltip;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -30,13 +30,14 @@ use person::{Directory, Person};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use speech::speaker::{END_GRACE, Speaker};
|
||||||
use state::{
|
use state::{
|
||||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
|
||||||
};
|
};
|
||||||
use ui::{
|
use ui::{
|
||||||
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
|
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
|
||||||
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
|
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage,
|
||||||
opens_run, rendered_text,
|
group_reactions, opens_run, rendered_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::file::*;
|
use crate::file::*;
|
||||||
@@ -45,8 +46,7 @@ use crate::file::*;
|
|||||||
const PRIVATE_NOTICE: &str =
|
const PRIVATE_NOTICE: &str =
|
||||||
"This conversation is private. Only members can see each other's messages.";
|
"This conversation is private. Only members can see each other's messages.";
|
||||||
|
|
||||||
/// Regex matching strings that consist entirely of emoji characters,
|
/// Regex matching strings that consist entirely of emoji characters.
|
||||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
|
||||||
static EMOJI_RE: LazyLock<Regex> =
|
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());
|
||||||
|
|
||||||
@@ -65,60 +65,50 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
|
|||||||
pub struct ChatPanel {
|
pub struct ChatPanel {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
/// Chat Room
|
/// Chat Room
|
||||||
room: WeakEntity<Room>,
|
room: WeakEntity<Room>,
|
||||||
|
|
||||||
/// Person directory shared with the room
|
/// Person directory shared with the room
|
||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
|
|
||||||
/// Message list state
|
/// Message list state
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
|
|
||||||
/// All messages (sorted by created_at)
|
/// All messages (sorted by created_at)
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
|
|
||||||
/// O(1) message lookup by EventId
|
/// O(1) message lookup by EventId
|
||||||
message_index: HashMap<EventId, usize>,
|
message_index: HashMap<EventId, usize>,
|
||||||
|
|
||||||
/// All reactions
|
/// All reactions
|
||||||
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
|
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
|
||||||
|
|
||||||
/// Mapping message ids to their rendered texts
|
/// Mapping message ids to their rendered texts
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
|
|
||||||
/// Mapping message (rumor event) ids to their reports
|
/// Mapping message (rumor event) ids to their reports
|
||||||
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
|
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
|
||||||
|
|
||||||
/// Chat input state
|
/// Chat input state
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
|
/// Dictation session for the chat input
|
||||||
|
speech: Entity<SpeechState>,
|
||||||
|
/// When the running dictation started, for the elapsed counter
|
||||||
|
speech_started: Option<instant::Instant>,
|
||||||
|
/// Reads messages aloud
|
||||||
|
speaker: Speaker,
|
||||||
|
/// The message being revealed in step with speech, with its estimated duration
|
||||||
|
speech_reveal: Option<(EventId, Duration)>,
|
||||||
|
/// Fallback reveal end for backends that cannot report utterance end
|
||||||
|
reveal_timeout: Option<Task<Result<(), Error>>>,
|
||||||
/// Subject input state
|
/// Subject input state
|
||||||
subject_input: Entity<InputState>,
|
subject_input: Entity<InputState>,
|
||||||
|
|
||||||
/// Subject bar visibility
|
/// Subject bar visibility
|
||||||
subject_bar: Entity<bool>,
|
subject_bar: Entity<bool>,
|
||||||
|
|
||||||
/// Replies to
|
/// Replies to
|
||||||
replies_to: Entity<HashSet<EventId>>,
|
replies_to: Entity<HashSet<EventId>>,
|
||||||
|
|
||||||
/// Media Attachment
|
/// Media Attachment
|
||||||
attachments: Entity<Vec<Url>>,
|
attachments: Entity<Vec<Url>>,
|
||||||
|
|
||||||
/// Uploaded, encrypted file attachments which are not sent yet
|
/// Uploaded, encrypted file attachments which are not sent yet
|
||||||
encrypted_attachments: Entity<Vec<PendingFile>>,
|
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||||
|
|
||||||
/// Decrypted attachments of file messages, by message id
|
/// Decrypted attachments of file messages, by message id
|
||||||
decrypted_files: HashMap<EventId, DecryptedFile>,
|
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||||
|
|
||||||
/// Upload state
|
/// Upload state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
|
||||||
/// Async operations
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
/// Event subscriptions
|
|
||||||
subscriptions: SmallVec<[Subscription; 3]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatPanel {
|
impl ChatPanel {
|
||||||
@@ -162,6 +152,19 @@ impl ChatPanel {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Define dictation state
|
||||||
|
let speech = cx.new(|cx| {
|
||||||
|
let state = SpeechState::new(cx)
|
||||||
|
.recognizer(speech::recognizer())
|
||||||
|
// Batch transcription of a long recording can take a while.
|
||||||
|
.stop_timeout(Duration::from_secs(180));
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let state = state.input(speech::input());
|
||||||
|
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
// Define subject input state
|
// Define subject input state
|
||||||
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
||||||
let subject_bar = cx.new(|_cx| false);
|
let subject_bar = cx.new(|_cx| false);
|
||||||
@@ -180,15 +183,39 @@ impl ChatPanel {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe the subject input event
|
// Subscribe the subject input event
|
||||||
cx.subscribe_in(
|
cx.subscribe_in(&subject_input, window, move |this, _, event, window, cx| {
|
||||||
&subject_input,
|
|
||||||
window,
|
|
||||||
move |this, _input, event, window, cx| {
|
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.change_subject(window, cx);
|
this.change_subject(window, cx);
|
||||||
};
|
};
|
||||||
},
|
}),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Insert the dictated text into the chat input
|
||||||
|
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
|
||||||
|
match event {
|
||||||
|
SpeechEvent::Started => {
|
||||||
|
this.speech_started = Some(instant::Instant::now());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
SpeechEvent::Final(text) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
this.input
|
||||||
|
.update(cx, |input, cx| input.insert(text.clone(), window, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SpeechEvent::Error(error) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
SpeechEvent::Cancelled => {
|
||||||
|
this.speech_started = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Define all functions that will run after the current cycle
|
// Define all functions that will run after the current cycle
|
||||||
@@ -198,6 +225,20 @@ impl ChatPanel {
|
|||||||
this.get_messages(window, cx);
|
this.get_messages(window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// End the speech reveal when the utterance finishes ahead of its estimate
|
||||||
|
let speaker = Speaker::new();
|
||||||
|
let end_events = speaker.end_events();
|
||||||
|
let reveal_end_task = cx.spawn(async move |this, cx| {
|
||||||
|
while end_events.recv_async().await.is_ok() {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.speech_reveal = None;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
@@ -208,6 +249,11 @@ impl ChatPanel {
|
|||||||
directory,
|
directory,
|
||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
|
speech,
|
||||||
|
speech_started: None,
|
||||||
|
speaker,
|
||||||
|
speech_reveal: None,
|
||||||
|
reveal_timeout: None,
|
||||||
subject_input,
|
subject_input,
|
||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
@@ -218,7 +264,7 @@ impl ChatPanel {
|
|||||||
reports_by_id,
|
reports_by_id,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tasks: vec![],
|
tasks: vec![reveal_end_task],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,22 +688,17 @@ impl ChatPanel {
|
|||||||
// Encrypt attachments which are not part of a message being written
|
// Encrypt attachments which are not part of a message being written
|
||||||
let encrypted = self.input.read(cx).value().trim().is_empty();
|
let encrypted = self.input.read(cx).value().trim().is_empty();
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask the user for a file to upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.upload_file(server, path, encrypted, window, cx);
|
this.upload_file(server, picked, encrypted, window, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -668,7 +709,7 @@ impl ChatPanel {
|
|||||||
fn upload_file(
|
fn upload_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
server: Url,
|
server: Url,
|
||||||
path: PathBuf,
|
picked: PickedFile,
|
||||||
encrypted: bool,
|
encrypted: bool,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -676,25 +717,18 @@ impl ChatPanel {
|
|||||||
self.set_uploading(true, cx);
|
self.set_uploading(true, cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = if encrypted {
|
let result = Uploaded::from_pick(encrypted, server.clone(), picked, cx).await;
|
||||||
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.update_in(cx, |this, window, cx| {
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
||||||
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
Ok(Uploaded::File(uploaded)) => {
|
||||||
Err(e) if encrypted => {
|
let UploadedFile { file, local } = *uploaded;
|
||||||
this.report_encrypted_upload_error(server, path, e, window, cx)
|
this.add_pending_file(file, local, cx);
|
||||||
}
|
}
|
||||||
|
Err(e) if encrypted => this.report_encrypted_upload_error(e, window, cx),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
@@ -711,8 +745,6 @@ impl ChatPanel {
|
|||||||
/// Report a failed encrypted upload, offering to retry it without encryption
|
/// Report a failed encrypted upload, offering to retry it without encryption
|
||||||
fn report_encrypted_upload_error(
|
fn report_encrypted_upload_error(
|
||||||
&mut self,
|
&mut self,
|
||||||
server: Url,
|
|
||||||
path: PathBuf,
|
|
||||||
error: Error,
|
error: Error,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -724,14 +756,12 @@ impl ChatPanel {
|
|||||||
.title("Encrypted upload failed")
|
.title("Encrypted upload failed")
|
||||||
.action(move |_this, _window, _cx| {
|
.action(move |_this, _window, _cx| {
|
||||||
let view = view.clone();
|
let view = view.clone();
|
||||||
let server = server.clone();
|
|
||||||
let path = path.clone();
|
|
||||||
|
|
||||||
Button::new("retry-without-encryption")
|
Button::new("retry-without-encryption")
|
||||||
.label("Upload without encryption")
|
.label("Pick the file again without encryption")
|
||||||
.on_click(move |_ev, window, cx| {
|
.on_click(move |_ev, window, cx| {
|
||||||
view.update(cx, |this, cx| {
|
view.update(cx, |this, cx| {
|
||||||
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
this.upload(window, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
@@ -761,9 +791,9 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
fn add_pending_file(&mut self, file: FileAttachment, local: LocalFile, cx: &mut Context<Self>) {
|
||||||
self.encrypted_attachments.update(cx, |this, cx| {
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
this.push(PendingFile { file, path });
|
this.push(PendingFile { file, local });
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -782,12 +812,13 @@ impl ChatPanel {
|
|||||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
let result = download_and_decrypt_file(&file, cx).await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(downloaded) => {
|
||||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Ready(downloaded.local));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.decrypted_files
|
this.decrypted_files
|
||||||
@@ -802,7 +833,7 @@ impl ChatPanel {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt the attachment of a file message and open it with the OS
|
/// Decrypt the attachment of a file message and open it with the platform handler
|
||||||
fn open_file(
|
fn open_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: EventId,
|
id: EventId,
|
||||||
@@ -810,25 +841,30 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
match self.decrypted_files.get(&id) {
|
if let Some(DecryptedFile::Ready(local)) = self.decrypted_files.get(&id) {
|
||||||
Some(DecryptedFile::Ready(path)) => {
|
let _ = local.open(cx).map_err(|e| {
|
||||||
cx.open_url(&file_url(path));
|
window.push_notification(Notification::error(e.to_string()).autohide(false), cx);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Some(DecryptedFile::Loading) => return,
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
let result = download_and_decrypt_file(&file, cx).await;
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(downloaded) => {
|
||||||
cx.open_url(&file_url(&path));
|
if let Err(e) = downloaded.open(cx) {
|
||||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Ready(downloaded.local));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.decrypted_files
|
this.decrypted_files
|
||||||
@@ -847,6 +883,17 @@ impl ChatPanel {
|
|||||||
self.directory.read(cx).person(public_key, cx)
|
self.directory.read(cx).person(public_key, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_dictation(
|
||||||
|
&mut self,
|
||||||
|
_: &ToggleDictation,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.speech.update(cx, |speech, cx| {
|
||||||
|
speech.toggle(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
match command {
|
match command {
|
||||||
Command::Insert(content) => {
|
Command::Insert(content) => {
|
||||||
@@ -1146,6 +1193,15 @@ impl ChatPanel {
|
|||||||
nostr.read(cx).current_user(),
|
nostr.read(cx).current_user(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let body = match self
|
||||||
|
.speech_reveal
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(reveal_id, _)| *reveal_id == id)
|
||||||
|
{
|
||||||
|
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
|
||||||
|
None => rendered_text,
|
||||||
|
};
|
||||||
|
|
||||||
MessageRow::new(ix)
|
MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
.hide_avatar(hide_avatar)
|
.hide_avatar(hide_avatar)
|
||||||
@@ -1170,7 +1226,7 @@ impl ChatPanel {
|
|||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
.when(message.file.is_none(), |this| this.child(body))
|
||||||
.child(MessageMedia::new(&message.media))
|
.child(MessageMedia::new(&message.media))
|
||||||
.when_some(message.file.as_ref(), |this, file| {
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
this.child(self.render_message_file(&id, file, cx))
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
@@ -1402,18 +1458,60 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let reply_id = *id;
|
let reply_id = *id;
|
||||||
let copy_id = *id;
|
|
||||||
let dropdown_id = *id;
|
let dropdown_id = *id;
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
|
|
||||||
actions
|
let actions = actions.reply(cx.listener(move |this, _event, _window, cx| {
|
||||||
.reply(cx.listener(move |this, _event, _window, cx| {
|
|
||||||
this.reply_to(&reply_id, cx);
|
this.reply_to(&reply_id, cx);
|
||||||
}))
|
}));
|
||||||
.copy(cx.listener(move |this, _event, _window, cx| {
|
|
||||||
this.copy_message(©_id, cx);
|
let actions = if let Some(message) = self.message(id) {
|
||||||
}))
|
actions.copy(
|
||||||
.dropdown(move |menu, _window, _cx| {
|
SharedString::from(format!("message-copy-{id}")),
|
||||||
|
message.preview(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
actions
|
||||||
|
};
|
||||||
|
|
||||||
|
let speaking = self.speech_reveal.is_some();
|
||||||
|
let speech_id = *id;
|
||||||
|
|
||||||
|
let actions = actions.speech(
|
||||||
|
speaking,
|
||||||
|
cx.listener(move |this, _event, window, cx| {
|
||||||
|
let text = this.message(&speech_id).map(|message| message.preview());
|
||||||
|
|
||||||
|
if let Some(text) = text {
|
||||||
|
match this.speaker.speak(&text) {
|
||||||
|
Ok(duration) => {
|
||||||
|
this.speech_reveal = Some((speech_id, duration));
|
||||||
|
|
||||||
|
// Re-enable speaking when no end event ever arrives
|
||||||
|
let session = (speech_id, duration);
|
||||||
|
let timer = cx.background_executor().timer(duration + END_GRACE);
|
||||||
|
this.reveal_timeout = Some(cx.spawn(async move |this, cx| {
|
||||||
|
timer.await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if this.speech_reveal.as_ref() == Some(&session) {
|
||||||
|
this.speech_reveal = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
actions.dropdown(move |menu, _window, _cx| {
|
||||||
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
|
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
|
||||||
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
|
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
|
||||||
})
|
})
|
||||||
@@ -1475,16 +1573,16 @@ impl ChatPanel {
|
|||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let state = self.decrypted_files.get(id);
|
let state = self.decrypted_files.get(id);
|
||||||
|
|
||||||
if let Some(path) = state
|
if let Some(local) = state
|
||||||
.and_then(|state| match state {
|
.and_then(|state| match state {
|
||||||
DecryptedFile::Ready(path) => Some(path),
|
DecryptedFile::Ready(local) => Some(local),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.filter(|_| file.is_image())
|
.filter(|_| file.is_image())
|
||||||
{
|
{
|
||||||
return div()
|
return div()
|
||||||
.child(
|
.child(
|
||||||
img(path.clone())
|
img(local.image_source())
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.h(px(250.))
|
.h(px(250.))
|
||||||
@@ -1560,7 +1658,7 @@ impl ChatPanel {
|
|||||||
.map(|this| {
|
.map(|this| {
|
||||||
if file.is_image() {
|
if file.is_image() {
|
||||||
this.child(
|
this.child(
|
||||||
img(pending.path.clone())
|
img(pending.local.image_source())
|
||||||
.size_16()
|
.size_16()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
@@ -1681,7 +1779,12 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
fn render_config_menu(
|
||||||
|
&self,
|
||||||
|
disabled: bool,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let (backup, signer_kind) = self
|
let (backup, signer_kind) = self
|
||||||
.room
|
.room
|
||||||
.read_with(cx, |this, _cx| {
|
.read_with(cx, |this, _cx| {
|
||||||
@@ -1694,6 +1797,7 @@ impl ChatPanel {
|
|||||||
.icon(IconName::Settings2)
|
.icon(IconName::Settings2)
|
||||||
.tooltip("Configuration")
|
.tooltip("Configuration")
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.disabled(disabled)
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
let auto = matches!(signer_kind, SignerKind::Auto);
|
let auto = matches!(signer_kind, SignerKind::Auto);
|
||||||
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
||||||
@@ -1724,7 +1828,12 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
|
fn render_emoji_menu(
|
||||||
|
&self,
|
||||||
|
disabled: bool,
|
||||||
|
_window: &Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let current_user = nostr.read(cx).current_user();
|
let current_user = nostr.read(cx).current_user();
|
||||||
|
|
||||||
@@ -1741,6 +1850,7 @@ impl ChatPanel {
|
|||||||
Button::new("emoji")
|
Button::new("emoji")
|
||||||
.icon(IconName::Emoji)
|
.icon(IconName::Emoji)
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.disabled(disabled)
|
||||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||||
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
||||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||||
@@ -1814,10 +1924,12 @@ impl Render for ChatPanel {
|
|||||||
|
|
||||||
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
||||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
|
let dictating = self.speech.read(cx).status().is_active();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all(self.id.clone()))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
|
.on_action(cx.listener(Self::toggle_dictation))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -1891,26 +2003,55 @@ impl Render for ChatPanel {
|
|||||||
.items_end()
|
.items_end()
|
||||||
.child(
|
.child(
|
||||||
Button::new("upload")
|
Button::new("upload")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Paperclip)
|
||||||
.tooltip("Upload media")
|
.tooltip("Upload media")
|
||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
.when(dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_8()
|
||||||
|
.flex_1()
|
||||||
|
.gap_2()
|
||||||
|
.pl_1()
|
||||||
|
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||||
|
.when_some(self.speech_started, |this, started| {
|
||||||
|
this.child(speech::elapsed(started, cx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
Textarea::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.small()
|
||||||
|
.min_h_8()
|
||||||
|
.py_1()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_end()
|
||||||
|
.gap_1()
|
||||||
|
.pl_1()
|
||||||
|
.child(SpeechButton::new(&self.speech)),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.pl_1()
|
.pl_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(self.render_emoji_menu(window, cx))
|
.child(self.render_emoji_menu(dictating, window, cx))
|
||||||
.child(self.render_config_menu(window, cx))
|
.child(self.render_config_menu(dictating, window, cx))
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
Button::new("send")
|
||||||
.icon(IconName::PaperPlaneFill)
|
.icon(IconName::PaperPlaneFill)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.send_text_message(window, cx);
|
this.send_text_message(window, cx);
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ futures.workspace = true
|
|||||||
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
web-sys = { version = "0.3", features = ["Window", "Storage"] }
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
pub use debounced_delay::*;
|
|
||||||
pub use display::*;
|
|
||||||
pub use event::*;
|
|
||||||
pub use media_extractor::*;
|
|
||||||
pub use parser::*;
|
|
||||||
pub use paths::*;
|
|
||||||
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
mod media_extractor;
|
mod media_extractor;
|
||||||
mod parser;
|
mod parser;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web_store;
|
||||||
|
|
||||||
|
pub use debounced_delay::*;
|
||||||
|
pub use display::*;
|
||||||
|
pub use event::*;
|
||||||
|
pub use media_extractor::*;
|
||||||
|
pub use parser::*;
|
||||||
|
pub use paths::*;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use anyhow::{Error, anyhow};
|
||||||
|
|
||||||
|
const LOCAL_STORAGE: &str = "localStorage";
|
||||||
|
|
||||||
|
fn storage() -> Result<web_sys::Storage, Error> {
|
||||||
|
web_sys::window()
|
||||||
|
.ok_or_else(|| anyhow!("no browser window"))?
|
||||||
|
.local_storage()
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))?
|
||||||
|
.ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a value previously written with [`set`]
|
||||||
|
pub fn get(key: &str) -> Result<Option<String>, Error> {
|
||||||
|
storage()?
|
||||||
|
.get_item(key)
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a value, replacing any previous one
|
||||||
|
pub fn set(key: &str, value: &str) -> Result<(), Error> {
|
||||||
|
storage()?
|
||||||
|
.set_item(key, value)
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a value if it exists
|
||||||
|
pub fn remove(key: &str) -> Result<(), Error> {
|
||||||
|
storage()?
|
||||||
|
.remove_item(key)
|
||||||
|
.map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}"))
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ common = { path = "../common" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
gpui-kit.workspace = true
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
+139
-29
@@ -1,12 +1,14 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
|
||||||
list, px,
|
px,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
@@ -16,6 +18,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
|
|||||||
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
|
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
use gpui_kit::component::scroll::Scrollbar;
|
||||||
|
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -23,8 +26,9 @@ use person::Directory;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, upload};
|
use speech::speaker::Speaker;
|
||||||
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
|
use state::{NostrRegistry, pick_file, upload_file};
|
||||||
|
use ui::{Avatar, RenderedText, RevealText, WelcomeMessage, rendered_text};
|
||||||
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||||
|
|
||||||
mod details;
|
mod details;
|
||||||
@@ -313,6 +317,16 @@ pub struct GroupPanel {
|
|||||||
reply_to: Option<EventId>,
|
reply_to: Option<EventId>,
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
|
/// Dictation session for the composer input
|
||||||
|
speech: Entity<SpeechState>,
|
||||||
|
/// When the running dictation started, for the elapsed counter
|
||||||
|
speech_started: Option<instant::Instant>,
|
||||||
|
/// Reads messages aloud
|
||||||
|
speaker: Speaker,
|
||||||
|
/// The message being revealed in step with speech, with its estimated duration
|
||||||
|
speech_reveal: Option<(EventId, Duration)>,
|
||||||
|
/// Fallback reveal end for backends that cannot report utterance end
|
||||||
|
reveal_timeout: Option<Task<Result<()>>>,
|
||||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||||
attachments: Vec<Url>,
|
attachments: Vec<Url>,
|
||||||
/// Whether an upload is in flight.
|
/// Whether an upload is in flight.
|
||||||
@@ -320,7 +334,7 @@ pub struct GroupPanel {
|
|||||||
/// Whether the user closed the encryption notice this visit
|
/// Whether the user closed the encryption notice this visit
|
||||||
notice_dismissed: bool,
|
notice_dismissed: bool,
|
||||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupPanel {
|
impl GroupPanel {
|
||||||
@@ -339,6 +353,18 @@ impl GroupPanel {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let speech = cx.new(|cx| {
|
||||||
|
let state = SpeechState::new(cx)
|
||||||
|
.recognizer(speech::recognizer())
|
||||||
|
// Batch transcription of a long recording can take a while.
|
||||||
|
.stop_timeout(Duration::from_secs(180));
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let state = state.input(speech::input());
|
||||||
|
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -349,6 +375,34 @@ impl GroupPanel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
|
||||||
|
match event {
|
||||||
|
SpeechEvent::Started => {
|
||||||
|
this.speech_started = Some(instant::Instant::now());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
SpeechEvent::Final(text) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
this.input.update(cx, |input, cx| {
|
||||||
|
input.insert(text.clone(), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SpeechEvent::Error(error) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
SpeechEvent::Cancelled => {
|
||||||
|
this.speech_started = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
||||||
if !matches!(event, GroupEvent::Sent(_)) {
|
if !matches!(event, GroupEvent::Sent(_)) {
|
||||||
this.sync(cx);
|
this.sync(cx);
|
||||||
@@ -372,9 +426,24 @@ impl GroupPanel {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
this.track_participants(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut this = Self {
|
// End the speech reveal when the utterance finishes ahead of its estimate
|
||||||
|
let speaker = Speaker::new();
|
||||||
|
let end_events = speaker.end_events();
|
||||||
|
let reveal_end_task = cx.spawn(async move |this, cx| {
|
||||||
|
while end_events.recv_async().await.is_ok() {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.speech_reveal = None;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
group: group.downgrade(),
|
group: group.downgrade(),
|
||||||
directory: Directory::new(cx),
|
directory: Directory::new(cx),
|
||||||
@@ -384,19 +453,19 @@ impl GroupPanel {
|
|||||||
notice_dismissed: false,
|
notice_dismissed: false,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
input,
|
input,
|
||||||
|
speech,
|
||||||
|
speech_started: None,
|
||||||
|
speaker,
|
||||||
|
speech_reveal: None,
|
||||||
|
reveal_timeout: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![reveal_end_task],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
}
|
||||||
|
|
||||||
this.track_participants(cx);
|
|
||||||
|
|
||||||
this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track the keys rendered by this panel: members, message authors, and
|
/// Track the keys rendered by this panel.
|
||||||
/// activity subjects
|
|
||||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(group) = self.group.upgrade() else {
|
let Some(group) = self.group.upgrade() else {
|
||||||
return;
|
return;
|
||||||
@@ -553,22 +622,17 @@ impl GroupPanel {
|
|||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
|
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
|
||||||
|
|
||||||
match upload(server, path, cx).await {
|
match upload_file(server, picked, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
this.attachments.push(url);
|
this.attachments.push(url);
|
||||||
@@ -979,7 +1043,13 @@ impl GroupPanel {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let directory = self.directory.clone();
|
let directory = self.directory.clone();
|
||||||
|
|
||||||
let content = self
|
let content = match self
|
||||||
|
.speech_reveal
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(reveal_id, _)| *reveal_id == message.id)
|
||||||
|
{
|
||||||
|
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
|
||||||
|
None => self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
@@ -994,7 +1064,8 @@ impl GroupPanel {
|
|||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx),
|
||||||
|
};
|
||||||
|
|
||||||
let admin = nostr
|
let admin = nostr
|
||||||
.read(cx)
|
.read(cx)
|
||||||
@@ -1015,12 +1086,24 @@ impl GroupPanel {
|
|||||||
panel: cx.weak_entity(),
|
panel: cx.weak_entity(),
|
||||||
admin,
|
admin,
|
||||||
pinned,
|
pinned,
|
||||||
|
speaking: self.speech_reveal.is_some(),
|
||||||
persons: self.directory.clone(),
|
persons: self.directory.clone(),
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_dictation(
|
||||||
|
&mut self,
|
||||||
|
_ev: &ToggleDictation,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.speech.update(cx, |speech, cx| {
|
||||||
|
speech.toggle(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let notice = self.group.upgrade().and_then(|group| {
|
let notice = self.group.upgrade().and_then(|group| {
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
@@ -1046,6 +1129,8 @@ impl GroupPanel {
|
|||||||
return self.render_notice(¬ice, cx);
|
return self.render_notice(¬ice, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dictating = self.speech.read(cx).status().is_active();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1061,21 +1146,45 @@ impl GroupPanel {
|
|||||||
.items_end()
|
.items_end()
|
||||||
.child(
|
.child(
|
||||||
Button::new("upload")
|
Button::new("upload")
|
||||||
.icon(IconName::Upload)
|
.icon(ui::IconName::Paperclip)
|
||||||
.tooltip("Upload file")
|
.tooltip("Upload media")
|
||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
.when(dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_8()
|
||||||
|
.flex_1()
|
||||||
|
.gap_2()
|
||||||
|
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||||
|
.when_some(self.speech_started, |this, started| {
|
||||||
|
this.child(speech::elapsed(started, cx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
Textarea::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.bg(cx.theme().input)
|
||||||
|
.small()
|
||||||
|
.min_h_8()
|
||||||
|
.py_1()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(SpeechButton::new(&self.speech))
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
Button::new("send")
|
||||||
.icon(ui::IconName::PaperPlaneFill)
|
.icon(ui::IconName::PaperPlaneFill)
|
||||||
.tooltip("Send")
|
.tooltip("Send")
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.disabled(dictating || self.uploading)
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.send(window, cx);
|
this.send(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -1273,6 +1382,7 @@ impl Render for GroupPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
|
.on_action(cx.listener(Self::toggle_dictation))
|
||||||
.child(self.render_encryption_notice(cx))
|
.child(self.render_encryption_notice(cx))
|
||||||
.child(self.render_banner(cx))
|
.child(self.render_banner(cx))
|
||||||
.child(self.render_pins(cx))
|
.child(self.render_pins(cx))
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ use gpui::{
|
|||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
|
use gpui_kit::component::notification::Notification;
|
||||||
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
|
||||||
use nip29::{Activity, Group, Reaction};
|
use nip29::{Activity, Group, Reaction};
|
||||||
use nostr_sdk::prelude::{EventId, Kind};
|
use nostr_sdk::prelude::{EventId, Kind};
|
||||||
use person::Directory;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
|
use speech::speaker::END_GRACE;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use ui::{
|
use ui::{
|
||||||
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
|
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
|
||||||
react_menu,
|
group_reactions, react_menu,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::GroupPanel;
|
use crate::GroupPanel;
|
||||||
@@ -26,6 +28,8 @@ pub(crate) struct Actions {
|
|||||||
pub panel: WeakEntity<GroupPanel>,
|
pub panel: WeakEntity<GroupPanel>,
|
||||||
pub admin: bool,
|
pub admin: bool,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
|
/// Whether a message is currently being spoken, disabling the speech buttons
|
||||||
|
pub speaking: bool,
|
||||||
pub persons: Entity<Directory>,
|
pub persons: Entity<Directory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +91,44 @@ pub(crate) fn render(
|
|||||||
let admin = actions.admin;
|
let admin = actions.admin;
|
||||||
let pinned = actions.pinned;
|
let pinned = actions.pinned;
|
||||||
let group = actions.group;
|
let group = actions.group;
|
||||||
let panel = actions.panel;
|
let speech_panel = actions.panel;
|
||||||
|
let panel = speech_panel.clone();
|
||||||
|
let speech_text = message.preview();
|
||||||
|
let speech_id = message.id;
|
||||||
|
let speaking = actions.speaking;
|
||||||
|
|
||||||
|
let row = row.overlay(
|
||||||
|
MessageActions::new().speech(speaking, move |_event, _window, cx| {
|
||||||
|
_ = speech_panel.update_in(cx, |panel, window, cx| {
|
||||||
|
match panel.speaker.speak(&speech_text) {
|
||||||
|
Ok(duration) => {
|
||||||
|
panel.speech_reveal = Some((speech_id, duration));
|
||||||
|
|
||||||
|
let session = (speech_id, duration);
|
||||||
|
let timer = cx.background_executor().timer(duration + END_GRACE);
|
||||||
|
|
||||||
|
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
|
||||||
|
timer.await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if this.speech_reveal.as_ref() == Some(&session) {
|
||||||
|
this.speech_reveal = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Reading the panel here would re-enter its ongoing update, so resolve
|
|
||||||
// the current user's custom emojis through the shared person directory.
|
|
||||||
let custom = nostr
|
let custom = nostr
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.current_user()
|
.current_user()
|
||||||
|
|||||||
+498
-163
@@ -1,9 +1,10 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashSet, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -16,12 +17,14 @@ use gpui_kit::component::notification::{Notification, NotificationType};
|
|||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::Directory;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
|
|
||||||
const IDENTIFIER: &str = "coop:device";
|
mod storage;
|
||||||
|
|
||||||
|
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||||
@@ -59,10 +62,18 @@ impl DeviceEvent {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DeviceRegistry {
|
pub struct DeviceRegistry {
|
||||||
signer: Entity<Option<UniversalSigner>>,
|
signer: Entity<Option<UniversalSigner>>,
|
||||||
/// Whether there is a pending request for encryption key approval
|
/// Ephemeral client key for the in-flight key request; never persisted
|
||||||
pub(crate) pending_request: bool,
|
app_keys: Option<Keys>,
|
||||||
|
/// The key request currently awaiting user approval on this device
|
||||||
|
pending_request: Option<PendingRequest>,
|
||||||
|
/// Handled key-sync request ids, bounded and persisted
|
||||||
|
processed_requests: VecDeque<EventId>,
|
||||||
/// Whether an announcement has been made for this device
|
/// Whether an announcement has been made for this device
|
||||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||||
|
/// Whether the local key is stale against the current announcement.
|
||||||
|
resync: Arc<AtomicBool>,
|
||||||
|
/// Whether a key rotation is in flight, to coalesce overlapping requests
|
||||||
|
rotating: bool,
|
||||||
/// Person directory for requesters shown in notifications
|
/// Person directory for requesters shown in notifications
|
||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
/// Relay notification listener
|
/// Relay notification listener
|
||||||
@@ -108,6 +119,21 @@ impl DeviceRegistry {
|
|||||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||||
// The old account's state must not leak into the new one
|
// The old account's state must not leak into the new one
|
||||||
this.announcement_existed.store(false, Ordering::Relaxed);
|
this.announcement_existed.store(false, Ordering::Relaxed);
|
||||||
|
this.resync.store(false, Ordering::Relaxed);
|
||||||
|
// The in-flight request and client key belong to the old account
|
||||||
|
this.app_keys = None;
|
||||||
|
|
||||||
|
if this.pending_request.take().is_some() {
|
||||||
|
// The old approval must not target the new account's signer
|
||||||
|
if let Some(window) = cx.windows().first().copied() {
|
||||||
|
window
|
||||||
|
.update(cx, |_view, window, cx| {
|
||||||
|
window.remove_notification::<DeviceNotification>(cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(cx);
|
||||||
this.get_announcement(cx);
|
this.get_announcement(cx);
|
||||||
}
|
}
|
||||||
@@ -115,15 +141,19 @@ impl DeviceRegistry {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
entity
|
let _ = entity.update(cx, |this, cx| {
|
||||||
.update(cx, |this, cx| this.handle_notifications(cx))
|
this.handle_notifications(cx);
|
||||||
.ok();
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
signer,
|
signer,
|
||||||
pending_request: false,
|
app_keys: None,
|
||||||
|
pending_request: None,
|
||||||
|
processed_requests: VecDeque::new(),
|
||||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||||
|
resync: Arc::new(AtomicBool::new(false)),
|
||||||
|
rotating: false,
|
||||||
directory: Directory::new(cx),
|
directory: Directory::new(cx),
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
@@ -141,6 +171,8 @@ impl DeviceRegistry {
|
|||||||
let announcement_existed = self.announcement_existed.clone();
|
let announcement_existed = self.announcement_existed.clone();
|
||||||
let (tx, rx) = flume::bounded::<Event>(100);
|
let (tx, rx) = flume::bounded::<Event>(100);
|
||||||
|
|
||||||
|
let read = credentials::read_processed_requests(cx);
|
||||||
|
|
||||||
self.notification_listener = Some(cx.background_spawn(async move {
|
self.notification_listener = Some(cx.background_spawn(async move {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
@@ -175,6 +207,21 @@ impl DeviceRegistry {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
// Hydrate the processed-request log before routing any events
|
||||||
|
match read.await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let processed = bytes
|
||||||
|
.map(|bytes| parse_processed_requests(&bytes))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
this.update(cx, |this, _cx| {
|
||||||
|
this.processed_requests = processed;
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
while let Ok(event) = rx.recv_async().await {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Custom(10044) => {
|
Kind::Custom(10044) => {
|
||||||
@@ -191,7 +238,7 @@ impl DeviceRegistry {
|
|||||||
// New response event from the master device
|
// New response event from the master device
|
||||||
Kind::Custom(4455) => {
|
Kind::Custom(4455) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.extract_encryption(event, cx);
|
this.handle_transfer(event, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -201,12 +248,62 @@ impl DeviceRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set whether there is a pending request for encryption key approval
|
/// Record a handled key-sync request id, persisting a bounded log
|
||||||
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
|
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
|
||||||
self.pending_request = pending;
|
if self.processed_requests.contains(&event_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.processed_requests.push_back(event_id);
|
||||||
|
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
|
||||||
|
self.processed_requests.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes: Vec<u8> = self
|
||||||
|
.processed_requests
|
||||||
|
.iter()
|
||||||
|
.flat_map(|id| id.to_hex().into_bytes())
|
||||||
|
.collect();
|
||||||
|
let write = credentials::write_processed_requests(cx, &bytes);
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
if let Err(e) = write.await {
|
||||||
|
log::error!("Failed to persist processed key-sync requests: {e}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Withdraw the pending approval.
|
||||||
|
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(pending) = self.pending_request.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.mark_request_processed(pending.event.id, cx);
|
||||||
|
|
||||||
|
if let Some(window) = cx.windows().first().copied() {
|
||||||
|
window
|
||||||
|
.update(cx, |_view, window, cx| {
|
||||||
|
window.remove_notification::<DeviceNotification>(cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Route a key transfer.
|
||||||
|
fn handle_transfer(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
|
let resolved = self
|
||||||
|
.pending_request
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|pending| transfer_addresses(&event, pending.client_key));
|
||||||
|
|
||||||
|
if resolved {
|
||||||
|
self.resolve_pending_request(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.extract_encryption(event, cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the signer
|
/// Get the signer
|
||||||
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
|
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
|
||||||
self.signer.read(cx).clone()
|
self.signer.read(cx).clone()
|
||||||
@@ -218,29 +315,49 @@ impl DeviceRegistry {
|
|||||||
*this = Some(UniversalSigner::new(new_signer));
|
*this = Some(UniversalSigner::new(new_signer));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
// The local key is now consistent with the announcement
|
||||||
|
self.resync.store(false, Ordering::Release);
|
||||||
cx.emit(DeviceEvent::Set);
|
cx.emit(DeviceEvent::Set);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backup the encryption's secret key to a file
|
/// Whether the local encryption key is stale against the current announcement.
|
||||||
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
pub fn is_resyncing(&self) -> bool {
|
||||||
|
self.resync.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backup the encryption's secret key, returning the `nsec1...` content
|
||||||
|
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, 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();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client, &signer).await?;
|
||||||
let content = keys.secret_key().to_bech32()?;
|
Ok(keys.secret_key().to_bech32()?)
|
||||||
|
})
|
||||||
if cfg!(target_arch = "wasm32") {
|
|
||||||
return Err(anyhow!("Not supported"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backup the encryption's secret key to a file
|
||||||
|
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
||||||
|
let backup = self.backup_bytes(cx);
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.background_spawn(async move {
|
||||||
|
let content = backup.await?;
|
||||||
smol::fs::write(path, &content).await?;
|
smol::fs::write(path, &content).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
backup.await?;
|
||||||
|
Err(anyhow!("File backups are not supported on web; copy the key from the backup panel instead."))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get device announcement for current user
|
/// Get device announcement for current user
|
||||||
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -310,6 +427,88 @@ impl DeviceRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the encryption key with a new one, archiving the old key.
|
||||||
|
pub fn rotate(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.rotating {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.rotating = true;
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result: Result<Keys, Error> = async {
|
||||||
|
// Archive the current key before replacing it
|
||||||
|
if let Ok(old) = get_keys(&client, &signer).await {
|
||||||
|
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = this
|
||||||
|
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(created.clone(), cx);
|
||||||
|
this.wait_for_request(cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.rotating = false;
|
||||||
|
if let Err(e) = result {
|
||||||
|
cx.emit(DeviceEvent::error(e.to_string()));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate the encryption key when the configured interval has elapsed.
|
||||||
|
///
|
||||||
|
/// Runs after the announcement reconciled, outside the blocking boot path.
|
||||||
|
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let Ok(keys) = load_device_keys(&client, &signer).await else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let elapsed = Timestamp::now()
|
||||||
|
.as_secs()
|
||||||
|
.saturating_sub(keys.created_at.as_secs());
|
||||||
|
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| this.rotate(cx)).ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load encryption keys retired by a rotation from the local database.
|
||||||
|
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
|
storage::archived_keys(client, signer).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Create new encryption key and announce it to user's relay list
|
/// Create new encryption key and announce it to user's relay list
|
||||||
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -318,6 +517,7 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
let n = keys.public_key();
|
let n = keys.public_key();
|
||||||
|
let created_at = Timestamp::now();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
// Construct an announcement event
|
// Construct an announcement event
|
||||||
@@ -327,7 +527,7 @@ impl DeviceRegistry {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Persist the key locally before announcing it
|
// Persist the key locally before announcing it
|
||||||
set_keys(&client, &signer, &secret).await?;
|
set_keys(&client, &signer, &secret, created_at).await?;
|
||||||
|
|
||||||
// Publish announcement
|
// Publish announcement
|
||||||
client
|
client
|
||||||
@@ -370,10 +570,12 @@ impl DeviceRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
this.wait_for_request(cx);
|
this.wait_for_request(cx);
|
||||||
|
this.rotate_if_due(cx);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.request(cx);
|
this.resync.store(true, Ordering::Release);
|
||||||
|
this.request(Some(device_pubkey), cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -401,15 +603,17 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request encryption keys from other device
|
/// Request encryption keys from other device
|
||||||
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn request(
|
||||||
|
&mut self,
|
||||||
|
expected_encryption: Option<PublicKey>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
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();
|
||||||
|
let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone();
|
||||||
let app_keys_task = get_or_init_app_keys(cx);
|
|
||||||
|
|
||||||
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
||||||
let app_keys = app_keys_task.await?;
|
|
||||||
let app_pubkey = app_keys.public_key();
|
let app_pubkey = app_keys.public_key();
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
@@ -421,8 +625,7 @@ impl DeviceRegistry {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
||||||
// Only reuse the stored approval if its key is still valid,
|
// Only reuse the stored approval if its key is still valid
|
||||||
// otherwise it's stale and a fresh request is needed
|
|
||||||
if decrypt_approval(&client, &app_keys, public_key, &event)
|
if decrypt_approval(&client, &app_keys, public_key, &event)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
@@ -431,9 +634,26 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No usable approval event found, construct a request event
|
// No usable approval event found, construct a request event.
|
||||||
|
//
|
||||||
|
// The `pubkey` tag is a compatibility alias for the draft's `P`,
|
||||||
|
// `relay` hints tell other devices where the response is accepted,
|
||||||
|
// `n` names the exact key this device expects to be transferred
|
||||||
|
let mut tags = vec![
|
||||||
|
Tag::custom("P", vec![app_pubkey]),
|
||||||
|
Tag::custom("pubkey", vec![app_pubkey]),
|
||||||
|
];
|
||||||
|
|
||||||
|
for url in write_relay_hints(&client, public_key).await? {
|
||||||
|
tags.push(Tag::custom("relay", vec![url.to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected) = expected_encryption {
|
||||||
|
tags.push(Tag::custom("n", vec![expected]));
|
||||||
|
}
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||||
.tags(vec![Tag::custom("P", vec![app_pubkey])])
|
.tags(tags)
|
||||||
.finalize_async(&signer)
|
.finalize_async(&signer)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -470,19 +690,13 @@ impl DeviceRegistry {
|
|||||||
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();
|
||||||
let app_keys_task = get_or_init_app_keys(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
let Some(app_keys) = self.app_keys.clone() else {
|
||||||
let app_keys = match app_keys_task.await {
|
log::warn!("Cannot wait for approval without a client key");
|
||||||
Ok(app_keys) => app_keys,
|
return;
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
cx.emit(DeviceEvent::error(e.to_string()));
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
this.update(cx, |_this, cx| {
|
this.update(cx, |_this, cx| {
|
||||||
cx.emit(DeviceEvent::Requesting(authorization_code(
|
cx.emit(DeviceEvent::Requesting(authorization_code(
|
||||||
&app_keys.public_key(),
|
&app_keys.public_key(),
|
||||||
@@ -492,7 +706,7 @@ impl DeviceRegistry {
|
|||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
// Only approvals addressed to this device's app key matter
|
// Only approvals addressed to this device's client key matter
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Custom(4455))
|
.kind(Kind::Custom(4455))
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
@@ -516,15 +730,18 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Parse the approval event to get encryption key then set it
|
/// Parse the approval event to get encryption key then set it
|
||||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
let app_keys_task = get_or_init_app_keys(cx);
|
|
||||||
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();
|
||||||
|
|
||||||
|
let Some(app_keys) = self.app_keys.clone() else {
|
||||||
|
log::warn!("Ignoring key transfer addressed to another device");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let approval_id = event.id;
|
let approval_id = event.id;
|
||||||
|
|
||||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||||
let app_keys = app_keys_task.await?;
|
|
||||||
let identity = signer.get_public_key_async().await?;
|
let identity = signer.get_public_key_async().await?;
|
||||||
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
||||||
|
|
||||||
@@ -536,14 +753,14 @@ impl DeviceRegistry {
|
|||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
|
// The client key was consumed; start fresh on the next request
|
||||||
|
this.app_keys = None;
|
||||||
// Clean up our request and the approval event from relays
|
// Clean up our request and the approval event from relays
|
||||||
this.cleanup_sync_events(approval_id, cx);
|
this.cleanup_sync_events(approval_id, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |_this, cx| {
|
log::warn!("Ignoring key transfer: {e}");
|
||||||
cx.emit(DeviceEvent::error(e.to_string()));
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -597,40 +814,58 @@ impl DeviceRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
// Get user's write relays
|
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
|
let request_id = event.id;
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
// Get device keys
|
// Get device keys
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client, &signer).await?;
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
|
let identity = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
// Extract the target public key from the event tags
|
// Extract the target public key from the event tags
|
||||||
let target = event
|
let target =
|
||||||
.tags
|
client_pubkey_from_event(&event).context("Target is not a valid public key")?;
|
||||||
.iter()
|
|
||||||
.find(|tag| tag.kind() == "P")
|
|
||||||
.and_then(|tag| tag.content())
|
|
||||||
.and_then(|content| PublicKey::parse(content).ok())
|
|
||||||
.context("Target is not a valid public key")?;
|
|
||||||
|
|
||||||
// Encrypt the device keys with the user's signer
|
// The requester may pin the exact key it expects
|
||||||
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
|
if let Some(requested) = requested_encryption_key(&event)
|
||||||
|
&& requested != keys.public_key()
|
||||||
|
{
|
||||||
|
return Err(anyhow!(
|
||||||
|
"The requesting device expects an encryption key this device no longer holds"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt to a fresh one-time keypair so the transfer event
|
||||||
|
// can never be decrypted with the long-lived encryption key.
|
||||||
|
let sender = Keys::generate();
|
||||||
|
let payload = sender.nip44_encrypt_async(&target, &secret).await?;
|
||||||
|
|
||||||
// Construct the response event
|
// Construct the response event
|
||||||
//
|
//
|
||||||
// P tag: the current device's public key
|
// P tag: the one-time sender key used for the encryption
|
||||||
// p tag: the requester's public key
|
// p tag: the account identity, for inbox routing
|
||||||
let event = EventBuilder::new(Kind::Custom(4455), payload)
|
// p tag: the requester's client key
|
||||||
|
let response = EventBuilder::new(Kind::Custom(4455), payload)
|
||||||
.tags(vec![
|
.tags(vec![
|
||||||
Tag::custom("P", vec![keys.public_key().to_hex()]),
|
Tag::custom("P", vec![sender.public_key().to_hex()]),
|
||||||
|
Tag::public_key(identity),
|
||||||
Tag::public_key(target),
|
Tag::public_key(target),
|
||||||
])
|
])
|
||||||
.finalize_async(&signer)
|
.finalize_async(&signer)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Send the response event to the user's relay list
|
let relays = merge_relays(
|
||||||
client.send_event(&event).to_nip65().await?;
|
write_relay_hints(&client, identity).await?,
|
||||||
|
request_relay_hints(&event),
|
||||||
|
);
|
||||||
|
|
||||||
|
let send = client.send_event(&response);
|
||||||
|
if relays.is_empty() {
|
||||||
|
send.to_nip65().await?;
|
||||||
|
} else {
|
||||||
|
send.to(relays).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -639,8 +874,11 @@ impl DeviceRegistry {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
// Record the request as handled so it cannot resurface
|
||||||
|
this.mark_request_processed(request_id, cx);
|
||||||
// Allow future requests to surface again
|
// Allow future requests to surface again
|
||||||
this.set_pending_request(false, cx);
|
this.pending_request = None;
|
||||||
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
@@ -665,21 +903,70 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Handle encryption request
|
/// Handle encryption request
|
||||||
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
// Ignore if there is already a pending request
|
let Some(client_key) = client_pubkey_from_event(&event) else {
|
||||||
if self.pending_request {
|
log::warn!("Ignoring invalid encryption key request: no valid requester key");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let read = credentials::read_processed_requests(cx);
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let processed = match read.await {
|
||||||
|
Ok(bytes) => bytes
|
||||||
|
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to read processed key-sync requests: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if processed || this.processed_requests.contains(&event.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.set_pending_request(true, cx);
|
|
||||||
|
|
||||||
let notification = match self.notification(event, cx) {
|
match this.pending_request.take() {
|
||||||
|
// A retry signs a new request id but keeps the same client key
|
||||||
|
Some(mut pending) if pending.client_key == client_key => {
|
||||||
|
this.mark_request_processed(event.id, cx);
|
||||||
|
pending.event = event;
|
||||||
|
this.pending_request = Some(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different client key is a remounted requester
|
||||||
|
other => {
|
||||||
|
if let Some(pending) = other {
|
||||||
|
this.mark_request_processed(pending.event.id, cx);
|
||||||
|
if let Some(window) = cx.windows().first().copied() {
|
||||||
|
window
|
||||||
|
.update(cx, |_view, window, cx| {
|
||||||
|
window.remove_notification::<DeviceNotification>(cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.show_approval(event, client_key, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build and surface the approval notification for a key request
|
||||||
|
fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
let notification = match self.notification(event.clone(), cx) {
|
||||||
Ok(notification) => notification,
|
Ok(notification) => notification,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Ignoring invalid encryption key request: {e}");
|
log::warn!("Ignoring invalid encryption key request: {e}");
|
||||||
self.set_pending_request(false, cx);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.pending_request = Some(PendingRequest { event, client_key });
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
if let Some(window) = cx.windows().first().copied() {
|
if let Some(window) = cx.windows().first().copied() {
|
||||||
if let Err(error) = window.update(cx, |_view, window, cx| {
|
if let Err(error) = window.update(cx, |_view, window, cx| {
|
||||||
window.push_notification(notification, cx);
|
window.push_notification(notification, cx);
|
||||||
@@ -702,19 +989,17 @@ impl DeviceRegistry {
|
|||||||
compare the code with the one shown on the requesting \
|
compare the code with the one shown on the requesting \
|
||||||
device.";
|
device.";
|
||||||
|
|
||||||
let requester = event
|
let requester =
|
||||||
.tags
|
client_pubkey_from_event(&event).context("Request has no valid requester key")?;
|
||||||
.iter()
|
|
||||||
.find(|tag| tag.kind() == "P")
|
|
||||||
.and_then(|tag| tag.content())
|
|
||||||
.and_then(|content| PublicKey::parse(content).ok())
|
|
||||||
.context("Request has no valid requester key")?;
|
|
||||||
|
|
||||||
let code = authorization_code(&requester);
|
let code = authorization_code(&requester);
|
||||||
let directory = self.directory.clone();
|
|
||||||
directory.update(cx, |directory, cx| directory.track([requester], cx));
|
|
||||||
let code_label = code.clone();
|
let code_label = code.clone();
|
||||||
|
|
||||||
|
let directory = self.directory.clone();
|
||||||
|
directory.update(cx, |directory, cx| {
|
||||||
|
directory.track([requester], cx);
|
||||||
|
});
|
||||||
|
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
let loading = Rc::new(Cell::new(false));
|
let loading = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
@@ -818,6 +1103,111 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
struct DeviceNotification;
|
struct DeviceNotification;
|
||||||
|
|
||||||
|
/// A key request awaiting user approval on this device
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PendingRequest {
|
||||||
|
event: Event,
|
||||||
|
client_key: PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the durable processed-request log bounded
|
||||||
|
const PROCESSED_REQUESTS_LIMIT: usize = 256;
|
||||||
|
|
||||||
|
/// Parse concatenated hex event ids
|
||||||
|
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
|
||||||
|
let Ok(hex) = std::str::from_utf8(bytes) else {
|
||||||
|
return VecDeque::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
hex.as_bytes()
|
||||||
|
.as_chunks::<64>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the client public key from an event.
|
||||||
|
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
|
||||||
|
let key = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "P")
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| PublicKey::parse(content).ok());
|
||||||
|
|
||||||
|
key.or_else(|| {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "pubkey")
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the transfer event is p-tagged to the given client key
|
||||||
|
fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool {
|
||||||
|
event.tags.iter().any(|tag| {
|
||||||
|
tag.kind() == "p"
|
||||||
|
&& tag
|
||||||
|
.content()
|
||||||
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
|
.is_some_and(|key| key == client_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `relay` hints attached to a key request.
|
||||||
|
fn request_relay_hints(event: &Event) -> Vec<RelayUrl> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|tag| tag.kind() == "relay")
|
||||||
|
.filter_map(|tag| {
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| RelayUrl::parse(content).ok())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the optional `n` tag of a key request.
|
||||||
|
fn requested_encryption_key(event: &Event) -> Option<PublicKey> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "n")
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the user's NIP-65 write relays.
|
||||||
|
async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result<Vec<RelayUrl>, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.author(identity)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||||
|
return Ok(vec![]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unmarked relays serve both reads and writes
|
||||||
|
Ok(nip65::extract_relay_list(&event)
|
||||||
|
.filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read))
|
||||||
|
.map(|(url, _)| url)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge relay lists, dropping duplicates
|
||||||
|
fn merge_relays(first: Vec<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
|
||||||
|
first
|
||||||
|
.into_iter()
|
||||||
|
.chain(second)
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The first eight hexadecimal characters of the client public key.
|
/// The first eight hexadecimal characters of the client public key.
|
||||||
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
||||||
let hex = public_key.to_hex();
|
let hex = public_key.to_hex();
|
||||||
@@ -825,36 +1215,6 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
|
|||||||
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
|
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get or create new app keys (async, returns a task)
|
|
||||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
|
||||||
let read = cx.read_credentials(CLIENT_NAME);
|
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
if let Ok(Some((_, secret))) = read.await
|
|
||||||
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
|
||||||
{
|
|
||||||
return Ok(keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No stored keys found or invalid — generate new ones
|
|
||||||
let keys = Keys::generate();
|
|
||||||
let user = keys.public_key().to_hex();
|
|
||||||
let secret = keys.secret_key().to_secret_bytes();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Err(e) = write.await {
|
|
||||||
log::error!("Keyring not available or panic: {e}")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(keys)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt and validate an approval event against the current announcement.
|
/// Decrypt and validate an approval event against the current announcement.
|
||||||
async fn decrypt_approval(
|
async fn decrypt_approval(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -862,13 +1222,7 @@ async fn decrypt_approval(
|
|||||||
identity: PublicKey,
|
identity: PublicKey,
|
||||||
event: &Event,
|
event: &Event,
|
||||||
) -> Result<Keys, Error> {
|
) -> Result<Keys, Error> {
|
||||||
let master = event
|
let master = client_pubkey_from_event(event).context("Invalid event's tags")?;
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find(|tag| tag.kind() == "P")
|
|
||||||
.and_then(|tag| tag.content())
|
|
||||||
.and_then(|content| PublicKey::parse(content).ok())
|
|
||||||
.context("Invalid event's tags")?;
|
|
||||||
|
|
||||||
let decrypted = app_keys
|
let decrypted = app_keys
|
||||||
.nip44_decrypt_async(&master, event.content.as_str())
|
.nip44_decrypt_async(&master, event.content.as_str())
|
||||||
@@ -877,13 +1231,41 @@ async fn decrypt_approval(
|
|||||||
let secret = SecretKey::from_hex(&decrypted)?;
|
let secret = SecretKey::from_hex(&decrypted)?;
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
// Reject approvals carrying a key that no longer matches the published announcement.
|
// Reject approvals carrying a key that no longer matches the published announcement
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Custom(10044))
|
.kind(Kind::Custom(10044))
|
||||||
.author(identity)
|
.author(identity)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
|
let announcement = client
|
||||||
|
.database()
|
||||||
|
.query(filter.clone())
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.next();
|
||||||
|
|
||||||
|
let announcement = match announcement {
|
||||||
|
Some(announcement) => Some(announcement),
|
||||||
|
None => {
|
||||||
|
let mut stream = client
|
||||||
|
.stream_events(filter)
|
||||||
|
.timeout(Duration::from_secs(3))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
stream
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.map(|(_url, result)| result)
|
||||||
|
.transpose()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(announcement) = announcement else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"No encryption announcement available to validate the approval"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
let announced = announcement
|
let announced = announcement
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
@@ -895,53 +1277,6 @@ async fn decrypt_approval(
|
|||||||
if keys.public_key() != announced {
|
if keys.public_key() != announced {
|
||||||
return Err(anyhow!("Approval key doesn't match the announcement"));
|
return Err(anyhow!("Approval key doesn't match the announcement"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(keys)
|
Ok(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt and store device keys in the local database.
|
|
||||||
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
|
||||||
where
|
|
||||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
|
||||||
{
|
|
||||||
let public_key = signer.get_public_key_async().await?;
|
|
||||||
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
|
|
||||||
|
|
||||||
// Construct the application data event
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
|
||||||
.tag(Tag::identifier(IDENTIFIER))
|
|
||||||
.finalize_async(signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Save the event to the database
|
|
||||||
client.database().save_event(&event).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get device keys from the local database.
|
|
||||||
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
|
||||||
where
|
|
||||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
|
||||||
{
|
|
||||||
let public_key = signer.get_public_key_async().await?;
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(IDENTIFIER)
|
|
||||||
.author(public_key);
|
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first() {
|
|
||||||
let content = signer
|
|
||||||
.nip44_decrypt_async(&public_key, &event.content)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let secret = SecretKey::parse(&content)?;
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
|
|
||||||
Ok(keys)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Key not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use anyhow::{Error, anyhow};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
/// The storage identifier for the device encryption key
|
||||||
|
const IDENTIFIER: &str = "coop:device";
|
||||||
|
/// The storage identifier for encryption keys retired by a rotation
|
||||||
|
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
|
||||||
|
/// Keep the archived key list bounded
|
||||||
|
const ARCHIVE_LIMIT: usize = 10;
|
||||||
|
/// Seconds in a day, for rotation intervals
|
||||||
|
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Device encryption key material with its creation time
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct DeviceKeys {
|
||||||
|
pub(crate) keys: Keys,
|
||||||
|
pub(crate) created_at: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode the stored key content: secret hex and creation time
|
||||||
|
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
|
||||||
|
format!("{secret}\n{}", created_at.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse stored key content.
|
||||||
|
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
|
||||||
|
match content.split_once('\n') {
|
||||||
|
Some((secret, created_at)) => {
|
||||||
|
let created_at = created_at
|
||||||
|
.trim()
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or_else(|_| Timestamp::now().as_secs());
|
||||||
|
Ok((
|
||||||
|
SecretKey::from_hex(secret)?,
|
||||||
|
Timestamp::from_secs(created_at),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse archived secrets from stored content, one hex secret per line
|
||||||
|
fn parse_archived_keys(content: &str) -> Vec<Keys> {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
|
||||||
|
.map(Keys::new)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the device keys with their creation time from the local database.
|
||||||
|
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(IDENTIFIER)
|
||||||
|
.author(public_key);
|
||||||
|
|
||||||
|
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||||
|
return Err(anyhow!("Key not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = signer
|
||||||
|
.nip44_decrypt_async(&public_key, &event.content)
|
||||||
|
.await?;
|
||||||
|
let (secret, created_at) = parse_keys_content(&content)?;
|
||||||
|
|
||||||
|
Ok(DeviceKeys {
|
||||||
|
keys: Keys::new(secret),
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt and store device keys in the local database.
|
||||||
|
pub(crate) async fn set_keys<S>(
|
||||||
|
client: &Client,
|
||||||
|
signer: &S,
|
||||||
|
secret: &str,
|
||||||
|
created_at: Timestamp,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
let content = signer
|
||||||
|
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Construct the application data event
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
|
.tag(Tag::identifier(IDENTIFIER))
|
||||||
|
.finalize_async(signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Save the event to the database
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an encryption secret to the bounded archive of retired keys.
|
||||||
|
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(ARCHIVE_IDENTIFIER)
|
||||||
|
.author(public_key);
|
||||||
|
|
||||||
|
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
|
||||||
|
{
|
||||||
|
Some(event) => {
|
||||||
|
let content = signer
|
||||||
|
.nip44_decrypt_async(&public_key, &event.content)
|
||||||
|
.await?;
|
||||||
|
content.lines().map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
archived.insert(0, secret.to_string());
|
||||||
|
archived.truncate(ARCHIVE_LIMIT);
|
||||||
|
|
||||||
|
let content = signer
|
||||||
|
.nip44_encrypt_async(&public_key, &archived.join("\n"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
|
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
|
||||||
|
.finalize_async(signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get device keys from the local database.
|
||||||
|
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
|
Ok(load_device_keys(client, signer).await?.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load encryption keys retired by a rotation from the local database.
|
||||||
|
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(ARCHIVE_IDENTIFIER)
|
||||||
|
.author(public_key);
|
||||||
|
|
||||||
|
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||||
|
return Ok(vec![]);
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = signer
|
||||||
|
.nip44_decrypt_async(&public_key, &event.content)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(parse_archived_keys(&content))
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
|
||||||
|
futures.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
|
|||||||
+43
-20
@@ -3,7 +3,10 @@ use std::sync::RwLock;
|
|||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::emojis_from_tags;
|
use common::emojis_from_tags;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
use futures::{FutureExt, pin_mut};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Subscription, Task,
|
||||||
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -51,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
Person(Person),
|
Person(Box<Person>),
|
||||||
Announcement(Event),
|
Announcement(Event),
|
||||||
Relays(Event),
|
Relays(Event),
|
||||||
Emojis(Event),
|
Emojis(Event),
|
||||||
@@ -98,8 +101,9 @@ impl PersonRegistry {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let client3 = client.clone();
|
let client3 = client.clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
tasks.push(cx.background_spawn(async move {
|
tasks.push(cx.background_spawn(async move {
|
||||||
Self::handle_requests(&client3, &metadata_rx).await;
|
Self::handle_requests(&client3, &metadata_rx, executor).await;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |this, cx| {
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
@@ -107,7 +111,7 @@ impl PersonRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
match event {
|
match event {
|
||||||
Dispatch::Person(person) => {
|
Dispatch::Person(person) => {
|
||||||
this.insert(person, cx);
|
this.insert(*person, cx);
|
||||||
}
|
}
|
||||||
Dispatch::Announcement(event) => {
|
Dispatch::Announcement(event) => {
|
||||||
this.set_announcement(&event, cx);
|
this.set_announcement(&event, cx);
|
||||||
@@ -171,7 +175,11 @@ impl PersonRegistry {
|
|||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
let person = Person::new(event.pubkey, metadata);
|
let person = Person::new(event.pubkey, metadata);
|
||||||
|
|
||||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
if tx
|
||||||
|
.send_async(Dispatch::Person(Box::new(person)))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
log::warn!("Channel closed, dropping metadata event");
|
log::warn!("Channel closed, dropping metadata event");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,30 +201,45 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle request for metadata
|
/// Handle request for metadata
|
||||||
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
|
async fn handle_requests(
|
||||||
|
client: &Client,
|
||||||
|
rx: &flume::Receiver<PublicKey>,
|
||||||
|
executor: BackgroundExecutor,
|
||||||
|
) {
|
||||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match flume::Selector::new()
|
let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse();
|
||||||
.recv(rx, |result| result.ok())
|
pin_mut!(timer);
|
||||||
.wait_timeout(Duration::from_secs(TIMEOUT))
|
|
||||||
{
|
let request = futures::select! {
|
||||||
Ok(Some(public_key)) => {
|
result = rx.recv_async().fuse() => match result {
|
||||||
batch.insert(public_key);
|
Ok(public_key) => public_key,
|
||||||
// Process the batch if it's full
|
Err(_) => {
|
||||||
if batch.len() >= 20
|
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !batch.is_empty()
|
if !batch.is_empty()
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
{
|
{
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
() = timer => {
|
||||||
|
if !batch.is_empty()
|
||||||
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
batch.insert(request);
|
||||||
|
// Process the batch if it's full
|
||||||
|
if batch.len() >= 20
|
||||||
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ impl Person {
|
|||||||
|
|
||||||
/// Set profile encryption keys announcement
|
/// Set profile encryption keys announcement
|
||||||
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
||||||
|
// A delayed or repeated announcement must not roll the key back
|
||||||
|
if let Some(current) = &self.announcement
|
||||||
|
&& !announcement.is_newer_than(current)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.announcement = Some(announcement);
|
self.announcement = Some(announcement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ impl WebOfTrust {
|
|||||||
.idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT));
|
.idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT));
|
||||||
|
|
||||||
self.syncing = true;
|
self.syncing = true;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||||
// Scope the reconcile to the follow set known so far
|
// Scope the reconcile to the follow set known so far
|
||||||
@@ -265,12 +266,13 @@ impl WebOfTrust {
|
|||||||
|
|
||||||
this.snapshot = Some(snapshot);
|
this.snapshot = Some(snapshot);
|
||||||
cx.emit(WoTEvent);
|
cx.emit(WoTEvent);
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
if grew {
|
if grew {
|
||||||
this.sync(cx);
|
this.sync(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}));
|
}));
|
||||||
@@ -314,6 +316,11 @@ impl WebOfTrust {
|
|||||||
.is_some_and(|snapshot| !snapshot.disabled)
|
.is_some_and(|snapshot| !snapshot.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a reconciliation with the relays is in flight
|
||||||
|
pub fn is_syncing(&self) -> bool {
|
||||||
|
self.syncing
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the snapshot covers a follow set
|
/// Whether the snapshot covers a follow set
|
||||||
fn has_data(&self) -> bool {
|
fn has_data(&self) -> bool {
|
||||||
self.snapshot
|
self.snapshot
|
||||||
|
|||||||
+70
-27
@@ -1,12 +1,17 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
|
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
|
||||||
|
/// Browser storage key for the settings JSON
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
const SETTINGS_KEY: &str = "coop.settings";
|
||||||
|
|
||||||
/// The user's appearance preference, persisted with the rest of the settings.
|
/// The user's appearance preference, persisted with the rest of the settings.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub enum AppearanceMode {
|
pub enum AppearanceMode {
|
||||||
@@ -63,21 +68,21 @@ pub fn init(cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read the persisted appearance mode synchronously.
|
/// Read the persisted appearance mode synchronously.
|
||||||
///
|
|
||||||
/// The async load fills the global settings later; the native theme must be
|
|
||||||
/// applied before the first window opens, so boot reads the mode straight
|
|
||||||
/// from the settings file instead.
|
|
||||||
fn persisted_settings() -> Option<Settings> {
|
fn persisted_settings() -> Option<Settings> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
let content = persisted_content();
|
||||||
{
|
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted settings JSON, from the config file or browser storage
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn persisted_content() -> Option<String> {
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
if let Ok(content) = std::fs::read_to_string(path)
|
std::fs::read_to_string(path).ok()
|
||||||
&& let Ok(settings) = serde_json::from_str::<Settings>(&content)
|
}
|
||||||
{
|
|
||||||
return Some(settings);
|
#[cfg(target_arch = "wasm32")]
|
||||||
}
|
fn persisted_content() -> Option<String> {
|
||||||
}
|
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||||
@@ -87,10 +92,6 @@ pub fn persisted_appearance_mode() -> AppearanceMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read the persisted theme tuning synchronously.
|
/// Read the persisted theme tuning synchronously.
|
||||||
///
|
|
||||||
/// Like [`persisted_appearance_mode`], the native theme is applied before the
|
|
||||||
/// async settings load completes, so boot reads the tuning straight from the
|
|
||||||
/// settings file instead.
|
|
||||||
pub fn persisted_theme() -> ThemeSettings {
|
pub fn persisted_theme() -> ThemeSettings {
|
||||||
persisted_settings()
|
persisted_settings()
|
||||||
.map(|settings| settings.theme)
|
.map(|settings| settings.theme)
|
||||||
@@ -99,7 +100,12 @@ pub fn persisted_theme() -> ThemeSettings {
|
|||||||
|
|
||||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||||
|
/// Minimum days between automatic encryption key rotations
|
||||||
|
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
|
||||||
|
/// Maximum days between automatic encryption key rotations
|
||||||
|
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
|
||||||
|
/// Days between automatic encryption key rotations when enabled
|
||||||
|
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
||||||
/// Built-in image shown behind chat and group panels
|
/// Built-in image shown behind chat and group panels
|
||||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||||
|
|
||||||
@@ -146,6 +152,7 @@ setting_accessors! {
|
|||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
pub wot_auto_trust: bool,
|
pub wot_auto_trust: bool,
|
||||||
pub nip4e: bool,
|
pub nip4e: bool,
|
||||||
|
pub nip4e_rotation_days: Option<u32>,
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
pub panel_background: bool,
|
pub panel_background: bool,
|
||||||
@@ -221,6 +228,9 @@ pub struct Settings {
|
|||||||
pub wot_auto_trust: bool,
|
pub wot_auto_trust: bool,
|
||||||
/// Enable decoupling encryption key
|
/// Enable decoupling encryption key
|
||||||
pub nip4e: bool,
|
pub nip4e: bool,
|
||||||
|
/// Days between automatic encryption key rotations; None disables rotation
|
||||||
|
#[serde(default)]
|
||||||
|
pub nip4e_rotation_days: Option<u32>,
|
||||||
/// Hide the notice that messages in public groups are not encrypted
|
/// Hide the notice that messages in public groups are not encrypted
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
@@ -253,6 +263,7 @@ impl Default for Settings {
|
|||||||
screening: true,
|
screening: true,
|
||||||
wot_auto_trust: true,
|
wot_auto_trust: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
|
nip4e_rotation_days: None,
|
||||||
hide_group_notice: false,
|
hide_group_notice: false,
|
||||||
theme: ThemeSettings::default(),
|
theme: ThemeSettings::default(),
|
||||||
panel_background: true,
|
panel_background: true,
|
||||||
@@ -329,22 +340,40 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the persisted theme tuning on top of the active theme
|
|
||||||
pub fn apply_theme_style(cx: &mut App) {
|
pub fn apply_theme_style(cx: &mut App) {
|
||||||
|
Self::apply_theme_tuning(Self::get_theme(cx), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
|
||||||
use gpui_kit::component::Theme;
|
use gpui_kit::component::Theme;
|
||||||
|
|
||||||
let theme = Self::get_theme(cx);
|
{
|
||||||
let styled = Theme::global_mut(cx);
|
let styled = Theme::global_mut(cx);
|
||||||
styled.font_size = px(theme.font_size);
|
styled.font_size = px(tuning.font_size);
|
||||||
styled.focus_ring = theme.focus_ring;
|
styled.focus_ring = tuning.focus_ring;
|
||||||
if let Some(radius) = theme.radius {
|
if let Some(radius) = tuning.radius {
|
||||||
styled.radius = px(radius);
|
styled.radius = px(radius);
|
||||||
}
|
}
|
||||||
if let Some(radius_lg) = theme.radius_lg {
|
if let Some(radius_lg) = tuning.radius_lg {
|
||||||
styled.radius_lg = px(radius_lg);
|
styled.radius_lg = px(radius_lg);
|
||||||
}
|
}
|
||||||
Theme::sync_base(cx);
|
}
|
||||||
cx.refresh_windows();
|
|
||||||
|
Theme::update(cx, |styled| {
|
||||||
|
let background = styled.background;
|
||||||
|
let foreground = styled.foreground;
|
||||||
|
let (hover_alpha, active_alpha) = if styled.is_dark() {
|
||||||
|
(0.08, 0.12)
|
||||||
|
} else {
|
||||||
|
(0.04, 0.06)
|
||||||
|
};
|
||||||
|
if styled.list_hover == background {
|
||||||
|
styled.list_hover = foreground.alpha(hover_alpha);
|
||||||
|
}
|
||||||
|
if styled.tab_active == background {
|
||||||
|
styled.tab_active = foreground.alpha(active_alpha);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
@@ -357,6 +386,14 @@ impl AppSettings {
|
|||||||
return Ok(serde_json::from_str(&content)?);
|
return Ok(serde_json::from_str(&content)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
|
||||||
|
return Ok(serde_json::from_str(&content)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(anyhow!("Not found"))
|
Err(anyhow!("Not found"))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,6 +426,11 @@ impl AppSettings {
|
|||||||
smol::fs::write(&path, content).await.ok();
|
smol::fs::write(&path, content).await.ok();
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
|
||||||
|
log::warn!("failed to persist settings: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +444,8 @@ impl AppSettings {
|
|||||||
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
||||||
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
||||||
}
|
}
|
||||||
cx.refresh_windows();
|
let tuning = self.inner.read(cx).theme;
|
||||||
|
Self::apply_theme_tuning(tuning, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if decoupling encryption key is enabled
|
/// Check if decoupling encryption key is enabled
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "speech"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
|
flume.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
|
tts.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
parakeet-rs = "=0.3.8"
|
||||||
|
ort = "=2.0.0-rc.13"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"blocking",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
js-sys = "0.3"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
web-sys = { version = "0.3", features = ["Window"] }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod model;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod parakeet;
|
||||||
|
pub mod speaker;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod web;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
|
||||||
|
Styled as _, div,
|
||||||
|
};
|
||||||
|
use gpui_kit::component::ActiveTheme as _;
|
||||||
|
use gpui_kit::component::speech::SpeechRecognizer;
|
||||||
|
|
||||||
|
/// A ticking `M:SS` counter for the dictation in progress.
|
||||||
|
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.with_animation(
|
||||||
|
"dictation-elapsed",
|
||||||
|
Animation::new(Duration::from_secs(1))
|
||||||
|
.repeat()
|
||||||
|
.with_max_fps(1.),
|
||||||
|
move |counter, _| counter.child(format_elapsed(started)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(started: instant::Instant) -> String {
|
||||||
|
let seconds = started.elapsed().as_secs();
|
||||||
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recognizer backing the composer's microphone button.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn recognizer() -> impl SpeechRecognizer {
|
||||||
|
parakeet::ParakeetRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recognizer backing the composer's microphone button.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn recognizer() -> impl SpeechRecognizer {
|
||||||
|
web::WebSpeechRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio source backing the composer's microphone button on the web.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
|
||||||
|
web::SelfCapturedInput
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
|
||||||
|
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
|
||||||
|
"source": "nvidia/parakeet-tdt-0.6b-v3",
|
||||||
|
"license": "CC-BY-4.0",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "decoder_joint-model.int8.onnx",
|
||||||
|
"size": 18202004,
|
||||||
|
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "encoder-model.int8.onnx",
|
||||||
|
"size": 652183999,
|
||||||
|
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vocab.txt",
|
||||||
|
"size": 93939,
|
||||||
|
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// How the model is currently available.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Status {
|
||||||
|
NotInstalled,
|
||||||
|
Downloading { downloaded: u64, total: u64 },
|
||||||
|
Installed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
pub fn is_downloading(self) -> bool {
|
||||||
|
matches!(self, Status::Downloading { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Manifest {
|
||||||
|
repository: String,
|
||||||
|
revision: String,
|
||||||
|
files: Vec<Artifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Artifact {
|
||||||
|
name: String,
|
||||||
|
size: u64,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest() -> &'static Manifest {
|
||||||
|
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
|
||||||
|
|
||||||
|
MANIFEST.get_or_init(|| {
|
||||||
|
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory the model artifacts are downloaded into.
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
common::config_dir()
|
||||||
|
.join("speech")
|
||||||
|
.join("parakeet-tdt-0.6b-v3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total download size in bytes.
|
||||||
|
pub fn total_size() -> u64 {
|
||||||
|
manifest().files.iter().map(|file| file.size).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The revision of the installed model, if its artifacts are complete.
|
||||||
|
fn installed_revision() -> Option<String> {
|
||||||
|
let manifest = manifest();
|
||||||
|
let dir = dir();
|
||||||
|
|
||||||
|
let complete = manifest.files.iter().all(|file| {
|
||||||
|
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
|
||||||
|
});
|
||||||
|
|
||||||
|
if complete
|
||||||
|
&& fs::read_to_string(dir.join("verified"))
|
||||||
|
.is_ok_and(|revision| revision == manifest.revision)
|
||||||
|
{
|
||||||
|
Some(manifest.revision.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the model is ready to load.
|
||||||
|
pub fn installed() -> bool {
|
||||||
|
status() == Status::Installed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current status, reconciling the download state with the disk.
|
||||||
|
pub fn status() -> Status {
|
||||||
|
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if let Download::Running {
|
||||||
|
downloaded, total, ..
|
||||||
|
} = state.download
|
||||||
|
{
|
||||||
|
return Status::Downloading { downloaded, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
if installed_revision().is_some() {
|
||||||
|
Status::Installed
|
||||||
|
} else {
|
||||||
|
Status::NotInstalled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the last download failure, if it has not been reported yet.
|
||||||
|
pub fn take_error() -> Option<String> {
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.error.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start downloading the model, unless a download is already running.
|
||||||
|
pub fn start_download() {
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if matches!(state.download, Download::Running { .. }) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
state.error = None;
|
||||||
|
state.download = Download::Running {
|
||||||
|
downloaded: 0,
|
||||||
|
total: total_size(),
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = std::thread::Builder::new()
|
||||||
|
.name("speech-model-download".into())
|
||||||
|
.spawn(move || download(cancel))
|
||||||
|
{
|
||||||
|
state.download = Download::Idle;
|
||||||
|
state.error = Some(error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request cancellation of the running download.
|
||||||
|
pub fn cancel_download() {
|
||||||
|
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if let Download::Running { cancel, .. } = &state.download {
|
||||||
|
cancel.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the downloaded model and any partial download.
|
||||||
|
pub fn delete() {
|
||||||
|
super::parakeet::unload();
|
||||||
|
cancel_download();
|
||||||
|
|
||||||
|
if let Err(error) = fs::remove_dir_all(dir())
|
||||||
|
&& error.kind() != io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
log::warn!("failed to remove the speech model: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download(cancel: Arc<AtomicBool>) {
|
||||||
|
let result = download_inner(&cancel);
|
||||||
|
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.download = Download::Idle;
|
||||||
|
|
||||||
|
// A cancelled download must not report a spurious failure.
|
||||||
|
if !cancelled(&cancel)
|
||||||
|
&& let Err(error) = result
|
||||||
|
{
|
||||||
|
state.error = Some(format!("{error:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_inner(cancel: &AtomicBool) -> Result<()> {
|
||||||
|
let manifest = manifest();
|
||||||
|
let dir = dir();
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
for file in &manifest.files {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let staging = dir.join(format!("{}.part", file.name));
|
||||||
|
let result = download_file(&client, manifest, file, &staging, cancel);
|
||||||
|
|
||||||
|
if result.is_err() || cancelled(cancel) {
|
||||||
|
let _unused = fs::remove_file(&staging);
|
||||||
|
result?;
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(dir.join("verified"), &manifest.revision)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_file(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
manifest: &Manifest,
|
||||||
|
file: &Artifact,
|
||||||
|
staging: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
|
use sha2::Digest as _;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://huggingface.co/{}/resolve/{}/{}",
|
||||||
|
manifest.repository, manifest.revision, file.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut response = client.get(url).send()?.error_for_status()?;
|
||||||
|
let mut out = fs::File::create(staging)?;
|
||||||
|
let mut hash = sha2::Sha256::new();
|
||||||
|
let mut size = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk = [0u8; 64 * 1024];
|
||||||
|
let read = response.read(&mut chunk)?;
|
||||||
|
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
size += read as u64;
|
||||||
|
if size > file.size {
|
||||||
|
bail!("Unexpected model size");
|
||||||
|
}
|
||||||
|
|
||||||
|
hash.update(&chunk[..read]);
|
||||||
|
out.write_all(&chunk[..read])?;
|
||||||
|
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
if let Download::Running { downloaded, .. } = &mut state.download {
|
||||||
|
*downloaded += read as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
|
||||||
|
bail!("Model checksum verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sync_all()?;
|
||||||
|
fs::rename(staging, dir().join(&file.name))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancelled(cancel: &AtomicBool) -> bool {
|
||||||
|
cancel.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Download {
|
||||||
|
Idle,
|
||||||
|
Running {
|
||||||
|
downloaded: u64,
|
||||||
|
total: u64,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
download: Download,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> &'static Mutex<State> {
|
||||||
|
static STATE: Mutex<State> = Mutex::new(State {
|
||||||
|
download: Download::Idle,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
&STATE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_pins_v3_artifacts_with_exact_sizes() {
|
||||||
|
let manifest = manifest();
|
||||||
|
|
||||||
|
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
|
||||||
|
assert_eq!(manifest.revision.len(), 40);
|
||||||
|
assert_eq!(manifest.files.len(), 3);
|
||||||
|
assert_eq!(total_size(), 670_479_942);
|
||||||
|
assert!(
|
||||||
|
manifest
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use gpui::{App, Task};
|
||||||
|
use gpui_kit::component::speech::{
|
||||||
|
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
|
||||||
|
};
|
||||||
|
use parakeet_rs::{ParakeetTDT, Transcriber as _};
|
||||||
|
|
||||||
|
const MODEL_RATE: u32 = 16_000;
|
||||||
|
const MAX_SECONDS: usize = 120;
|
||||||
|
|
||||||
|
/// The on-device recognizer.
|
||||||
|
pub struct ParakeetRecognizer;
|
||||||
|
|
||||||
|
impl SpeechRecognizer for ParakeetRecognizer {
|
||||||
|
fn audio_format(&self) -> AudioFormat {
|
||||||
|
AudioFormat::new(MODEL_RATE, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self, _cx: &App) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
sink: SpeechSink,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
|
if super::model::installed() {
|
||||||
|
worker().send_or_log(Command::Warm(super::model::dir()));
|
||||||
|
} else if super::model::status().is_downloading() {
|
||||||
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
|
"The speech model is still downloading. Try again when it finishes."
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
|
"No speech model is installed. Download it from Settings."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session buffers everything it is pushed,
|
||||||
|
// so audio is accepted right away.
|
||||||
|
sink.ready(cx);
|
||||||
|
|
||||||
|
Ok(Box::new(ParakeetSession {
|
||||||
|
sink,
|
||||||
|
samples: Vec::new(),
|
||||||
|
_result: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParakeetSession {
|
||||||
|
sink: SpeechSink,
|
||||||
|
samples: Vec<f32>,
|
||||||
|
_result: Option<Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecognitionSession for ParakeetSession {
|
||||||
|
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
|
||||||
|
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
|
||||||
|
self.samples
|
||||||
|
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, cx: &mut App) {
|
||||||
|
let samples = std::mem::take(&mut self.samples);
|
||||||
|
let sink = self.sink.clone();
|
||||||
|
|
||||||
|
// Speech too short to transcribe ends the session at once.
|
||||||
|
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
|
||||||
|
if samples.len() < MODEL_RATE as usize / 5 || silent {
|
||||||
|
sink.finish(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded();
|
||||||
|
worker().send_or_log(Command::Transcribe { samples, sender });
|
||||||
|
|
||||||
|
self._result = Some(cx.spawn(async move |cx| {
|
||||||
|
let result = receiver.recv_async().await;
|
||||||
|
|
||||||
|
cx.update(|cx| match result {
|
||||||
|
Ok(Ok(text)) if !text.is_empty() => {
|
||||||
|
sink.phrase(text, cx);
|
||||||
|
sink.finish(cx);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => sink.finish(cx),
|
||||||
|
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
|
||||||
|
Err(_) => sink.error(
|
||||||
|
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
|
||||||
|
cx,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Command {
|
||||||
|
Warm(PathBuf),
|
||||||
|
Transcribe {
|
||||||
|
samples: Vec<f32>,
|
||||||
|
sender: flume::Sender<Result<String, anyhow::Error>>,
|
||||||
|
},
|
||||||
|
Unload,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to the single inference worker.
|
||||||
|
fn worker() -> &'static flume::Sender<Command> {
|
||||||
|
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
|
||||||
|
|
||||||
|
WORKER.get_or_init(|| {
|
||||||
|
let (sender, receiver) = flume::unbounded::<Command>();
|
||||||
|
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("speech-parakeet".into())
|
||||||
|
.spawn(move || run_worker(receiver));
|
||||||
|
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
log::error!("failed to spawn the speech worker: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
sender
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_worker(receiver: flume::Receiver<Command>) {
|
||||||
|
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
|
||||||
|
|
||||||
|
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
|
||||||
|
match command {
|
||||||
|
Command::Unload => {
|
||||||
|
cached = None;
|
||||||
|
}
|
||||||
|
Command::Warm(dir) => {
|
||||||
|
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
load(&mut cached, &dir).map(|_| ())
|
||||||
|
}));
|
||||||
|
|
||||||
|
match flatten_panic(loaded) {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(error)) | Err(error) => {
|
||||||
|
log::warn!("failed to load the speech model: {error:#}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Transcribe { samples, sender } => {
|
||||||
|
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
transcribe(&mut cached, samples)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let result = match flatten_panic(transcribed) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
cached = None;
|
||||||
|
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sender.send(result).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its result arrived");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<'a>(
|
||||||
|
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
|
||||||
|
dir: &PathBuf,
|
||||||
|
) -> Result<&'a mut ParakeetTDT> {
|
||||||
|
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
|
||||||
|
let model = ParakeetTDT::from_pretrained(dir, None)
|
||||||
|
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
|
||||||
|
*cached = Some((dir.clone(), model));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
|
||||||
|
let model = load(cached, &super::model::dir())?;
|
||||||
|
|
||||||
|
model
|
||||||
|
.transcribe_samples(samples, MODEL_RATE, 1, None)
|
||||||
|
.map(|result| result.text)
|
||||||
|
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
trait SendOrLog {
|
||||||
|
fn send_or_log(&self, command: Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendOrLog for flume::Sender<Command> {
|
||||||
|
fn send_or_log(&self, command: Command) {
|
||||||
|
if self.send(command).is_err() {
|
||||||
|
log::debug!("speech worker is gone; command dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire cached weights, e.g. after the model was deleted.
|
||||||
|
pub(crate) fn unload() {
|
||||||
|
worker().send_or_log(Command::Unload);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
|
||||||
|
result.map_err(|_| anyhow!("The speech worker panicked"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tts::Tts;
|
||||||
|
|
||||||
|
/// Words per minute of an engine at its normal rate, for estimating speech duration.
|
||||||
|
const NORMAL_WPM: f32 = 160.0;
|
||||||
|
|
||||||
|
/// Extra time past the estimate before speech is considered finished, for backends that
|
||||||
|
/// cannot report utterance end.
|
||||||
|
pub const END_GRACE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Reads text aloud through the platform's speech backend, created on first use.
|
||||||
|
pub struct Speaker {
|
||||||
|
tts: Option<Tts>,
|
||||||
|
end_tx: flume::Sender<()>,
|
||||||
|
end_rx: flume::Receiver<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Speaker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (end_tx, end_rx) = flume::unbounded();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tts: None,
|
||||||
|
end_tx,
|
||||||
|
end_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloneable stream of utterance-finish events.
|
||||||
|
pub fn end_events(&self) -> flume::Receiver<()> {
|
||||||
|
self.end_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speak the text, interrupting what this speaker is already saying.
|
||||||
|
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
|
||||||
|
let end_tx = self.end_tx.clone();
|
||||||
|
let tts = match self.tts.as_ref() {
|
||||||
|
Some(tts) => tts,
|
||||||
|
None => self.tts.insert({
|
||||||
|
let tts = Tts::default()?;
|
||||||
|
if let Err(error) = tts.on_utterance_end(move |_| {
|
||||||
|
// A send error only means the panel dropped its event loop.
|
||||||
|
let _ = end_tx.send(());
|
||||||
|
}) {
|
||||||
|
log::warn!("utterance end events unavailable ({error})");
|
||||||
|
}
|
||||||
|
tts
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
tts.speak(text, true)?;
|
||||||
|
Ok(Self::estimate(tts, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop speaking, a speaker that never spoke is already silent.
|
||||||
|
pub fn stop(&mut self) -> Result<(), tts::Error> {
|
||||||
|
match self.tts.as_ref() {
|
||||||
|
Some(tts) => tts.stop()?,
|
||||||
|
None => return Ok(()),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort speaking state, a speaker that never spoke is silent.
|
||||||
|
pub fn speaking(&self) -> bool {
|
||||||
|
self.tts
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|tts| tts.is_speaking().ok())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long speaking the text is expected to take at the engine's current speed.
|
||||||
|
fn estimate(tts: &Tts, text: &str) -> Duration {
|
||||||
|
let words = text.split_whitespace().count().max(1) as f32;
|
||||||
|
let speed = match (tts.get_rate(), tts.normal_rate()) {
|
||||||
|
(Ok(rate), Ok(normal)) if normal > 0. => (rate / normal).clamp(0.2, 4.),
|
||||||
|
_ => 1.,
|
||||||
|
};
|
||||||
|
Duration::from_secs_f32(words / NORMAL_WPM * 60. / speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Speaker {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use gpui::App;
|
||||||
|
use gpui_kit::Subscription;
|
||||||
|
use gpui_kit::component::speech::{
|
||||||
|
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
|
||||||
|
SpeechSink,
|
||||||
|
};
|
||||||
|
use js_sys::{Array, Function, JsString, Reflect};
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::{JsCast as _, JsValue};
|
||||||
|
use web_sys::window;
|
||||||
|
|
||||||
|
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
|
||||||
|
pub struct WebSpeechRecognizer;
|
||||||
|
|
||||||
|
impl SpeechRecognizer for WebSpeechRecognizer {
|
||||||
|
fn audio_format(&self) -> AudioFormat {
|
||||||
|
AudioFormat::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self, _cx: &App) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
sink: SpeechSink,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
|
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
|
||||||
|
|
||||||
|
Ok(Box::new(recognition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SelfCapturedInput;
|
||||||
|
|
||||||
|
impl AudioInput for SelfCapturedInput {
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
_format: AudioFormat,
|
||||||
|
_sink: AudioSink,
|
||||||
|
_cx: &mut App,
|
||||||
|
) -> Result<Subscription, SpeechError> {
|
||||||
|
Ok(Subscription::new(|| {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
Hypothesis(String),
|
||||||
|
Phrase(String),
|
||||||
|
Error(String),
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Recognition {
|
||||||
|
recognition: JsValue,
|
||||||
|
on_result: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
on_error: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
on_end: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
_task: gpui::Task<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recognition {
|
||||||
|
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
|
||||||
|
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
|
||||||
|
|
||||||
|
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_function())
|
||||||
|
.or_else(|| {
|
||||||
|
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_function())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
|
||||||
|
|
||||||
|
let recognition =
|
||||||
|
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
|
||||||
|
.map_err(|error| js_error("constructing the recognizer", error))?;
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded::<Event>();
|
||||||
|
let error_sender = sender.clone();
|
||||||
|
let end_sender = sender.clone();
|
||||||
|
|
||||||
|
let finalized = RefCell::new(0usize);
|
||||||
|
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
|
||||||
|
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
|
||||||
|
Reflect::get(value, &JsString::from(property)).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(results) = read(&event, "results") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let length = read(&results, "length")
|
||||||
|
.and_then(|length| length.as_f64())
|
||||||
|
.unwrap_or(0.0) as usize;
|
||||||
|
|
||||||
|
let mut finalized = finalized.borrow_mut();
|
||||||
|
let mut interim = String::new();
|
||||||
|
|
||||||
|
for index in *finalized..length {
|
||||||
|
let Some(result) = read(&results, &index.to_string()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(alternative) = read(&result, "0") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_final = read(&result, "isFinal")
|
||||||
|
.and_then(|final_value| final_value.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_final {
|
||||||
|
*finalized += 1;
|
||||||
|
if sender.send(Event::Phrase(text)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled mid-utterance");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interim.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled mid-utterance");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
|
||||||
|
let message = Reflect::get(&event, &JsString::from("error"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|error| error.as_string())
|
||||||
|
.unwrap_or_else(|| "unknown speech error".into());
|
||||||
|
|
||||||
|
if error_sender.send(Event::Error(message)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its error arrived");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
|
||||||
|
if end_sender.send(Event::End).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its end arrived");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
|
||||||
|
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
|
||||||
|
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
|
||||||
|
set_bool(&recognition, "continuous", true)?;
|
||||||
|
set_bool(&recognition, "interimResults", true)?;
|
||||||
|
|
||||||
|
call_method(&recognition, "start")?;
|
||||||
|
|
||||||
|
// The browser consumes audio as soon as it has permission
|
||||||
|
sink.ready(cx);
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |cx| {
|
||||||
|
while let Ok(event) = receiver.recv_async().await {
|
||||||
|
let done = matches!(event, Event::End);
|
||||||
|
|
||||||
|
// The speech state ignores sink calls once its session ended,
|
||||||
|
// so a late event after a cancel is harmless.
|
||||||
|
cx.update(|cx| match event {
|
||||||
|
Event::Hypothesis(text) => sink.hypothesis(text, cx),
|
||||||
|
Event::Phrase(text) => sink.phrase(text, cx),
|
||||||
|
Event::Error(message) => {
|
||||||
|
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
|
||||||
|
}
|
||||||
|
Event::End => sink.finish(cx),
|
||||||
|
});
|
||||||
|
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
recognition,
|
||||||
|
on_result: Some(on_result),
|
||||||
|
on_error: Some(on_error),
|
||||||
|
on_end: Some(on_end),
|
||||||
|
_task: task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
self.on_result = None;
|
||||||
|
self.on_error = None;
|
||||||
|
self.on_end = None;
|
||||||
|
|
||||||
|
let _ignored = call_method(&self.recognition, "abort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Recognition {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecognitionSession for Recognition {
|
||||||
|
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
|
||||||
|
// The browser recognizer captures the microphone itself.
|
||||||
|
// the state still pushes audio through its input, which is a no-op here.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, _cx: &mut App) {
|
||||||
|
// The end handler reports completion, so no result is fabricated here.
|
||||||
|
let _ignored = call_method(&self.recognition, "stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
|
||||||
|
Reflect::set(target, &JsString::from(property), callback)
|
||||||
|
.map_err(|error| js_error(property, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
|
||||||
|
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
|
||||||
|
.map_err(|error| js_error(property, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
|
||||||
|
let function = Reflect::get(target, &JsString::from(method))
|
||||||
|
.map_err(|error| js_error(method, error))?
|
||||||
|
.unchecked_into::<Function>();
|
||||||
|
|
||||||
|
function
|
||||||
|
.call0(target)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| js_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
|
||||||
|
let description = error
|
||||||
|
.as_string()
|
||||||
|
.or_else(|| {
|
||||||
|
Reflect::get(&error, &JsString::from("message"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|message| message.as_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown error".into());
|
||||||
|
|
||||||
|
anyhow!("{what} failed: {description}")
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ futures.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
|
||||||
@@ -28,7 +29,24 @@ sha2.workspace = true
|
|||||||
data-encoding.workspace = true
|
data-encoding.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
js-sys = "0.3"
|
||||||
|
nostr-browser-signer.workspace = true
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
nostr-sqlite.workspace = true
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"Blob",
|
||||||
|
"BlobPropertyBag",
|
||||||
|
"File",
|
||||||
|
"FileList",
|
||||||
|
"HtmlAnchorElement",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"Url",
|
||||||
|
"Window",
|
||||||
|
] }
|
||||||
|
sqlite-wasm-rs = "0.5"
|
||||||
|
sqlite-wasm-vfs = "0.2"
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||||
|
|||||||
+21
-18
@@ -1,17 +1,10 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
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 nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::file::sha256_hex;
|
|
||||||
|
|
||||||
/// Upload a blob to a blossom server and return its URL
|
/// Upload a blob to a blossom server and return its URL
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) async fn upload_blob(
|
pub(crate) async fn upload_blob(
|
||||||
@@ -41,16 +34,26 @@ pub(crate) async fn upload_blob(
|
|||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
/// Upload a blob to a blossom server and return its URL.
|
||||||
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(crate) async fn upload_blob(
|
||||||
Err(anyhow!("File upload not supported on web"))
|
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 base = server.clone();
|
||||||
|
let hash = sha256.to_string();
|
||||||
|
|
||||||
|
match client
|
||||||
|
.upload_blob(data, Some(content_type.to_string()), None, Some(&keys))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(blob) => Ok(blob.url),
|
||||||
|
Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?),
|
||||||
|
Err(e) => Err(anyhow!(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use common::web_store;
|
||||||
|
use gpui::{App, AppContext as _, Task};
|
||||||
|
|
||||||
|
use crate::USER_KEYRING;
|
||||||
|
|
||||||
|
/// The storage key for the log of handled NIP-4e key-sync request ids
|
||||||
|
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
|
||||||
|
|
||||||
|
/// Read the stored credential as `(username, secret)`
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.read_credentials(USER_KEYRING);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
Task::ready(
|
||||||
|
web_store::get(USER_KEYRING)
|
||||||
|
.map(|stored| stored.map(|content| (String::new(), content.into_bytes()))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the stored credential
|
||||||
|
pub(crate) fn write(cx: &App, username: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.write_credentials(USER_KEYRING, username, secret);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = (cx, username);
|
||||||
|
let content = match std::str::from_utf8(secret) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow::anyhow!("credential is not UTF-8: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
Task::ready(web_store::set(USER_KEYRING, content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the stored credential
|
||||||
|
pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
return cx.delete_credentials(USER_KEYRING);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
Task::ready(web_store::remove(USER_KEYRING))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read named bytes from the platform keyring,
|
||||||
|
/// falling back to browser storage on web where no keyring exists
|
||||||
|
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let key = key.to_owned();
|
||||||
|
let read = cx.read_credentials(&key);
|
||||||
|
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
|
||||||
|
Task::ready(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write named bytes to the platform keyring,
|
||||||
|
/// falling back to browser storage on web where no keyring exists
|
||||||
|
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let key = key.to_owned();
|
||||||
|
let secret = secret.to_vec();
|
||||||
|
let write = cx.write_credentials(&key, "", &secret);
|
||||||
|
cx.background_spawn(write)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
let content = match std::str::from_utf8(secret) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
Task::ready(web_store::set(key, content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the log of handled key-sync request ids, as concatenated hex ids
|
||||||
|
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
||||||
|
read_named(cx, PROCESSED_REQUESTS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the log of handled key-sync request ids, as concatenated hex ids
|
||||||
|
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
|
||||||
|
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
|
||||||
|
}
|
||||||
+194
-52
@@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
|||||||
use anyhow::{Error, anyhow, bail};
|
use anyhow::{Error, anyhow, bail};
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use futures::AsyncReadExt;
|
use futures::AsyncReadExt;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::PathPromptOptions;
|
||||||
use gpui::http_client::AsyncBody;
|
use gpui::http_client::AsyncBody;
|
||||||
use gpui::{AsyncApp, SharedString};
|
use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr::nips::nip94::Sha256Hash;
|
use nostr::nips::nip94::Sha256Hash;
|
||||||
@@ -115,6 +117,56 @@ impl FileAttachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file the user picked, read into memory.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PickedFile {
|
||||||
|
pub name: String,
|
||||||
|
pub mime: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub local: LocalFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to a file stored on the user's device
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LocalFile {
|
||||||
|
/// A path on disk (desktop)
|
||||||
|
Path(PathBuf),
|
||||||
|
/// An in-memory blob URL (web)
|
||||||
|
Url(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFile {
|
||||||
|
/// The source an `img` element can render
|
||||||
|
pub fn image_source(&self) -> gpui::ImageSource {
|
||||||
|
match self {
|
||||||
|
LocalFile::Path(path) => path.clone().into(),
|
||||||
|
LocalFile::Url(url) => SharedUri::from(url.clone()).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL the platform opener understands
|
||||||
|
pub fn open_url(&self) -> String {
|
||||||
|
match self {
|
||||||
|
LocalFile::Path(path) => format!("file://{}", path.display()),
|
||||||
|
LocalFile::Url(url) => url.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the file with the platform handler
|
||||||
|
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||||
|
match self {
|
||||||
|
LocalFile::Path(_) => {
|
||||||
|
cx.open_url(&self.open_url());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
LocalFile::Url(url) => crate::web_file::open_url(url),
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
LocalFile::Url(_) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||||
let key = Aes256Gcm::generate_key(OsRng);
|
let key = Aes256Gcm::generate_key(OsRng);
|
||||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||||
@@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
|||||||
Sha256Hash::from_byte_array(hash).to_hex()
|
Sha256Hash::from_byte_array(hash).to_hex()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
/// Ask the user for a single file and read it into memory
|
||||||
pub async fn upload_encrypted(
|
pub fn pick_file(cx: &gpui::App) -> Task<Result<Option<PickedFile>, Error>> {
|
||||||
server: Url,
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
path: PathBuf,
|
{
|
||||||
cx: &AsyncApp,
|
let dialog = cx.prompt_for_paths(PathPromptOptions {
|
||||||
) -> Result<FileAttachment, Error> {
|
files: true,
|
||||||
|
directories: false,
|
||||||
|
multiple: false,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let Some(path) = dialog.await?? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?;
|
||||||
|
|
||||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned());
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.display().to_string());
|
||||||
let data = smol::fs::read(&path).await?;
|
let data = smol::fs::read(&path).await?;
|
||||||
|
|
||||||
let encrypted = encrypt(&data)?;
|
Ok(Some(PickedFile {
|
||||||
|
name,
|
||||||
|
mime,
|
||||||
|
data,
|
||||||
|
local: LocalFile::Path(path),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = cx;
|
||||||
|
cx.background_spawn(async move { crate::web_file::pick_file().await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a file unencrypted and return its URL
|
||||||
|
pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
|
let PickedFile { mime, data, .. } = file;
|
||||||
|
let sha256 = sha256_hex(&data);
|
||||||
|
|
||||||
|
crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a file encrypted as an opaque blob, returning the attachment metadata
|
||||||
|
pub async fn upload_encrypted_file(
|
||||||
|
server: Url,
|
||||||
|
file: PickedFile,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<FileAttachment, Error> {
|
||||||
|
let encrypted = encrypt(&file.data)?;
|
||||||
let sha256 = sha256_hex(&encrypted.data);
|
let sha256 = sha256_hex(&encrypted.data);
|
||||||
let original_sha256 = sha256_hex(&data);
|
let original_sha256 = sha256_hex(&file.data);
|
||||||
let size = encrypted.data.len() as u64;
|
let size = encrypted.data.len() as u64;
|
||||||
let base_url = server.to_string();
|
let base_url = server.to_string();
|
||||||
|
|
||||||
@@ -207,26 +301,17 @@ pub async fn upload_encrypted(
|
|||||||
|
|
||||||
Ok(FileAttachment {
|
Ok(FileAttachment {
|
||||||
url,
|
url,
|
||||||
mime,
|
mime: file.mime,
|
||||||
key: encrypted.key,
|
key: encrypted.key,
|
||||||
nonce: encrypted.nonce,
|
nonce: encrypted.nonce,
|
||||||
sha256: Some(sha256),
|
sha256: Some(sha256),
|
||||||
original_sha256: Some(original_sha256),
|
original_sha256: Some(original_sha256),
|
||||||
size: Some(size),
|
size: Some(size),
|
||||||
dim: None,
|
dim: None,
|
||||||
name,
|
name: Some(file.name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub async fn upload_encrypted(
|
|
||||||
_server: Url,
|
|
||||||
_path: PathBuf,
|
|
||||||
_cx: &AsyncApp,
|
|
||||||
) -> Result<FileAttachment, Error> {
|
|
||||||
Err(anyhow!("File upload not supported on web"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn download_and_decrypt(
|
pub(crate) async fn download_and_decrypt(
|
||||||
url: &Url,
|
url: &Url,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt(
|
|||||||
decrypt(&data, key, nonce)
|
decrypt(&data, key, nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and decrypt a file attachment into a temporary file.
|
/// A downloaded and decrypted attachment on the user's device
|
||||||
///
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// The same attachment always maps to the same path, so callers can render the
|
pub struct DownloadedFile {
|
||||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
pub local: LocalFile,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
pub mime: String,
|
||||||
pub async fn download_and_decrypt_to_file(
|
pub name: String,
|
||||||
file: &FileAttachment,
|
/// The decrypted bytes, kept so the web can offer them as a download
|
||||||
cx: &AsyncApp,
|
#[cfg(target_arch = "wasm32")]
|
||||||
) -> Result<PathBuf, Error> {
|
pub(crate) data: Vec<u8>,
|
||||||
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)
|
impl DownloadedFile {
|
||||||
.and_then(|extensions| extensions.first())
|
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||||
.copied()
|
match &self.local {
|
||||||
.unwrap_or("bin");
|
LocalFile::Path(path) => {
|
||||||
|
cx.open_with_system(path);
|
||||||
let path = std::env::temp_dir()
|
Ok(())
|
||||||
.join("coop-files")
|
}
|
||||||
.join(format!("{name}.{extension}"));
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
LocalFile::Url(url) => {
|
||||||
if smol::fs::metadata(&path).await.is_ok() {
|
if self.mime.starts_with("image/") {
|
||||||
return Ok(path);
|
crate::web_file::open_url(url)
|
||||||
|
} else {
|
||||||
|
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
LocalFile::Url(_) => Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save the file to the user's downloads (web)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn save(&self) -> Result<(), Error> {
|
||||||
|
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt a file attachment to the user's device
|
||||||
|
pub async fn download_and_decrypt_file(
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<DownloadedFile, Error> {
|
||||||
let data = download_and_decrypt(
|
let data = download_and_decrypt(
|
||||||
&file.url,
|
&file.url,
|
||||||
&file.key,
|
&file.key,
|
||||||
@@ -297,6 +398,37 @@ pub async fn download_and_decrypt_to_file(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let name = file
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| fallback_name(&file.url));
|
||||||
|
|
||||||
|
let local = store_file(&name, &file.mime, &data).await?;
|
||||||
|
|
||||||
|
Ok(DownloadedFile {
|
||||||
|
local,
|
||||||
|
mime: file.mime.clone(),
|
||||||
|
name,
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist decrypted bytes somewhere the platform can render or open them
|
||||||
|
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let digest = sha256_hex(name.as_bytes());
|
||||||
|
let extension = mime_guess::get_mime_extensions_str(mime)
|
||||||
|
.and_then(|extensions| extensions.first())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("bin");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join("coop-files")
|
||||||
|
.join(format!("{digest}.{extension}"));
|
||||||
|
|
||||||
|
if smol::fs::metadata(&path).await.is_err() {
|
||||||
let Some(parent) = path.parent() else {
|
let Some(parent) = path.parent() else {
|
||||||
bail!("Invalid file path");
|
bail!("Invalid file path");
|
||||||
};
|
};
|
||||||
@@ -306,16 +438,26 @@ pub async fn download_and_decrypt_to_file(
|
|||||||
let partial = path.with_extension("download");
|
let partial = path.with_extension("download");
|
||||||
smol::fs::write(&partial, data).await?;
|
smol::fs::write(&partial, data).await?;
|
||||||
smol::fs::rename(&partial, &path).await?;
|
smol::fs::rename(&partial, &path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(path)
|
Ok(LocalFile::Path(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = name;
|
||||||
|
let url = crate::web_file::blob_url(mime, data)?;
|
||||||
|
Ok(LocalFile::Url(url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
/// A file name derived from the attachment URL's last path segment
|
||||||
pub async fn download_and_decrypt_to_file(
|
fn fallback_name(url: &Url) -> String {
|
||||||
_file: &FileAttachment,
|
url.path()
|
||||||
_cx: &AsyncApp,
|
.rsplit('/')
|
||||||
) -> Result<PathBuf, Error> {
|
.find(|segment| !segment.is_empty())
|
||||||
Err(anyhow!("File download not supported on web"))
|
.unwrap_or("attachment")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
|
|||||||
+63
-18
@@ -4,26 +4,33 @@ use std::sync::Arc;
|
|||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use browser_signer_proxy::prelude::*;
|
use browser_signer_proxy::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use nostr_browser_signer::BrowserSigner;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use nostr_memory::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
pub mod credentials;
|
||||||
mod file;
|
mod file;
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod nip4e;
|
mod nip4e;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web_database;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web_file;
|
||||||
|
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
pub use file::*;
|
pub use file::*;
|
||||||
pub use nip4e::*;
|
pub use nip4e::*;
|
||||||
@@ -107,7 +114,6 @@ impl NostrRegistry {
|
|||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
// Construct the nostr lmdb instance
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let database = cx.foreground_executor().block_on(async move {
|
let database = cx.foreground_executor().block_on(async move {
|
||||||
NostrLmdb::open(config_dir().join("nostr"))
|
NostrLmdb::open(config_dir().join("nostr"))
|
||||||
@@ -116,7 +122,7 @@ impl NostrRegistry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let database = MemoryDatabase::unbounded();
|
let database = web_database::take();
|
||||||
|
|
||||||
// Construct the nostr client
|
// Construct the nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
@@ -137,9 +143,7 @@ impl NostrRegistry {
|
|||||||
let _ = entity.update(cx, |this, cx| {
|
let _ = entity.update(cx, |this, cx| {
|
||||||
this.connect_bootstrap_relays(cx);
|
this.connect_bootstrap_relays(cx);
|
||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if let Some(secret) = cli_key {
|
||||||
cx.emit(StateEvent::NoSigner);
|
|
||||||
} else if let Some(secret) = cli_key {
|
|
||||||
// Use CLI-provided key -- same path as restore_session
|
// Use CLI-provided key -- same path as restore_session
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
@@ -241,7 +245,7 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
/// Restore the signer from the stored credential
|
/// Restore the signer from the stored credential
|
||||||
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = credentials::read(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -278,6 +282,11 @@ impl NostrRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.connect_proxy(cx);
|
this.connect_proxy(cx);
|
||||||
})?;
|
})?;
|
||||||
|
} else if content == "extension" {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.connect_extension(cx);
|
||||||
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_, cx| {
|
this.update(cx, |_, cx| {
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
@@ -313,7 +322,7 @@ impl NostrRegistry {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PublicKey, Error>> {
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
let password = password.to_owned();
|
let password = password.to_owned();
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = credentials::read(cx);
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let content = user_keyring
|
let content = user_keyring
|
||||||
@@ -372,9 +381,8 @@ impl NostrRegistry {
|
|||||||
let (keys, ncryptsec) = job.await?;
|
let (keys, ncryptsec) = job.await?;
|
||||||
let public_key = keys.public_key();
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
let write = cx.update(|cx| {
|
let write =
|
||||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes()));
|
||||||
});
|
|
||||||
write.await?;
|
write.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -411,9 +419,8 @@ impl NostrRegistry {
|
|||||||
let keys = decrypt_task.await?;
|
let keys = decrypt_task.await?;
|
||||||
let public_key = keys.public_key();
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
let write = cx.update(|cx| {
|
let write =
|
||||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes()));
|
||||||
});
|
|
||||||
write.await?;
|
write.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -439,7 +446,7 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let credential = with_master_key(&uri_string, &keys);
|
let credential = with_master_key(&uri_string, &keys);
|
||||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
let write = credentials::write(cx, "bunker", credential.as_bytes());
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let signer = NostrConnect::builder(connect_uri, keys)
|
let signer = NostrConnect::builder(connect_uri, keys)
|
||||||
@@ -463,12 +470,50 @@ impl NostrRegistry {
|
|||||||
self.current_user = None;
|
self.current_user = None;
|
||||||
self.passphrase_required = false;
|
self.passphrase_required = false;
|
||||||
|
|
||||||
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
credentials::delete(cx).detach_and_log_err(cx);
|
||||||
cx.emit(StateEvent::SignerChanged);
|
cx.emit(StateEvent::SignerChanged);
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connect the browser extension signer (NIP-07).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn connect_extension(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let signer = match BrowserSigner::new() {
|
||||||
|
Ok(signer) => signer,
|
||||||
|
Err(e) => {
|
||||||
|
cx.emit(StateEvent::error(format!("No Nostr extension found: {e}.")));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match signer.get_public_key_async().await {
|
||||||
|
Ok(public_key) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
|
||||||
|
this.signer.swap_inner(signer);
|
||||||
|
this.current_user = Some(public_key);
|
||||||
|
this.passphrase_required = false;
|
||||||
|
|
||||||
|
cx.emit(StateEvent::SignerChanged);
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
credentials::delete(cx).detach_and_log_err(cx);
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
/// Start the browser proxy
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use nostr_sdk::prelude::*;
|
|||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
/// The public key of the device that created this announcement.
|
/// The public key of the device that created this announcement.
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
|
/// The id of the announcement event, for ordering
|
||||||
|
event_id: EventId,
|
||||||
|
/// The creation time of the announcement event
|
||||||
|
created_at: Timestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Announcement {
|
impl Announcement {
|
||||||
@@ -16,11 +20,20 @@ impl Announcement {
|
|||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||||
|
|
||||||
Some(Self { public_key })
|
Some(Self {
|
||||||
|
public_key,
|
||||||
|
event_id: event.id,
|
||||||
|
created_at: event.created_at,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the public key of the device that created this announcement.
|
/// Returns the public key of the device that created this announcement.
|
||||||
pub fn public_key(&self) -> PublicKey {
|
pub fn public_key(&self) -> PublicKey {
|
||||||
self.public_key
|
self.public_key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_newer_than(&self, other: &Self) -> bool {
|
||||||
|
self.created_at > other.created_at
|
||||||
|
|| (self.created_at == other.created_at && self.event_id < other.event_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
|||||||
auth_url: Url,
|
auth_url: Url,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
crate::web_file::open_url(auth_url.as_str())
|
||||||
|
.map_err(nostr_connect::error::Error::other)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use nostr_memory::prelude::*;
|
||||||
|
use nostr_sqlite::prelude::NostrSqlite;
|
||||||
|
use sqlite_wasm_rs::WasmOsCallback;
|
||||||
|
use sqlite_wasm_vfs::relaxed_idb;
|
||||||
|
|
||||||
|
const DATABASE_PATH: &str = "coop.sqlite3";
|
||||||
|
const VFS_NAME: &str = "coop-relaxed-idb";
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static DATABASE: RefCell<Option<NostrSqlite>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the IndexedDB storage backend and open the database.
|
||||||
|
pub async fn open() {
|
||||||
|
match open_persistent().await {
|
||||||
|
Some(database) => DATABASE.with(|slot| *slot.borrow_mut() = Some(database)),
|
||||||
|
None => log::error!("Falling back to the in-memory nostr database"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the opened database to the client constructor
|
||||||
|
pub(crate) fn take() -> Arc<dyn NostrDatabase> {
|
||||||
|
let database = DATABASE.with(|slot| slot.borrow_mut().take());
|
||||||
|
|
||||||
|
match database {
|
||||||
|
Some(database) => Arc::new(database),
|
||||||
|
None => {
|
||||||
|
log::error!("Persistent web database was not opened, falling back to memory");
|
||||||
|
Arc::new(MemoryDatabase::unbounded())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the database on IndexedDB storage, or `None` when unavailable
|
||||||
|
async fn open_persistent() -> Option<NostrSqlite> {
|
||||||
|
let options = relaxed_idb::RelaxedIdbCfgBuilder::new()
|
||||||
|
.vfs_name(VFS_NAME)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Err(e) = relaxed_idb::install::<WasmOsCallback>(&options, true).await {
|
||||||
|
log::warn!("IndexedDB storage unavailable: {e:?}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match NostrSqlite::builder()
|
||||||
|
.persistent_with_vfs(DATABASE_PATH, VFS_NAME)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(database) => {
|
||||||
|
log::info!("Nostr database opened on IndexedDB storage");
|
||||||
|
Some(database)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to open the nostr database on IndexedDB: {e:?}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Browser file interoperability: a persistent hidden `<input type="file">`
|
||||||
|
//! picker, blob URLs for rendering, and anchor-based downloads.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use anyhow::{Error, anyhow};
|
||||||
|
use flume::Sender;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window};
|
||||||
|
|
||||||
|
use crate::file::PickedFile;
|
||||||
|
|
||||||
|
type PickResult = Result<Option<PickedFile>, Error>;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// The picker input lives for the whole page so its change closure does
|
||||||
|
/// not have to be leaked or re-registered on every pick
|
||||||
|
static PICKER: RefCell<Option<Picker>> = const { RefCell::new(None) };
|
||||||
|
/// The sender the active pick delivers through
|
||||||
|
static PENDING: RefCell<Option<Sender<PickResult>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Picker {
|
||||||
|
input: HtmlInputElement,
|
||||||
|
/// Keep the change handler alive for the life of the page
|
||||||
|
_on_change: Closure<dyn FnMut()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the user for a single file and read its bytes
|
||||||
|
pub async fn pick_file() -> PickResult {
|
||||||
|
let (sender, receiver) = flume::bounded(1);
|
||||||
|
|
||||||
|
PICKER.with(|picker| -> Result<(), Error> {
|
||||||
|
let mut picker = picker.borrow_mut();
|
||||||
|
if picker.is_none() {
|
||||||
|
*picker = Some(init_picker()?);
|
||||||
|
}
|
||||||
|
let picker = picker.as_ref().expect("picker just initialized");
|
||||||
|
|
||||||
|
PENDING.with(|pending| *pending.borrow_mut() = Some(sender));
|
||||||
|
picker.input.click();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
receiver
|
||||||
|
.recv_async()
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("File picker closed"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_picker() -> Result<Picker, Error> {
|
||||||
|
let document = js_window()
|
||||||
|
.ok_or_else(|| anyhow!("no browser window"))?
|
||||||
|
.document()
|
||||||
|
.ok_or_else(|| anyhow!("no browser document"))?;
|
||||||
|
|
||||||
|
let input = document
|
||||||
|
.create_element("input")
|
||||||
|
.map_err(|e| anyhow!("failed to create file input: {e:?}"))?
|
||||||
|
.dyn_into::<HtmlInputElement>()
|
||||||
|
.map_err(|_| anyhow!("created element is not an input"))?;
|
||||||
|
|
||||||
|
input.set_type("file");
|
||||||
|
input.style().set_property("display", "none").ok();
|
||||||
|
|
||||||
|
let on_change = Closure::wrap(Box::new(move || {
|
||||||
|
let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
sender.send_async(read_selected_file().await).await.ok();
|
||||||
|
});
|
||||||
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
|
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||||
|
|
||||||
|
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
|
||||||
|
body.append_child(&input)
|
||||||
|
.map_err(|e| anyhow!("failed to mount file input: {e:?}"))?;
|
||||||
|
|
||||||
|
Ok(Picker {
|
||||||
|
input,
|
||||||
|
_on_change: on_change,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the first file of the picker input, if one was chosen
|
||||||
|
async fn read_selected_file() -> PickResult {
|
||||||
|
let picker = PICKER
|
||||||
|
.with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone()))
|
||||||
|
.ok_or_else(|| anyhow!("file picker is not initialized"))?;
|
||||||
|
|
||||||
|
let Some(file) = picker.files().and_then(|files| files.get(0)) else {
|
||||||
|
// The dialog was dismissed without choosing a file
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mime = non_empty(file.type_());
|
||||||
|
let data = read_file_bytes(&file).await?;
|
||||||
|
let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?);
|
||||||
|
|
||||||
|
Ok(Some(PickedFile {
|
||||||
|
name: file.name(),
|
||||||
|
mime,
|
||||||
|
data,
|
||||||
|
local,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a browser `File` into memory
|
||||||
|
async fn read_file_bytes(file: &File) -> Result<Vec<u8>, Error> {
|
||||||
|
let promise = file.array_buffer();
|
||||||
|
let buffer = wasm_bindgen_futures::JsFuture::from(promise)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("failed to read file: {e:?}"))?;
|
||||||
|
|
||||||
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an object URL for bytes, so images and downloads render from memory
|
||||||
|
pub fn blob_url(mime: &str, data: &[u8]) -> Result<String, Error> {
|
||||||
|
let array = js_sys::Uint8Array::from(data);
|
||||||
|
let parts = js_sys::Array::new();
|
||||||
|
parts.push(&array);
|
||||||
|
|
||||||
|
let bag = web_sys::BlobPropertyBag::new();
|
||||||
|
bag.set_type(mime);
|
||||||
|
|
||||||
|
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag)
|
||||||
|
.map_err(|e| anyhow!("failed to create blob: {e:?}"))?;
|
||||||
|
|
||||||
|
DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer bytes to the user as a browser download
|
||||||
|
pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> {
|
||||||
|
let window = js_window().ok_or_else(|| anyhow!("no browser window"))?;
|
||||||
|
let document = window
|
||||||
|
.document()
|
||||||
|
.ok_or_else(|| anyhow!("no browser document"))?;
|
||||||
|
|
||||||
|
let url = blob_url(mime, data)?;
|
||||||
|
|
||||||
|
let anchor: HtmlAnchorElement = document
|
||||||
|
.create_element("a")
|
||||||
|
.map_err(|e| anyhow!("failed to create download link: {e:?}"))?
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|_| anyhow!("created element is not an anchor"))?;
|
||||||
|
anchor.set_href(&url);
|
||||||
|
anchor.set_download(name);
|
||||||
|
anchor.style().set_property("display", "none").ok();
|
||||||
|
|
||||||
|
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
|
||||||
|
body.append_child(&anchor)
|
||||||
|
.map_err(|e| anyhow!("failed to mount download link: {e:?}"))?;
|
||||||
|
anchor.click();
|
||||||
|
body.remove_child(&anchor)
|
||||||
|
.map_err(|e| anyhow!("failed to remove download link: {e:?}"))?;
|
||||||
|
|
||||||
|
// Keep the blob alive briefly so the download is not cancelled
|
||||||
|
let callback = Closure::wrap(Box::new(move || {
|
||||||
|
DomUrl::revoke_object_url(&url).ok();
|
||||||
|
}) as Box<dyn FnMut()>);
|
||||||
|
window
|
||||||
|
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
callback.as_ref().unchecked_ref(),
|
||||||
|
10_000,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?;
|
||||||
|
// The closure only runs once, after which it can be released
|
||||||
|
callback.forget();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a URL in a new browser tab
|
||||||
|
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||||
|
js_window()
|
||||||
|
.ok_or_else(|| anyhow!("no browser window"))?
|
||||||
|
.open_with_url_and_target(url, "_blank")
|
||||||
|
.map_err(|e| anyhow!("failed to open url: {e:?}"))?
|
||||||
|
.ok_or_else(|| anyhow!("the browser blocked opening the url"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: impl Into<String>) -> String {
|
||||||
|
let value: String = value.into();
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
"application/octet-stream".to_string()
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,17 @@ pub enum IconName {
|
|||||||
Link,
|
Link,
|
||||||
Lock,
|
Lock,
|
||||||
Message,
|
Message,
|
||||||
|
Paperclip,
|
||||||
PaperPlaneFill,
|
PaperPlaneFill,
|
||||||
Plus,
|
Plus,
|
||||||
PlusChat,
|
PlusChat,
|
||||||
Profile,
|
Profile,
|
||||||
Relay,
|
Relay,
|
||||||
|
Speech,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Unlock,
|
||||||
Upload,
|
Upload,
|
||||||
Warning,
|
Warning,
|
||||||
}
|
}
|
||||||
@@ -51,14 +54,17 @@ impl IconNamed for IconName {
|
|||||||
Self::Link => "icons/link.svg",
|
Self::Link => "icons/link.svg",
|
||||||
Self::Lock => "icons/lock.svg",
|
Self::Lock => "icons/lock.svg",
|
||||||
Self::Message => "icons/message.svg",
|
Self::Message => "icons/message.svg",
|
||||||
|
Self::Paperclip => "icons/paperclip.svg",
|
||||||
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
Self::PlusChat => "icons/plus-chat.svg",
|
Self::PlusChat => "icons/plus-chat.svg",
|
||||||
Self::Profile => "icons/profile.svg",
|
Self::Profile => "icons/profile.svg",
|
||||||
Self::Relay => "icons/relay.svg",
|
Self::Relay => "icons/relay.svg",
|
||||||
|
Self::Speech => "icons/speech.svg",
|
||||||
Self::Search => "icons/search.svg",
|
Self::Search => "icons/search.svg",
|
||||||
Self::Settings => "icons/settings.svg",
|
Self::Settings => "icons/settings.svg",
|
||||||
Self::Settings2 => "icons/settings2.svg",
|
Self::Settings2 => "icons/settings2.svg",
|
||||||
|
Self::Unlock => "icons/unlock.svg",
|
||||||
Self::Upload => "icons/upload.svg",
|
Self::Upload => "icons/upload.svg",
|
||||||
Self::Warning => "icons/warning.svg",
|
Self::Warning => "icons/warning.svg",
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-16
@@ -1,18 +1,21 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
|
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
|
||||||
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
|
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
|
||||||
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
|
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
|
||||||
img, px,
|
StyleRefinement, Styled, StyledImage, Window, div, img, px,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||||
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
|
ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex,
|
||||||
|
v_flex,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
@@ -596,13 +599,14 @@ pub fn react_menu(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
|
/// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct MessageActions {
|
pub struct MessageActions {
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
|
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
|
||||||
reply: Option<SharedClickListener>,
|
reply: Option<SharedClickListener>,
|
||||||
copy: Option<SharedClickListener>,
|
copy: Option<(ElementId, SharedString)>,
|
||||||
|
speech: Option<(bool, SharedClickListener)>,
|
||||||
dropdown: Option<DropdownBuilder>,
|
dropdown: Option<DropdownBuilder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +618,7 @@ impl MessageActions {
|
|||||||
reactions: SmallVec::new(),
|
reactions: SmallVec::new(),
|
||||||
reply: None,
|
reply: None,
|
||||||
copy: None,
|
copy: None,
|
||||||
|
speech: None,
|
||||||
dropdown: None,
|
dropdown: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,9 +639,20 @@ impl MessageActions {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add the copy button.
|
/// Add the copy button for the given value.
|
||||||
pub fn copy(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
|
/// The id must be unique per bar, the clipboard component keys its copied state on it.
|
||||||
self.copy = Some(Rc::new(handler));
|
pub fn copy(mut self, id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
|
||||||
|
self.copy = Some((id.into(), value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the read-aloud button, disabled while a message is already being spoken.
|
||||||
|
pub fn speech(
|
||||||
|
mut self,
|
||||||
|
disabled: bool,
|
||||||
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.speech = Some((disabled, Rc::new(handler)));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,10 +685,13 @@ impl RenderOnce for MessageActions {
|
|||||||
reactions,
|
reactions,
|
||||||
reply,
|
reply,
|
||||||
copy,
|
copy,
|
||||||
|
speech,
|
||||||
dropdown,
|
dropdown,
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
let has_center = reply.is_some() || copy.is_some();
|
// The leading separator only separates reactions from the center buttons
|
||||||
|
let has_center =
|
||||||
|
!reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some());
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.p_0p5()
|
.p_0p5()
|
||||||
@@ -707,14 +726,18 @@ impl RenderOnce for MessageActions {
|
|||||||
.on_click(move |event, window, cx| reply(event, window, cx)),
|
.on_click(move |event, window, cx| reply(event, window, cx)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when_some(copy, |this, copy| {
|
.when_some(copy, |this, (id, value)| {
|
||||||
|
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
|
||||||
|
})
|
||||||
|
.when_some(speech, |this, (disabled, speech)| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("copy")
|
Button::new("speech")
|
||||||
.icon(IconName::Copy)
|
.icon(crate::IconName::Speech)
|
||||||
.tooltip("Copy")
|
.tooltip("Read aloud")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(move |event, window, cx| copy(event, window, cx)),
|
.disabled(disabled)
|
||||||
|
.on_click(move |event, window, cx| speech(event, window, cx)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when_some(dropdown, |this, dropdown| {
|
.when_some(dropdown, |this, dropdown| {
|
||||||
@@ -731,3 +754,44 @@ impl RenderOnce for MessageActions {
|
|||||||
.refine_style(&style)
|
.refine_style(&style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A message's text revealed word by word, paced by the estimated speech duration.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct RevealText {
|
||||||
|
text: SharedString,
|
||||||
|
duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RevealText {
|
||||||
|
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for RevealText {
|
||||||
|
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||||
|
let words: Vec<SharedString> = self
|
||||||
|
.text
|
||||||
|
.split_whitespace()
|
||||||
|
.map(SharedString::from)
|
||||||
|
.collect();
|
||||||
|
let total = words.len().max(1) as f32;
|
||||||
|
|
||||||
|
div().flex().flex_wrap().gap_x_1().with_animation(
|
||||||
|
"speech-reveal",
|
||||||
|
Animation::new(self.duration).with_max_fps(30.),
|
||||||
|
move |this, progress| {
|
||||||
|
let visible = ((progress * total).ceil() as usize).min(words.len());
|
||||||
|
|
||||||
|
this.children(words.iter().enumerate().map(|(ix, word)| {
|
||||||
|
div()
|
||||||
|
.when(ix >= visible, |this| this.invisible())
|
||||||
|
.child(word.clone())
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ chat_ui = { path = "../chat_ui" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
nip29_ui = { path = "../nip29_ui" }
|
nip29_ui = { path = "../nip29_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
auto_update = { path = "../auto_update" }
|
auto_update = { path = "../auto_update" }
|
||||||
|
|
||||||
@@ -27,4 +28,5 @@ nostr-connect.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
Subscription, Task, Window, div, px, relative,
|
Styled, Subscription, Task, Window, div, px, relative,
|
||||||
};
|
};
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
|
use gpui_kit::component::form::{field, v_form};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, StateEvent};
|
||||||
|
use ui::IconName;
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||||
@@ -29,11 +32,9 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
|
|||||||
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, _cx| {
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
this.width(px(450.))
|
this.close_button(false)
|
||||||
.close_button(false)
|
|
||||||
.overlay_closable(false)
|
.overlay_closable(false)
|
||||||
.keyboard(false)
|
.keyboard(false)
|
||||||
.title("Unlock your identity")
|
|
||||||
.child(import.clone())
|
.child(import.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -73,12 +74,21 @@ impl ImportIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let pass_input = cx.new(|cx| {
|
||||||
|
let input = InputState::new(window, cx).masked(true);
|
||||||
|
|
||||||
|
if mode == Mode::Unlock {
|
||||||
|
input.placeholder("Passphrase to unlock your identity")
|
||||||
|
} else {
|
||||||
|
input
|
||||||
|
}
|
||||||
|
});
|
||||||
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
let subscriptions = vec![
|
let mut subscriptions = vec![
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.login(cx);
|
this.login(cx);
|
||||||
@@ -96,6 +106,14 @@ impl ImportIdentity {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
|
||||||
|
if let StateEvent::Error(error) = event {
|
||||||
|
this.set_error(error.clone(), cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
mode,
|
mode,
|
||||||
key_input,
|
key_input,
|
||||||
@@ -188,6 +206,14 @@ impl ImportIdentity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn extension(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.connect_extension(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.loading = status;
|
self.loading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -219,16 +245,59 @@ impl ImportIdentity {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_unlock(&self, cx: &mut Context<Self>) -> Div {
|
||||||
|
v_flex()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
DialogHeader::new()
|
||||||
|
.child(DialogTitle::new().child("Unlock your identity"))
|
||||||
|
.child(
|
||||||
|
DialogDescription::new()
|
||||||
|
.child("Enter the passphrase used to encrypt this identity."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_form().child(
|
||||||
|
field()
|
||||||
|
.label("Passphrase")
|
||||||
|
.required(true)
|
||||||
|
.child(Input::new(&self.pass_input)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().danger)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
DialogFooter::new().child(
|
||||||
|
Button::new("unlock")
|
||||||
|
.primary()
|
||||||
|
.icon(IconName::Unlock)
|
||||||
|
.label("Unlock")
|
||||||
|
.loading(self.loading)
|
||||||
|
.disabled(self.loading)
|
||||||
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.login(cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
if self.mode == Mode::Unlock {
|
||||||
|
return self.render_unlock(cx);
|
||||||
|
}
|
||||||
|
|
||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
||||||
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
|
||||||
let is_unlock = self.mode == Mode::Unlock;
|
|
||||||
|
|
||||||
let value = self.key_input.read(cx).value().trim().to_string();
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
let require_password = value.starts_with("ncryptsec1");
|
let require_password = value.starts_with("ncryptsec1");
|
||||||
let require_encrypt = value.starts_with("nsec1");
|
let require_encrypt = value.starts_with("nsec1");
|
||||||
@@ -242,25 +311,19 @@ impl Render for ImportIdentity {
|
|||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when(!is_unlock, |this| {
|
.child(
|
||||||
this.child(
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child("Use secret key or bunker connection")
|
.child("Use secret key or bunker connection")
|
||||||
.child(Input::new(&self.key_input)),
|
.child(Input::new(&self.key_input)),
|
||||||
)
|
)
|
||||||
})
|
.when(require_password, |this| {
|
||||||
.when(is_unlock || require_password, |this| {
|
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(if is_unlock {
|
.child("Decrypt Password:")
|
||||||
"Passphrase to unlock your identity:"
|
|
||||||
} else {
|
|
||||||
"Decrypt Password:"
|
|
||||||
})
|
|
||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -305,26 +368,41 @@ impl Render for ImportIdentity {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child({
|
||||||
|
let content = v_flex().gap_1().child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label(if is_unlock { "Unlock" } else { "Continue" })
|
.label("Continue")
|
||||||
.primary()
|
.primary()
|
||||||
.loading(self.loading)
|
.loading(self.loading)
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.login(cx);
|
this.login(cx);
|
||||||
})),
|
})),
|
||||||
)
|
);
|
||||||
.when(!is_wasm && !is_unlock, |this| {
|
|
||||||
this.child(
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let content = content.child(
|
||||||
Button::new("proxy")
|
Button::new("proxy")
|
||||||
.label("Connect via Web Extension (Experimental)")
|
.label("Connect via Web Extension")
|
||||||
.ghost()
|
.ghost()
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.proxy(cx);
|
this.proxy(cx);
|
||||||
})),
|
})),
|
||||||
)
|
);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let content = content.child(
|
||||||
|
Button::new("extension")
|
||||||
|
.label("Continue with Browser Extension")
|
||||||
|
.ghost()
|
||||||
|
.disabled(self.loading)
|
||||||
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.extension(cx);
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
content
|
||||||
})
|
})
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use gpui::{
|
|||||||
Window, div, px, uniform_list,
|
Window, div, px, uniform_list,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||||
@@ -205,6 +204,7 @@ impl NewChat {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,15 +369,13 @@ impl NewChat {
|
|||||||
.p_0p5()
|
.p_0p5()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.bg(cx.theme().secondary)
|
.bg(cx.theme().secondary)
|
||||||
.text_xs()
|
|
||||||
.font_medium()
|
|
||||||
.hover(|this| this.bg(cx.theme().primary))
|
.hover(|this| this.bg(cx.theme().primary))
|
||||||
.child(
|
.child(
|
||||||
Avatar::new(profile.avatar())
|
Avatar::new(profile.avatar())
|
||||||
.seed(profile.avatar_seed())
|
.seed(profile.avatar_seed())
|
||||||
.xsmall(),
|
.xsmall(),
|
||||||
)
|
)
|
||||||
.child(div().max_w_32().truncate().child(profile.name()))
|
.child(div().max_w_32().truncate().text_xs().child(profile.name()))
|
||||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
this.toggle_member(&public_key, cx);
|
this.toggle_member(&public_key, cx);
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use anyhow::Error;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
||||||
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _,
|
ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||||
StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, div, px, rems,
|
Subscription, Task, WeakEntity, Window, div, px, rems,
|
||||||
};
|
};
|
||||||
use gpui_base::{Popup, Select};
|
use gpui_base::{Popup, Select};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
@@ -18,7 +18,7 @@ use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_
|
|||||||
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
|
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::upload;
|
use state::{pick_file, upload_file};
|
||||||
use ui::{Avatar, settings_row};
|
use ui::{Avatar, settings_row};
|
||||||
|
|
||||||
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
|
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
|
||||||
@@ -297,16 +297,11 @@ impl NewGroup {
|
|||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -314,7 +309,7 @@ impl NewGroup {
|
|||||||
this.set_uploading(true, cx);
|
this.set_uploading(true, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match upload(server, path, cx).await {
|
match upload_file(server, picked, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.picture = Some(url.to_string().into());
|
this.picture = Some(url.to_string().into());
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ use anyhow::Error;
|
|||||||
use chat::{ChatRegistry, Room, RoomKind};
|
use chat::{ChatRegistry, Room, RoomKind};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement,
|
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
|
Styled, Task, Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dock::DockPlacement;
|
use gpui_kit::component::dock::DockPlacement;
|
||||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, pick_file, upload_file};
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
|
|
||||||
use crate::Workspace;
|
use crate::Workspace;
|
||||||
@@ -49,8 +49,6 @@ struct ProfileDialog {
|
|||||||
website_input: Entity<InputState>,
|
website_input: Entity<InputState>,
|
||||||
/// Uploading state
|
/// Uploading state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
/// Copied states
|
|
||||||
copied: bool,
|
|
||||||
/// Updating state
|
/// Updating state
|
||||||
updating: bool,
|
updating: bool,
|
||||||
/// Tasks
|
/// Tasks
|
||||||
@@ -92,7 +90,6 @@ impl ProfileDialog {
|
|||||||
bio_input,
|
bio_input,
|
||||||
website_input,
|
website_input,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
copied: false,
|
|
||||||
updating: false,
|
updating: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
@@ -141,35 +138,6 @@ impl ProfileDialog {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let item = ClipboardItem::new_string(value);
|
|
||||||
cx.write_to_clipboard(item);
|
|
||||||
|
|
||||||
self.set_copied(true, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.copied = status;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
if status {
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
// Reset the copied state after a delay
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_copied(false, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.uploading = status;
|
self.uploading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -179,17 +147,12 @@ impl ProfileDialog {
|
|||||||
// 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);
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask the user for an image to upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let picker = pick_file(cx);
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// Selecting no file means the prompt was cancelled
|
||||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
let Some(picked) = picker.await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,7 +161,7 @@ impl ProfileDialog {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Upload via blossom client
|
// Upload via blossom client
|
||||||
match upload(server, path, cx).await {
|
match upload_file(server, picked, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.avatar_input.update(cx, |this, cx| {
|
this.avatar_input.update(cx, |this, cx| {
|
||||||
@@ -373,7 +336,8 @@ impl Render for ProfileDialog {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.bg(cx.theme().sidebar)
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.bg(cx.theme().muted)
|
||||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
|
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
|
||||||
.when(self.editing, |this| {
|
.when(self.editing, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -398,26 +362,14 @@ impl Render for ProfileDialog {
|
|||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().muted)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(shorten_pkey)
|
.child(shorten_pkey)
|
||||||
.child(
|
.child(Clipboard::new("copy").value_fn({
|
||||||
Button::new("copy")
|
let public_key = self.public_key;
|
||||||
.icon({
|
move |_, _| public_key.to_bech32().unwrap_or_default().into()
|
||||||
if self.copied {
|
|
||||||
IconName::CircleCheck
|
|
||||||
} else {
|
|
||||||
IconName::Copy
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.xsmall()
|
|
||||||
.secondary()
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
||||||
let pk = this.public_key.to_bech32().unwrap();
|
|
||||||
this.copy(pk, window, cx)
|
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.when(self.editing, |this| {
|
.when(self.editing, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -549,7 +501,7 @@ fn field(
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1p5()
|
.gap_1()
|
||||||
.child(label(text, cx))
|
.child(label(text, cx))
|
||||||
.child(div().text_xs().text_color(color).child(content))
|
.child(div().text_sm().text_color(color).child(content))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ impl RelayManager {
|
|||||||
|
|
||||||
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
TabBar::new("relays-tabs")
|
TabBar::new("relays-tabs")
|
||||||
.segmented()
|
.pill()
|
||||||
.selected_index(self.active_tab)
|
.selected_index(self.active_tab)
|
||||||
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
|
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
|
||||||
this.active_tab = *ix;
|
this.active_tab = *ix;
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::PathPromptOptions;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::Task;
|
||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
|
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
|
||||||
Styled, Subscription, Window, div,
|
Subscription, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||||
|
use gpui_kit::component::kbd::Kbd;
|
||||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui_kit::component::progress::Progress;
|
||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use settings::{AppSettings, AppearanceMode};
|
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use speech::model::{self as speech_model};
|
||||||
use ui::{settings_block, settings_row};
|
use ui::{settings_block, settings_row};
|
||||||
|
|
||||||
/// Bounds for the theme number inputs
|
/// Bounds for the theme number inputs
|
||||||
@@ -27,6 +40,8 @@ pub struct Preferences {
|
|||||||
font_size_input: Entity<InputState>,
|
font_size_input: Entity<InputState>,
|
||||||
radius_input: Entity<InputState>,
|
radius_input: Entity<InputState>,
|
||||||
radius_lg_input: Entity<InputState>,
|
radius_lg_input: Entity<InputState>,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
_speech_poll: Task<()>,
|
||||||
/// Keeps the control subscriptions alive while the dialog is open
|
/// Keeps the control subscriptions alive while the dialog is open
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -92,11 +107,40 @@ impl Preferences {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let timer = Duration::from_millis(500);
|
||||||
|
let mut last = speech_model::status();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(timer).await;
|
||||||
|
|
||||||
|
let status = speech_model::status();
|
||||||
|
let error = speech_model::take_error();
|
||||||
|
|
||||||
|
if status == last {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
last = status;
|
||||||
|
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
if let Some(error) = error {
|
||||||
|
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
file_input,
|
file_input,
|
||||||
font_size_input,
|
font_size_input,
|
||||||
radius_input,
|
radius_input,
|
||||||
radius_lg_input,
|
radius_lg_input,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
_speech_poll,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +194,18 @@ impl Preferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the user for an image to show behind chat and group panels
|
/// Ask the user for an image to show behind chat and group panels
|
||||||
fn choose_background(cx: &mut App) {
|
fn choose_background(
|
||||||
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let note = Notification::warning("Background images are not supported on web yet.");
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
let task = cx.prompt_for_paths(PathPromptOptions {
|
let task = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
directories: false,
|
directories: false,
|
||||||
@@ -174,10 +229,103 @@ impl Preferences {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_speech(
|
||||||
|
&mut self,
|
||||||
|
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let size = speech_model::total_size() / 1_000_000;
|
||||||
|
let status = speech_model::status();
|
||||||
|
|
||||||
|
let (description, control) = match &status {
|
||||||
|
speech_model::Status::NotInstalled => (
|
||||||
|
format!("{size} MB download"),
|
||||||
|
Button::new("download-model")
|
||||||
|
.label("Download")
|
||||||
|
.secondary()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::start_download();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
speech_model::Status::Downloading { downloaded, total } => (
|
||||||
|
format!(
|
||||||
|
"Downloading · {} of {} MB",
|
||||||
|
downloaded / 1_000_000,
|
||||||
|
total / 1_000_000
|
||||||
|
),
|
||||||
|
Button::new("cancel-download")
|
||||||
|
.label("Cancel")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::cancel_download();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
speech_model::Status::Installed => (
|
||||||
|
format!("Parakeet v3 · {size} MB"),
|
||||||
|
Button::new("remove-model")
|
||||||
|
.label("Remove")
|
||||||
|
.secondary()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::delete();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut block = v_flex().w_full().gap_3().child(settings_row(
|
||||||
|
"Speech model",
|
||||||
|
description,
|
||||||
|
control,
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let speech_model::Status::Downloading { downloaded, total } = &status {
|
||||||
|
let (downloaded, total) = (*downloaded, *total);
|
||||||
|
|
||||||
|
block = block.child(
|
||||||
|
Progress::new("speech-model-download")
|
||||||
|
.value(downloaded as f32 / total.max(1) as f32 * 100.)
|
||||||
|
.small(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
block.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the keystroke actually bound to the dictation action at startup
|
||||||
|
fn render_dictation_shortcut(
|
||||||
|
&self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let control = match Kbd::global_binding_for_action(&ToggleDictation, window) {
|
||||||
|
Some(kbd) => kbd.into_any_element(),
|
||||||
|
None => div().into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings_row("Shortcut", "Press to start or stop dictation", control, cx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Preferences {
|
impl Render for Preferences {
|
||||||
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 SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
||||||
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||||
@@ -189,6 +337,7 @@ impl Render for Preferences {
|
|||||||
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
||||||
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||||
|
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
|
||||||
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||||
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
|
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
|
||||||
|
|
||||||
@@ -196,6 +345,7 @@ impl Render for Preferences {
|
|||||||
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let nip4e = AppSettings::get_nip4e(cx);
|
let nip4e = AppSettings::get_nip4e(cx);
|
||||||
|
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
|
||||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||||
let theme = AppSettings::get_theme(cx);
|
let theme = AppSettings::get_theme(cx);
|
||||||
let panel_background = AppSettings::get_panel_background(cx);
|
let panel_background = AppSettings::get_panel_background(cx);
|
||||||
@@ -207,7 +357,7 @@ impl Render for Preferences {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.mt_2()
|
.my_2()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
.child(
|
||||||
@@ -330,8 +480,8 @@ impl Render for Preferences {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.disabled(!panel_background)
|
.disabled(!panel_background)
|
||||||
.on_click(|_, _window, cx| {
|
.on_click(|_, window, cx| {
|
||||||
Self::choose_background(cx);
|
Self::choose_background(window, cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -359,6 +509,25 @@ impl Render for Preferences {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
|
.child(settings_row(
|
||||||
|
"Rotate Encryption Key",
|
||||||
|
NIP4E_ROTATION,
|
||||||
|
Switch::new("nip4e_rotation")
|
||||||
|
.checked(nip4e_rotation_days.is_some())
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
|
||||||
|
let days = if enabled {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(NIP4E_ROTATION_DEFAULT_DAYS)
|
||||||
|
};
|
||||||
|
AppSettings::update_nip4e_rotation_days(days, cx);
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(self.render_speech(cx))
|
||||||
|
.child(self.render_dictation_shortcut(window, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
.child(settings_block(
|
.child(settings_block(
|
||||||
"Media Upload Service",
|
"Media Upload Service",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::ops::Deref as _;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -166,7 +165,7 @@ impl CoopDockSkin {
|
|||||||
.on_drag(ResizePanel, move |info, _, _, cx| {
|
.on_drag(ResizePanel, move |info, _, _, cx| {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
shared.resizing_dock().set(Some(placement));
|
shared.resizing_dock().set(Some(placement));
|
||||||
cx.new(|_| info.deref().clone())
|
cx.new(|_| info.clone())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ impl Render for DragPanelPreview {
|
|||||||
.cursor_grab()
|
.cursor_grab()
|
||||||
.p_1()
|
.p_1()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
|
.font_family(cx.theme().font_family.clone())
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(cx.theme().radius_lg)
|
.rounded(cx.theme().radius_lg)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use ::settings::{AppSettings, AppearanceMode};
|
use ::settings::{AppSettings, AppearanceMode};
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use common::download_dir;
|
use common::download_dir;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
@@ -89,11 +91,12 @@ impl Workspace {
|
|||||||
let workspace = cx.entity().downgrade();
|
let workspace = cx.entity().downgrade();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
|
|
||||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||||
|
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
|
||||||
Theme::sync_system_appearance(Some(window), cx);
|
Theme::sync_system_appearance(Some(window), cx);
|
||||||
}));
|
AppSettings::apply_theme_style(cx);
|
||||||
}
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the nostr events
|
||||||
@@ -337,7 +340,11 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
Command::BackupEncryption => {
|
Command::BackupEncryption => {
|
||||||
let device = DeviceRegistry::global(cx).downgrade();
|
let device = DeviceRegistry::global(cx).downgrade();
|
||||||
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let save_dialog =
|
||||||
|
cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||||
// Get the output path from the save dialog
|
// Get the output path from the save dialog
|
||||||
@@ -346,7 +353,8 @@ impl Workspace {
|
|||||||
Ok(Ok(None)) | Err(_) => return Ok(()),
|
Ok(Ok(None)) | Err(_) => return Ok(()),
|
||||||
Ok(Err(error)) => {
|
Ok(Err(error)) => {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
let message = format!("Failed to pick save location: {error:#}");
|
let message =
|
||||||
|
format!("Failed to pick save location: {error:#}");
|
||||||
let note = Notification::error(message).autohide(false);
|
let note = Notification::error(message).autohide(false);
|
||||||
window.push_notification(note, cx);
|
window.push_notification(note, cx);
|
||||||
})?;
|
})?;
|
||||||
@@ -355,8 +363,8 @@ impl Workspace {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get the backup task
|
// Get the backup task
|
||||||
let backup =
|
let backup = device
|
||||||
device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
|
.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
|
||||||
|
|
||||||
// Run the backup task
|
// Run the backup task
|
||||||
backup.await?;
|
backup.await?;
|
||||||
@@ -369,10 +377,30 @@ impl Workspace {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?;
|
||||||
|
let content = backup.await?;
|
||||||
|
|
||||||
|
cx.update(|_window, _cx| {
|
||||||
|
state::web_file::download(
|
||||||
|
"encryption.txt",
|
||||||
|
"text/plain",
|
||||||
|
content.as_bytes(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))??;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
Command::ImportEncryption => {
|
Command::ImportEncryption => {
|
||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
Command::Update =>
|
||||||
|
{
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
auto_updater.update(cx, |this, cx| this.check(cx));
|
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
|
ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{Input, InputState};
|
use gpui_kit::component::input::{Input, InputState};
|
||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::{ActiveTheme, Sizable, v_flex};
|
use gpui_kit::component::{ActiveTheme, v_flex};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::USER_KEYRING;
|
use state::USER_KEYRING;
|
||||||
|
|
||||||
@@ -32,9 +30,6 @@ pub struct BackupPanel {
|
|||||||
/// Secret key input
|
/// Secret key input
|
||||||
nsec_input: Entity<InputState>,
|
nsec_input: Entity<InputState>,
|
||||||
|
|
||||||
/// Copied status
|
|
||||||
copied: bool,
|
|
||||||
|
|
||||||
/// Background tasks
|
/// Background tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
@@ -54,7 +49,6 @@ impl BackupPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
npub_input,
|
npub_input,
|
||||||
nsec_input,
|
nsec_input,
|
||||||
copied: false,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,31 +75,6 @@ impl BackupPanel {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_secret(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let value = self.nsec_input.read(cx).value();
|
|
||||||
let item = ClipboardItem::new_string(value.to_string());
|
|
||||||
cx.write_to_clipboard(item);
|
|
||||||
|
|
||||||
// Set the copied status to true
|
|
||||||
self.set_copied(true, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.copied = status;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
// Clear the error message after a delay
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_copied(false, cx);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl gpui_kit::component::dock::BasePanel for BackupPanel {
|
impl gpui_kit::component::dock::BasePanel for BackupPanel {
|
||||||
@@ -174,20 +143,12 @@ impl Render for BackupPanel {
|
|||||||
.child(Input::new(&self.nsec_input).disabled(true)),
|
.child(Input::new(&self.nsec_input).disabled(true)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("copy")
|
Clipboard::new("copy-secret")
|
||||||
.icon(IconName::Copy)
|
.accessibility_label("Copy secret key")
|
||||||
.label({
|
.value_fn({
|
||||||
if self.copied {
|
let nsec_input = self.nsec_input.clone();
|
||||||
"Copied"
|
move |_, cx| nsec_input.read(cx).value()
|
||||||
} else {
|
}),
|
||||||
"Copy secret key"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.primary()
|
|
||||||
.small()
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.copy_secret(cx);
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
|
Anchor, App, Context, Div, InteractiveElement, ParentElement, SharedString, Stateful, Styled,
|
||||||
Stateful, Styled, Window, div, px,
|
Window, div, px,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::{AnyElement, Entity, IntoElement};
|
||||||
|
use gpui_kit::base::StyledExt as _;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui_kit::component::Disableable;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::popover::Popover;
|
||||||
ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex,
|
use gpui_kit::component::separator::Separator;
|
||||||
};
|
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::NostrRegistry;
|
use person::shorten_pubkey;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use state::{CLIENT_NAME, NostrRegistry};
|
||||||
use ui::{Avatar, IconName};
|
use ui::{Avatar, IconName};
|
||||||
|
|
||||||
use super::Sidebar;
|
use super::Sidebar;
|
||||||
@@ -25,9 +34,14 @@ impl Sidebar {
|
|||||||
current_user: &PublicKey,
|
current_user: &PublicKey,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Stateful<Div> {
|
) -> Stateful<Div> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let updater = AutoUpdater::try_global(cx);
|
let updater = AutoUpdater::try_global(cx);
|
||||||
let profile = self.directory.read(cx).person(current_user, cx);
|
let profile = self.directory.read(cx).person(current_user, cx);
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
|
let encryption_key = profile
|
||||||
|
.announcement()
|
||||||
|
.map(|announcement| announcement.public_key());
|
||||||
|
let encryption_enabled = AppSettings::get_nip4e(cx);
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("sidebar-user")
|
.id("sidebar-user")
|
||||||
@@ -35,23 +49,20 @@ impl Sidebar {
|
|||||||
.h(TAB_BAR_HEIGHT)
|
.h(TAB_BAR_HEIGHT)
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.px_2()
|
.px_2p5()
|
||||||
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
||||||
.child(
|
.child(
|
||||||
Button::new("current-user")
|
Button::new("user")
|
||||||
.text()
|
.text()
|
||||||
.dropdown_caret(true)
|
.dropdown_caret(true)
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.flex_shrink_1()
|
.flex_shrink_1()
|
||||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
this.menu_with_icon(
|
this.menu("Profile", Box::new(Command::ShowProfile))
|
||||||
"Profile",
|
.menu("Relays", Box::new(Command::ShowRelayList))
|
||||||
IconName::Profile,
|
|
||||||
Box::new(Command::ShowProfile),
|
|
||||||
)
|
|
||||||
.menu_with_icon("Relays", IconName::Relay, Box::new(Command::ShowRelayList))
|
|
||||||
.separator()
|
.separator()
|
||||||
|
.label(format!("{CLIENT_NAME} {}", env!("CARGO_PKG_VERSION")))
|
||||||
.item(
|
.item(
|
||||||
PopupMenuItem::element(|_, cx| {
|
PopupMenuItem::element(|_, cx| {
|
||||||
div().text_color(cx.theme().danger).child("Log out")
|
div().text_color(cx.theme().danger).child("Log out")
|
||||||
@@ -62,11 +73,19 @@ impl Sidebar {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child({
|
||||||
h_flex()
|
let actions = h_flex().gap_1();
|
||||||
.gap_1()
|
|
||||||
.when_some(updater, |this, updater| {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let actions = actions.when_some(updater, |this, updater| {
|
||||||
this.child(self.render_updater(updater, cx))
|
this.child(self.render_updater(updater, cx))
|
||||||
|
});
|
||||||
|
|
||||||
|
actions
|
||||||
|
.when(encryption_enabled, |actions| {
|
||||||
|
actions.when_some(encryption_key, |actions, encryption_key| {
|
||||||
|
actions.child(self.render_encryption(encryption_key))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
Button::new("sidebar-search")
|
Button::new("sidebar-search")
|
||||||
@@ -104,20 +123,103 @@ impl Sidebar {
|
|||||||
this.hide_group(cx);
|
this.hide_group(cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bottom navigation, pinned below the list like signed's sidebar.
|
/// The encryption key popover: public key with copy, backup and reset
|
||||||
pub(super) fn render_footer(&self, _cx: &mut Context<Self>) -> Div {
|
fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement {
|
||||||
|
Popover::new("sidebar-encryption")
|
||||||
|
.anchor(Anchor::TopRight)
|
||||||
|
.trigger(
|
||||||
|
Button::new("encryption-key")
|
||||||
|
.icon(IconName::Lock)
|
||||||
|
.tooltip("Encryption Key")
|
||||||
|
.ghost()
|
||||||
|
.small(),
|
||||||
|
)
|
||||||
|
.content(move |_, _, cx| {
|
||||||
|
let popover = cx.entity();
|
||||||
|
let Ok(npub) = encryption_key.to_bech32();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.w_64()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("Public key"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(shorten_pubkey(encryption_key, 12))),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Clipboard::new("encryption-key-copy")
|
||||||
|
.tooltip("Copy public key")
|
||||||
|
.value(npub),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(Separator::horizontal())
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.justify_end()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
Button::new("encryption-backup")
|
||||||
|
.label("Backup")
|
||||||
|
.outline()
|
||||||
|
.small()
|
||||||
|
.on_click({
|
||||||
|
let popover = popover.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
popover
|
||||||
|
.update(cx, |state, cx| state.dismiss(window, cx));
|
||||||
|
window.dispatch_action(
|
||||||
|
Box::new(Command::BackupEncryption),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("encryption-reset")
|
||||||
|
.label("Reset")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
popover.update(cx, |state, cx| state.dismiss(window, cx));
|
||||||
|
window.dispatch_action(
|
||||||
|
Box::new(Command::ResetEncryption),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
|
||||||
v_flex()
|
v_flex()
|
||||||
.p_2()
|
.p_2()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_start()
|
.items_start()
|
||||||
.justify_start()
|
.justify_start()
|
||||||
.child(
|
.when(!loading, |this| {
|
||||||
Nav::new("sidebar-settings")
|
this.child(
|
||||||
|
Nav::new("settings")
|
||||||
.prefix(Icon::new(IconName::Settings).small())
|
.prefix(Icon::new(IconName::Settings).small())
|
||||||
.center_prefix(true)
|
.center_prefix(true)
|
||||||
.label("Settings")
|
.label("Settings")
|
||||||
@@ -126,8 +228,10 @@ impl Sidebar {
|
|||||||
window.dispatch_action(Box::new(Command::ShowSettings), cx)
|
window.dispatch_action(Box::new(Command::ShowSettings), cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
||||||
let status = updater.read(cx).status();
|
let status = updater.read(cx).status();
|
||||||
let up_to_date = updater.read(cx).up_to_date();
|
let up_to_date = updater.read(cx).up_to_date();
|
||||||
|
|||||||
@@ -5,20 +5,23 @@ use chat::{ChatEvent, ChatRegistry};
|
|||||||
use common::DebouncedDelay;
|
use common::DebouncedDelay;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
|
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
|
||||||
Task, WeakEntity, Window, div, img, retain_all,
|
StyledImage, Subscription, Task, WeakEntity, Window, div, img, retain_all,
|
||||||
};
|
};
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::{Presence, Transition};
|
||||||
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
||||||
use gpui_kit::component::input::{InputEvent, InputState};
|
use gpui_kit::component::input::{InputEvent, InputState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::spinner::Spinner;
|
use gpui_kit::component::progress::ProgressCircle;
|
||||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Directory, PersonRegistry};
|
use person::{Directory, PersonRegistry};
|
||||||
|
pub(crate) use rows::open_room;
|
||||||
|
use search::SearchResult;
|
||||||
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||||
use ui::title_bar_drag_handlers;
|
use ui::title_bar_drag_handlers;
|
||||||
@@ -33,8 +36,8 @@ mod onboarding;
|
|||||||
mod rows;
|
mod rows;
|
||||||
mod search;
|
mod search;
|
||||||
|
|
||||||
pub(crate) use rows::open_room;
|
const PROGRESS_TICK_MS: u64 = 500;
|
||||||
use search::SearchResult;
|
const PROGRESS_CAP: f32 = 95.0;
|
||||||
|
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -88,6 +91,11 @@ pub struct Sidebar {
|
|||||||
/// The async global profile search.
|
/// The async global profile search.
|
||||||
global_task: Option<Task<()>>,
|
global_task: Option<Task<()>>,
|
||||||
|
|
||||||
|
/// Simulated gift wrap processing progress, shown while the backlog loads
|
||||||
|
chat_progress: f32,
|
||||||
|
/// Simulated web of trust sync progress
|
||||||
|
sync_progress: f32,
|
||||||
|
|
||||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
@@ -97,7 +105,8 @@ impl Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let groups = GroupsRegistry::global(cx);
|
let groups = GroupsRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let wot = PersonRegistry::global(cx).read(cx).wot();
|
let settings = AppSettings::global(cx);
|
||||||
|
let directory = Directory::new(cx);
|
||||||
|
|
||||||
let find_input = cx.new(|cx| {
|
let find_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
@@ -105,20 +114,22 @@ impl Sidebar {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
cx.subscribe_in(&chat, window, move |this, _, event, _, cx| {
|
||||||
if event == &ChatEvent::Ping {
|
match event {
|
||||||
|
ChatEvent::Ping => {
|
||||||
this.new_requests = true;
|
this.new_requests = true;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
};
|
}
|
||||||
}),
|
ChatEvent::Loaded => {
|
||||||
);
|
this.chat_progress = 0.0;
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,7 +137,7 @@ impl Sidebar {
|
|||||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
GroupsEvent::Updated => {
|
GroupsEvent::Updated => {
|
||||||
// TODO: find a better way?
|
// TODO: we must not trigger re-render directly like this
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
GroupsEvent::Error(error) => {
|
GroupsEvent::Error(error) => {
|
||||||
@@ -198,6 +209,21 @@ impl Sidebar {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// The user header reads settings and the tracked profiles on render, so
|
||||||
|
// the encryption button reacts to toggles and announcement arrivals
|
||||||
|
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
|
||||||
|
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
|
||||||
|
|
||||||
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(timer).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.tick_progress(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock,
|
dock,
|
||||||
@@ -211,7 +237,7 @@ impl Sidebar {
|
|||||||
chats_open: None,
|
chats_open: None,
|
||||||
contacts_open: false,
|
contacts_open: false,
|
||||||
contacts: None,
|
contacts: None,
|
||||||
directory: Directory::new(cx),
|
directory,
|
||||||
selected_contacts: HashSet::new(),
|
selected_contacts: HashSet::new(),
|
||||||
requests_open: false,
|
requests_open: false,
|
||||||
new_requests: false,
|
new_requests: false,
|
||||||
@@ -223,7 +249,9 @@ impl Sidebar {
|
|||||||
global_finding: false,
|
global_finding: false,
|
||||||
global_results: None,
|
global_results: None,
|
||||||
global_task: None,
|
global_task: None,
|
||||||
tasks: smallvec![],
|
chat_progress: 0.0,
|
||||||
|
sync_progress: 0.0,
|
||||||
|
tasks,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +279,80 @@ impl Sidebar {
|
|||||||
self.hide_group(cx);
|
self.hide_group(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance the simulated loading progress of any active background work.
|
||||||
|
fn tick_progress(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let logged_in = NostrRegistry::global(cx).read(cx).current_user().is_some();
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||||
|
|
||||||
|
let chat_loading = logged_in && chat.read(cx).loading();
|
||||||
|
let syncing = logged_in && wot.read(cx).is_syncing();
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
if chat_loading {
|
||||||
|
let next = self.advance_progress(self.chat_progress);
|
||||||
|
if next > self.chat_progress {
|
||||||
|
self.chat_progress = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if self.chat_progress != 0.0 {
|
||||||
|
self.chat_progress = 0.0;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncing {
|
||||||
|
let next = self.advance_progress(self.sync_progress);
|
||||||
|
if next > self.sync_progress {
|
||||||
|
self.sync_progress = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if self.sync_progress != 0.0 {
|
||||||
|
self.sync_progress = 0.0;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bump simulated progress by a random step
|
||||||
|
fn advance_progress(&self, progress: f32) -> f32 {
|
||||||
|
(progress + rand::random::<f32>() * 8.0 + 1.0).min(PROGRESS_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A floating pill showing simulated progress for a background operation.
|
||||||
|
fn render_loading_pill(
|
||||||
|
&self,
|
||||||
|
id: &'static str,
|
||||||
|
label: &'static str,
|
||||||
|
progress: f32,
|
||||||
|
opacity: f32,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> Div {
|
||||||
|
h_flex()
|
||||||
|
.opacity(opacity)
|
||||||
|
.h_9()
|
||||||
|
.w_full()
|
||||||
|
.px_5()
|
||||||
|
.gap_2p5()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
ProgressCircle::new(id)
|
||||||
|
.value(progress)
|
||||||
|
.color(cx.theme().primary)
|
||||||
|
.accessibility_label(label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl gpui_kit::component::dock::BasePanel for Sidebar {
|
impl gpui_kit::component::dock::BasePanel for Sidebar {
|
||||||
@@ -290,6 +392,9 @@ impl Render for Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let loading = chat.read(cx).loading();
|
let loading = chat.read(cx).loading();
|
||||||
|
|
||||||
|
let wot = PersonRegistry::global(cx).read(cx).wot();
|
||||||
|
let syncing = wot.read(cx).is_syncing();
|
||||||
|
|
||||||
let group = self.group.clone().and_then(|group| group.upgrade());
|
let group = self.group.clone().and_then(|group| group.upgrade());
|
||||||
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
|
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
|
||||||
let picking_contacts = self.contacts_open && !self.requests_open;
|
let picking_contacts = self.contacts_open && !self.requests_open;
|
||||||
@@ -343,32 +448,46 @@ impl Render for Sidebar {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(logged_in && !picking_contacts, |this| {
|
.when(logged_in && !picking_contacts, |this| {
|
||||||
this.child(self.render_footer(cx))
|
this.child(self.render_footer(loading || syncing, cx))
|
||||||
})
|
})
|
||||||
.when(loading && logged_in, |this| {
|
.when(logged_in, |this| {
|
||||||
|
let pill_motion = Transition::new(cx.theme().motion_tokens().duration_normal)
|
||||||
|
.easing(cx.theme().motion_tokens().easing_move.clone());
|
||||||
|
|
||||||
|
let chat_presence = Presence::new("chat-presence", loading)
|
||||||
|
.transition(pill_motion.clone())
|
||||||
|
.sample(window, cx);
|
||||||
|
|
||||||
|
let wot_presence = Presence::new("wot-presence", syncing)
|
||||||
|
.transition(pill_motion)
|
||||||
|
.sample(window, cx);
|
||||||
|
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
v_flex()
|
||||||
.absolute()
|
.absolute()
|
||||||
.bottom_4()
|
.bottom_0()
|
||||||
.left_0()
|
.left_0()
|
||||||
.h_9()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.px_8()
|
.bg(cx.theme().sidebar)
|
||||||
.child(
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
h_flex()
|
.children(chat_presence.should_render().then(|| {
|
||||||
.gap_2()
|
self.render_loading_pill(
|
||||||
.w_full()
|
"chat-sync",
|
||||||
.h_9()
|
"Syncing messages",
|
||||||
.justify_center()
|
self.chat_progress,
|
||||||
.bg(cx.theme().background.opacity(0.85))
|
chat_presence.progress,
|
||||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
cx,
|
||||||
.rounded_full()
|
)
|
||||||
.text_xs()
|
}))
|
||||||
.font_semibold()
|
.children(wot_presence.should_render().then(|| {
|
||||||
.text_color(cx.theme().muted_foreground)
|
self.render_loading_pill(
|
||||||
.child(Spinner::new().small().color(cx.theme().primary))
|
"wot-sync",
|
||||||
.child("Getting messages..."),
|
"Syncing contacts",
|
||||||
),
|
self.sync_progress,
|
||||||
|
wot_presence.progress,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
|
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::v_flex;
|
use gpui_kit::component::v_flex;
|
||||||
|
use instant::Instant;
|
||||||
|
|
||||||
use crate::dialogs::import;
|
use crate::dialogs::import;
|
||||||
|
|
||||||
@@ -11,12 +10,8 @@ const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
|
|||||||
|
|
||||||
/// Pick one of the signed-out backgrounds at random.
|
/// Pick one of the signed-out backgrounds at random.
|
||||||
pub(super) fn pick_banner() -> SharedString {
|
pub(super) fn pick_banner() -> SharedString {
|
||||||
let nanos = SystemTime::now()
|
let nanos = Instant::now().elapsed().subsec_nanos();
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|elapsed| elapsed.subsec_nanos())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
|
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
|
||||||
|
|
||||||
SIGNED_OUT_BANNERS[index].into()
|
SIGNED_OUT_BANNERS[index].into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-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 = "2.0.0-alpha2"
|
version = "2.0.0-alpha3"
|
||||||
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"]
|
||||||
@@ -26,6 +26,11 @@ icons = [
|
|||||||
"resources/icon.ico",
|
"resources/icon.ico",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Merged over the generated Info.plist; required for the speech input to
|
||||||
|
# request microphone and speech recognition access on macOS.
|
||||||
|
[package.metadata.packager.macos]
|
||||||
|
info_plist_path = "resources/Info.plist"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
assets = { path = "../crates/assets" }
|
assets = { path = "../crates/assets" }
|
||||||
workspace = { path = "../crates/workspace" }
|
workspace = { path = "../crates/workspace" }
|
||||||
@@ -33,6 +38,7 @@ ui = { path = "../crates/ui" }
|
|||||||
state = { path = "../crates/state" }
|
state = { path = "../crates/state" }
|
||||||
nip4e = { path = "../crates/nip4e" }
|
nip4e = { path = "../crates/nip4e" }
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
|
chat_ui = { path = "../crates/chat_ui" }
|
||||||
nip29 = { path = "../crates/nip29" }
|
nip29 = { path = "../crates/nip29" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
auto_update = { path = "../crates/auto_update" }
|
auto_update = { path = "../crates/auto_update" }
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Dictate text into messages.</string>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>Turn your speech into text.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+15
-15
@@ -1,5 +1,6 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
|
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
|
||||||
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
||||||
@@ -45,13 +46,19 @@ fn main() {
|
|||||||
// Register the `quit` function
|
// Register the `quit` function
|
||||||
cx.on_action(quit);
|
cx.on_action(quit);
|
||||||
|
|
||||||
// Register the `quit` function with CMD+Q (macOS)
|
// Register the `quit` function with CMD+Q (macOS) and dictation with CMD+D
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("cmd-q", Quit, None),
|
||||||
|
KeyBinding::new("cmd-d", ToggleDictation, None),
|
||||||
|
]);
|
||||||
|
|
||||||
// Register the `quit` function with Super+Q (others)
|
// Register the `quit` function with Super+Q (others) and dictation with Ctrl+D
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("super-q", Quit, None),
|
||||||
|
KeyBinding::new("ctrl-d", ToggleDictation, None),
|
||||||
|
]);
|
||||||
|
|
||||||
// Set menu items
|
// Set menu items
|
||||||
cx.set_menus(vec![Menu {
|
cx.set_menus(vec![Menu {
|
||||||
@@ -65,14 +72,13 @@ fn main() {
|
|||||||
|
|
||||||
let appearance = settings::persisted_appearance_mode();
|
let appearance = settings::persisted_appearance_mode();
|
||||||
let font_size = settings::persisted_theme().font_size;
|
let font_size = settings::persisted_theme().font_size;
|
||||||
match apply_native_theme(appearance, font_size, cx) {
|
|
||||||
Ok(()) => start_theme_watcher(appearance, cx),
|
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
|
||||||
Err(error) => {
|
|
||||||
log::warn!("native theme unavailable ({error})");
|
log::warn!("native theme unavailable ({error})");
|
||||||
apply_appearance(appearance, cx);
|
apply_appearance(appearance, cx);
|
||||||
AppSettings::apply_theme_style(cx);
|
AppSettings::apply_theme_style(cx);
|
||||||
}
|
}
|
||||||
}
|
start_theme_watcher(cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(cx, cli_key);
|
state::init(cx, cli_key);
|
||||||
@@ -206,14 +212,12 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
|||||||
/// Holds the watcher subscription so it lives as long as the app.
|
/// Holds the watcher subscription so it lives as long as the app.
|
||||||
struct ThemeWatcher {
|
struct ThemeWatcher {
|
||||||
_subscription: ThemeSubscription,
|
_subscription: ThemeSubscription,
|
||||||
/// The appearance mode the observer in `workspace` was registered with.
|
|
||||||
appearance: std::cell::Cell<AppearanceMode>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Global for ThemeWatcher {}
|
impl Global for ThemeWatcher {}
|
||||||
|
|
||||||
/// Start a background task to watch for OS theme changes and re-apply the native theme.
|
/// Start a background task to watch for OS theme changes and re-apply the native theme.
|
||||||
fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
|
fn start_theme_watcher(cx: &mut App) {
|
||||||
let (sender, receiver) = flume::unbounded();
|
let (sender, receiver) = flume::unbounded();
|
||||||
match native_theme::watch::on_theme_change(move |event| {
|
match native_theme::watch::on_theme_change(move |event| {
|
||||||
if let Err(error) = sender.send(event) {
|
if let Err(error) = sender.send(event) {
|
||||||
@@ -223,7 +227,6 @@ fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
|
|||||||
Ok(subscription) => {
|
Ok(subscription) => {
|
||||||
cx.set_global(ThemeWatcher {
|
cx.set_global(ThemeWatcher {
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
appearance: std::cell::Cell::new(initial_appearance),
|
|
||||||
});
|
});
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
while receiver.recv_async().await.is_ok() {
|
while receiver.recv_async().await.is_ok() {
|
||||||
@@ -231,9 +234,6 @@ fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
|
|||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
let appearance = AppSettings::get_appearance_mode(cx);
|
let appearance = AppSettings::get_appearance_mode(cx);
|
||||||
let font_size = AppSettings::get_theme(cx).font_size;
|
let font_size = AppSettings::get_theme(cx).font_size;
|
||||||
if let Some(watcher) = cx.try_global::<ThemeWatcher>() {
|
|
||||||
watcher.appearance.set(appearance);
|
|
||||||
}
|
|
||||||
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
|
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
|
||||||
log::warn!("failed to apply updated OS theme ({error})");
|
log::warn!("failed to apply updated OS theme ({error})");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ channel = "1.98"
|
|||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
targets = [
|
targets = [
|
||||||
"x86_64-apple-darwin",
|
|
||||||
"aarch64-apple-darwin",
|
"aarch64-apple-darwin",
|
||||||
"x86_64-unknown-linux-gnu",
|
"x86_64-unknown-linux-gnu",
|
||||||
"x86_64-pc-windows-msvc",
|
"x86_64-pc-windows-msvc",
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ publish.workspace = true
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
assets = { path = "../crates/assets" }
|
||||||
workspace = { path = "../crates/workspace" }
|
workspace = { path = "../crates/workspace" }
|
||||||
ui = { path = "../crates/ui" }
|
ui = { path = "../crates/ui" }
|
||||||
state = { path = "../crates/state" }
|
state = { path = "../crates/state" }
|
||||||
nip4e = { path = "../crates/nip4e" }
|
nip4e = { path = "../crates/nip4e" }
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
|
chat_ui = { path = "../crates/chat_ui" }
|
||||||
nip29 = { path = "../crates/nip29" }
|
nip29 = { path = "../crates/nip29" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
person = { path = "../crates/person" }
|
person = { path = "../crates/person" }
|
||||||
@@ -29,6 +31,8 @@ console_error_panic_hook = "0.1"
|
|||||||
tracing-wasm = "0.2"
|
tracing-wasm = "0.2"
|
||||||
console_log = "1.0"
|
console_log = "1.0"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
js-sys = "0.3"
|
||||||
universal-time = { git = "https://github.com/shadowylab/universal-time" }
|
universal-time = { git = "https://github.com/shadowylab/universal-time" }
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
|||||||
+8
-10
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help dev build-wasm build-web build clean install
|
.PHONY: help dev build-wasm build-wasm-dev build-web build-web-prod build build-prod preview clean install watch-wasm
|
||||||
|
|
||||||
help: ## Show help information
|
help: ## Show help information
|
||||||
@echo "Coop - Available commands:"
|
@echo "Coop - Available commands:"
|
||||||
@@ -7,11 +7,9 @@ help: ## Show help information
|
|||||||
|
|
||||||
install: ## Install all dependencies
|
install: ## Install all dependencies
|
||||||
@echo "Checking Rust WASM target..."
|
@echo "Checking Rust WASM target..."
|
||||||
@rustup target add wasm32-unknown-unknown || true
|
@rustup target add wasm32-unknown-unknown --toolchain nightly || true
|
||||||
@echo "Checking wasm-bindgen-cli..."
|
|
||||||
@cargo install wasm-bindgen-cli || true
|
|
||||||
@echo "Installing frontend dependencies..."
|
@echo "Installing frontend dependencies..."
|
||||||
@cd www && deno install
|
@cd www && npm install
|
||||||
|
|
||||||
build-wasm: ## Build WASM (release mode)
|
build-wasm: ## Build WASM (release mode)
|
||||||
@./script/build-wasm.sh --release
|
@./script/build-wasm.sh --release
|
||||||
@@ -20,20 +18,20 @@ build-wasm-dev: ## Build WASM (debug mode)
|
|||||||
@./script/build-wasm.sh
|
@./script/build-wasm.sh
|
||||||
|
|
||||||
build-web: ## Build frontend
|
build-web: ## Build frontend
|
||||||
@cd www && deno task build
|
@cd www && npm run build
|
||||||
|
|
||||||
build-web-prod: ## Build frontend for production (GitHub Pages)
|
build-web-prod: ## Build frontend for production (GitHub Pages)
|
||||||
@cd www && deno install && NODE_ENV=production deno task build
|
@cd www && npm install && NODE_ENV=production npm run build
|
||||||
|
|
||||||
build: build-wasm build-web ## Build complete project (WASM + frontend)
|
build: build-wasm build-web ## Build complete project (WASM + frontend)
|
||||||
|
|
||||||
build-prod: build-wasm build-web-prod ## Build complete project for production
|
build-prod: build-wasm build-web-prod ## Build complete project for production
|
||||||
|
|
||||||
dev: build-wasm-dev ## Start development server
|
dev: build-wasm-dev ## Start development server
|
||||||
@cd www && deno install && deno task dev
|
@cd www && npm install && npm run dev
|
||||||
|
|
||||||
preview: ## Preview production build
|
preview: ## Preview production build
|
||||||
@cd www && deno task preview
|
@cd www && npm run preview
|
||||||
|
|
||||||
clean: ## Clean build artifacts
|
clean: ## Clean build artifacts
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
@@ -43,4 +41,4 @@ clean: ## Clean build artifacts
|
|||||||
|
|
||||||
watch-wasm: ## Watch Rust code changes and auto-rebuild WASM
|
watch-wasm: ## Watch Rust code changes and auto-rebuild WASM
|
||||||
@echo "Watching WASM changes..."
|
@echo "Watching WASM changes..."
|
||||||
@cargo watch -x 'build --target wasm32-unknown-unknown' -s './script/build-wasm.sh'
|
@cargo watch -w ../crates -w ../Cargo.toml -w ../Cargo.lock -s './script/build-wasm.sh'
|
||||||
|
|||||||
+39
-18
@@ -11,7 +11,8 @@ echo -e "${GREEN}Building Coop Web...${NC}"
|
|||||||
|
|
||||||
# Get the script directory
|
# Get the script directory
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
PROJECT_ROOT="$SCRIPT_DIR/.."
|
PROJECT_ROOT="$SCRIPT_DIR/../.."
|
||||||
|
WEB_ROOT="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
RELEASE_FLAG=""
|
RELEASE_FLAG=""
|
||||||
@@ -20,27 +21,47 @@ if [[ "$1" == "--release" ]]; then
|
|||||||
echo -e "${YELLOW}Building in release mode${NC}"
|
echo -e "${YELLOW}Building in release mode${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# macOS: ensure LLVM from Homebrew is available
|
NIGHTLY="nightly-2026-10-31"
|
||||||
|
CARGO="cargo +$NIGHTLY"
|
||||||
|
|
||||||
|
# Step 0: Toolchain prerequisites
|
||||||
|
echo -e "${GREEN}Step 0: Checking toolchain...${NC}"
|
||||||
|
rustup target add wasm32-unknown-unknown --toolchain "$NIGHTLY"
|
||||||
|
|
||||||
if [[ "$(uname)" == "Darwin" ]]; then
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
echo -e "${GREEN}Detected macOS, setting up LLVM...${NC}"
|
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
|
||||||
if brew list llvm &>/dev/null; then
|
# llvm-ar for archives comes from the rustup llvm-tools component
|
||||||
echo -e "${GREEN}LLVM already installed, skipping install${NC}"
|
if ! xcrun --find clang &>/dev/null; then
|
||||||
else
|
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
|
||||||
echo -e "${YELLOW}LLVM not found, installing via Homebrew...${NC}"
|
exit 1
|
||||||
brew install llvm
|
|
||||||
fi
|
fi
|
||||||
LLVM_PATH=$(brew --prefix llvm)
|
rustup component add llvm-tools-preview --toolchain "$NIGHTLY"
|
||||||
export AR="${LLVM_PATH}/bin/llvm-ar"
|
|
||||||
export CC="${LLVM_PATH}/bin/clang"
|
RUSTUP_HOME=$(rustup show home)
|
||||||
echo -e "${GREEN}LLVM path: ${LLVM_PATH}${NC}"
|
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||||
|
HOST_ARCH="aarch64-apple-darwin"
|
||||||
|
else
|
||||||
|
HOST_ARCH="x86_64-apple-darwin"
|
||||||
|
fi
|
||||||
|
LLVM_BIN="$RUSTUP_HOME/toolchains/$NIGHTLY-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
|
||||||
|
|
||||||
|
export CC_wasm32_unknown_unknown=clang
|
||||||
|
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"
|
||||||
|
echo -e "${GREEN}Using Apple clang + rustup llvm-ar for wasm32 C code${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Match wasm-bindgen-cli to the version in Cargo.lock
|
||||||
|
LOCKED_BINDGEN=$(grep -A1 '^name = "wasm-bindgen"$' "$PROJECT_ROOT/Cargo.lock" | grep version | cut -d'"' -f2)
|
||||||
|
if [[ "$(wasm-bindgen --version 2>/dev/null | awk '{print $2}')" != "$LOCKED_BINDGEN" ]]; then
|
||||||
|
echo -e "${YELLOW}Installing wasm-bindgen-cli $LOCKED_BINDGEN to match Cargo.lock...${NC}"
|
||||||
|
cargo install -f wasm-bindgen-cli --version "$LOCKED_BINDGEN"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 1: Build WASM
|
# Step 1: Build WASM
|
||||||
echo -e "${GREEN}Step 1: Building WASM...${NC}"
|
echo -e "${GREEN}Step 1: Building WASM...${NC}"
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
export CARGO_TARGET_DIR="$PROJECT_ROOT/target"
|
export CARGO_TARGET_DIR="$PROJECT_ROOT/target"
|
||||||
RUSTFLAGS='-C target-feature=+bulk-memory -C link-arg=--max-memory=4294967296' \
|
RUSTFLAGS='-C target-feature=+bulk-memory -C link-arg=--max-memory=4294967296' \
|
||||||
cargo build --target wasm32-unknown-unknown $RELEASE_FLAG
|
$CARGO build -p coop_web --target wasm32-unknown-unknown $RELEASE_FLAG
|
||||||
|
|
||||||
# Determine the build directory
|
# Determine the build directory
|
||||||
if [[ "$RELEASE_FLAG" == "--release" ]]; then
|
if [[ "$RELEASE_FLAG" == "--release" ]]; then
|
||||||
@@ -61,12 +82,12 @@ fi
|
|||||||
# Step 2: Generate JavaScript bindings
|
# Step 2: Generate JavaScript bindings
|
||||||
echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}"
|
echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}"
|
||||||
wasm-bindgen "$WASM_PATH" \
|
wasm-bindgen "$WASM_PATH" \
|
||||||
--out-dir "$PROJECT_ROOT/www/wasm" \
|
--out-dir "$WEB_ROOT/www/wasm" \
|
||||||
--target web \
|
--target web \
|
||||||
--no-typescript
|
--no-typescript
|
||||||
|
|
||||||
echo -e "${GREEN}✓ Build completed successfully!${NC}"
|
echo -e "${GREEN}✓ Build completed successfully!${NC}"
|
||||||
echo -e "${YELLOW}Next steps:${NC}"
|
echo -e "${YELLOW}Next steps:${NC}"
|
||||||
echo -e " cd www"
|
echo -e " cd web/www"
|
||||||
echo -e " deno install"
|
echo -e " npm install"
|
||||||
echo -e " deno run dev"
|
echo -e " npm run dev"
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Deploy the web build to Cloudflare Pages (direct upload via wrangler).
|
||||||
|
# Auth: run `npx wrangler login` once, or export CLOUDFLARE_API_TOKEN
|
||||||
|
# (and CLOUDFLARE_ACCOUNT_ID if the token has access to multiple accounts).
|
||||||
|
# One-time project setup: npx wrangler pages project create coop-web --production-branch master
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
PROJECT_ROOT="$SCRIPT_DIR/../.."
|
||||||
|
WWW_DIR="$SCRIPT_DIR/../www"
|
||||||
|
DIST_DIR="$WWW_DIR/dist"
|
||||||
|
|
||||||
|
PROJECT_NAME="coop-web"
|
||||||
|
PRODUCTION_BRANCH="master"
|
||||||
|
|
||||||
|
BRANCH=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--branch)
|
||||||
|
BRANCH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unknown argument: $1${NC}"
|
||||||
|
echo "Usage: $0 [--branch <name>]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$BRANCH" ]]; then
|
||||||
|
BRANCH="$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Building Coop Web for deploy (branch: $BRANCH)...${NC}"
|
||||||
|
|
||||||
|
"$SCRIPT_DIR/build-wasm.sh" --release
|
||||||
|
|
||||||
|
echo -e "${GREEN}Building frontend...${NC}"
|
||||||
|
(cd "$WWW_DIR" && npm install --no-fund --no-audit && npm run build)
|
||||||
|
|
||||||
|
# Cloudflare Pages rejects files over 25 MiB
|
||||||
|
MAX_FILE_BYTES=$((25 * 1024 * 1024))
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
SIZE=$(stat -f%z "$file")
|
||||||
|
if [[ "$SIZE" -gt "$MAX_FILE_BYTES" ]]; then
|
||||||
|
echo -e "${RED}Error: $file is $((SIZE / 1024 / 1024)) MiB, over Cloudflare's 25 MiB per-file limit.${NC}"
|
||||||
|
echo "Shrink the wasm (wasm-opt -Oz, font subsetting) before deploying."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < <(find "$DIST_DIR" -type f -print0)
|
||||||
|
|
||||||
|
echo -e "${GREEN}Deploying to Cloudflare Pages project '$PROJECT_NAME'...${NC}"
|
||||||
|
npx wrangler pages deploy "$DIST_DIR" \
|
||||||
|
--project-name "$PROJECT_NAME" \
|
||||||
|
--branch "$BRANCH" \
|
||||||
|
--commit-dirty=true
|
||||||
|
|
||||||
|
if [[ "$BRANCH" == "$PRODUCTION_BRANCH" ]]; then
|
||||||
|
echo -e "${GREEN}✓ Deployed to production.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}✓ Deployed as preview for branch '$BRANCH'.${NC}"
|
||||||
|
echo -e "${YELLOW}Pass --branch $PRODUCTION_BRANCH to deploy to production.${NC}"
|
||||||
|
fi
|
||||||
+111
-23
@@ -1,57 +1,99 @@
|
|||||||
|
#![cfg(target_arch = "wasm32")]
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::*;
|
use gpui::*;
|
||||||
use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider};
|
use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
struct CustomTimeProvider;
|
thread_local! {
|
||||||
|
/// Dropping the handle releases the app, so the page's lifetime must own it.
|
||||||
|
static APPLICATION: RefCell<Option<ApplicationHandle>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
impl WallClock for CustomTimeProvider {
|
/// Serves `Date.now()` as the wall clock and `performance.now()` as the monotonic clock.
|
||||||
|
struct BrowserTimeProvider;
|
||||||
|
|
||||||
|
impl WallClock for BrowserTimeProvider {
|
||||||
fn system_time(&self) -> SystemTime {
|
fn system_time(&self) -> SystemTime {
|
||||||
SystemTime::from_unix_duration(instant::Duration::from_secs(0))
|
SystemTime::from_unix_duration(core::time::Duration::from_secs_f64(
|
||||||
|
js_sys::Date::now() / 1000.0,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MonotonicClock for CustomTimeProvider {
|
impl MonotonicClock for BrowserTimeProvider {
|
||||||
fn instant(&self) -> Instant {
|
fn instant(&self) -> Instant {
|
||||||
Instant::from_ticks(instant::Duration::from_secs(0))
|
Instant::from_ticks(core::time::Duration::from_secs_f64(instant::now() / 1000.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
define_time_provider!(CustomTimeProvider);
|
define_time_provider!(BrowserTimeProvider);
|
||||||
|
|
||||||
#[wasm_bindgen]
|
fn init_logging() {
|
||||||
pub fn run() -> Result<(), JsValue> {
|
// Called from both entry points; the first one wins
|
||||||
|
static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
ONCE.get_or_init(|| {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
// Initialize logging to browser console
|
// Initialize logging to browser console
|
||||||
console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger");
|
console_log::init_with_level(log::Level::Info).ok();
|
||||||
|
|
||||||
// Also initialize tracing for WASM
|
// Also initialize tracing for WASM
|
||||||
tracing_wasm::set_as_global_default();
|
tracing_wasm::set_as_global_default();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_family = "wasm")]
|
/// Open the persistent nostr database before the app starts.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub async fn open_database() -> Result<(), JsValue> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
state::web_database::open().await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn run() -> Result<(), JsValue> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
// Sets the panic hook and GPUI's console logger
|
||||||
gpui_platform::web_init();
|
gpui_platform::web_init();
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
let platform = std::rc::Rc::new(gpui_web::WebPlatform::new_with_backend_and_font_fallback(
|
||||||
let app = gpui_platform::application();
|
false,
|
||||||
|
gpui_web::WebBackendPreference::Auto,
|
||||||
|
gpui_web::CanvasFontFallback::EmojiAndCjk,
|
||||||
|
));
|
||||||
|
|
||||||
#[cfg(target_family = "wasm")]
|
let http_client = Arc::new(platform.fetch_http_client());
|
||||||
let app = {
|
|
||||||
let app = gpui_platform::single_threaded_web();
|
|
||||||
|
|
||||||
// Temporary fix: intentionally leak the `Rc<AppCell>` to keep the application alive
|
let app = Application::with_platform(platform)
|
||||||
struct WasmApplication(std::rc::Rc<AppCell>);
|
.with_http_client(http_client)
|
||||||
let wasm_app = unsafe { std::mem::transmute::<Application, WasmApplication>(app) };
|
.with_assets(assets::CombinedAssets);
|
||||||
std::mem::forget(wasm_app.0.clone());
|
|
||||||
unsafe { std::mem::transmute::<WasmApplication, Application>(wasm_app) }
|
APPLICATION.with(|application| {
|
||||||
};
|
*application.borrow_mut() = Some(app.run_embedded(|cx| {
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("cmd-d", chat_ui::ToggleDictation, None),
|
||||||
|
KeyBinding::new("ctrl-d", chat_ui::ToggleDictation, None),
|
||||||
|
]);
|
||||||
|
|
||||||
app.run(|cx| {
|
|
||||||
// Initialize the framework before anything renders
|
|
||||||
gpui_kit::init(cx);
|
gpui_kit::init(cx);
|
||||||
|
|
||||||
|
load_embedded_fonts(cx);
|
||||||
|
apply_web_fonts(cx);
|
||||||
|
|
||||||
// Initialize settings
|
// Initialize settings
|
||||||
settings::init(cx);
|
settings::init(cx);
|
||||||
|
|
||||||
|
apply_appearance(settings::persisted_appearance_mode(), cx);
|
||||||
|
settings::AppSettings::apply_theme_style(cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(cx, None);
|
state::init(cx, None);
|
||||||
|
|
||||||
@@ -75,7 +117,53 @@ pub fn run() -> Result<(), JsValue> {
|
|||||||
.expect("Failed to open window. Please restart the application.");
|
.expect("Failed to open window. Please restart the application.");
|
||||||
|
|
||||||
cx.activate(true);
|
cx.activate(true);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register every bundled font with the text system before any text is laid out
|
||||||
|
fn load_embedded_fonts(cx: &mut App) {
|
||||||
|
let font_paths = assets::Assets::iter()
|
||||||
|
.filter(|path| path.starts_with("fonts/") && path.ends_with(".ttf"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let fonts = font_paths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|path| assets::Assets::get(path.as_ref()))
|
||||||
|
.map(|font| font.data)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
cx.text_system()
|
||||||
|
.add_fonts(fonts)
|
||||||
|
.expect("Failed to load bundled fonts");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_web_fonts(cx: &mut App) {
|
||||||
|
use gpui_kit::component::Theme;
|
||||||
|
|
||||||
|
const UI_FONT: &str = "Inter";
|
||||||
|
|
||||||
|
Theme::update(cx, |theme| {
|
||||||
|
theme.font_family = UI_FONT.into();
|
||||||
|
theme.mono_font_family = UI_FONT.into();
|
||||||
|
|
||||||
|
for config in [&mut theme.light_theme, &mut theme.dark_theme] {
|
||||||
|
let config = Rc::make_mut(config);
|
||||||
|
config.font_family = Some(UI_FONT.into());
|
||||||
|
config.mono_font_family = Some(UI_FONT.into());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Honor the appearance mode against the default palettes
|
||||||
|
fn apply_appearance(appearance: settings::AppearanceMode, cx: &mut App) {
|
||||||
|
use gpui_kit::component::{Theme, ThemeMode};
|
||||||
|
|
||||||
|
match appearance {
|
||||||
|
settings::AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||||
|
settings::AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||||
|
settings::AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
-396
@@ -1,396 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "5",
|
|
||||||
"specifiers": {
|
|
||||||
"npm:vite-plugin-static-copy@^3.2.0": "3.4.0_vite@8.1.5",
|
|
||||||
"npm:vite-plugin-wasm@^3.3.0": "3.6.0_vite@8.1.5",
|
|
||||||
"npm:vite@8": "8.1.5"
|
|
||||||
},
|
|
||||||
"npm": {
|
|
||||||
"@emnapi/core@1.11.1": {
|
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
|
||||||
"dependencies": [
|
|
||||||
"@emnapi/wasi-threads",
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@emnapi/runtime@1.11.1": {
|
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
|
||||||
"dependencies": [
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@emnapi/wasi-threads@1.2.2": {
|
|
||||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
|
||||||
"dependencies": [
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": {
|
|
||||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
|
||||||
"dependencies": [
|
|
||||||
"@emnapi/core",
|
|
||||||
"@emnapi/runtime",
|
|
||||||
"@tybys/wasm-util"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@oxc-project/types@0.139.0": {
|
|
||||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="
|
|
||||||
},
|
|
||||||
"@rolldown/binding-android-arm64@1.1.5": {
|
|
||||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
|
||||||
"os": ["android"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-darwin-arm64@1.1.5": {
|
|
||||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
|
||||||
"os": ["darwin"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-darwin-x64@1.1.5": {
|
|
||||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
|
||||||
"os": ["darwin"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-freebsd-x64@1.1.5": {
|
|
||||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
|
||||||
"os": ["freebsd"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.1.5": {
|
|
||||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["arm"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.1.5": {
|
|
||||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-arm64-musl@1.1.5": {
|
|
||||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.1.5": {
|
|
||||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["ppc64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.1.5": {
|
|
||||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["s390x"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-x64-gnu@1.1.5": {
|
|
||||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-linux-x64-musl@1.1.5": {
|
|
||||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-openharmony-arm64@1.1.5": {
|
|
||||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
|
||||||
"os": ["openharmony"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-wasm32-wasi@1.1.5": {
|
|
||||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
|
||||||
"dependencies": [
|
|
||||||
"@emnapi/core",
|
|
||||||
"@emnapi/runtime",
|
|
||||||
"@napi-rs/wasm-runtime"
|
|
||||||
],
|
|
||||||
"cpu": ["wasm32"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.1.5": {
|
|
||||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
|
||||||
"os": ["win32"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"@rolldown/binding-win32-x64-msvc@1.1.5": {
|
|
||||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
|
||||||
"os": ["win32"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"@rolldown/pluginutils@1.0.1": {
|
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
|
|
||||||
},
|
|
||||||
"@tybys/wasm-util@0.10.3": {
|
|
||||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
|
||||||
"dependencies": [
|
|
||||||
"tslib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"anymatch@3.1.3": {
|
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
|
||||||
"dependencies": [
|
|
||||||
"normalize-path",
|
|
||||||
"picomatch@2.3.2"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"binary-extensions@2.3.0": {
|
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="
|
|
||||||
},
|
|
||||||
"braces@3.0.3": {
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"dependencies": [
|
|
||||||
"fill-range"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chokidar@3.6.0": {
|
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
|
||||||
"dependencies": [
|
|
||||||
"anymatch",
|
|
||||||
"braces",
|
|
||||||
"glob-parent",
|
|
||||||
"is-binary-path",
|
|
||||||
"is-glob",
|
|
||||||
"normalize-path",
|
|
||||||
"readdirp"
|
|
||||||
],
|
|
||||||
"optionalDependencies": [
|
|
||||||
"fsevents"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"detect-libc@2.1.2": {
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
|
||||||
},
|
|
||||||
"fdir@6.5.0_picomatch@4.0.5": {
|
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
|
||||||
"dependencies": [
|
|
||||||
"picomatch@4.0.5"
|
|
||||||
],
|
|
||||||
"optionalPeers": [
|
|
||||||
"picomatch@4.0.5"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fill-range@7.1.1": {
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"dependencies": [
|
|
||||||
"to-regex-range"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fsevents@2.3.3": {
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"os": ["darwin"],
|
|
||||||
"scripts": true
|
|
||||||
},
|
|
||||||
"glob-parent@5.1.2": {
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dependencies": [
|
|
||||||
"is-glob"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"is-binary-path@2.1.0": {
|
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
|
||||||
"dependencies": [
|
|
||||||
"binary-extensions"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"is-extglob@2.1.1": {
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
|
|
||||||
},
|
|
||||||
"is-glob@4.0.3": {
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"dependencies": [
|
|
||||||
"is-extglob"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"is-number@7.0.0": {
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
|
|
||||||
},
|
|
||||||
"lightningcss-android-arm64@1.33.0": {
|
|
||||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
|
||||||
"os": ["android"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"lightningcss-darwin-arm64@1.33.0": {
|
|
||||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
|
||||||
"os": ["darwin"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"lightningcss-darwin-x64@1.33.0": {
|
|
||||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
|
||||||
"os": ["darwin"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"lightningcss-freebsd-x64@1.33.0": {
|
|
||||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
|
||||||
"os": ["freebsd"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"lightningcss-linux-arm-gnueabihf@1.33.0": {
|
|
||||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["arm"]
|
|
||||||
},
|
|
||||||
"lightningcss-linux-arm64-gnu@1.33.0": {
|
|
||||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"lightningcss-linux-arm64-musl@1.33.0": {
|
|
||||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"lightningcss-linux-x64-gnu@1.33.0": {
|
|
||||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"lightningcss-linux-x64-musl@1.33.0": {
|
|
||||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
|
||||||
"os": ["linux"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"lightningcss-win32-arm64-msvc@1.33.0": {
|
|
||||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
|
||||||
"os": ["win32"],
|
|
||||||
"cpu": ["arm64"]
|
|
||||||
},
|
|
||||||
"lightningcss-win32-x64-msvc@1.33.0": {
|
|
||||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
|
||||||
"os": ["win32"],
|
|
||||||
"cpu": ["x64"]
|
|
||||||
},
|
|
||||||
"lightningcss@1.33.0": {
|
|
||||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
|
||||||
"dependencies": [
|
|
||||||
"detect-libc"
|
|
||||||
],
|
|
||||||
"optionalDependencies": [
|
|
||||||
"lightningcss-android-arm64",
|
|
||||||
"lightningcss-darwin-arm64",
|
|
||||||
"lightningcss-darwin-x64",
|
|
||||||
"lightningcss-freebsd-x64",
|
|
||||||
"lightningcss-linux-arm-gnueabihf",
|
|
||||||
"lightningcss-linux-arm64-gnu",
|
|
||||||
"lightningcss-linux-arm64-musl",
|
|
||||||
"lightningcss-linux-x64-gnu",
|
|
||||||
"lightningcss-linux-x64-musl",
|
|
||||||
"lightningcss-win32-arm64-msvc",
|
|
||||||
"lightningcss-win32-x64-msvc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nanoid@3.3.16": {
|
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
|
||||||
"bin": true
|
|
||||||
},
|
|
||||||
"normalize-path@3.0.0": {
|
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
|
|
||||||
},
|
|
||||||
"p-map@7.0.6": {
|
|
||||||
"integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="
|
|
||||||
},
|
|
||||||
"picocolors@1.1.1": {
|
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
|
||||||
},
|
|
||||||
"picomatch@2.3.2": {
|
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="
|
|
||||||
},
|
|
||||||
"picomatch@4.0.5": {
|
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="
|
|
||||||
},
|
|
||||||
"postcss@8.5.23": {
|
|
||||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
|
||||||
"dependencies": [
|
|
||||||
"nanoid",
|
|
||||||
"picocolors",
|
|
||||||
"source-map-js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"readdirp@3.6.0": {
|
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
|
||||||
"dependencies": [
|
|
||||||
"picomatch@2.3.2"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"rolldown@1.1.5": {
|
|
||||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
|
||||||
"dependencies": [
|
|
||||||
"@oxc-project/types",
|
|
||||||
"@rolldown/pluginutils"
|
|
||||||
],
|
|
||||||
"optionalDependencies": [
|
|
||||||
"@rolldown/binding-android-arm64",
|
|
||||||
"@rolldown/binding-darwin-arm64",
|
|
||||||
"@rolldown/binding-darwin-x64",
|
|
||||||
"@rolldown/binding-freebsd-x64",
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf",
|
|
||||||
"@rolldown/binding-linux-arm64-gnu",
|
|
||||||
"@rolldown/binding-linux-arm64-musl",
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu",
|
|
||||||
"@rolldown/binding-linux-s390x-gnu",
|
|
||||||
"@rolldown/binding-linux-x64-gnu",
|
|
||||||
"@rolldown/binding-linux-x64-musl",
|
|
||||||
"@rolldown/binding-openharmony-arm64",
|
|
||||||
"@rolldown/binding-wasm32-wasi",
|
|
||||||
"@rolldown/binding-win32-arm64-msvc",
|
|
||||||
"@rolldown/binding-win32-x64-msvc"
|
|
||||||
],
|
|
||||||
"bin": true
|
|
||||||
},
|
|
||||||
"source-map-js@1.2.1": {
|
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
|
||||||
},
|
|
||||||
"tinyglobby@0.2.17": {
|
|
||||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
|
||||||
"dependencies": [
|
|
||||||
"fdir",
|
|
||||||
"picomatch@4.0.5"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"to-regex-range@5.0.1": {
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"dependencies": [
|
|
||||||
"is-number"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tslib@2.8.1": {
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
|
||||||
},
|
|
||||||
"vite-plugin-static-copy@3.4.0_vite@8.1.5": {
|
|
||||||
"integrity": "sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==",
|
|
||||||
"dependencies": [
|
|
||||||
"chokidar",
|
|
||||||
"p-map",
|
|
||||||
"picocolors",
|
|
||||||
"tinyglobby",
|
|
||||||
"vite"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"vite-plugin-wasm@3.6.0_vite@8.1.5": {
|
|
||||||
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
|
|
||||||
"dependencies": [
|
|
||||||
"vite"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"vite@8.1.5": {
|
|
||||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
|
||||||
"dependencies": [
|
|
||||||
"lightningcss",
|
|
||||||
"picomatch@4.0.5",
|
|
||||||
"postcss",
|
|
||||||
"rolldown",
|
|
||||||
"tinyglobby"
|
|
||||||
],
|
|
||||||
"optionalDependencies": [
|
|
||||||
"fsevents"
|
|
||||||
],
|
|
||||||
"bin": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"workspace": {
|
|
||||||
"packageJson": {
|
|
||||||
"dependencies": [
|
|
||||||
"npm:vite-plugin-static-copy@^3.2.0",
|
|
||||||
"npm:vite-plugin-wasm@^3.3.0",
|
|
||||||
"npm:vite@8"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,9 @@ async function init() {
|
|||||||
const wasm = await import('./wasm/coop_web.js');
|
const wasm = await import('./wasm/coop_web.js');
|
||||||
await wasm.default();
|
await wasm.default();
|
||||||
|
|
||||||
|
// Open the persistent nostr database before the app starts
|
||||||
|
await wasm.open_database();
|
||||||
|
|
||||||
// Initialize the story gallery
|
// Initialize the story gallery
|
||||||
await wasm.run();
|
await wasm.run();
|
||||||
|
|
||||||
|
|||||||
Generated
+1075
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"wasm": "cargo build --target wasm32-unknown-unknown --release && wasm-bindgen ../../../target/wasm32-unknown-unknown/release/gpui_component_story_web.wasm --out-dir ./src/wasm --target web --no-typescript"
|
"wasm": "../script/build-wasm.sh --release"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^8",
|
"vite": "^8",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
Referrer-Policy: no-referrer
|
||||||
|
|
||||||
|
/index.html
|
||||||
|
Cache-Control: no-cache
|
||||||
|
|
||||||
|
/assets/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
|||||||
viteStaticCopy({
|
viteStaticCopy({
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
src: path.resolve(__dirname, "../../../assets/icons"),
|
src: path.resolve(__dirname, "../../assets/icons"),
|
||||||
dest: "assets",
|
dest: "assets",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
|||||||
server.middlewares.use(
|
server.middlewares.use(
|
||||||
"/coop/assets",
|
"/coop/assets",
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
const assetsPath = path.resolve(__dirname, "../../../assets");
|
const assetsPath = path.resolve(__dirname, "../../assets");
|
||||||
const filePath = path.join(
|
const filePath = path.join(
|
||||||
assetsPath,
|
assetsPath,
|
||||||
req.url.replace("/assets", ""),
|
req.url.replace("/assets", ""),
|
||||||
@@ -61,10 +61,6 @@ export default defineConfig({
|
|||||||
strict: false,
|
strict: false,
|
||||||
allow: [".."],
|
allow: [".."],
|
||||||
},
|
},
|
||||||
headers: {
|
|
||||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ["./src/wasm"],
|
exclude: ["./src/wasm"],
|
||||||
|
|||||||
Reference in New Issue
Block a user