feat(gui): redesign the Shared with Me page

Show only sections with content, invitations first, and an empty state
when nothing is shared. Rows drop the mount path from the subtitle, show
access as a tinted pill (Can view / Can edit / Can manage) and move
Open, Show in My Files and Leave into a menu. The invitations banner is
gone now that invitations lead the page.
This commit is contained in:
nvrl committed 2026-09-23 04:18:41 +02:00
1 parent 52651caf8e
commit af84ce2168
5 files changed
+239 -222

No files matched your search

+3
View File
@@ -80,3 +80,6 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); } .album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
.album-card:hover .photo-thumb { transform: scale(1.05); } .album-card:hover .photo-thumb { transform: scale(1.05); }
.view-switch button { padding-left: 18px; padding-right: 18px; } .view-switch button { padding-left: 18px; padding-right: 18px; }
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
-2
View File
@@ -591,8 +591,6 @@ fn build_window(app: &adw::Application) {
title: shared_widgets.title.clone(), title: shared_widgets.title.clone(),
back: shared_widgets.back.clone(), back: shared_widgets.back.clone(),
add_bookmark: shared_widgets.add_bookmark.clone(), add_bookmark: shared_widgets.add_bookmark.clone(),
banner: shared_widgets.banner.clone(),
scroll: shared_widgets.scroll.clone(),
nav: RefCell::new(Vec::new()), nav: RefCell::new(Vec::new()),
rows: RefCell::new(Vec::new()), rows: RefCell::new(Vec::new()),
inflight: Cell::new(false), inflight: Cell::new(false),
+228 -216
View File
@@ -15,9 +15,6 @@ pub(crate) struct SharedState {
/// Header button that leaves the current shared folder. /// Header button that leaves the current shared folder.
pub(crate) back: gtk4::Button, pub(crate) back: gtk4::Button,
pub(crate) add_bookmark: gtk4::Button, pub(crate) add_bookmark: gtk4::Button,
/// "N pending invitations" above the sections at the top level.
pub(crate) banner: adw::Banner,
pub(crate) scroll: gtk4::ScrolledWindow,
/// Where in a shared folder the page currently is, as `(uid, name)` from the /// 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 /// 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. /// mount, so descending is uid-addressed and the stack *is* the breadcrumb.
@@ -42,8 +39,6 @@ pub(crate) struct SharedWidgets {
pub(crate) refresh: gtk4::Button, pub(crate) refresh: gtk4::Button,
pub(crate) title: adw::WindowTitle, pub(crate) title: adw::WindowTitle,
pub(crate) back: gtk4::Button, pub(crate) back: gtk4::Button,
pub(crate) banner: adw::Banner,
pub(crate) scroll: gtk4::ScrolledWindow,
} }
/// The Shared page: three stacked sections — items shared *with* me (each with a /// The Shared page: three stacked sections — items shared *with* me (each with a
@@ -52,27 +47,29 @@ pub(crate) struct SharedWidgets {
/// so the page addresses them by uid/id/token and always re-lists from the daemon. /// 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) { pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
let add_bookmark = gtk4::Button::builder() let add_bookmark = gtk4::Button::builder()
.child( .icon_name("bookmark-new-symbolic")
&adw::ButtonContent::builder() .tooltip_text("Add Bookmark — save a public link to open it from here")
.label("Add Bookmark")
.icon_name("list-add-symbolic")
.build(),
)
.tooltip_text("Save a shared link to open it from here")
.build(); .build();
let refresh = refresh_button(); let refresh = refresh_button();
let shared_with_me = adw::PreferencesGroup::builder() // Only sections with something in them are shown, so an account with one
.title("Shared with me") // share reads as one list rather than three boxes, two of them saying
.build(); // "nothing here". Invitations come first: they are the one thing on the
// page waiting for a decision.
let invitations = adw::PreferencesGroup::builder() let invitations = adw::PreferencesGroup::builder()
.title("Invitations") .title("Invitations")
.description("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("Bookmarks")
.visible(false)
.build(); .build();
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
groups.append(&shared_with_me);
groups.append(&invitations); groups.append(&invitations);
groups.append(&shared_with_me);
groups.append(&bookmarks); groups.append(&bookmarks);
let clamp = adw::Clamp::builder().child(&groups).build(); let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
@@ -107,20 +104,15 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
inner.set_margin_end(18); inner.set_margin_end(18);
inner.append(&content); inner.append(&content);
let banner = adw::Banner::builder().button_label("Review").build();
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&banner);
body.append(&inner);
let back = gtk4::Button::builder() let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic") .icon_name("go-previous-symbolic")
.tooltip_text("Back") .tooltip_text("Back")
.visible(false) .visible(false)
.build(); .build();
let (frame, header, title) = page_frame("Shared with Me", &body); let (frame, header, title) = page_frame("Shared with Me", &inner);
header.pack_start(&back); header.pack_start(&back);
header.pack_start(&add_bookmark);
header.pack_end(&refresh); header.pack_end(&refresh);
header.pack_end(&add_bookmark);
( (
frame.upcast(), frame.upcast(),
@@ -135,35 +127,17 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
refresh, refresh,
title, title,
back, back,
banner,
scroll,
}, },
) )
} }
/// Install the Shared page's retry, back and Add-Bookmark buttons and the /// Install the Shared page's retry, back and Add-Bookmark buttons.
/// invitations banner.
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: &gtk4::Button, add_bookmark: &gtk4::Button) { pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: &gtk4::Button, add_bookmark: &gtk4::Button) {
let ui_back = ui.clone(); let ui_back = ui.clone();
ui.shared.back.connect_clicked(move |_| { ui.shared.back.connect_clicked(move |_| {
ui_back.shared.nav.borrow_mut().pop(); ui_back.shared.nav.borrow_mut().pop();
load_shared(&ui_back); load_shared(&ui_back);
}); });
// Review scrolls the Invitations section into view; the page keeps it
// below the shares, where it is easy to miss on a long list.
let ui_review = ui.clone();
ui.shared.banner.connect_button_clicked(move |_| {
let shared = &ui_review.shared;
let Some(child) = shared.scroll.child() else {
return;
};
if let Some(bounds) = shared.invitations_group.compute_bounds(&child) {
shared.scroll.vadjustment().set_value(f64::from(bounds.y()));
}
shared
.invitations_group
.child_focus(gtk4::DirectionType::TabForward);
});
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| {
service::restart(); service::restart();
@@ -295,12 +269,12 @@ pub(crate) fn shared_path(nav: &[(String, String)]) -> String {
.join(" / ") .join(" / ")
} }
/// The invitations banner's text, or `None` when there are none to review. /// The Invitations heading, with the count once there is more than one.
pub(crate) fn invitations_banner_title(count: usize) -> Option<String> { pub(crate) fn invitations_title(count: usize) -> String {
match count { if count > 1 {
0 => None, format!("Invitations ({count})")
1 => Some("1 pending invitation".to_string()), } else {
n => Some(format!("{n} pending invitations")), "Invitations".to_string()
} }
} }
@@ -314,15 +288,13 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
ui.shared.content.set_visible_child_name("list"); ui.shared.content.set_visible_child_name("list");
ui.shared.invitations_group.set_visible(false); ui.shared.invitations_group.set_visible(false);
ui.shared.bookmarks_group.set_visible(false); ui.shared.bookmarks_group.set_visible(false);
ui.shared.banner.set_revealed(false);
ui.shared.back.set_visible(true); ui.shared.back.set_visible(true);
ui.shared.with_me_group.set_visible(true);
ui.shared.add_bookmark.set_visible(false); ui.shared.add_bookmark.set_visible(false);
let nav = ui.shared.nav.borrow().clone(); let nav = ui.shared.nav.borrow().clone();
ui.shared.title.set_subtitle(&shared_path(&nav)); ui.shared.title.set_subtitle(&shared_path(&nav));
ui.shared ui.shared.with_me_group.set_title("");
.with_me_group
.set_title(nav.last().map_or("", |(_, name)| name.as_str()));
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
@@ -332,7 +304,7 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
rows.push((ui.shared.with_me_group.clone(), row.upcast())); rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else { } else {
for entry in entries { for entry in entries {
let row = shared_entry_row(ui, entry); let row = shared_entry_row(ui, entry, false);
ui.shared.with_me_group.add(&row); ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast())); rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} }
@@ -342,7 +314,10 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
/// A row for one node shared with me: folders descend into, files download and /// A row for one node shared with me: folders descend into, files download and
/// open with the user's default application. /// open with the user's default application.
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow { ///
/// `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() let row = adw::ActionRow::builder()
.title(&entry.name) .title(&entry.name)
.activatable(true) .activatable(true)
@@ -354,8 +329,8 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
// What I am allowed to do with someone else's file is the thing this page // 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 // cannot leave implicit: a viewer share is browsable and unwritable, and
// that is only visible if it is said. // that is only visible if it is said.
if let Some(badge) = role_badge(&entry.role) { if let Some(pill) = role_pill(&entry.role) {
row.add_suffix(&badge); row.add_suffix(&pill);
} }
// The "shared by" in the subtitle is what the invitation claims. When its // 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 // signature does not check out against that person's keys, the claim must
@@ -369,6 +344,10 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row.add_suffix(&warning); row.add_suffix(&warning);
} }
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); 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 ui_act = ui.clone();
let uid = entry.uid.clone(); let uid = entry.uid.clone();
let name = entry.name.clone(); let name = entry.name.clone();
@@ -388,35 +367,69 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row 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<(&str, MenuAction)> = Vec::new();
let (ui_open, uid, name, is_dir) = (
ui.clone(),
entry.uid.clone(),
entry.name.clone(),
entry.is_dir,
);
items.push((
"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((
"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((
"Leave…",
Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)),
));
}
let button = more_menu_button(items);
button.set_valign(gtk4::Align::Center);
button
}
/// Tooltip on the warning shown next to a share whose invitation did not verify. /// Tooltip on the warning shown next to a share whose invitation did not verify.
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \ const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
It may not really be from them."; It may not really be from them.";
/// A shared row's subtitle: who shared it and when (share roots only — that is /// A shared row's subtitle: who shared it and when (share roots only — that is
/// where the invitation lives), its size (files only), and where it can be /// where the invitation lives) and its size (files only). Where the share sits
/// reached in the local mount once the synthetic `Shared with me/` directory has /// in the mount is one menu item away ("Show in My Files"), not spelled out on
/// interned it. An unlisted share has no local path yet, and says nothing rather /// every row, where the long path wrapped the subtitle onto a second line.
/// than pointing at a directory that is not there.
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String { pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
if !entry.shared_by.is_empty() { if !entry.shared_by.is_empty() {
let date = if entry.shared_at > 0 { parts.push(entry.shared_by.clone());
format_modified(entry.shared_at) if entry.shared_at > 0 {
} else { parts.push(format_modified(entry.shared_at));
String::new() }
};
parts.push(if date.is_empty() {
format!("Shared by {}", entry.shared_by)
} else {
format!("Shared by {} on {date}", entry.shared_by)
});
} }
if !entry.is_dir { if !entry.is_dir {
parts.push(human_bytes(entry.size)); parts.push(human_bytes(entry.size));
} }
if !entry.path.is_empty() {
parts.push(format!("in {}", entry.path));
}
parts.join(" · ") parts.join(" · ")
} }
@@ -433,13 +446,25 @@ pub(crate) fn role_label(role: &str) -> Option<&'static str> {
} }
} }
/// A dim role pill for a share I did not create. /// What a role lets me do, as a short phrase for the row's pill. A bare
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> { /// "Editor" beside a Leave button read like one more button.
let badge = gtk4::Label::new(Some(role_label(role)?)); pub(crate) fn role_access(role: &str) -> Option<&'static str> {
badge.add_css_class("dim-label"); match role {
badge.add_css_class("caption"); "viewer" => Some("Can view"),
badge.set_valign(gtk4::Align::Center); "editor" => Some("Can edit"),
Some(badge) "admin" => Some("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));
Some(pill)
} }
/// Download a file shared with me into the daemon's cache and hand it to the /// Download a file shared with me into the daemon's cache and hand it to the
@@ -522,138 +547,127 @@ pub(crate) fn repaint_shared(
for (group, row) in ui.shared.rows.borrow_mut().drain(..) { for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
group.remove(&row); group.remove(&row);
} }
ui.shared.content.set_visible_child_name("list");
ui.shared.invitations_group.set_visible(true);
ui.shared.bookmarks_group.set_visible(true);
ui.shared.with_me_group.set_title("Shared with me");
ui.shared.title.set_subtitle(""); ui.shared.title.set_subtitle("");
ui.shared.back.set_visible(false); ui.shared.back.set_visible(false);
ui.shared.add_bookmark.set_visible(true); ui.shared.add_bookmark.set_visible(true);
match invitations_banner_title(invitations.len()) { if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() {
Some(text) => { shared_status(
ui.shared.banner.set_title(&text); ui,
ui.shared.banner.set_revealed(true); "emblem-shared-symbolic",
} "Nothing Shared with You",
None => ui.shared.banner.set_revealed(false), "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() {
""
} else {
"Shared with you"
});
ui.shared
.invitations_group
.set_title(&invitations_title(invitations.len()));
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
// Shared with me: name + Leave. for entry in shared {
if shared.is_empty() { let row = shared_entry_row(ui, entry, true);
let row = dim_row("Nothing is shared with you.");
ui.shared.with_me_group.add(&row); ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast())); rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else {
for entry in shared {
let row = shared_entry_row(ui, entry);
let leave = gtk4::Button::builder()
.label("Leave")
.valign(gtk4::Align::Center)
.build();
leave.add_css_class("flat");
let ui_leave = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
row.add_suffix(&leave);
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
}
} }
// Invitations: inviter + item, Accept / Reject. // Invitations: inviter + item, Accept / Reject.
if invitations.is_empty() { for inv in invitations {
let row = dim_row("No pending invitations."); let item = inv
.name
.clone()
.unwrap_or_else(|| "a shared item".to_string());
let row = adw::ActionRow::builder()
.title(&item)
.subtitle(format!("From {}", inv.inviter_email))
.build();
row.add_prefix(&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("Reject")
.valign(gtk4::Align::Center)
.build();
reject.add_css_class("flat");
let accept = gtk4::Button::builder()
.label("Accept")
.valign(gtk4::Align::Center)
.build();
accept.add_css_class("suggested-action");
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(|| "this item".into());
reject.connect_clicked(move |btn| {
let ui = ui_rej.clone();
let id = id_rej.clone();
confirm_destructive(
btn,
"Reject Invitation?",
&format!("You won't have access to {name_rej} unless it is shared again."),
"Reject",
move || respond_invitation(&ui, &id, false),
);
});
row.add_suffix(&accept);
row.add_suffix(&reject);
ui.shared.invitations_group.add(&row); ui.shared.invitations_group.add(&row);
rows.push((ui.shared.invitations_group.clone(), row.upcast())); rows.push((ui.shared.invitations_group.clone(), row.upcast()));
} else {
for inv in invitations {
let item = inv
.name
.clone()
.unwrap_or_else(|| "a shared item".to_string());
let row = adw::ActionRow::builder()
.title(&item)
.subtitle(format!("from {}", inv.inviter_email))
.build();
row.add_prefix(&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("Reject")
.valign(gtk4::Align::Center)
.build();
reject.add_css_class("flat");
let accept = gtk4::Button::builder()
.label("Accept")
.valign(gtk4::Align::Center)
.build();
accept.add_css_class("suggested-action");
let ui_acc = ui.clone();
let id_acc = inv.id.clone();
accept.connect_clicked(move |_| {
respond_invitation(&ui_acc, &id_acc, true);
});
let ui_rej = ui.clone();
let id_rej = inv.id.clone();
let name_rej = inv.name.clone().unwrap_or_else(|| "this item".into());
reject.connect_clicked(move |btn| {
let ui = ui_rej.clone();
let id = id_rej.clone();
confirm_destructive(
btn,
"Reject Invitation?",
&format!("You won't have access to {name_rej} unless it is shared again."),
"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. // Bookmarks: name/URL, Open / Remove.
if bookmarks.is_empty() { for bm in bookmarks {
let row = dim_row("No saved bookmarks."); let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
let row = adw::ActionRow::builder()
.title(&title)
.subtitle(&bm.url)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove bookmark")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text("Open in browser")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let url_open = bm.url.clone();
open.connect_clicked(move |_| open_uri(&url_open));
let ui_rm = ui.clone();
let token = bm.token.clone();
let name_rm = title.clone();
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
row.add_suffix(&open);
row.add_suffix(&remove);
ui.shared.bookmarks_group.add(&row); ui.shared.bookmarks_group.add(&row);
rows.push((ui.shared.bookmarks_group.clone(), row.upcast())); rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
} else {
for bm in bookmarks {
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
let row = adw::ActionRow::builder()
.title(&title)
.subtitle(&bm.url)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove bookmark")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text("Open in browser")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let url_open = bm.url.clone();
open.connect_clicked(move |_| open_uri(&url_open));
let ui_rm = ui.clone();
let token = bm.token.clone();
let name_rm = title.clone();
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
row.add_suffix(&open);
row.add_suffix(&remove);
ui.shared.bookmarks_group.add(&row);
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
}
} }
*ui.shared.rows.borrow_mut() = rows; *ui.shared.rows.borrow_mut() = rows;
@@ -828,13 +842,10 @@ mod tests {
fn a_share_root_says_who_shared_it() { fn a_share_root_says_who_shared_it() {
let mut root = entry(true, "", "viewer"); let mut root = entry(true, "", "viewer");
root.shared_by = "alice@proton.me".into(); root.shared_by = "alice@proton.me".into();
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me"); assert_eq!(shared_row_subtitle(&root), "alice@proton.me");
root.shared_at = 1_700_000_000; root.shared_at = 1_700_000_000;
let subtitle = shared_row_subtitle(&root); let subtitle = shared_row_subtitle(&root);
assert!( assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}");
subtitle.starts_with("Shared by alice@proton.me on "),
"{subtitle}"
);
} }
#[test] #[test]
@@ -850,9 +861,23 @@ mod tests {
} }
#[test] #[test]
fn a_resident_share_reports_where_it_can_be_reached() { fn a_resident_share_keeps_its_path_out_of_the_subtitle() {
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor")); let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
assert_eq!(subtitle, "in Shared with me/Team Budget"); assert_eq!(subtitle, "");
}
#[test]
fn a_role_pill_says_what_it_allows() {
assert_eq!(role_access("viewer"), Some("Can view"));
assert_eq!(role_access("editor"), Some("Can edit"));
assert_eq!(role_access("admin"), 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] #[test]
@@ -876,17 +901,4 @@ mod tests {
]; ];
assert_eq!(shared_path(&nav), "Team / Reports"); assert_eq!(shared_path(&nav), "Team / Reports");
} }
#[test]
fn the_invitations_banner_counts_and_hides_at_zero() {
assert_eq!(invitations_banner_title(0), None);
assert_eq!(
invitations_banner_title(1).as_deref(),
Some("1 pending invitation")
);
assert_eq!(
invitations_banner_title(3).as_deref(),
Some("3 pending invitations")
);
}
} }
@@ -304,7 +304,7 @@ fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
} }
/// Go to the folder holding a shared item in My Files. /// Go to the folder holding a shared item in My Files.
fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
let parent = entry let parent = entry
.path .path
.rsplit_once('/') .rsplit_once('/')
+7 -3
View File
@@ -132,9 +132,13 @@ Schema: **31** (unchanged).
- **New app icon.** A white cloud with the download arrow cut out of it, on a rounded Proton - **New app icon.** A white cloud with the download arrow cut out of it, on a rounded Proton
purple tile with a darker lower edge, drawn to the GNOME app-icon grid. It stays legible down to purple tile with a darker lower edge, drawn to the GNOME app-icon grid. It stays legible down to
16 px on light and dark panels. 16 px on light and dark panels.
- **Shared with Me.** Inside a shared folder the header shows the path from the share down, and a - **Shared with Me redesign.** Only sections with something in them are shown, and a page
back button in the header replaces the Back row. Pending invitations are announced in a banner with nothing shared says so in one place. Pending invitations sit at the top, with an Accept
at the top of the page; Review scrolls to them. button beside each. A row reads as the name over who shared it, when, and its size; what the
share allows is a tinted pill ("Can view", "Can edit", "Can manage") rather than a bare
role word that looked like a button. Open, Show in My Files and Leave… are in the row's ⋮
menu, and folders show a chevron. Inside a shared folder the header shows the path from the
share down, with a back button. Add Bookmark is an icon in the header.
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command. `pdfs favorite` command.