mimick/library/
shared_links_view.rs1use 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
15pub(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
62pub(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(©);
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
162fn 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
173fn 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}