Skip to main content

mimick/library/
shared_links_view.rs

1//! "Shared Links" page: every public link the user has made, with copy, edit, and delete.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use gtk::prelude::*;
7use libadwaita::prelude::*;
8
9use crate::api_client::SharedLink;
10
11use super::LibraryWindowUi;
12use super::context_menu::show_alert_dialog;
13use super::shared_link_dialog::{self, LinkDialogMode, copy_link_url, format_expiry};
14
15/// Widgets for the Shared Links content page.
16pub(super) struct SharedLinksView {
17    pub root: gtk::ScrolledWindow,
18    list: libadwaita::PreferencesGroup,
19    empty: libadwaita::StatusPage,
20    rows: Rc<RefCell<Vec<gtk::Widget>>>,
21}
22
23pub(super) fn build_shared_links_view() -> SharedLinksView {
24    let list = libadwaita::PreferencesGroup::builder()
25        .title("Shared Links")
26        .description("Anyone with a link can view what it shares.")
27        .build();
28    let empty = libadwaita::StatusPage::builder()
29        .icon_name("emblem-shared-symbolic")
30        .title("No Shared Links")
31        .description(
32            "Create one from an album's or a photo's right-click menu, or from a selection.",
33        )
34        .visible(false)
35        .build();
36    let content = gtk::Box::builder()
37        .orientation(gtk::Orientation::Vertical)
38        .margin_top(16)
39        .margin_bottom(16)
40        .margin_start(16)
41        .margin_end(16)
42        .build();
43    content.append(&list);
44    content.append(&empty);
45    let clamp = libadwaita::Clamp::builder()
46        .maximum_size(820)
47        .child(&content)
48        .build();
49    let root = gtk::ScrolledWindow::builder()
50        .hscrollbar_policy(gtk::PolicyType::Never)
51        .vexpand(true)
52        .child(&clamp)
53        .build();
54    SharedLinksView {
55        root,
56        list,
57        empty,
58        rows: Rc::default(),
59    }
60}
61
62/// Show the Shared Links page and (re)load its links.
63pub(super) fn show(ui: &Rc<LibraryWindowUi>) {
64    ui.content_stack.set_visible_child_name("shared-links");
65    reload(ui.clone());
66}
67
68fn reload(ui: Rc<LibraryWindowUi>) {
69    glib::MainContext::default().spawn_local(async move {
70        match ui.ctx.api_client.fetch_shared_links().await {
71            Ok(links) => render(&ui, &links),
72            Err(err) => show_alert_dialog(
73                &ui,
74                "Could Not Load Shared Links",
75                &format!("{err}\n\nYour API key needs the 'sharedLink.read' permission."),
76            ),
77        }
78    });
79}
80
81fn render(ui: &Rc<LibraryWindowUi>, links: &[SharedLink]) {
82    let view = &ui.shared_links;
83    for row in view.rows.borrow_mut().drain(..) {
84        view.list.remove(&row);
85    }
86    for link in links {
87        let row = link_row(ui, link);
88        view.list.add(&row);
89        view.rows.borrow_mut().push(row.upcast());
90    }
91    view.list.set_visible(!links.is_empty());
92    view.empty.set_visible(links.is_empty());
93}
94
95fn link_row(ui: &Rc<LibraryWindowUi>, link: &SharedLink) -> libadwaita::ActionRow {
96    let row = libadwaita::ActionRow::builder()
97        .title(glib::markup_escape_text(&link_title(link)))
98        .subtitle(glib::markup_escape_text(&link_subtitle(link)))
99        .subtitle_lines(2)
100        .build();
101    let copy = icon_button("edit-copy-symbolic", "Copy link");
102    let edit = icon_button("document-edit-symbolic", "Edit link");
103    let delete = icon_button("user-trash-symbolic", "Delete link");
104    let (ui_copy, link_copy) = (ui.clone(), link.clone());
105    copy.connect_clicked(move |_| {
106        let (ui, link) = (ui_copy.clone(), link_copy.clone());
107        glib::MainContext::default().spawn_local(async move {
108            copy_link_url(&ui, &link, "Link Copied").await;
109        });
110    });
111    let (ui_edit, link_edit) = (ui.clone(), link.clone());
112    edit.connect_clicked(move |_| {
113        let ui_after = ui_edit.clone();
114        shared_link_dialog::present(
115            ui_edit.clone(),
116            LinkDialogMode::Edit(Box::new(link_edit.clone())),
117            Rc::new(move || reload(ui_after.clone())),
118        );
119    });
120    let (ui_delete, link_id) = (ui.clone(), link.id.clone());
121    delete.connect_clicked(move |_| confirm_delete(ui_delete.clone(), link_id.clone()));
122    row.add_suffix(&copy);
123    row.add_suffix(&edit);
124    row.add_suffix(&delete);
125    row
126}
127
128fn icon_button(icon: &str, tooltip: &str) -> gtk::Button {
129    gtk::Button::builder()
130        .icon_name(icon)
131        .tooltip_text(tooltip)
132        .valign(gtk::Align::Center)
133        .css_classes(["flat"])
134        .build()
135}
136
137fn confirm_delete(ui: Rc<LibraryWindowUi>, link_id: String) {
138    let dialog = libadwaita::AlertDialog::builder()
139        .heading("Delete this link?")
140        .body("Anyone using it will lose access. This can't be undone.")
141        .build();
142    dialog.add_response("cancel", "Cancel");
143    dialog.add_response("delete", "Delete");
144    dialog.set_response_appearance("delete", libadwaita::ResponseAppearance::Destructive);
145    dialog.set_close_response("cancel");
146    let ui_for_choice = ui.clone();
147    dialog.connect_response(None, move |_, response| {
148        if response != "delete" {
149            return;
150        }
151        let (ui, link_id) = (ui_for_choice.clone(), link_id.clone());
152        glib::MainContext::default().spawn_local(async move {
153            match ui.ctx.api_client.delete_shared_link(&link_id).await {
154                Ok(()) => reload(ui),
155                Err(err) => show_alert_dialog(&ui, "Could Not Delete Link", &err),
156            }
157        });
158    });
159    dialog.present(Some(&ui.window));
160}
161
162/// Album name for album links, otherwise the number of shared items.
163fn link_title(link: &SharedLink) -> String {
164    match &link.album {
165        Some(album) => album.album_name.clone(),
166        None => match link.assets.len() {
167            1 => "1 item".to_string(),
168            n => format!("{n} items"),
169        },
170    }
171}
172
173/// Description, expiry, and any restrictions, joined with " · ".
174fn link_subtitle(link: &SharedLink) -> String {
175    let mut parts = Vec::new();
176    if let Some(description) = link.description.as_deref().filter(|d| !d.is_empty()) {
177        parts.push(description.to_string());
178    }
179    parts.push(match link.expires_at.as_deref() {
180        Some(at) => format!("Expires {}", format_expiry(at)),
181        None => "Never expires".to_string(),
182    });
183    if link.password.is_some() {
184        parts.push("Password".to_string());
185    }
186    if !link.allow_download {
187        parts.push("No downloads".to_string());
188    }
189    if link.allow_upload {
190        parts.push("Uploads allowed".to_string());
191    }
192    parts.join(" · ")
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn link(json: serde_json::Value) -> SharedLink {
200        let mut base = serde_json::json!({
201            "id": "l1",
202            "key": "k",
203            "type": "INDIVIDUAL",
204            "createdAt": "2026-10-01T00:00:00.000Z",
205            "allowDownload": true,
206            "allowUpload": false,
207            "showMetadata": true,
208            "assets": [{ "id": "a" }, { "id": "b" }]
209        });
210        for (key, value) in json.as_object().unwrap() {
211            base[key] = value.clone();
212        }
213        serde_json::from_value(base).unwrap()
214    }
215
216    #[test]
217    fn title_uses_album_name_or_item_count() {
218        assert_eq!(link_title(&link(serde_json::json!({}))), "2 items");
219        let album = link(serde_json::json!({
220            "type": "ALBUM",
221            "album": { "id": "a1", "albumName": "Trips" },
222            "assets": []
223        }));
224        assert_eq!(link_title(&album), "Trips");
225        let single = link(serde_json::json!({ "assets": [{ "id": "a" }] }));
226        assert_eq!(link_title(&single), "1 item");
227    }
228
229    #[test]
230    fn subtitle_lists_description_expiry_and_restrictions() {
231        assert_eq!(link_subtitle(&link(serde_json::json!({}))), "Never expires");
232        let restricted = link(serde_json::json!({
233            "description": "Beach",
234            "password": "x",
235            "allowDownload": false,
236            "allowUpload": true
237        }));
238        assert_eq!(
239            link_subtitle(&restricted),
240            "Beach · Never expires · Password · No downloads · Uploads allowed"
241        );
242        let expiring = link(serde_json::json!({ "expiresAt": "2026-10-09T12:00:00Z" }));
243        assert!(link_subtitle(&expiring).starts_with("Expires "));
244    }
245}