use crate::*; pub(crate) struct SharedState { // Shared with me page. Two views under a switcher in the header: what other // people share with me, browsed like My files, and the public links I saved. // Neither lives in the mount, so both are addressed by uid or token and // re-listed from the daemon. pub(crate) files: FileList, /// "files" and "links". pub(crate) views: adw::ViewStack, /// "list", or "status" while the page loads or can't be read. pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, /// The files view: "list", or "empty" with [`SharedState::files_empty`]. pub(crate) files_content: gtk4::Stack, pub(crate) files_empty: adw::StatusPage, /// Leaves the current shared folder. pub(crate) back: gtk4::Button, /// The breadcrumb trail from "Shared with me" down to the current folder. pub(crate) crumb: gtk4::Box, /// "{n} invitations waiting", with Review opening the invitations dialog. pub(crate) invitations_banner: adw::Banner, /// The invitations from the last top-level load. pub(crate) invitations: RefCell>, /// When the invitations were last read, for the sidebar badge's poll. pub(crate) invitations_at: Cell>, pub(crate) invitations_inflight: Cell, /// The links view: "list", or "empty". pub(crate) links_content: gtk4::Stack, pub(crate) links_group: adw::PreferencesGroup, /// Link rows added last time, removed before the next paint (adw groups /// have no clear-all). pub(crate) link_rows: RefCell>, /// Where in a shared folder the page currently is, as `(uid, name)` from the /// top level down. Empty = the top level. Shared subtrees have no path in the /// mount, so descending is uid-addressed and the stack *is* the breadcrumb. pub(crate) nav: RefCell>, /// Runs the loads. A newer one, such as a navigation made while a folder /// was loading, supersedes the one before it. pub(crate) loader: Rc, /// The [`SharedState::nav`] the files on screen were painted for, or `None` /// before the first paint. pub(crate) listed: RefCell>>, } impl SharedState { pub(crate) fn new(widgets: &SharedWidgets) -> Self { SharedState { files: widgets.files.clone(), views: widgets.views.clone(), content: widgets.content.clone(), status: widgets.status.clone(), retry: widgets.retry.clone(), files_content: widgets.files_content.clone(), files_empty: widgets.files_empty.clone(), back: widgets.back.clone(), crumb: widgets.crumb.clone(), invitations_banner: widgets.invitations_banner.clone(), invitations: RefCell::new(Vec::new()), invitations_at: Cell::new(None), invitations_inflight: Cell::new(false), links_content: widgets.links_content.clone(), links_group: widgets.links_group.clone(), link_rows: RefCell::new(Vec::new()), nav: RefCell::new(Vec::new()), loader: Loader::new(&widgets.content), listed: RefCell::new(None), } } } /// Widgets the Shared page's load/repaint touch. pub(crate) struct SharedWidgets { pub(crate) files: FileList, pub(crate) views: adw::ViewStack, pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, pub(crate) files_content: gtk4::Stack, pub(crate) files_empty: adw::StatusPage, pub(crate) back: gtk4::Button, pub(crate) crumb: gtk4::Box, pub(crate) invitations_banner: adw::Banner, pub(crate) links_content: gtk4::Stack, pub(crate) links_group: adw::PreferencesGroup, pub(crate) layout: gtk4::Button, pub(crate) add_link: gtk4::Button, pub(crate) add_link_empty: gtk4::Button, } /// The Shared with me page: the files and folders other people share with me, /// in the same list and grid as My files, and a second view of the public /// links I saved. Pending invitations wait in a banner above both. pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { let add_link = gtk4::Button::builder() .label(gettext("Save Link…")) .tooltip_text(gettext("Save a public link to open it from here")) .visible(false) .build(); add_link.add_css_class("flat"); // Files: a path bar over the list, as in My files. let files = FileList::new(); files.show_list(true); let layout = layout_button(&files); let back = gtk4::Button::builder() .icon_name("go-previous-symbolic") .tooltip_text(gettext("Back")) .sensitive(false) .build(); back.add_css_class("flat"); let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); crumb.set_valign(gtk4::Align::Center); let crumb_scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::External) .vscrollbar_policy(gtk4::PolicyType::Never) .hexpand(true) .child(&crumb) .build(); let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); path_bar.append(&back); path_bar.append(&crumb_scroll); let files_empty = adw::StatusPage::builder() .icon_name("pdfs-people-symbolic") .vexpand(true) .build(); files_empty.add_css_class("compact"); let files_content = gtk4::Stack::new(); files_content.add_named(&files.views, Some("list")); files_content.add_named(&files_empty, Some("empty")); let files_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12); files_page.append(&path_bar); files_page.append(&files_content); // Saved links: few enough, and plain enough, for a boxed list. let links_group = adw::PreferencesGroup::new(); let links_scroll = gtk4::ScrolledWindow::builder() .vexpand(true) .child(&adw::Clamp::builder().child(&links_group).build()) .build(); let add_link_empty = gtk4::Button::builder() .label(gettext("Save Link…")) .halign(gtk4::Align::Center) .build(); add_link_empty.add_css_class("pill"); add_link_empty.add_css_class("suggested-action"); let links_empty = adw::StatusPage::builder() .icon_name("pdfs-link-symbolic") .title(gettext("No Saved Links")) .description(gettext( "Save a Proton Drive public link someone sent you to open it from here later.", )) .vexpand(true) .child(&add_link_empty) .build(); links_empty.add_css_class("compact"); let links_content = gtk4::Stack::new(); links_content.add_named(&links_scroll, Some("list")); links_content.add_named(&links_empty, Some("empty")); let views = adw::ViewStack::new(); views.set_vexpand(true); views.add_titled_with_icon( &files_page, Some("files"), &gettext("Shared with me"), "pdfs-people-symbolic", ); views.add_titled_with_icon( &links_content, Some("links"), &gettext("Saved Links"), "pdfs-link-symbolic", ); let switcher = adw::ViewSwitcher::builder() .stack(&views) .policy(adw::ViewSwitcherPolicy::Wide) .build(); let retry = gtk4::Button::builder() .label(gettext("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("pdfs-people-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(&views, 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(&content); let invitations_banner = adw::Banner::builder() .button_label(gettext("Review")) .build(); let (frame, header) = page_frame_with(&switcher, &inner); // Under the header, above both views: an invitation is waiting for a // decision whichever view is open. frame.add_top_bar(&invitations_banner); header.pack_end(&layout); header.pack_end(&add_link); ( frame.upcast(), SharedWidgets { files, views, content, status, retry, files_content, files_empty, back, crumb, invitations_banner, links_content, links_group, layout, add_link, add_link_empty, }, ) } /// Install the file list, its columns, and the page's buttons. pub(crate) fn wire_shared(ui: &Rc, widgets: &SharedWidgets) { let files = &ui.shared.files; files.wire( ui, FileListBehavior { activate: open_shared_entry, entry_menu: shared_entry_menu, bulk_menu: shared_bulk_menu, background_menu: shared_background_menu, badges: false, drag_and_drop: false, }, ); files.column_view.append_column(&shared_by_column()); files .column_view .append_column(&text_column(&pgettext("column", "Access"), |e| { role_access(&e.role).unwrap_or_else(|| "—".to_string()) })); files .column_view .append_column(&text_column(&pgettext("column", "Date Shared"), |e| { if e.shared_at > 0 { dates::short_date(e.shared_at) } else { "—".to_string() } })); files .column_view .append_column(&text_column(&pgettext("column", "Size"), |e| { if e.is_dir { "—".to_string() } else { human_bytes(e.size) } })); let ui_back = ui.clone(); widgets.back.connect_clicked(move |_| { ui_back.shared.nav.borrow_mut().pop(); load_shared(&ui_back); }); let ui_retry = ui.clone(); widgets .retry .connect_clicked(move |_| restart_service_then(&ui_retry, load_shared)); for button in [&widgets.add_link, &widgets.add_link_empty] { let ui_add = ui.clone(); button.connect_clicked(move |_| prompt_add_bookmark(&ui_add)); } let ui_review = ui.clone(); widgets .invitations_banner .connect_button_clicked(move |_| open_invitations_dialog(&ui_review)); // The header offers what the view on screen can use. let (layout, add_link) = (widgets.layout.clone(), widgets.add_link.clone()); let sync = move |views: &adw::ViewStack| { let links = views.visible_child_name().as_deref() == Some("links"); layout.set_visible(!links); add_link.set_visible(links); }; sync(&widgets.views); widgets.views.connect_visible_child_name_notify(sync); } /// Show a status page in place of both views. pub(crate) fn shared_status(ui: &Rc, 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(>k4::glib::markup_escape_text(description))); ui.shared.retry.set_visible(retry); ui.shared.content.set_visible_child_name("status"); } /// Fetch the top level (shared-with-me, invitations, saved links) in parallel /// and repaint the page once all three land. /// /// Inside a shared folder ([`SharedState::nav`] non-empty) only that folder's /// children are fetched: invitations and saved links belong to the top level. pub(crate) fn load_shared(ui: &Rc) { cancel_file_thumbnails(ui); let current = ui.shared.nav.borrow().last().cloned(); if let Some((uid, _)) = current { load_shared_folder(ui, uid); return; } let ticket = begin_shared_load( ui, "pdfs-people-symbolic", gettext("Reading your shared items."), ); 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(); if !ticket.is_current() { return; } // 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(_)) { 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_invitations(&ui, invitations); repaint_links(&ui, &bookmark_items); repaint_shared_files(&ui, &shared_items); }); } /// List one shared folder's children and repaint the files view as that /// folder. The uid comes from the entry that was opened (or from the nav stack /// on a reload) — a shared subtree is reachable no other way. fn load_shared_folder(ui: &Rc, uid: String) { let ticket = begin_shared_load( ui, "folder-symbolic", gettext("Reading this shared folder."), ); ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedFolder { uid }); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); if !ticket.is_current() { return; } match result { Ok(Ok(Response::Entries { entries })) => { repaint_shared_files(&ui, &entries); } Ok(Ok(Response::Error { message, kind })) => { // The folder is gone or access was revoked: fall back to the top // level rather than stranding the page on a dead uid. ui.shared.nav.borrow_mut().pop(); toast_failure(&ui, &gettext("Couldn't open shared folder"), &message, kind); load_shared(&ui); } _ => { shared_unreachable(&ui); } } }); } /// Start a load of the view [`SharedState::nav`] points at. A reload of the /// folder on screen keeps its entries up and usable. Moving to another folder /// greys them out until the new entries arrive, since they belong elsewhere. fn begin_shared_load(ui: &Rc, icon: &'static str, description: String) -> LoadTicket { let ui_p = ui.clone(); let placeholder = move || { ui_p.shared.files.model.remove_all(); shared_status(&ui_p, icon, &gettext("Loading…"), &description, false); }; if *ui.shared.listed.borrow() == Some(ui.shared.nav.borrow().clone()) { ui.shared.loader.refresh(placeholder) } else { ui.shared.listed.borrow_mut().take(); ui.shared.loader.replace(placeholder) } } /// 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) { service_unreachable(ui, "shared", shared_status, load_shared); } /// Paint the folder [`SharedState::nav`] points at (the top level when empty). fn repaint_shared_files(ui: &Rc, entries: &[DirEntry]) { ui.shared.content.set_visible_child_name("list"); let nav = ui.shared.nav.borrow().clone(); repaint_shared_crumb(ui, &nav); let top = nav.is_empty(); *ui.shared.listed.borrow_mut() = Some(nav); if entries.is_empty() { ui.shared.files.model.remove_all(); let (icon, title, description) = if top { ( "pdfs-people-symbolic", gettext("Nothing Shared with You"), gettext("Files and folders other people share with you appear here."), ) } else { ( "folder-open-symbolic", gettext("This folder is empty"), gettext("Nothing has been added to this shared folder yet."), ) }; ui.shared.files_empty.set_icon_name(Some(icon)); ui.shared.files_empty.set_title(&title); ui.shared.files_empty.set_description(Some(&description)); ui.shared.files_content.set_visible_child_name("empty"); return; } ui.shared.files_content.set_visible_child_name("list"); // Only the rows that changed are swapped, so a refresh keeps the selection // and the scroll position. replace_items(&ui.shared.files.model, entries); } /// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder /// down to the current one, which is a plain heading. fn repaint_shared_crumb(ui: &Rc, nav: &[(String, String)]) { let crumb = &ui.shared.crumb; while let Some(child) = crumb.first_child() { crumb.remove(&child); } ui.shared.back.set_sensitive(!nav.is_empty()); crumb.append(&shared_crumb_node( ui, &gettext("Shared with me"), 0, nav.is_empty(), )); for (i, (_, name)) in nav.iter().enumerate() { crumb.append(&crumb_separator()); crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len())); } } /// One breadcrumb segment: a heading for the current folder, or a flat button /// that goes back up to `depth` folders below the top level. fn shared_crumb_node(ui: &Rc, label: &str, depth: usize, current: bool) -> gtk4::Widget { if current { return crumb_current(label); } let button = gtk4::Button::builder().label(label).build(); button.add_css_class("flat"); let ui = ui.clone(); button.connect_clicked(move |_| { ui.shared.nav.borrow_mut().truncate(depth); load_shared(&ui); }); button.upcast() } /// Whether the files on screen are the top level, where each entry is a share /// root of its own and can be left. fn at_share_roots(ui: &Rc) -> bool { ui.shared .listed .borrow() .as_ref() .is_some_and(|nav| nav.is_empty()) } /// Folders open in place; files download and open with the default app. fn open_shared_entry(ui: &Rc, entry: &DirEntry) { if entry.is_dir { ui.shared .nav .borrow_mut() .push((entry.uid.clone(), entry.name.clone())); load_shared(ui); } else { open_shared_file(ui, &entry.uid, &entry.name); } } fn shared_entry_menu(ui: &Rc, entry: &DirEntry) -> ActionMenu { let mut menu = ActionMenu::new(); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&pgettext("verb", "Open"), move || { open_shared_entry(&ui_c, &entry_c) }); // Only once the mount has interned the share does it have a place there. if !entry.path.is_empty() { let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Show in My Files"), move || { show_in_my_files(&ui_c, &entry_c) }); } // Leaving a folder's child on its own is not a thing the API offers. if at_share_roots(ui) { menu.section(); let (ui_c, entry_c) = (ui.clone(), entry.clone()); menu.item(&gettext("Leave…"), move || { prompt_leave_shared(&ui_c, std::slice::from_ref(&entry_c)) }); } menu } fn shared_bulk_menu(ui: &Rc, entries: Vec) -> ActionMenu { let mut menu = ActionMenu::new(); menu.labelled_section(&ngettext_f( "{n} selected", "{n} selected", entries.len() as u64, &[], )); if at_share_roots(ui) { let ui_c = ui.clone(); menu.item(&gettext("Leave…"), move || { prompt_leave_shared(&ui_c, &entries) }); } menu } fn shared_background_menu(ui: &Rc) -> ActionMenu { let mut menu = ActionMenu::new(); let ui_c = ui.clone(); menu.item(&gettext("Select All"), move || { ui_c.shared.files.selection.select_all(); }); let ui_c = ui.clone(); menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c)); menu.section(); let ui_c = ui.clone(); menu.item(&gettext("Save Link…"), move || prompt_add_bookmark(&ui_c)); menu } /// The Shared by column: who shared the item, and a warning pill when their /// invitation did not verify. fn shared_by_column() -> gtk4::ColumnViewColumn { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); let label = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); label.add_css_class("dim-label"); // The name beside it is what the invitation claims. When its signature // does not check out against that person's keys, the claim must not // read as fact — someone may be impersonating a contact to get a file // opened. let pill = gtk4::Label::new(Some(&gettext("Unverified sender"))); pill.add_css_class("role-pill"); pill.add_css_class("warning-pill"); pill.add_css_class("caption"); pill.set_valign(gtk4::Align::Center); pill.set_tooltip_text(Some(&gettext(UNVERIFIED_INVITER_TOOLTIP))); let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); cell.append(&label); cell.append(&pill); item.set_child(Some(&cell)); }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let cell = item.child().and_downcast::().unwrap(); let label = cell.first_child().and_downcast::().unwrap(); let pill = cell.last_child().unwrap(); let obj = item.item().and_downcast::().unwrap(); let entry = obj.borrow::(); let text = if entry.shared_by.is_empty() { "—" } else { entry.shared_by.as_str() }; label.set_label(text); label.set_tooltip_text(Some(text)); pill.set_visible(entry.shared_by_unverified); }); let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Shared by")), Some(factory)); column.set_resizable(true); column } /// Tooltip on the warning shown next to a share whose invitation did not verify. const UNVERIFIED_INVITER_TOOLTIP: &str = gettext_noop( "The invitation's signature doesn't match the sender's keys. It may not really be from them.", ); /// The display name for a wire role, or `None` when there is nothing to show: /// owned content carries no role, and a role the API did not report is not /// guessed at (the SDK's `from_permissions_exact` returns `None` for an /// unrecognised mask rather than degrading it to viewer). pub(crate) fn role_label(role: &str) -> Option { match role { "viewer" => Some(pgettext("role", "Viewer")), "editor" => Some(pgettext("role", "Editor")), "admin" => Some(pgettext("role", "Admin")), _ => None, } } /// What a role lets me do, as a short phrase. A bare "Editor" read like a /// job title rather than a permission. pub(crate) fn role_access(role: &str) -> Option { match role { "viewer" => Some(gettext("Can view")), "editor" => Some(gettext("Can edit")), "admin" => Some(gettext("Can manage")), _ => None, } } /// Download a file shared with me into the daemon's cache and hand it to the /// user's default application — the shared-item twin of the browser's /// download-and-open, addressed by uid because the file lives outside the mount. /// The daemon reports the download as a transfer, so it shows with its /// progress beside every other one. pub(crate) fn open_shared_file(ui: &Rc, uid: &str, name: &str) { // Ignore a repeat activation of a file already downloading, so an impatient // double-click doesn't kick off a second round-trip. if !ui.opening.borrow_mut().insert(uid.to_string()) { return; } ui.busy_begin(); // Translators: {name} is a file name. toast(ui, &gettext_f("Downloading “{name}”…", &[("name", name)])); let rx = spawn_request( ui.dirs.control_socket(), Request::OpenSharedFile { uid: uid.to_string(), }, ); let ui = ui.clone(); let uid = uid.to_string(); let name = name.to_string(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); ui.opening.borrow_mut().remove(&uid); match result { // A cache blob is named by content hash, so the open rules key off // the shared node's name instead. Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name), Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &gettext("Couldn't open file"), &message, kind) } _ => toast_error( &ui, &gettext("Couldn't open file"), &gettext("The Proton Drive service didn't respond."), ), } }); } /// Read the invitations from anywhere unless the last reading is younger than /// `max_age`, so the sidebar's Shared with me row can say that some wait. pub(crate) fn refresh_invitations_badge(ui: &Rc, max_age: Duration) { let state = &ui.shared; if state.invitations_inflight.get() || state .invitations_at .get() .is_some_and(|at| at.elapsed() < max_age) { return; } state.invitations_inflight.set(true); let rx = spawn_request(ui.dirs.control_socket(), Request::ListInvitations); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.shared.invitations_inflight.set(false); match result { Ok(Ok(Response::Invitations { items })) => repaint_invitations(&ui, items), // Wait out `max_age` before asking a failing daemon again. _ => ui.shared.invitations_at.set(Some(Instant::now())), } }); } /// Keep the top level's invitations and say in the banner and the sidebar /// how many wait. fn repaint_invitations(ui: &Rc, invitations: Vec) { ui.shared.invitations_at.set(Some(Instant::now())); set_sidebar_badge(ui, "shared", invitations.len() as u64); let banner = &ui.shared.invitations_banner; banner.set_revealed(!invitations.is_empty()); if !invitations.is_empty() { banner.set_title(&ngettext_f( "{n} invitation waiting", "{n} invitations waiting", invitations.len() as u64, &[], )); } *ui.shared.invitations.borrow_mut() = invitations; } /// The invitations waiting for a decision, one card each with Decline and /// Accept. A card leaves the dialog once answered; the dialog closes with the /// last one. fn open_invitations_dialog(ui: &Rc) { let invitations = ui.shared.invitations.borrow().clone(); if invitations.is_empty() { return; } let group = adw::PreferencesGroup::builder() .description(gettext("Accept to add the item to your shared files.")) .build(); let dialog = adw::Dialog::builder() .title(gettext("Invitations")) .content_width(480) .build(); let left = Rc::new(Cell::new(invitations.len())); for inv in invitations { let row = invitation_row(&inv); let answered = { let (group, row, dialog, left) = (group.clone(), row.clone(), dialog.clone(), left.clone()); move || { group.remove(&row); left.set(left.get() - 1); if left.get() == 0 { dialog.close(); } } }; let (accept, decline) = invitation_buttons(&row); let (ui_c, id, answered_c) = (ui.clone(), inv.id.clone(), answered.clone()); accept.connect_clicked(move |_| { respond_invitation(&ui_c, &id, true); answered_c(); }); let (ui_c, id) = (ui.clone(), inv.id.clone()); let name = inv.name.clone().unwrap_or_else(|| gettext("this item")); decline.connect_clicked(move |btn| { let (ui, id, answered) = (ui_c.clone(), id.clone(), answered.clone()); confirm_destructive( btn, &gettext("Decline Invitation?"), // Translators: {name} is the shared item's name, or "this item". &gettext_f( "You won't have access to {name} unless it is shared again.", &[("name", &name)], ), &gettext("Decline"), move || { respond_invitation(&ui, &id, false); answered(); }, ); }); group.add(&row); } let page = adw::PreferencesPage::new(); page.add(&group); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&adw::HeaderBar::new()); toolbar.set_content(Some(&page)); dialog.set_child(Some(&toolbar)); dialog.present(ui_window(ui).as_ref()); } /// One invitation's card: the item, and who it is from. fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow { let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item")); let row = adw::ActionRow::builder() .title(&item) // Translators: {email} is the address of the person who sent the invitation. .subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)])) .use_markup(false) .build(); row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir { "folder-symbolic" } else { "text-x-generic-symbolic" })); row } /// Add labelled Accept and Decline buttons to an invitation's card. fn invitation_buttons(row: &adw::ActionRow) -> (gtk4::Button, gtk4::Button) { let decline = gtk4::Button::builder() .label(gettext("Decline")) .valign(gtk4::Align::Center) .build(); decline.add_css_class("flat"); let accept = gtk4::Button::builder() .label(gettext("Accept")) .valign(gtk4::Align::Center) .build(); accept.add_css_class("suggested-action"); row.add_suffix(&decline); row.add_suffix(&accept); (accept, decline) } /// Rebuild the Saved Links view. fn repaint_links(ui: &Rc, bookmarks: &[BookmarkInfo]) { for row in ui.shared.link_rows.borrow_mut().drain(..) { ui.shared.links_group.remove(&row); } ui.shared .links_content .set_visible_child_name(if bookmarks.is_empty() { "empty" } else { "list" }); let mut rows = Vec::new(); for bm in bookmarks { let title = bm.name.clone().unwrap_or_else(|| gettext("Shared link")); let row = adw::ActionRow::builder() .title(&title) .subtitle(&bm.url) .use_markup(false) .subtitle_lines(1) .activatable(true) .tooltip_text(gettext("Open in browser")) .build(); row.add_prefix(>k4::Image::from_icon_name(if bm.is_dir { "folder-symbolic" } else { "pdfs-link-symbolic" })); let url = bm.url.clone(); row.connect_activated(move |_| open_uri(&url)); row.add_suffix(>k4::Image::from_icon_name("adw-external-link-symbolic")); let mut menu = ActionMenu::new(); let url = bm.url.clone(); menu.item(&gettext("Open in Browser"), move || open_uri(&url)); let (ui_c, row_c, url) = (ui.clone(), row.clone(), bm.url.clone()); menu.item(&gettext("Copy Link"), move || { row_c.clipboard().set_text(&url); toast(&ui_c, &gettext("Link copied")); }); menu.section(); let (ui_c, token, name) = (ui.clone(), bm.token.clone(), title.clone()); menu.item(&gettext("Remove…"), move || { prompt_remove_bookmark(&ui_c, &token, &name) }); row.add_suffix(&menu.button()); ui.shared.links_group.add(&row); rows.push(row.upcast()); } *ui.shared.link_rows.borrow_mut() = rows; } /// Accept or reject an invitation, then reload the Shared page. pub(crate) fn respond_invitation(ui: &Rc, 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 { ( gettext("Invitation accepted"), gettext("Couldn't accept the invitation"), ) } else { ( gettext("Invitation rejected"), gettext("Couldn't reject the invitation"), ) }; run_shared_mutation(ui, req, &done, &failed); } /// Confirm, then leave shared items. Only share roots can be left. pub(crate) fn prompt_leave_shared(ui: &Rc, entries: &[DirEntry]) { let body = match entries { // Translators: {name} is the shared item's name. [one] => gettext_f( "Leave “{name}”? You'll lose access until you're invited again.", &[("name", &one.name)], ), _ => ngettext_f( "Leave {n} shared item? You'll lose access until you're invited again.", "Leave {n} shared items? You'll lose access until you're invited again.", entries.len() as u64, &[], ), }; let dialog = adw::AlertDialog::builder() .heading(gettext("Leave Shared Item?")) .body(body) .build(); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("leave", &pgettext("verb", "Leave")); dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let ui_c = ui.clone(); let uids: Vec = entries.iter().map(|e| e.uid.clone()).collect(); dialog.connect_response(Some("leave"), move |_, _| { let count = uids.len(); run_requests( &ui_c, uids.iter() .map(|uid| Request::LeaveShared { uid: uid.clone() }) .collect(), gettext_noop("Couldn't leave the shared item"), move |ui| { load_shared(ui); toast( ui, &ngettext_f( "Left {n} shared item", "Left {n} shared items", count as u64, &[], ), ); }, ); }); dialog.present(ui_window(ui).as_ref()); } /// Send `requests` one after the other, stopping at the first failure, which /// is reported under `failed` (an untranslated msgid). `done` runs once all /// succeeded. After a failure the page on screen is reloaded instead, since /// the requests before it went through. pub(crate) fn run_requests( ui: &Rc, requests: Vec, failed: &'static str, done: impl FnOnce(&Rc) + 'static, ) { ui.busy_begin(); let ui = ui.clone(); let socket = ui.dirs.control_socket(); glib::spawn_future_local(async move { for req in requests { let result = spawn_request(socket.clone(), req).recv().await; let error = match result { Ok(Ok(Response::Ok { .. })) => continue, Ok(Ok(Response::Error { message, kind })) => Some((message, kind)), _ => None, }; ui.busy_end(); reload_current_page(&ui); match error { Some((message, kind)) => toast_failure(&ui, &gettext(failed), &message, kind), None => toast_error( &ui, &gettext(failed), &gettext("The Proton Drive service didn't respond."), ), } return; } ui.busy_end(); done(&ui); }); } /// Confirm, then remove a saved link. The shared item itself is untouched. pub(crate) fn prompt_remove_bookmark(ui: &Rc, token: &str, name: &str) { let win = ui_window(ui); let dialog = adw::AlertDialog::builder() .heading(gettext("Remove Saved Link?")) // Translators: {name} is the name of the item the saved link points to. .body(gettext_f( "Remove “{name}” from your saved links? The shared item itself is not affected.", &[("name", name)], )) .build(); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("remove", &gettext("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(), }, &gettext("Link removed"), &gettext("Couldn't remove the link"), ); } }); dialog.present(win.as_ref()); } /// Prompt for a public-link URL (and optional password) and save it. pub(crate) fn prompt_add_bookmark(ui: &Rc) { let win = ui_window(ui); let dialog = adw::AlertDialog::builder() .heading(gettext("Save Link")) .body(gettext("Paste a Proton Drive public link to save it here.")) .build(); let group = adw::PreferencesGroup::new(); let url_row = adw::EntryRow::builder() .title(gettext("Public link URL")) .activates_default(true) .build(); let pw_row = adw::PasswordEntryRow::builder() .title(gettext("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", &gettext("Cancel")); dialog.add_response("save", &gettext("Save")); dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("save")); dialog.set_close_response("cancel"); let ui = ui.clone(); dialog.connect_response(None, move |_, resp| { if resp != "save" { return; } let url = url_row.text().trim().to_string(); if url.is_empty() { toast_error( &ui, &gettext("Couldn't save the link"), &gettext("A URL is required."), ); return; } let pw = pw_row.text().to_string(); let password = if pw.is_empty() { None } else { Some(pw) }; // The new link lands in the Saved Links view; show it there. ui.shared.views.set_visible_child_name("links"); run_shared_mutation( &ui, Request::CreateBookmark { url, password }, &gettext("Link saved"), &gettext("Couldn't save the link"), ); }); 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, req: Request, done: &str, failed: &str) { ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); let done = done.to_string(); let failed = failed.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, &gettext("The Proton Drive service didn't respond."), ), } }); } #[cfg(test)] mod tests { use super::*; #[test] fn a_role_says_what_it_allows() { assert_eq!(role_access("viewer").as_deref(), Some("Can view")); assert_eq!(role_access("editor").as_deref(), Some("Can edit")); assert_eq!(role_access("admin").as_deref(), Some("Can manage")); assert_eq!(role_access(""), None); } #[test] fn only_a_known_role_becomes_a_badge() { // "" is owned content (no role applies) and an unrecognised mask is // deliberately not guessed at — `from_permissions_exact` returns None // rather than degrading to viewer, and this must not undo that. for role in ["viewer", "editor", "admin"] { assert!(role_label(role).is_some(), "{role} should have a label"); } assert!(role_label("").is_none()); assert!(role_label("inherited").is_none()); } }