From 15985f86844179b49fd994a844f16a53cdf615d1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 23 Sep 2026 04:09:32 +0200 Subject: [PATCH] feat(core,fuse,gui,cli): stop sharing and share dialog polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StopSharing / StopSharingByUid remove the public link, external invitations, invitations and members, trying every step and reporting what is still shared. Shared by Me gets a Stop Sharing… item and pdfs unshare works without an id. The Share dialog validates addresses inline, shows spinners on buttons while requests run, offers a link expiry and shows an existing link's expiry. --- crates/pdfs-cli/src/main.rs | 60 ++++- crates/pdfs-core/src/control.rs | 16 +- crates/pdfs-fuse/src/control.rs | 27 ++ crates/pdfs-fuse/src/sharing.rs | 135 ++++++++++ crates/pdfs-gui/src/app/pages/browser.rs | 1 + crates/pdfs-gui/src/app/pages/shared_by_me.rs | 37 ++- .../pdfs-gui/src/app/widgets/share_dialog.rs | 250 ++++++++++++++++-- docs/CHANGELOG.md | 8 + 8 files changed, 497 insertions(+), 37 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 0943c6f..af5eb62 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -403,14 +403,18 @@ enum Command { /// New role: viewer, editor or admin. role: String, }, - /// Remove a member or pending invitation from a node's share. + /// Stop sharing a node, or remove one member or pending invitation. + /// + /// With only a path, removes the public link, every invitation and every + /// member. With an id and kind (from `pdfs members`), removes that entry. Unshare { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// Entry id (membership or invitation id), from `pdfs members`. - id: String, + #[arg(requires = "kind")] + id: Option, /// Entry kind: member, proton or external. - kind: ShareKindArg, + kind: Option, }, /// Inspect and restore a file's earlier versions. Versions { @@ -1261,12 +1265,17 @@ fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) - })?) } -fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> { - ok_or_bail(control_request(CtlRequest::RemoveShareEntry { - path: path_arg(&path)?, - id, - kind: kind.to_kind(), - })?) +fn cmd_unshare(path: PathBuf, id: Option, kind: Option) -> Result<()> { + let path = path_arg(&path)?; + let request = match (id, kind) { + (Some(id), Some(kind)) => CtlRequest::RemoveShareEntry { + path, + id, + kind: kind.to_kind(), + }, + _ => CtlRequest::StopSharing { path }, + }; + ok_or_bail(control_request(request)?) } fn cmd_public_link(action: PublicLinkCmd) -> Result<()> { @@ -3353,3 +3362,36 @@ mod login_wait_tests { assert!(shared_by_line(&entry).unwrap().contains("WARNING")); } } + +#[cfg(test)] +mod unshare_tests { + use super::*; + use clap::Parser; + + #[test] + fn unshare_with_only_a_path_stops_sharing() { + let cli = Cli::try_parse_from(["pdfs", "unshare", "docs/report.pdf"]).unwrap(); + assert!(matches!( + cli.command, + Command::Unshare { + id: None, + kind: None, + .. + } + )); + } + + #[test] + fn unshare_with_an_id_needs_its_kind() { + assert!(Cli::try_parse_from(["pdfs", "unshare", "docs", "abc"]).is_err()); + let cli = Cli::try_parse_from(["pdfs", "unshare", "docs", "abc", "member"]).unwrap(); + assert!(matches!( + cli.command, + Command::Unshare { + id: Some(_), + kind: Some(_), + .. + } + )); + } +} diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 77ee3d7..1460bff 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -518,6 +518,13 @@ pub enum Request { RemovePublicLink { path: String, id: String }, /// The by-uid twin of [`Request::RemovePublicLink`]. RemovePublicLinkByUid { uid: String, id: String }, + /// Stop sharing the node at `path`: remove its public link, every pending + /// invitation and every member. Each removal is attempted even if an + /// earlier one fails. Replies with [`Response::Ok`], or an error naming + /// what is still shared. + StopSharing { path: String }, + /// The by-uid twin of [`Request::StopSharing`]. + StopSharingByUid { uid: String }, // ---- revisions -------------------------------------------------------- /// List the version history of the file at mountpoint-relative `path`, @@ -2885,6 +2892,12 @@ mod tests { }, "RemovePublicLinkByUid", ), + ( + Request::StopSharingByUid { + uid: "vol~link".into(), + }, + "StopSharingByUid", + ), ]; for (request, variant) in cases { @@ -2903,7 +2916,8 @@ mod tests { | Request::UpdateShareRoleByUid { uid, .. } | Request::RemoveShareEntryByUid { uid, .. } | Request::CreatePublicLinkByUid { uid, .. } - | Request::RemovePublicLinkByUid { uid, .. } => uid, + | Request::RemovePublicLinkByUid { uid, .. } + | Request::StopSharingByUid { uid } => uid, other => panic!("decoded into the wrong dispatch variant: {other:?}"), }; assert_eq!(decoded_uid, "vol~link"); diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 19c2015..f3212c7 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -29,6 +29,26 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid}; const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024; const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10); +/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal +/// into an error that names what is still shared. +fn stop_sharing_reply( + core: &Core, + target: &str, + result: CoreResult, +) -> CtlResponse { + match result.map(|outcome| outcome.into_result()) { + Ok(Ok(message)) => { + core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true); + CtlResponse::Ok { message } + } + Ok(Err(left)) => { + core.log_activity(ActivityKind::Unshare, target, &left, false); + CtlResponse::error(CoreError::remote(left)) + } + Err(e) => CtlResponse::error(e), + } +} + /// A client that connects, sends a request and then never reads its reply used /// to park a handler thread forever in `write_all`; 64 of those exhaust /// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest @@ -1310,6 +1330,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Err(e) => CtlResponse::error(e), } } + Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)), + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::StopSharingByUid { uid }) => { + stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid)) + } Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.list_revisions(&rel) { Ok(items) => CtlResponse::Revisions { items }, diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index adc87dd..ee9b21a 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -388,6 +388,66 @@ impl Core { .map_err(|e| CoreError::from_api(&e, "remove public link")) } + /// Stop sharing the node at `rel`: remove its public link, then every + /// external invitation, Proton invitation and member. The SDK has no call + /// that deletes a share outright, so this composes the per-entry ones. + pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult { + let (_ino, uid) = self.resolve(rel)?; + self.stop_sharing_for_uid(&uid) + } + + /// [`Core::stop_sharing`] for a node addressed through any daemon location. + pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult { + let uid = self.resolve_anywhere(uid)?; + self.stop_sharing_for_uid(&uid) + } + + /// Each step runs even when an earlier one failed, so a single stuck + /// member does not leave the public link up. The link goes first: it is the + /// widest exposure. Only a failure to list what is there fails the whole + /// request; a failed removal is counted in the outcome. + fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult { + let mut outcome = StopSharingOutcome::default(); + let link = self + .rt + .block_on(self.client.get_public_link(uid)) + .map_err(|e| CoreError::from_api(&e, "get public link"))?; + if let Some(link) = link { + let result = self.rt.block_on(self.client.remove_public_link(&link)); + outcome.record("the public link", result); + } + let external = self + .rt + .block_on(self.client.list_external_invitations(uid)) + .map_err(|e| CoreError::from_api(&e, "list external invitations"))?; + for ext in external { + let result = self + .rt + .block_on(self.client.delete_external_invitation(&ext)); + outcome.record(&ext.invitee_email, result); + } + let invitations = self + .rt + .block_on(self.client.list_share_invitations(uid)) + .map_err(|e| CoreError::from_api(&e, "list invitations"))?; + for inv in invitations { + let result = self.rt.block_on(self.client.delete_invitation(&inv)); + outcome.record(&inv.invitee_email, result); + } + let members = self + .rt + .block_on(self.client.list_share_members(uid)) + .map_err(|e| CoreError::from_api(&e, "list members"))?; + for member in members { + let result = self.rt.block_on(self.client.remove_member(&member)); + outcome.record(&member.email, result); + } + if !outcome.failed.is_empty() { + debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain"); + } + Ok(outcome) + } + // ---- shared with me --------------------------------------------------- /// List nodes shared with me that I have accepted. @@ -657,6 +717,47 @@ impl Core { } } +/// What [`Core::stop_sharing`] managed to remove. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct StopSharingOutcome { + /// Entries removed (the link, invitations and members alike). + pub(crate) removed: usize, + /// What could not be removed, by email (or "the public link"), with why. + pub(crate) failed: Vec<(String, String)>, +} + +impl StopSharingOutcome { + fn record(&mut self, what: &str, result: Result<(), E>) { + match result { + Ok(()) => self.removed += 1, + Err(e) => self.failed.push((what.to_string(), e.to_string())), + } + } + + /// The reply for the control socket: a sentence for a full success, an + /// error naming what is left otherwise. + pub(crate) fn into_result(self) -> Result { + if self.failed.is_empty() { + return Ok(match self.removed { + 0 => "it was not shared".to_string(), + 1 => "stopped sharing: removed 1 entry".to_string(), + n => format!("stopped sharing: removed {n} entries"), + }); + } + let left = self + .failed + .iter() + .map(|(what, why)| format!("{what} ({why})")) + .collect::>() + .join(", "); + Err(format!( + "removed {} of {}; still shared: {left}", + self.removed, + self.removed + self.failed.len() + )) + } +} + /// Walk one inode space from `uid` up to its root, building the path. `None` if /// that mount has never interned the node, or if the chain breaks partway — /// a broken chain is a missing path, never a partial one. @@ -671,3 +772,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option { parts.reverse(); Some(parts.join("/")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stopping_a_share_that_is_gone_says_so() { + let outcome = StopSharingOutcome::default(); + assert_eq!(outcome.into_result().unwrap(), "it was not shared"); + } + + #[test] + fn a_failed_removal_names_what_is_still_shared() { + let mut outcome = StopSharingOutcome::default(); + outcome.record::("the public link", Ok(())); + outcome.record("bob@example.com", Err("forbidden")); + outcome.record::("carol@example.com", Ok(())); + assert_eq!( + outcome.into_result().unwrap_err(), + "removed 2 of 3; still shared: bob@example.com (forbidden)" + ); + } + + #[test] + fn a_full_removal_counts_the_entries() { + let mut outcome = StopSharingOutcome::default(); + outcome.record::("the public link", Ok(())); + outcome.record::("bob@example.com", Ok(())); + assert_eq!( + outcome.into_result().unwrap(), + "stopped sharing: removed 2 entries" + ); + } +} diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index b732aea..f0f8dad 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -2117,6 +2117,7 @@ pub(crate) fn reload_listing(ui: &Rc) { match ui.stack.visible_child_name().as_deref() { Some("browser") => load_browser(ui), Some("trash") => load_trash(ui), + Some("sharedbyme") => load_shared_by_me(ui), _ => {} } } diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 33cfb63..a26e84c 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -231,7 +231,8 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { *ui.shared_by_me.rows.borrow_mut() = rows; } -/// The ⋮ menu on a shared item: open it, the link, and the Share dialog. +/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and +/// Stop Sharing. /// /// Manage access opens the per-node Share dialog, which addresses a pathless /// node by uid, so every shared item can be managed from here. @@ -254,9 +255,43 @@ fn shared_by_me_menu(ui: &Rc, entry: &DirEntry, url: Option) -> gtk4 "Manage Access…", Box::new(move || open_share_dialog(&ui_c, &entry_c)), )); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + items.push(( + "Stop Sharing…", + Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)), + )); more_menu_button(items) } +/// Confirm, then remove every member, invitation and the public link. +pub(crate) fn prompt_stop_sharing(ui: &Rc, entry: &DirEntry) { + let dialog = adw::AlertDialog::builder() + .heading("Stop sharing?") + .body(format!( + "Everyone you invited loses access to “{}”, pending invitations are \ + withdrawn and its public link stops working.", + entry.name + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("stop", "Stop Sharing"); + dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let ui_c = ui.clone(); + let uid = entry.uid.clone(); + let name = entry.name.clone(); + dialog.connect_response(Some("stop"), move |_, _| { + run_mutation( + &ui_c, + Request::StopSharingByUid { uid: uid.clone() }, + format!("Stopped sharing {name}"), + "Couldn't stop sharing", + ); + }); + dialog.present(ui_window(ui).as_ref()); +} + /// Open a shared item: a folder in My Files, a file the way My Files would. fn open_shared_by_me(ui: &Rc, entry: &DirEntry) { if entry.is_dir { diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index e66d07f..80db419 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -21,6 +21,99 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 { } } +/// The expiry choices offered when creating a public link, in dropdown order, +/// with their lifetime in days (`None` never expires). +pub(crate) const LINK_EXPIRY: [(&str, Option); 4] = [ + ("Never", None), + ("1 day", Some(1)), + ("7 days", Some(7)), + ("30 days", Some(30)), +]; + +/// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`. +pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option { + LINK_EXPIRY + .get(idx as usize) + .and_then(|(_, days)| *days) + .map(|days| now + days * 86_400) +} + +/// A loose shape check for an email address: one `@` with something before +/// it and a dotted domain after it. The server does the real validation; this +/// only catches typos before the Invite button is pressed. +pub(crate) fn looks_like_email(s: &str) -> bool { + let Some((local, domain)) = s.split_once('@') else { + return false; + }; + !local.is_empty() + && !domain.contains('@') + && !s.chars().any(char::is_whitespace) + && domain + .split_once('.') + .is_some_and(|(host, rest)| !host.is_empty() && !rest.is_empty()) + && !domain.ends_with('.') +} + +/// Split the invite field into addresses. Returns `Err` with the first entry +/// that doesn't look like an email, and `Ok` with an empty list when the field +/// is blank. +pub(crate) fn parse_emails(raw: &str) -> Result, String> { + raw.split([',', ' ', ';']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + if looks_like_email(s) { + Ok(s.to_string()) + } else { + Err(s.to_string()) + } + }) + .collect() +} + +/// A button showing a spinner while its request runs. It goes insensitive +/// on [`Busy::start`] and gets its label or icon back when dropped, so moving +/// the guard into the request's future ends the busy state on every path. +pub(crate) struct Busy { + button: gtk4::Button, + label: Option, + icon: Option, +} + +impl Busy { + pub(crate) fn start(button: >k4::Button) -> Self { + let label = button.label(); + let icon = button.icon_name(); + let spinner = gtk4::Spinner::builder().spinning(true).build(); + match &label { + Some(text) => { + let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + content.append(&spinner); + content.append(>k4::Label::new(Some(text))); + button.set_child(Some(&content)); + } + None => button.set_child(Some(&spinner)), + } + button.set_sensitive(false); + Self { + button: button.clone(), + label, + icon, + } + } +} + +impl Drop for Busy { + fn drop(&mut self) { + if let Some(label) = &self.label { + self.button.set_label(label); + } else if let Some(icon) = &self.icon { + self.button.set_icon_name(icon); + } + self.button.set_sensitive(true); + } +} + /// How an open Share dialog addresses its node. /// /// A node the browser is showing has a mountpoint-relative path. A node reached @@ -94,19 +187,19 @@ impl ShareTarget { } } - fn create_link(&self, role: String, password: Option) -> Request { + fn create_link(&self, role: String, password: Option, expires: Option) -> Request { match self { ShareTarget::Path(path) => Request::CreatePublicLink { path: path.clone(), role, password, - expires: None, + expires, }, ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid { uid: uid.clone(), role, password, - expires: None, + expires, }, } } @@ -179,6 +272,7 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { .margin_top(6) .build(); invite_btn.add_css_class("suggested-action"); + invite_btn.set_sensitive(false); let invite_wrap = adw::PreferencesRow::builder() .activatable(false) .child(&invite_btn) @@ -227,6 +321,25 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { link_rows: RefCell::new(Vec::new()), }); + // Check the addresses as they are typed: a malformed one marks the row as + // an error and keeps the Invite button off, so a typo never reaches the + // server as a failed invitation. + let btn = invite_btn.clone(); + email_row.connect_changed(move |row| { + let parsed = parse_emails(&row.text()); + match &parsed { + Err(bad) => { + row.add_css_class("error"); + row.set_tooltip_text(Some(&format!("“{bad}” isn't an email address"))); + } + Ok(_) => { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + btn.set_sensitive(parsed.is_ok_and(|emails| !emails.is_empty())); + }); + // Enter in either free-text field sends the invitations, no mouse needed. let btn = invite_btn.clone(); email_row.connect_entry_activated(move |_| btn.emit_clicked()); @@ -235,16 +348,15 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { // Invite button. let state_inv = state.clone(); - invite_btn.connect_clicked(move |_| { - let raw = email_row.text(); - let emails: Vec = raw - .split([',', ' ', ';']) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); + invite_btn.connect_clicked(move |btn| { + // Enter in a field reaches here even while the button is off. + if !btn.is_sensitive() { + return; + } + let Ok(emails) = parse_emails(&email_row.text()) else { + return; + }; if emails.is_empty() { - toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email."); return; } let role = role_index_to_wire(role_drop.selected()).to_string(); @@ -257,6 +369,7 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { state_inv.target.invite(emails, role, message), "Invitations sent", "Couldn't send invitations", + Some(Busy::start(btn)), Some(Box::new(move || { email_clear.set_text(""); msg_clear.set_text(""); @@ -340,6 +453,7 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr "Role updated", "Couldn't update the role", None, + None, ); }); row.add_suffix(&drop); @@ -365,6 +479,7 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr remove.connect_clicked(move |btn| { let state = state_rm.clone(); let id = id.clone(); + let button = btn.clone(); confirm_destructive( btn, "Remove Access?", @@ -376,6 +491,7 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr state.target.remove_entry(id.clone(), kind), "Access removed", "Couldn't remove access", + Some(Busy::start(&button)), None, ); }, @@ -400,14 +516,14 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi match link { Some(link) => { let url = link.url.clone().unwrap_or_default(); - let subtitle = if link.has_password { - format!( - "Anyone with the link ({}) · password-protected", - capitalize(&link.role) - ) - } else { - format!("Anyone with the link ({})", capitalize(&link.role)) - }; + let mut subtitle = format!("Anyone with the link ({})", capitalize(&link.role)); + if link.has_password { + subtitle.push_str(" · password-protected"); + } + if let Some(expires) = link.expires { + subtitle.push_str(" · "); + subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000)); + } let row = adw::ActionRow::builder() .title(if url.is_empty() { "Public link" } else { &url }) .subtitle(&subtitle) @@ -441,6 +557,7 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi remove.connect_clicked(move |btn| { let state = state_rm.clone(); let id = id.clone(); + let button = btn.clone(); confirm_destructive( btn, "Remove Public Link?", @@ -453,6 +570,7 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi state.target.remove_link(id.clone()), "Public link removed", "Couldn't remove the link", + Some(Busy::start(&button)), None, ); }, @@ -475,6 +593,14 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi let pw_row = adw::PasswordEntryRow::builder() .title("Password (optional)") .build(); + let expiry_names: Vec<&str> = LINK_EXPIRY.iter().map(|(name, _)| *name).collect(); + let expiry_drop = gtk4::DropDown::builder() + .model(>k4::StringList::new(&expiry_names)) + .selected(0) + .valign(gtk4::Align::Center) + .build(); + let expiry_row = adw::ActionRow::builder().title("Expires").build(); + expiry_row.add_suffix(&expiry_drop); let create = gtk4::Button::builder() .label("Create Public Link") .halign(gtk4::Align::End) @@ -492,7 +618,10 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi let state_c = state.clone(); let pw_for = pw_row.clone(); - create.connect_clicked(move |_| { + create.connect_clicked(move |btn| { + if !btn.is_sensitive() { + return; + } let role = if role_drop.selected() == 1 { "editor" } else { @@ -501,14 +630,17 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi .to_string(); let pw = pw_for.text().to_string(); let password = if pw.is_empty() { None } else { Some(pw) }; - share_dialog_create_link(&state_c, role, password); + let expires = link_expiry_at(expiry_drop.selected(), glib::real_time() / 1_000_000); + share_dialog_create_link(&state_c, role, password, expires, Busy::start(btn)); }); state.link_group.add(&role_row); state.link_group.add(&pw_row); + state.link_group.add(&expiry_row); state.link_group.add(&create_wrap); rows.push(role_row.upcast()); rows.push(pw_row.upcast()); + rows.push(expiry_row.upcast()); rows.push(create_wrap.upcast()); } } @@ -521,14 +653,18 @@ pub(crate) fn share_dialog_create_link( state: &Rc, role: String, password: Option, + expires: Option, + busy: Busy, ) { let rx = spawn_request( state.ui.dirs.control_socket(), - state.target.create_link(role, password), + state.target.create_link(role, password, expires), ); let state = state.clone(); glib::spawn_future_local(async move { - match rx.recv().await { + let reply = rx.recv().await; + drop(busy); + match reply { Ok(Ok(Response::PublicLink { .. })) => { toast(&state.ui, "Public link created"); share_dialog_reload(&state); @@ -546,18 +682,22 @@ pub(crate) fn share_dialog_create_link( } /// Run a Share-dialog mutation, then reload the dialog on success. `on_success` -/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload. +/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload; +/// `busy` keeps the button that started it spinning until the reply arrives. pub(crate) fn share_dialog_op( state: &Rc, req: Request, done: &'static str, failed: &'static str, + busy: Option, on_success: Option>, ) { let rx = spawn_request(state.ui.dirs.control_socket(), req); let state = state.clone(); glib::spawn_future_local(async move { - match rx.recv().await { + let reply = rx.recv().await; + drop(busy); + match reply { Ok(Ok(Response::Ok { .. })) => { if let Some(cb) = on_success { cb(); @@ -575,3 +715,61 @@ pub(crate) fn share_dialog_op( } }); } + +/// How an existing link's expiry reads in its subtitle: the date it stops +/// working, or that it already has. +pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String { + if expires <= now { + return "expired".to_string(); + } + let date = glib::DateTime::from_unix_local(expires) + .ok() + .and_then(|at| at.format("%x").ok()) + .map(|s| s.to_string()) + .unwrap_or_default(); + format!("expires {date}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_check_accepts_plain_addresses() { + assert!(looks_like_email("a@b.co")); + assert!(looks_like_email("first.last+tag@mail.example.org")); + } + + #[test] + fn email_check_rejects_typos() { + for bad in [ + "a", "a@", "@b.co", "a@b", "a@b.", "a@.co", "a@@b.co", "a@b@c.co", + ] { + assert!(!looks_like_email(bad), "{bad}"); + } + } + + #[test] + fn parse_emails_splits_and_reports_first_bad_entry() { + assert_eq!( + parse_emails("a@b.co, c@d.org;e@f.net"), + Ok(vec!["a@b.co".into(), "c@d.org".into(), "e@f.net".into()]) + ); + assert_eq!(parse_emails(" , "), Ok(vec![])); + assert_eq!(parse_emails("a@b.co oops"), Err("oops".into())); + } + + #[test] + fn link_expiry_counts_days_from_now() { + assert_eq!(link_expiry_at(0, 1_000), None); + assert_eq!(link_expiry_at(1, 1_000), Some(1_000 + 86_400)); + assert_eq!(link_expiry_at(3, 0), Some(30 * 86_400)); + assert_eq!(link_expiry_at(99, 0), None); + } + + #[test] + fn past_expiry_reads_expired() { + assert_eq!(link_expiry_label(10, 20), "expired"); + assert!(link_expiry_label(2_000_000_000, 0).starts_with("expires ")); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 821e9b3..1fdb368 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -43,6 +43,11 @@ Schema: **31** (unchanged). → Show tray icon brings the icon back. "Stop Proton Drive…" asks for confirmation in the app, then stops the service and the tray. `pdfs-app` accepts `--page ` and `--confirm-stop`. A second launch raises the running window instead of opening another one. +- **Stop sharing.** Shared by Me → item menu → "Stop Sharing…" and `pdfs unshare ` (with no + id) remove the public link, every invitation and every member in one go. Each step is tried even + if an earlier one fails, and a partial result names what is still shared. +- **Public link expiry.** The Share dialog's create-link form has an "Expires" choice (Never, + 1 day, 7 days, 30 days), and an existing link shows when it expires or that it has expired. ### Changed - **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync @@ -116,6 +121,9 @@ Schema: **31** (unchanged). daemon logs again on every run now shows once, with a count. A "Needs attention" section on top lists the conflicts still waiting for a decision. Each logged conflict has a Resolve… button that opens the same dialog as the Sync page, and reads as resolved once the copy is gone. +- **Share dialog.** The Invite button stays off until every address looks like an email; a + malformed one marks the field red and names the entry in a tooltip. Buttons show a spinner + while their request runs, so a slow invite or link removal can't be sent twice. - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the `pdfs favorite` command.