mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
498 lines
18 KiB
Rust
498 lines
18 KiB
Rust
use crate::*;
|
|
|
|
pub(crate) struct SharedState {
|
|
// Shared page. Three sections rebuilt wholesale on each load: shared-with-me,
|
|
// incoming invitations, and bookmarks. Rows are tracked so a reload can remove
|
|
// the old ones before appending the new (adw groups have no clear-all).
|
|
pub(crate) content: gtk4::Stack,
|
|
pub(crate) status: adw::StatusPage,
|
|
pub(crate) retry: gtk4::Button,
|
|
pub(crate) with_me_group: adw::PreferencesGroup,
|
|
pub(crate) invitations_group: adw::PreferencesGroup,
|
|
pub(crate) bookmarks_group: adw::PreferencesGroup,
|
|
pub(crate) rows: RefCell<Vec<(adw::PreferencesGroup, gtk4::Widget)>>,
|
|
/// Guards the Shared page's load so overlapping navigations don't stack.
|
|
pub(crate) inflight: Cell<bool>,
|
|
/// When the Shared page last painted good data. `None` = never / invalidated,
|
|
/// forcing a fetch on next visit. See [`PAGE_TTL`].
|
|
pub(crate) loaded_at: Cell<Option<Instant>>,
|
|
}
|
|
|
|
/// Widgets the Shared page's load/repaint touch.
|
|
pub(crate) struct SharedWidgets {
|
|
pub(crate) content: gtk4::Stack,
|
|
pub(crate) status: adw::StatusPage,
|
|
pub(crate) shared_with_me: adw::PreferencesGroup,
|
|
pub(crate) invitations: adw::PreferencesGroup,
|
|
pub(crate) bookmarks: adw::PreferencesGroup,
|
|
pub(crate) retry: gtk4::Button,
|
|
pub(crate) add_bookmark: gtk4::Button,
|
|
pub(crate) refresh: gtk4::Button,
|
|
}
|
|
|
|
/// The Shared page: three stacked sections — items shared *with* me (each with a
|
|
/// Leave action), invitations addressed to me pending accept/reject, and public
|
|
/// links I've bookmarked (open / remove). All three live outside the mount tree,
|
|
/// so the page addresses them by uid/id/token and always re-lists from the daemon.
|
|
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
|
let title = gtk4::Label::builder()
|
|
.label("Shared with me")
|
|
.halign(gtk4::Align::Start)
|
|
.build();
|
|
title.add_css_class("title-2");
|
|
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
|
titles.set_hexpand(true);
|
|
titles.append(&title);
|
|
|
|
let add_bookmark = gtk4::Button::builder()
|
|
.label("Add Bookmark")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
add_bookmark.add_css_class("flat");
|
|
let refresh = refresh_button();
|
|
|
|
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
header.append(&titles);
|
|
header.append(&refresh);
|
|
header.append(&add_bookmark);
|
|
|
|
let shared_with_me = adw::PreferencesGroup::builder()
|
|
.title("Shared with me")
|
|
.build();
|
|
let invitations = adw::PreferencesGroup::builder()
|
|
.title("Invitations")
|
|
.build();
|
|
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
|
|
|
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
|
groups.append(&shared_with_me);
|
|
groups.append(&invitations);
|
|
groups.append(&bookmarks);
|
|
let clamp = adw::Clamp::builder().child(&groups).build();
|
|
let scroll = gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.child(&clamp)
|
|
.build();
|
|
|
|
let retry = gtk4::Button::builder()
|
|
.label("Retry")
|
|
.halign(gtk4::Align::Center)
|
|
.build();
|
|
retry.add_css_class("pill");
|
|
retry.add_css_class("suggested-action");
|
|
retry.set_visible(false);
|
|
let status = adw::StatusPage::builder()
|
|
.icon_name("emblem-shared-symbolic")
|
|
.vexpand(true)
|
|
.child(&retry)
|
|
.build();
|
|
status.add_css_class("compact");
|
|
|
|
let content = gtk4::Stack::new();
|
|
content.set_vexpand(true);
|
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
|
content.add_named(&scroll, Some("list"));
|
|
content.add_named(&status, Some("status"));
|
|
|
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
inner.set_margin_top(12);
|
|
inner.set_margin_bottom(12);
|
|
inner.set_margin_start(12);
|
|
inner.set_margin_end(12);
|
|
inner.append(&header);
|
|
inner.append(&content);
|
|
|
|
(
|
|
inner.upcast(),
|
|
SharedWidgets {
|
|
content,
|
|
status,
|
|
shared_with_me,
|
|
invitations,
|
|
bookmarks,
|
|
retry,
|
|
add_bookmark,
|
|
refresh,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Install the Shared page's retry and Add-Bookmark buttons.
|
|
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: >k4::Button, add_bookmark: >k4::Button) {
|
|
let ui_retry = ui.clone();
|
|
retry.connect_clicked(move |_| {
|
|
service::restart();
|
|
load_shared(&ui_retry);
|
|
});
|
|
let ui_add = ui.clone();
|
|
add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add));
|
|
}
|
|
|
|
/// Show a status page in place of the Shared sections.
|
|
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
|
ui.shared.status.set_icon_name(Some(icon));
|
|
ui.shared.status.set_title(title);
|
|
ui.shared.status.set_description(Some(description));
|
|
ui.shared.retry.set_visible(retry);
|
|
ui.shared.content.set_visible_child_name("status");
|
|
}
|
|
|
|
/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in
|
|
/// parallel and repaint the page once all three land.
|
|
pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
|
if ui.shared.inflight.get() {
|
|
return;
|
|
}
|
|
ui.shared.inflight.set(true);
|
|
shared_status(
|
|
ui,
|
|
"emblem-shared-symbolic",
|
|
"Loading…",
|
|
"Reading your shared items.",
|
|
false,
|
|
);
|
|
|
|
ui.busy_begin();
|
|
let socket = ui.dirs.control_socket();
|
|
let shared_rx = spawn_request(socket.clone(), Request::ListSharedWithMe);
|
|
let invites_rx = spawn_request(socket.clone(), Request::ListInvitations);
|
|
let bookmarks_rx = spawn_request(socket, Request::ListBookmarks);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let shared = shared_rx.recv().await;
|
|
let invites = invites_rx.recv().await;
|
|
let bookmarks = bookmarks_rx.recv().await;
|
|
ui.busy_end();
|
|
ui.shared.inflight.set(false);
|
|
|
|
// A transport failure on any of the three means the daemon isn't up.
|
|
if matches!(shared, Ok(Err(_)) | Err(_))
|
|
|| matches!(invites, Ok(Err(_)) | Err(_))
|
|
|| matches!(bookmarks, Ok(Err(_)) | Err(_))
|
|
{
|
|
ui.shared.loaded_at.set(None);
|
|
shared_unreachable(&ui);
|
|
return;
|
|
}
|
|
|
|
let shared_items = match shared {
|
|
Ok(Ok(Response::Entries { entries })) => entries,
|
|
_ => Vec::new(),
|
|
};
|
|
let invitations = match invites {
|
|
Ok(Ok(Response::Invitations { items })) => items,
|
|
_ => Vec::new(),
|
|
};
|
|
let bookmark_items = match bookmarks {
|
|
Ok(Ok(Response::Bookmarks { items })) => items,
|
|
_ => Vec::new(),
|
|
};
|
|
repaint_shared(&ui, &shared_items, &invitations, &bookmark_items);
|
|
ui.shared.loaded_at.set(Some(Instant::now()));
|
|
});
|
|
}
|
|
|
|
/// The daemon didn't answer the Shared page. Same still-starting vs. down split
|
|
/// as the other pages.
|
|
pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
|
|
if service::is_failed() || !service::is_active() {
|
|
shared_status(
|
|
ui,
|
|
"network-offline-symbolic",
|
|
"Not connected",
|
|
"The Proton Drive mount service isn't running.",
|
|
true,
|
|
);
|
|
return;
|
|
}
|
|
shared_status(
|
|
ui,
|
|
"folder-remote-symbolic",
|
|
"Connecting…",
|
|
"Waiting for the Proton Drive mount service to come up.",
|
|
false,
|
|
);
|
|
let ui = ui.clone();
|
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
if ui.stack.visible_child_name().as_deref() == Some("shared") {
|
|
load_shared(&ui);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Rebuild the three Shared sections. Rows added last time are removed first —
|
|
/// adw groups have no clear-all — then each section is repopulated, showing a
|
|
/// dim placeholder row when empty.
|
|
pub(crate) fn repaint_shared(
|
|
ui: &Rc<Ui>,
|
|
shared: &[DirEntry],
|
|
invitations: &[InvitationInfo],
|
|
bookmarks: &[BookmarkInfo],
|
|
) {
|
|
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
|
group.remove(&row);
|
|
}
|
|
ui.shared.content.set_visible_child_name("list");
|
|
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
|
|
|
// Shared with me: name + Leave.
|
|
if shared.is_empty() {
|
|
let row = dim_row("Nothing is shared with you.");
|
|
ui.shared.with_me_group.add(&row);
|
|
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
|
} else {
|
|
for entry in shared {
|
|
let row = adw::ActionRow::builder().title(&entry.name).build();
|
|
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
|
"folder-symbolic"
|
|
} else {
|
|
"text-x-generic-symbolic"
|
|
}));
|
|
let leave = gtk4::Button::builder()
|
|
.label("Leave")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
leave.add_css_class("flat");
|
|
let ui_leave = ui.clone();
|
|
let uid = entry.uid.clone();
|
|
let name = entry.name.clone();
|
|
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
|
|
row.add_suffix(&leave);
|
|
ui.shared.with_me_group.add(&row);
|
|
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
|
}
|
|
}
|
|
|
|
// Invitations: inviter + item, Accept / Reject.
|
|
if invitations.is_empty() {
|
|
let row = dim_row("No pending invitations.");
|
|
ui.shared.invitations_group.add(&row);
|
|
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
|
} else {
|
|
for inv in invitations {
|
|
let item = inv
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "a shared item".to_string());
|
|
let row = adw::ActionRow::builder()
|
|
.title(&item)
|
|
.subtitle(format!("from {}", inv.inviter_email))
|
|
.build();
|
|
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
|
"folder-symbolic"
|
|
} else {
|
|
"text-x-generic-symbolic"
|
|
}));
|
|
let reject = gtk4::Button::builder()
|
|
.icon_name("window-close-symbolic")
|
|
.tooltip_text("Reject")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
reject.add_css_class("flat");
|
|
let accept = gtk4::Button::builder()
|
|
.label("Accept")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
accept.add_css_class("suggested-action");
|
|
let ui_acc = ui.clone();
|
|
let id_acc = inv.id.clone();
|
|
accept.connect_clicked(move |_| {
|
|
respond_invitation(&ui_acc, &id_acc, true);
|
|
});
|
|
let ui_rej = ui.clone();
|
|
let id_rej = inv.id.clone();
|
|
reject.connect_clicked(move |_| {
|
|
respond_invitation(&ui_rej, &id_rej, false);
|
|
});
|
|
row.add_suffix(&accept);
|
|
row.add_suffix(&reject);
|
|
ui.shared.invitations_group.add(&row);
|
|
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
|
}
|
|
}
|
|
|
|
// Bookmarks: name/URL, Open / Remove.
|
|
if bookmarks.is_empty() {
|
|
let row = dim_row("No saved bookmarks.");
|
|
ui.shared.bookmarks_group.add(&row);
|
|
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
|
} else {
|
|
for bm in bookmarks {
|
|
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
|
|
let row = adw::ActionRow::builder()
|
|
.title(&title)
|
|
.subtitle(&bm.url)
|
|
.build();
|
|
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
|
let remove = gtk4::Button::builder()
|
|
.icon_name("user-trash-symbolic")
|
|
.tooltip_text("Remove bookmark")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
remove.add_css_class("flat");
|
|
let open = gtk4::Button::builder()
|
|
.icon_name("external-link-symbolic")
|
|
.tooltip_text("Open in browser")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
open.add_css_class("flat");
|
|
let url_open = bm.url.clone();
|
|
open.connect_clicked(move |_| open_uri(&url_open));
|
|
let ui_rm = ui.clone();
|
|
let token = bm.token.clone();
|
|
let name_rm = title.clone();
|
|
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
|
|
row.add_suffix(&open);
|
|
row.add_suffix(&remove);
|
|
ui.shared.bookmarks_group.add(&row);
|
|
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
|
}
|
|
}
|
|
|
|
*ui.shared.rows.borrow_mut() = rows;
|
|
}
|
|
|
|
/// Accept or reject an invitation, then reload the Shared page.
|
|
pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
|
|
let req = if accept {
|
|
Request::AcceptInvitation { id: id.to_string() }
|
|
} else {
|
|
Request::RejectInvitation { id: id.to_string() }
|
|
};
|
|
let (done, failed) = if accept {
|
|
("Invitation accepted", "Couldn't accept the invitation")
|
|
} else {
|
|
("Invitation rejected", "Couldn't reject the invitation")
|
|
};
|
|
run_shared_mutation(ui, req, done, failed);
|
|
}
|
|
|
|
/// Confirm, then leave a node shared with me.
|
|
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|
let win = ui_window(ui);
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading("Leave shared item")
|
|
.body(format!(
|
|
"Leave “{name}”? You'll lose access until you're invited again."
|
|
))
|
|
.build();
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("leave", "Leave");
|
|
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let ui = ui.clone();
|
|
let uid = uid.to_string();
|
|
dialog.connect_response(None, move |_, resp| {
|
|
if resp == "leave" {
|
|
run_shared_mutation(
|
|
&ui,
|
|
Request::LeaveShared { uid: uid.clone() },
|
|
"Left shared item",
|
|
"Couldn't leave the shared item",
|
|
);
|
|
}
|
|
});
|
|
dialog.present(win.as_ref());
|
|
}
|
|
|
|
/// Confirm, then remove a saved bookmark.
|
|
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
|
|
let win = ui_window(ui);
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading("Remove bookmark")
|
|
.body(format!("Remove the bookmark for “{name}”?"))
|
|
.build();
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("remove", "Remove");
|
|
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let ui = ui.clone();
|
|
let token = token.to_string();
|
|
dialog.connect_response(None, move |_, resp| {
|
|
if resp == "remove" {
|
|
run_shared_mutation(
|
|
&ui,
|
|
Request::DeleteBookmark {
|
|
token: token.clone(),
|
|
},
|
|
"Bookmark removed",
|
|
"Couldn't remove the bookmark",
|
|
);
|
|
}
|
|
});
|
|
dialog.present(win.as_ref());
|
|
}
|
|
|
|
/// Prompt for a public-link URL (and optional password) and save it as a bookmark.
|
|
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
|
let win = ui_window(ui);
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading("Add bookmark")
|
|
.body("Paste a Proton Drive public link to save it here.")
|
|
.build();
|
|
let group = adw::PreferencesGroup::new();
|
|
let url_row = adw::EntryRow::builder()
|
|
.title("Public link URL")
|
|
.activates_default(true)
|
|
.build();
|
|
let pw_row = adw::PasswordEntryRow::builder()
|
|
.title("Password (if the link has one)")
|
|
.activates_default(true)
|
|
.build();
|
|
group.add(&url_row);
|
|
group.add(&pw_row);
|
|
dialog.set_extra_child(Some(&group));
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("add", "Add");
|
|
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
|
|
dialog.set_default_response(Some("add"));
|
|
dialog.set_close_response("cancel");
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |_, resp| {
|
|
if resp != "add" {
|
|
return;
|
|
}
|
|
let url = url_row.text().trim().to_string();
|
|
if url.is_empty() {
|
|
toast_error(&ui, "Couldn't add bookmark", "A URL is required.");
|
|
return;
|
|
}
|
|
let pw = pw_row.text().to_string();
|
|
let password = if pw.is_empty() { None } else { Some(pw) };
|
|
run_shared_mutation(
|
|
&ui,
|
|
Request::CreateBookmark { url, password },
|
|
"Bookmark saved",
|
|
"Couldn't add the bookmark",
|
|
);
|
|
});
|
|
dialog.present(win.as_ref());
|
|
}
|
|
|
|
/// Run a mutation raised from the Shared page and reload the page on success.
|
|
pub(crate) fn run_shared_mutation(
|
|
ui: &Rc<Ui>,
|
|
req: Request,
|
|
done: &'static str,
|
|
failed: &'static str,
|
|
) {
|
|
ui.busy_begin();
|
|
let rx = spawn_request(ui.dirs.control_socket(), req);
|
|
let ui = ui.clone();
|
|
let done = done.to_string();
|
|
glib::spawn_future_local(async move {
|
|
let result = rx.recv().await;
|
|
ui.busy_end();
|
|
match result {
|
|
Ok(Ok(Response::Ok { .. })) => {
|
|
load_shared(&ui);
|
|
toast(&ui, &done);
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
|
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
|
}
|
|
});
|
|
}
|