mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
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.
928 lines
34 KiB
Rust
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: >k4::Button, add_bookmark: >k4::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(>k4::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(>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(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(>k4::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");
|
|
}
|
|
}
|