support file picking and uploads on web
This commit is contained in:
Generated
+4
@@ -7446,6 +7446,7 @@ dependencies = [
|
|||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"gpui_tokio",
|
"gpui_tokio",
|
||||||
"instant",
|
"instant",
|
||||||
|
"js-sys",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr-blossom",
|
"nostr-blossom",
|
||||||
@@ -7459,6 +7460,9 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"smol",
|
"smol",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+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 })
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-57
@@ -1,5 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
@@ -10,10 +9,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};
|
||||||
@@ -31,7 +29,7 @@ use regex::Regex;
|
|||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
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,
|
||||||
@@ -642,22 +640,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 +661,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 +669,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 +697,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 +708,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 +743,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 +764,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 +785,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 +793,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;
|
});
|
||||||
}
|
return;
|
||||||
Some(DecryptedFile::Loading) => return,
|
}
|
||||||
_ => {}
|
|
||||||
};
|
if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) {
|
||||||
|
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
|
||||||
@@ -1475,16 +1463,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 +1548,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)
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ use anyhow::Result;
|
|||||||
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, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||||
list, px,
|
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
@@ -23,7 +22,7 @@ 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 state::{NostrRegistry, pick_file, upload_file};
|
||||||
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
|
use ui::{Avatar, RenderedText, 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};
|
||||||
|
|
||||||
@@ -553,22 +552,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);
|
||||||
|
|||||||
+21
-8
@@ -221,25 +221,38 @@ impl DeviceRegistry {
|
|||||||
cx.emit(DeviceEvent::Set);
|
cx.emit(DeviceEvent::Set);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backup the encryption's secret key to a file
|
/// Backup the encryption's secret key, returning the `nsec1...` content
|
||||||
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
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") {
|
/// Backup the encryption's secret key to a file
|
||||||
return Err(anyhow!("Not supported"));
|
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
|
||||||
|
|||||||
@@ -28,8 +28,21 @@ 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-browser-signer.workspace = true
|
||||||
nostr-memory.workspace = true
|
nostr-memory.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",
|
||||||
|
] }
|
||||||
|
|
||||||
[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())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-62
@@ -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>> {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let dialog = cx.prompt_for_paths(PathPromptOptions {
|
||||||
|
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 name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.display().to_string());
|
||||||
|
let data = smol::fs::read(&path).await?;
|
||||||
|
|
||||||
|
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,
|
server: Url,
|
||||||
path: PathBuf,
|
file: PickedFile,
|
||||||
cx: &AsyncApp,
|
cx: &AsyncApp,
|
||||||
) -> Result<FileAttachment, Error> {
|
) -> Result<FileAttachment, Error> {
|
||||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
let encrypted = encrypt(&file.data)?;
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().into_owned());
|
|
||||||
let data = smol::fs::read(&path).await?;
|
|
||||||
|
|
||||||
let encrypted = encrypt(&data)?;
|
|
||||||
let sha256 = sha256_hex(&encrypted.data);
|
let 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,25 +398,66 @@ pub async fn download_and_decrypt_to_file(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let Some(parent) = path.parent() else {
|
let name = file
|
||||||
bail!("Invalid file path");
|
.name
|
||||||
};
|
.clone()
|
||||||
smol::fs::create_dir_all(parent).await?;
|
.unwrap_or_else(|| fallback_name(&file.url));
|
||||||
|
|
||||||
// Write under a temporary name first, so an interrupted download is never reused
|
let local = store_file(&name, &file.mime, &data).await?;
|
||||||
let partial = path.with_extension("download");
|
|
||||||
smol::fs::write(&partial, data).await?;
|
|
||||||
smol::fs::rename(&partial, &path).await?;
|
|
||||||
|
|
||||||
Ok(path)
|
Ok(DownloadedFile {
|
||||||
|
local,
|
||||||
|
mime: file.mime.clone(),
|
||||||
|
name,
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
data,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
/// Persist decrypted bytes somewhere the platform can render or open them
|
||||||
pub async fn download_and_decrypt_to_file(
|
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
||||||
_file: &FileAttachment,
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
_cx: &AsyncApp,
|
{
|
||||||
) -> Result<PathBuf, Error> {
|
let digest = sha256_hex(name.as_bytes());
|
||||||
Err(anyhow!("File download not supported on web"))
|
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 {
|
||||||
|
bail!("Invalid file path");
|
||||||
|
};
|
||||||
|
smol::fs::create_dir_all(parent).await?;
|
||||||
|
|
||||||
|
// Write under a temporary name first, so an interrupted download is never reused
|
||||||
|
let partial = path.with_extension("download");
|
||||||
|
smol::fs::write(&partial, data).await?;
|
||||||
|
smol::fs::rename(&partial, &path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LocalFile::Path(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let _ = name;
|
||||||
|
let url = crate::web_file::blob_url(mime, data)?;
|
||||||
|
Ok(LocalFile::Url(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file name derived from the attachment URL's last path segment
|
||||||
|
fn fallback_name(url: &Url) -> String {
|
||||||
|
url.path()
|
||||||
|
.rsplit('/')
|
||||||
|
.find(|segment| !segment.is_empty())
|
||||||
|
.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> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -27,7 +28,9 @@ mod nip05;
|
|||||||
mod nip4e;
|
mod nip4e;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
#[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::*;
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +4,8 @@ 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, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
|
SharedString, 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 _;
|
||||||
@@ -18,7 +18,7 @@ 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;
|
||||||
@@ -179,17 +179,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 +193,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| {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::PathPromptOptions;
|
||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription,
|
||||||
Styled, Subscription, Window, div,
|
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};
|
||||||
@@ -150,29 +152,41 @@ 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(
|
||||||
let task = cx.prompt_for_paths(PathPromptOptions {
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
|
||||||
files: true,
|
cx: &mut App,
|
||||||
directories: false,
|
) {
|
||||||
multiple: false,
|
#[cfg(target_arch = "wasm32")]
|
||||||
prompt: None,
|
{
|
||||||
});
|
let note = Notification::warning("Background images are not supported on web yet.");
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
}
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let Ok(Ok(Some(mut paths))) = task.await else {
|
{
|
||||||
return;
|
let task = cx.prompt_for_paths(PathPromptOptions {
|
||||||
};
|
files: true,
|
||||||
|
directories: false,
|
||||||
let Some(path) = paths.pop() else {
|
multiple: false,
|
||||||
return;
|
prompt: None,
|
||||||
};
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
AppSettings::update_panel_background_path(Some(path), cx);
|
|
||||||
cx.refresh_windows();
|
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.detach();
|
cx.spawn(async move |cx| {
|
||||||
|
let Ok(Ok(Some(mut paths))) = task.await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(path) = paths.pop() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
AppSettings::update_panel_background_path(Some(path), cx);
|
||||||
|
cx.refresh_windows();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,8 +344,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(
|
||||||
|
|||||||
+49
-25
@@ -5,6 +5,7 @@ use anyhow::Error;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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,
|
||||||
@@ -338,34 +339,57 @@ 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| {
|
||||||
|
// Get the output path from the save dialog
|
||||||
|
let output_path = match save_dialog.await {
|
||||||
|
Ok(Ok(Some(path))) => path,
|
||||||
|
Ok(Ok(None)) | Err(_) => return Ok(()),
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
let message =
|
||||||
|
format!("Failed to pick save location: {error:#}");
|
||||||
|
let note = Notification::error(message).autohide(false);
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
})?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the backup task
|
||||||
|
let backup = device
|
||||||
|
.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
|
||||||
|
|
||||||
|
// Run the backup task
|
||||||
|
backup.await?;
|
||||||
|
|
||||||
|
// Open the backup file with the system's default application
|
||||||
|
cx.update(|_window, cx| {
|
||||||
|
cx.open_with_system(output_path.as_path());
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
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
|
let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?;
|
||||||
let output_path = match save_dialog.await {
|
let content = backup.await?;
|
||||||
Ok(Ok(Some(path))) => path,
|
|
||||||
Ok(Ok(None)) | Err(_) => return Ok(()),
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
let message = format!("Failed to pick save location: {error:#}");
|
|
||||||
let note = Notification::error(message).autohide(false);
|
|
||||||
window.push_notification(note, cx);
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the backup task
|
cx.update(|_window, _cx| {
|
||||||
let backup =
|
state::web_file::download(
|
||||||
device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
|
"encryption.txt",
|
||||||
|
"text/plain",
|
||||||
// Run the backup task
|
content.as_bytes(),
|
||||||
backup.await?;
|
)
|
||||||
|
})
|
||||||
// Open the backup file with the system's default application
|
.map_err(|e| anyhow::anyhow!(e))??;
|
||||||
cx.update(|_window, cx| {
|
|
||||||
cx.open_with_system(output_path.as_path());
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::{AnyElement, Entity, IntoElement};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
|
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
|
||||||
Stateful, Styled, Window, div, px,
|
|
||||||
};
|
};
|
||||||
|
#[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::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::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use ui::{Avatar, IconName};
|
use ui::{Avatar, IconName};
|
||||||
|
|||||||
Reference in New Issue
Block a user