mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Back, Forward and Up in My files restore the folder's scroll offset and selection as they were when it was left; opening a folder any other way still starts at the top. Closing the photo viewer scrolls the grid to the photo it showed last. Alt+Left and Backspace go back a level in Shared with me and Computers and leave an open album or place; Escape leaves an album or place too, after ending selection mode. The shortcuts window lists it. A fresh Photos load, or leaving the grid for Albums or Places, ends a scrubber jump that was still paging.
1158 lines
42 KiB
Rust
1158 lines
42 KiB
Rust
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<Vec<InvitationInfo>>,
|
|
/// When the invitations were last read, for the sidebar badge's poll.
|
|
pub(crate) invitations_at: Cell<Option<Instant>>,
|
|
pub(crate) invitations_inflight: Cell<bool>,
|
|
/// 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<Vec<gtk4::Widget>>,
|
|
/// 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)>>,
|
|
/// 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<Loader>,
|
|
/// The [`SharedState::nav`] the files on screen were painted for, or `None`
|
|
/// before the first paint.
|
|
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
|
|
}
|
|
|
|
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<Ui>, 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 |_| shared_back(&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<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(>k4::glib::markup_escape_text(description)));
|
|
ui.shared.retry.set_visible(retry);
|
|
ui.shared.content.set_visible_child_name("status");
|
|
}
|
|
|
|
/// Back out of a shared folder to the level above: the page's Back button, and
|
|
/// Alt+Left or Backspace. A no-op at the top.
|
|
pub(crate) fn shared_back(ui: &Rc<Ui>) {
|
|
if ui.shared.nav.borrow_mut().pop().is_some() {
|
|
load_shared(ui);
|
|
}
|
|
}
|
|
|
|
/// 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<Ui>) {
|
|
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<Ui>, 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<Ui>, 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<Ui>) {
|
|
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<Ui>, 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();
|
|
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
|
|
*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. Another folder starts from its first entry.
|
|
replace_items(&ui.shared.files.model, entries);
|
|
if another_folder {
|
|
ui.shared.files.scroll_to_top();
|
|
}
|
|
}
|
|
|
|
/// 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<Ui>, 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<Ui>, 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<Ui>) -> 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<Ui>, 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<Ui>, 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<Ui>, entries: Vec<DirEntry>) -> 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<Ui>) -> 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::<gtk4::ListItem>().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::<gtk4::ListItem>().unwrap();
|
|
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
let label = cell.first_child().and_downcast::<gtk4::Label>().unwrap();
|
|
let pill = cell.last_child().unwrap();
|
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
|
let entry = obj.borrow::<DirEntry>();
|
|
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<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. A bare "Editor" read like a
|
|
/// job title rather than a permission.
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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<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 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<Ui>, 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<Ui>, invitations: Vec<InvitationInfo>) {
|
|
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<Ui>) {
|
|
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<Ui>, 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<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 shared items. Only share roots can be left.
|
|
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, 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<String> = 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<Ui>,
|
|
requests: Vec<Request>,
|
|
failed: &'static str,
|
|
done: impl FnOnce(&Rc<Ui>) + '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<Ui>, 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<Ui>) {
|
|
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<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 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());
|
|
}
|
|
}
|