mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui,cli): stop sharing and share dialog polish
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 <path> 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.
This commit is contained in:
1 parent
dfc48375e2
commit
15985f8684
8 files changed
+497
-37
No files matched your search
@@ -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<String>,
|
||||
/// Entry kind: member, proton or external.
|
||||
kind: ShareKindArg,
|
||||
kind: Option<ShareKindArg>,
|
||||
},
|
||||
/// 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<String>, kind: Option<ShareKindArg>) -> 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(_),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<super::sharing::StopSharingOutcome>,
|
||||
) -> 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 },
|
||||
|
||||
@@ -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<StopSharingOutcome> {
|
||||
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<StopSharingOutcome> {
|
||||
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<StopSharingOutcome> {
|
||||
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<E: std::fmt::Display>(&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<String, String> {
|
||||
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::<Vec<_>>()
|
||||
.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<String> {
|
||||
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::<String>("the public link", Ok(()));
|
||||
outcome.record("bob@example.com", Err("forbidden"));
|
||||
outcome.record::<String>("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::<String>("the public link", Ok(()));
|
||||
outcome.record::<String>("bob@example.com", Ok(()));
|
||||
assert_eq!(
|
||||
outcome.into_result().unwrap(),
|
||||
"stopped sharing: removed 2 entries"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2117,6 +2117,7 @@ pub(crate) fn reload_listing(ui: &Rc<Ui>) {
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, 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<Ui>, entry: &DirEntry, url: Option<String>) -> 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<Ui>, 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<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
|
||||
@@ -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<i64>); 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<i64> {
|
||||
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<Vec<String>, 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<glib::GString>,
|
||||
icon: Option<glib::GString>,
|
||||
}
|
||||
|
||||
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<String>) -> Request {
|
||||
fn create_link(&self, role: String, password: Option<String>, expires: Option<i64>) -> 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<Ui>, 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<Ui>, 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<Ui>, entry: &DirEntry) {
|
||||
|
||||
// Invite button.
|
||||
let state_inv = state.clone();
|
||||
invite_btn.connect_clicked(move |_| {
|
||||
let raw = email_row.text();
|
||||
let emails: Vec<String> = 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<Ui>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>,
|
||||
role: String,
|
||||
password: Option<String>,
|
||||
expires: Option<i64>,
|
||||
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<ShareDialog>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
busy: Option<Busy>,
|
||||
on_success: Option<Box<dyn Fn()>>,
|
||||
) {
|
||||
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 "));
|
||||
}
|
||||
}
|
||||
@@ -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 <name>` 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 <path>` (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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user