Files
proton-drive-linux/crates/pdfs-gui/src/app/pages/shared.rs
T
nvrl d9064ef880 feat(core,gui): gettext translations and language preference
The app, the tray and the search prompt route every user-visible string
through GNU gettext in the `pdfs` text domain, with named placeholders and
plural forms. po/ holds the template, update and build scripts, and catalogs
for de, en_GB, es, fr, it, nl, pl and pt_BR. Preferences gains a Language row
that overrides the system language from the next start (config `language`).

Packages compile the catalogs into /usr/share/locale and the desktop files
carry translated descriptions and keywords. docs/TRANSLATING.md describes
the workflow.
2026-09-23 08:07:50 +02:00

928 lines
34 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,
/// The header title; its subtitle carries the path inside a shared folder.
pub(crate) title: adw::WindowTitle,
/// Header button that leaves the current shared folder.
pub(crate) back: gtk4::Button,
pub(crate) add_bookmark: gtk4::Button,
/// 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<Vec<(String, String)>>,
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,
pub(crate) title: adw::WindowTitle,
pub(crate) back: 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 add_bookmark = gtk4::Button::builder()
.icon_name("bookmark-new-symbolic")
.tooltip_text(gettext(
"Add Bookmark — save a public link to open it from here",
))
.build();
let refresh = refresh_button();
// Only sections with something in them are shown, so an account with one
// share reads as one list rather than three boxes, two of them saying
// "nothing here". Invitations come first: they are the one thing on the
// page waiting for a decision.
let invitations = adw::PreferencesGroup::builder()
.title(gettext("Invitations"))
.description(gettext("Accept to add the item to your shared files."))
.visible(false)
.build();
let shared_with_me = adw::PreferencesGroup::new();
let bookmarks = adw::PreferencesGroup::builder()
.title(gettext("Bookmarks"))
.visible(false)
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
groups.append(&invitations);
groups.append(&shared_with_me);
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(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("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(18);
inner.set_margin_bottom(18);
inner.set_margin_start(18);
inner.set_margin_end(18);
inner.append(&content);
let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text(gettext("Back"))
.visible(false)
.build();
let (frame, header, title) = page_frame(&gettext("Shared with Me"), &inner);
header.pack_start(&back);
header.pack_end(&refresh);
header.pack_end(&add_bookmark);
(
frame.upcast(),
SharedWidgets {
content,
status,
shared_with_me,
invitations,
bookmarks,
retry,
add_bookmark,
refresh,
title,
back,
},
)
}
/// Install the Shared page's retry, back and Add-Bookmark buttons.
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: &gtk4::Button, add_bookmark: &gtk4::Button) {
let ui_back = ui.clone();
ui.shared.back.connect_clicked(move |_| {
ui_back.shared.nav.borrow_mut().pop();
load_shared(&ui_back);
});
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.
///
/// Inside a shared folder ([`SharedState::nav`] non-empty) only that folder's
/// children are fetched: invitations and bookmarks belong to the top level, and
/// carrying them down a subtree would read as if they lived there.
pub(crate) fn load_shared(ui: &Rc<Ui>) {
if ui.shared.inflight.get() {
return;
}
cancel_file_thumbnails(ui);
let current = ui.shared.nav.borrow().last().cloned();
if let Some((uid, _)) = current {
load_shared_folder(ui, uid);
return;
}
ui.shared.inflight.set(true);
shared_status(
ui,
"emblem-shared-symbolic",
&gettext("Loading…"),
&gettext("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()));
});
}
/// List one shared folder's children and repaint the page as that folder's view.
/// The uid comes from the row that was activated (or from the nav stack on a
/// reload) — a shared subtree is reachable no other way.
fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
ui.shared.inflight.set(true);
shared_status(
ui,
"folder-symbolic",
&gettext("Loading…"),
&gettext("Reading this shared folder."),
false,
);
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();
ui.shared.inflight.set(false);
match result {
Ok(Ok(Response::Entries { entries })) => {
repaint_shared_folder(&ui, &entries);
ui.shared.loaded_at.set(Some(Instant::now()));
}
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);
}
_ => {
ui.shared.loaded_at.set(None);
shared_unreachable(&ui);
}
}
});
}
/// The header subtitle inside a shared folder: the folders from the share root
/// down, the way a breadcrumb reads.
pub(crate) fn shared_path(nav: &[(String, String)]) -> String {
nav.iter()
.map(|(_, name)| name.as_str())
.collect::<Vec<_>>()
.join(" / ")
}
/// The Invitations heading, with the count once there is more than one.
pub(crate) fn invitations_title(count: usize) -> String {
if count > 1 {
// Translators: the Invitations heading; {count} is the number of pending invitations (2 or more).
gettext_f("Invitations ({count})", &[("count", &count.to_string())])
} else {
gettext("Invitations")
}
}
/// Paint the contents of the shared folder at the top of the nav stack; the
/// header's back button leads up again. Invitations and bookmarks are hidden here —
/// they are top-level things, not contents of this folder.
fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
group.remove(&row);
}
ui.shared.content.set_visible_child_name("list");
ui.shared.invitations_group.set_visible(false);
ui.shared.bookmarks_group.set_visible(false);
ui.shared.back.set_visible(true);
ui.shared.with_me_group.set_visible(true);
ui.shared.add_bookmark.set_visible(false);
let nav = ui.shared.nav.borrow().clone();
ui.shared.title.set_subtitle(&shared_path(&nav));
ui.shared.with_me_group.set_title("");
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
if entries.is_empty() {
let row = dim_row(&gettext("This folder is empty."));
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else {
for entry in entries {
let row = shared_entry_row(ui, entry, false);
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
}
}
*ui.shared.rows.borrow_mut() = rows;
}
/// A row for one node shared with me: folders descend into, files download and
/// open with the user's default application.
///
/// `leavable` rows are share roots: only those carry a Leave action, since
/// leaving a folder's child on its own is not a thing the API offers.
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(&entry.name)
.activatable(true)
.build();
let subtitle = shared_row_subtitle(entry);
if !subtitle.is_empty() {
row.set_subtitle(&subtitle);
}
// What I am allowed to do with someone else's file is the thing this page
// cannot leave implicit: a viewer share is browsable and unwritable, and
// that is only visible if it is said.
if let Some(pill) = role_pill(&entry.role) {
row.add_suffix(&pill);
}
// The "shared by" in the subtitle 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.
if entry.shared_by_unverified {
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
warning.add_css_class("warning");
warning.set_valign(gtk4::Align::Center);
warning.set_tooltip_text(Some(&gettext(UNVERIFIED_INVITER_TOOLTIP)));
row.add_suffix(&warning);
}
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
row.add_suffix(&shared_entry_menu(ui, entry, leavable));
if entry.is_dir {
row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
}
let ui_act = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
let is_dir = entry.is_dir;
row.connect_activated(move |_| {
if is_dir {
ui_act
.shared
.nav
.borrow_mut()
.push((uid.clone(), name.clone()));
load_shared(&ui_act);
} else {
open_shared_file(&ui_act, &uid, &name);
}
});
row
}
/// A shared row's ⋮ menu: open it, find it in My Files when the mount has
/// interned it, and leave it (share roots only).
fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton {
let mut items: Vec<(String, MenuAction)> = Vec::new();
let (ui_open, uid, name, is_dir) = (
ui.clone(),
entry.uid.clone(),
entry.name.clone(),
entry.is_dir,
);
items.push((
pgettext("verb", "Open"),
Box::new(move || {
if is_dir {
ui_open
.shared
.nav
.borrow_mut()
.push((uid.clone(), name.clone()));
load_shared(&ui_open);
} else {
open_shared_file(&ui_open, &uid, &name);
}
}),
));
if !entry.path.is_empty() {
let (ui_show, entry_show) = (ui.clone(), entry.clone());
items.push((
gettext("Show in My Files"),
Box::new(move || show_in_my_files(&ui_show, &entry_show)),
));
}
if leavable {
let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone());
items.push((
gettext("Leave…"),
Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)),
));
}
let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
let button = more_menu_button(labels.iter().map(String::as_str).zip(actions).collect());
button.set_valign(gtk4::Align::Center);
button
}
/// 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.",
);
/// A shared row's subtitle: who shared it and when (share roots only — that is
/// where the invitation lives) and its size (files only). Where the share sits
/// in the mount is one menu item away ("Show in My Files"), not spelled out on
/// every row, where the long path wrapped the subtitle onto a second line.
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new();
if !entry.shared_by.is_empty() {
parts.push(entry.shared_by.clone());
if entry.shared_at > 0 {
parts.push(format_modified(entry.shared_at));
}
}
if !entry.is_dir {
parts.push(human_bytes(entry.size));
}
parts.join(" · ")
}
/// 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<String> {
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 for the row's pill. A bare
/// "Editor" beside a Leave button read like one more button.
pub(crate) fn role_access(role: &str) -> Option<String> {
match role {
"viewer" => Some(gettext("Can view")),
"editor" => Some(gettext("Can edit")),
"admin" => Some(gettext("Can manage")),
_ => None,
}
}
/// A tinted, rounded pill saying what I may do with a share I did not create.
pub(crate) fn role_pill(role: &str) -> Option<gtk4::Label> {
let pill = gtk4::Label::new(Some(&role_access(role)?));
pill.add_css_class("role-pill");
pill.add_css_class("caption");
pill.set_valign(gtk4::Align::Center);
pill.set_tooltip_text(role_label(role).as_deref());
Some(pill)
}
/// 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.
fn open_shared_file(ui: &Rc<Ui>, 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 mount service didn't respond."),
),
}
});
}
/// 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",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
shared_status(
ui,
"folder-remote-symbolic",
&gettext("Connecting…"),
&gettext("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.title.set_subtitle("");
ui.shared.back.set_visible(false);
ui.shared.add_bookmark.set_visible(true);
if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() {
shared_status(
ui,
"emblem-shared-symbolic",
&gettext("Nothing Shared with You"),
&gettext(
"Files and folders other people share with you appear here. Public links you bookmark are kept here too.",
),
false,
);
return;
}
ui.shared.content.set_visible_child_name("list");
ui.shared
.invitations_group
.set_visible(!invitations.is_empty());
ui.shared.bookmarks_group.set_visible(!bookmarks.is_empty());
ui.shared.with_me_group.set_visible(!shared.is_empty());
// The list only needs a heading when another section sits beside it.
ui.shared
.with_me_group
.set_title(&if invitations.is_empty() && bookmarks.is_empty() {
String::new()
} else {
gettext("Shared with you")
});
ui.shared
.invitations_group
.set_title(&invitations_title(invitations.len()));
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
for entry in shared {
let row = shared_entry_row(ui, entry, true);
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
}
// Invitations: inviter + item, Accept / Reject.
for inv in invitations {
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)]))
.build();
row.add_prefix(&gtk4::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(gettext("Reject"))
.valign(gtk4::Align::Center)
.build();
reject.add_css_class("flat");
let accept = gtk4::Button::builder()
.label(gettext("Accept"))
.valign(gtk4::Align::Center)
.build();
accept.add_css_class("suggested-action");
accept.add_css_class("pill");
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();
let name_rej = inv.name.clone().unwrap_or_else(|| gettext("this item"));
reject.connect_clicked(move |btn| {
let ui = ui_rej.clone();
let id = id_rej.clone();
confirm_destructive(
btn,
&gettext("Reject 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_rej)],
),
&gettext("Reject"),
move || respond_invitation(&ui, &id, 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.
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)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text(gettext("Remove bookmark"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text(gettext("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 {
(
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 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(gettext("Leave shared item"))
// Translators: {name} is the shared item's name.
.body(gettext_f(
"Leave “{name}”? You'll lose access until you're invited again.",
&[("name", name)],
))
.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 = 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() },
&gettext("Left shared item"),
&gettext("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(gettext("Remove bookmark"))
// Translators: {name} is the bookmark's name.
.body(gettext_f(
"Remove the bookmark for “{name}”?",
&[("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("Bookmark removed"),
&gettext("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(gettext("Add bookmark"))
.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("add", &gettext("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,
&gettext("Couldn't add bookmark"),
&gettext("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 },
&gettext("Bookmark saved"),
&gettext("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: &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 mount service didn't respond.")),
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry {
DirEntry {
name: "Budget.ods".into(),
is_dir,
size: 2048,
modified: 0,
pinned: false,
cached: false,
uid: "vol~link".into(),
path: path.into(),
role: role.into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}
}
#[test]
fn a_share_root_says_who_shared_it() {
let mut root = entry(true, "", "viewer");
root.shared_by = "alice@proton.me".into();
assert_eq!(shared_row_subtitle(&root), "alice@proton.me");
root.shared_at = 1_700_000_000;
let subtitle = shared_row_subtitle(&root);
assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}");
}
#[test]
fn a_share_with_no_local_path_says_nothing_about_one() {
// Before the synthetic directory has been listed, the daemon cannot
// resolve a path — pointing at one that isn't there would be worse than
// silence.
assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), "");
assert_eq!(
shared_row_subtitle(&entry(false, "", "viewer")),
human_bytes(2048)
);
}
#[test]
fn a_resident_share_keeps_its_path_out_of_the_subtitle() {
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
assert_eq!(subtitle, "");
}
#[test]
fn a_role_pill_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 the_invitations_heading_counts_only_several() {
assert_eq!(invitations_title(1), "Invitations");
assert_eq!(invitations_title(4), "Invitations (4)");
}
#[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());
}
#[test]
fn the_header_path_reads_from_the_share_root_down() {
assert_eq!(shared_path(&[]), "");
let nav = vec![
("u1".to_string(), "Team".to_string()),
("u2".to_string(), "Reports".to_string()),
];
assert_eq!(shared_path(&nav), "Team / Reports");
}
}