shares.rs
โއ
Raw
1//! Share creation dialog and the "Your shares" management panel (milestone 6).
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen_futures::spawn_local;
6
7use std::collections::{BTreeMap, HashMap};
8use std::sync::Arc;
9
10use crate::api::{
11 self, AdminPimLink, AdminShare, Me, PimLend, PimOwnShares, PimShareMode, ShareInfo, UserInfo,
12};
13use crate::components::icon::icon_svg;
14use crate::components::modal::Modal;
15use crate::components::toast::{ToastMsg, show, show_error};
16use crate::i18n;
17use crate::icons::IconName;
18use crate::router::{dav_share_url, share_url};
19use crate::util::{copy_button, format_date, local_date_time, retry_card};
20use crate::views::dialogs::ConfirmDialog;
21use crate::views::pim::{mode_label, webcal};
22
23// ---------------------------------------------------------------------------
24// Expiry helpers (compute the RFC 3339 expiry client-side)
25// ---------------------------------------------------------------------------
26
27/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
28/// "never". Presets are computed from "now"; "custom" parses the
29/// datetime-local value as the browser's local time. `Err` for a custom
30/// choice without a valid date.
31pub(crate) fn expiry_to_rfc3339(choice: &str, custom: &str) -> Result<Option<String>, ()> {
32 let now = js_sys::Date::now();
33 let h = 60.0 * 60.0 * 1000.0;
34 let ms = match choice {
35 "1h" => now + h,
36 "1d" => now + 24.0 * h,
37 "1w" => now + 7.0 * 24.0 * h,
38 "1mo" => now + 30.0 * 24.0 * h,
39 // `Date.parse` treats a bare datetime as local time, so the result
40 // is already the correct UTC instant. An empty field gives NaN.
41 "custom" => js_sys::Date::parse(custom),
42 _ => return Ok(None),
43 };
44 if ms.is_nan() {
45 return Err(());
46 }
47 Ok(Some(
48 js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
49 .to_iso_string()
50 .into(),
51 ))
52}
53
54// ---------------------------------------------------------------------------
55// Share creation dialog (two-phase: form -> link)
56// ---------------------------------------------------------------------------
57
58#[component]
59pub fn ShareDialog(
60 name: String,
61 root_id: i64,
62 path: String,
63 allow_writable: bool,
64 root_writable: bool,
65 close: Callback<()>,
66) -> impl IntoView {
67 let toast = use_context::<ToastMsg>().expect("toast context");
68 let (writable, set_writable) = signal(false);
69 let (choice, set_choice) = signal("never".to_string());
70 let (custom, set_custom) = signal(String::new());
71 let (password, set_password) = signal(String::new());
72 let (created, set_created) = signal(Option::<ShareInfo>::None);
73 let (busy, set_busy) = signal(false);
74 let name_msg = name.clone();
75
76 let do_create = move |_| {
77 if busy.get() {
78 return;
79 }
80 let Ok(exp) = expiry_to_rfc3339(&choice.get(), &custom.get()) else {
81 show_error(toast, i18n::t(i18n::k::ERR_BAD_EXPIRES_AT));
82 return;
83 };
84 set_busy.set(true);
85 let wr = writable.get();
86 let rid = root_id;
87 let p = path.clone();
88 // An empty field means "no password", not a password of "".
89 let pw = password.get().trim().to_string();
90 let pw = (!pw.is_empty()).then_some(pw);
91 spawn_local(async move {
92 match api::create_share(rid, &p, wr, exp.as_deref(), pw.as_deref()).await {
93 Ok(info) => set_created.set(Some(info)),
94 Err(e) => {
95 show(
96 toast,
97 i18n::t_fmt(i18n::k::SHARE_CREATE_ERR, &e.to_string()),
98 );
99 set_busy.set(false);
100 }
101 }
102 });
103 };
104
105 view! {
106 <Modal class="share-modal" on_close=close>
107 {move || match created.get() {
108 Some(ref info) => {
109 let link = share_url(&info.token);
110 let dav_link = dav_share_url(&info.token);
111 let is_file = info.is_file;
112 view! {
113 <h2 class="modal-title">{i18n::tr(i18n::k::SHARE_LINK)}</h2>
114 <p class="modal-message">
115 {if writable.get() {
116 i18n::t_fmt(i18n::k::SHARE_PERM_RW, &name_msg)
117 } else {
118 i18n::t_fmt(i18n::k::SHARE_PERM_RO, &name_msg)
119 }}
120 </p>
121 <div class="share-link-row">
122 <input
123 class="share-link-input"
124 readonly=true
125 value=link.clone()
126 on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
127 />
128 {copy_button(
129 "btn",
130 Some(i18n::k::COPY_LINK),
131 i18n::t(i18n::k::COPY),
132 link,
133 i18n::k::LINK_COPIED,
134 toast,
135 )}
136 </div>
137 // Folder shares only: a file share has no
138 // collection to mount.
139 {(!is_file).then(|| view! {
140 <div class="share-link-row">
141 <input
142 class="share-link-input"
143 readonly=true
144 value=dav_link.clone()
145 title={i18n::tr(i18n::k::COPY_DAV_LINK)}
146 on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
147 />
148 {copy_button(
149 "btn",
150 Some(i18n::k::COPY_DAV_LINK),
151 "WebDAV",
152 dav_link,
153 i18n::k::LINK_COPIED,
154 toast,
155 )}
156 </div>
157 })}
158 <p class="muted share-expiry-note">
159 {match info.expires_at {
160 Some(ref e) => i18n::t_fmt(i18n::k::EXPIRES_AT, &format_date(e)),
161 None => i18n::t(i18n::k::NEVER_EXPIRES).to_string(),
162 }}
163 </p>
164 {info.has_password.then(|| view! {
165 <p class="muted share-expiry-note">
166 {i18n::tr(i18n::k::SHARE_PROTECTED)}
167 </p>
168 })}
169 <div class="modal-actions">
170 <button class="btn btn-primary" on:click=move |_| close.run(())>{i18n::t(i18n::k::DONE)}</button>
171 </div>
172 }
173 .into_any()
174 }
175 None => view! {
176 <h2 class="modal-title">{i18n::t_fmt(i18n::k::SHARE_TITLE, &name)}</h2>
177 <p class="modal-message">{i18n::tr(i18n::k::SHARE_DIALOG_HINT)}</p>
178 {move || if allow_writable && root_writable {
179 view! {
180 <label class="share-check">
181 <input
182 type="checkbox"
183 checked=move || writable.get()
184 on:change=move |ev| set_writable.set(event_target_checked(&ev))
185 />
186 {i18n::tr(i18n::k::ALLOW_EDITING)}
187 </label>
188 }
189 .into_any()
190 } else {
191 view! {
192 <p class="muted">
193 {move || {
194 if root_writable {
195 i18n::t(i18n::k::RW_DISABLED).to_string()
196 } else {
197 i18n::t(i18n::k::RW_RO_FOLDER).to_string()
198 }
199 }}
200 </p>
201 }
202 .into_any()
203 }}
204 <div class="share-expiry-row">
205 <label for="share-expiry">{i18n::tr(i18n::k::EXPIRES)}</label>
206 <select
207 id="share-expiry"
208 on:change=move |ev| set_choice.set(event_target_value(&ev))
209 >
210 <option value="never">{i18n::tr(i18n::k::NEVER)}</option>
211 <option value="1h">{i18n::t(i18n::k::EXPIRY_1H)}</option>
212 <option value="1d">{i18n::t(i18n::k::EXPIRY_1D)}</option>
213 <option value="1w">{i18n::t(i18n::k::EXPIRY_1W)}</option>
214 <option value="1mo">{i18n::t(i18n::k::EXPIRY_1MO)}</option>
215 <option value="custom">{i18n::tr(i18n::k::CUSTOM)}</option>
216 </select>
217 </div>
218 <Show when=move || choice.get() == "custom">
219 <div class="share-expiry-row">
220 <label for="share-custom-dt">{i18n::tr(i18n::k::UNTIL)}</label>
221 <input
222 type="datetime-local"
223 id="share-custom-dt"
224 value=move || custom.get()
225 on:change=move |ev| set_custom.set(event_target_value(&ev))
226 />
227 </div>
228 </Show>
229 <div class="share-pw-row">
230 <label for="share-pw">{i18n::tr(i18n::k::PASSWORD)}</label>
231 <input
232 type="password"
233 id="share-pw"
234 autocomplete="new-password"
235 value=move || password.get()
236 on:input=move |ev| set_password.set(event_target_value(&ev))
237 />
238 </div>
239 <p class="muted share-pw-hint">{i18n::tr(i18n::k::SHARE_PASSWORD_HINT)}</p>
240 <div class="modal-actions">
241 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
242 <button
243 class="btn btn-primary"
244 disabled=move || busy.get()
245 on:click=do_create.clone()
246 >
247 {move || {
248 if busy.get() {
249 i18n::t(i18n::k::CREATING).to_string()
250 } else {
251 i18n::t(i18n::k::CREATE_LINK).to_string()
252 }
253 }}
254 </button>
255 </div>
256 }
257 .into_any(),
258 }}
259 </Modal>
260 }
261}
262
263// ---------------------------------------------------------------------------
264// Share management view
265//
266// A plain user sees their own links. An admin sees every link on the server,
267// one collapsible section per account. A share outlives the account that made
268// it (README "Shares"), so without this view nobody could end those links.
269// ---------------------------------------------------------------------------
270
271// Both endpoints load into `AdminShare`, so grouping and rows never branch on
272// which one answered. The own list has no creator, so the viewer fills it in.
273
274/// One collapsible section: an account and its links.
275struct Group {
276 id: i64,
277 name: String,
278 active: bool,
279 /// The viewer's own links: rendered first, and open by default.
280 mine: bool,
281 shares: Vec<ShareInfo>,
282}
283
284/// Bucket rows by creator. The map key sorts the sections: the viewer's own
285/// first, the rest by account name. Non-admins only ever get their own group.
286/// Rows are pushed in the order the server sent them, so newest stays first.
287fn group_rows(rows: &[AdminShare], my_id: i64) -> Vec<Group> {
288 let mut groups: BTreeMap<(bool, String, i64), Group> = BTreeMap::new();
289 for r in rows {
290 let mine = r.creator_id == my_id;
291 groups
292 .entry((!mine, r.creator_name.to_lowercase(), r.creator_id))
293 .or_insert_with(|| Group {
294 id: r.creator_id,
295 name: r.creator_name.clone(),
296 active: r.creator_active,
297 mine,
298 shares: Vec::new(),
299 })
300 .shares
301 .push(r.share.clone());
302 }
303 groups.into_values().collect()
304}
305
306/// A bulk deletion that is waiting for the answer to the confirmation dialog.
307#[derive(Clone, PartialEq)]
308struct Pending {
309 ids: Vec<i64>,
310 /// The account the question names; `None` asks about everything listed.
311 who: Option<String>,
312}
313
314/// End one link. Only the admin route can end a link the viewer does not own,
315/// so an admin always takes it and everyone else always takes the user route.
316async fn end_share(is_admin: bool, id: i64) -> bool {
317 if is_admin {
318 api::admin_delete_share(id).await
319 } else {
320 api::delete_share(id).await
321 }
322 .is_ok()
323}
324
325/// One share row: name, meta line, copy buttons, delete. Every section renders
326/// its rows through this, so an admin sees exactly what the owner sees.
327fn share_row(s: &ShareInfo, toast: ToastMsg, del: Callback<i64>) -> AnyView {
328 let link = share_url(&s.token);
329 let dav_link = dav_share_url(&s.token);
330 let is_file = s.is_file;
331 let glyph = if is_file {
332 IconName::File
333 } else {
334 IconName::Folder
335 };
336 let mut meta = format!(
337 "{} ยท {}",
338 s.target,
339 if s.writable {
340 i18n::t(i18n::k::MODE_RW)
341 } else {
342 i18n::t(i18n::k::MODE_RO)
343 }
344 );
345 if let Some(e) = &s.expires_at {
346 meta.push_str(&format!(
347 " ยท {}",
348 i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
349 ));
350 }
351 meta.push_str(&format!(
352 " ยท {}",
353 i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
354 ));
355 if s.has_password {
356 meta.push_str(&format!(" ยท {}", i18n::t(i18n::k::SHARE_PROTECTED)));
357 }
358 let name = s.name.clone();
359 let id = s.id;
360 view! {
361 <div class="share-item">
362 <div class="share-item-main">
363 <div class="share-item-name">
364 {icon_svg(glyph, "ic-row")}
365 <span>{name}</span>
366 </div>
367 <div class="share-item-meta muted">{meta}</div>
368 </div>
369 <div class="share-item-actions">
370 {copy_button(
371 "btn btn-sm",
372 Some(i18n::k::COPY_LINK),
373 i18n::t(i18n::k::COPY),
374 link,
375 i18n::k::LINK_COPIED,
376 toast,
377 )}
378 // Folder shares only: a file share has no collection to mount.
379 {(!is_file).then(|| copy_button(
380 "btn btn-sm",
381 Some(i18n::k::COPY_DAV_LINK),
382 "WebDAV",
383 dav_link,
384 i18n::k::LINK_COPIED,
385 toast,
386 ))}
387 <button
388 class="btn btn-sm btn-danger"
389 title={i18n::tr(i18n::k::DELETE_SHARE)}
390 on:click=move |_| del.run(id)
391 >
392 {icon_svg(IconName::Delete, "ic-btn")}
393 {i18n::t(i18n::k::DELETE)}
394 </button>
395 </div>
396 </div>
397 }
398 .into_any()
399}
400
401/// One collapsible section of share rows. `open` keeps each section's state
402/// by `gid`; `default_open` holds until the user toggles it.
403fn share_group(
404 open: RwSignal<HashMap<i64, bool>>,
405 gid: i64,
406 default_open: bool,
407 title: impl IntoView + 'static,
408 warn: bool,
409 extra: impl IntoView + 'static,
410 rows: Vec<AnyView>,
411) -> impl IntoView {
412 let n = rows.len();
413 view! {
414 <details
415 class="share-group"
416 prop:open=move || open.with(|m| m.get(&gid).copied().unwrap_or(default_open))
417 // `<details>` announces every toggle the user makes, already in
418 // its new state.
419 on:toggle=move |ev: web_sys::Event| {
420 let on = ev
421 .target()
422 .and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
423 .is_some_and(|d| d.open());
424 open.update(|m| {
425 m.insert(gid, on);
426 });
427 }
428 >
429 <summary class="share-group-head">
430 {icon_svg(IconName::ChevronRight, "share-caret")}
431 <span class="share-group-title">{title}</span>
432 // Visible while collapsed: the case the admin view exists for.
433 {warn.then(|| view! {
434 <span class="share-group-warn">{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}</span>
435 })}
436 <span class="share-group-count muted">{n}</span>
437 {extra}
438 </summary>
439 <div class="share-list">{rows}</div>
440 </details>
441 }
442}
443
444#[component]
445pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
446 let toast = use_context::<ToastMsg>().expect("toast context");
447 // One extraction of the signed-in user; everything below reads a field.
448 // The shell renders this view only behind a signed-in `me`.
449 let user: Memo<UserInfo> = Memo::new(move |_| {
450 me.with(|m| m.as_ref().and_then(|m| m.user.clone()))
451 .expect("SharesView needs a signed-in user")
452 });
453 let admin = move || user.with(|u| u.is_admin);
454 let my_id = move || user.with(|u| u.id);
455 // A profile change must not remount the feeds and reset their groups.
456 let pim_user = Memo::new(move |_| (admin(), my_id()));
457 // None until the first load answers, so "loading" and "no shares" stay
458 // distinguishable.
459 let (rows, set_rows) = signal(Option::<Vec<AdminShare>>::None);
460 // Set when the list could not be loaded; the error card offers a retry.
461 let (load_err, set_load_err) = signal(Option::<String>::None);
462 let (confirm, set_confirm) = signal(None::<Pending>);
463 let (busy, set_busy) = signal(false);
464 // Which sections are open, by account. Kept out of the DOM: the listing is
465 // rebuilt on every delete, and a section must not snap shut under the user.
466 let open = RwSignal::new(HashMap::<i64, bool>::new());
467
468 let load = move || {
469 set_load_err.set(None);
470 // `try_get`, not `get`: a bulk delete calls this after its awaits, when
471 // the view may be gone. Reading a disposed memo panics and kills the app.
472 let Some(u) = user.try_get() else {
473 return;
474 };
475 // The own list does not name a creator, so the viewer's own id and name
476 // fill that in. `me` is loaded before this view renders.
477 let is_admin = u.is_admin;
478 let own_id = u.id;
479 let own_name = u.name;
480 spawn_local(async move {
481 let got = if is_admin {
482 api::list_all_shares().await
483 } else {
484 api::list_shares().await.map(|own| {
485 own.into_iter()
486 .map(|share| AdminShare {
487 share,
488 creator_id: own_id,
489 creator_name: own_name.clone(),
490 creator_active: true,
491 })
492 .collect()
493 })
494 };
495 match got {
496 Ok(rs) => set_rows.set(Some(rs)),
497 Err(e) => set_load_err.set(Some(e.to_string())),
498 }
499 });
500 };
501 load();
502
503 let delete_one = Callback::new(move |id: i64| {
504 let is_admin = admin();
505 let toast2 = toast;
506 spawn_local(async move {
507 if end_share(is_admin, id).await {
508 set_rows.update(|rs| {
509 if let Some(list) = rs {
510 list.retain(|r| r.share.id != id);
511 }
512 });
513 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
514 } else {
515 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
516 }
517 });
518 });
519
520 // Bulk delete: no server-side bulk endpoint, so this walks the ids one by one.
521 let delete_all = Callback::new(move |_| {
522 let Some(p) = confirm.get() else { return };
523 set_confirm.set(None);
524 if p.ids.is_empty() || busy.get() {
525 return;
526 }
527 set_busy.set(true);
528 let total = p.ids.len();
529 let is_admin = admin();
530 spawn_local(async move {
531 let mut failed = 0usize;
532 for id in &p.ids {
533 if !end_share(is_admin, *id).await {
534 failed += 1;
535 }
536 }
537 if failed == 0 {
538 show(
539 toast,
540 i18n::t_fmt(i18n::k::DELETED_N_SHARES, &total.to_string()),
541 );
542 } else if failed < total {
543 show(
544 toast,
545 i18n::t_fmt2(
546 i18n::k::DELETE_PARTIAL,
547 &failed.to_string(),
548 &total.to_string(),
549 ),
550 );
551 } else {
552 show_error(toast, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
553 }
554 // Re-read: after a partial failure, editing the list by hand is
555 // not trustworthy.
556 load();
557 set_busy.set(false);
558 });
559 });
560
561 view! {
562 <div class="shares-view">
563 <div class="view-header">
564 <div class="users-header">
565 <h3 class="admin-section-title">
566 {move || {
567 if admin() {
568 i18n::t(i18n::k::ALL_SHARES).to_string()
569 } else {
570 i18n::t(i18n::k::YOUR_SHARES).to_string()
571 }
572 }}
573 </h3>
574 {move || {
575 let n = rows.get().map(|r| r.len()).unwrap_or(0);
576 (n > 0)
577 .then(|| {
578 view! {
579 <button
580 class="btn btn-sm btn-danger"
581 disabled=move || busy.get()
582 on:click=move |_: web_sys::MouseEvent| {
583 let Some(list) = rows.get() else { return };
584 let ids: Vec<i64> =
585 list.iter().map(|r| r.share.id).collect();
586 if ids.is_empty() {
587 return;
588 }
589 set_confirm.set(Some(Pending { ids, who: None }));
590 }
591 >
592 {icon_svg(IconName::Delete, "ic-btn")}
593 {i18n::tr(i18n::k::DELETE_ALL)}
594 </button>
595 }
596 })
597 .into_any()
598 }}
599 </div>
600 <p class="muted view-hint">
601 {move || {
602 if admin() {
603 i18n::t(i18n::k::ALL_SHARES_HINT).to_string()
604 } else {
605 i18n::t(i18n::k::SHARES_VIEW_HINT).to_string()
606 }
607 }}
608 </p>
609 </div>
610 {move || match rows.get() {
611 None => match load_err.get() {
612 Some(msg) => retry_card(msg, Callback::new(move |_| load())).into_any(),
613 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
614 .into_any(),
615 },
616 Some(ref list) if list.is_empty() => view! {
617 <div class="empty-state">
618 {icon_svg(IconName::Share, "empty-glyph")}
619 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
620 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
621 </div>
622 }
623 .into_any(),
624 Some(ref list) => {
625 let is_admin = admin();
626 let mine = my_id();
627 let sections = group_rows(list, mine)
628 .into_iter()
629 .map(|g| {
630 let own = g.mine;
631 // One copy, shared by the section title and the
632 // delete-all question: both name the account.
633 let name: Arc<str> = g.name.into();
634 let items = g.shares
635 .iter()
636 .map(|s| share_row(s, toast, delete_one))
637 .collect::<Vec<_>>();
638 let ids: Vec<i64> = g.shares.iter().map(|s| s.id).collect();
639 let title = {
640 let name = name.clone();
641 move || {
642 if own {
643 i18n::t(i18n::k::YOUR_SHARES).to_string()
644 } else {
645 name.to_string()
646 }
647 }
648 };
649 // A non-admin has one section, and the heading
650 // button already deletes all of it.
651 let del_all = is_admin.then(|| {
652 view! {
653 <button
654 class="btn btn-sm btn-danger share-group-del"
655 title={i18n::tr(i18n::k::DELETE_ALL_TITLE)}
656 disabled=move || busy.get()
657 on:click=move |ev: web_sys::MouseEvent| {
658 // Otherwise this click toggles the
659 // section it sits in.
660 ev.prevent_default();
661 if ids.is_empty() {
662 return;
663 }
664 set_confirm.set(Some(Pending {
665 ids: ids.clone(),
666 who: (!own).then(|| name.to_string()),
667 }));
668 }
669 >
670 {icon_svg(IconName::Delete, "ic-btn")}
671 {i18n::t(i18n::k::DELETE_ALL)}
672 </button>
673 }
674 });
675 // Own links open, everyone else's collapsed: an
676 // admin lands on their own links, not on a wall
677 // of other people's tokens.
678 share_group(open, g.id, own, title, !g.active, del_all, items)
679 })
680 .collect::<Vec<_>>();
681 view! { <div class="share-groups">{sections}</div> }
682 .into_any()
683 }
684 }}
685
686 {move || {
687 let (admin, my_id) = pim_user.get();
688 view! { <PimShares admin=admin my_id=my_id/> }
689 }}
690
691 // Delete-all confirmation, for one account or for everything.
692 {move || {
693 let p = confirm.get()?;
694 // Name the account: the links being deleted are not the
695 // viewer's.
696 let msg = match &p.who {
697 Some(name) => i18n::t_fmt(i18n::k::DELETE_ALL_USER_MSG, name),
698 None => i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &p.ids.len().to_string()),
699 };
700 Some(view! {
701 <ConfirmDialog
702 title=i18n::t(i18n::k::DELETE_ALL_TITLE).to_string()
703 message=msg
704 submit=i18n::t(i18n::k::DELETE_ALL).to_string()
705 danger=true
706 on_submit=delete_all
707 on_close=Callback::new(move |_| set_confirm.set(None))
708 />
709 })
710 }}
711 </div>
712 }
713}
714
715/// Calendar and address book feeds and loans, grouped like the file shares
716/// above. Everyone sees their own; an admin also sees everyone else's feeds.
717/// Loans between other users stay private: they expose nothing publicly.
718#[component]
719fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
720 let toast = use_context::<ToastMsg>().expect("toast context");
721 let (own, set_own) = signal(Option::<PimOwnShares>::None);
722 let (others, set_others) = signal(Vec::<AdminPimLink>::new());
723 // Open sections, kept across reloads like the file shares'. Own groups
724 // use the ids below, other owners their account id.
725 let open = RwSignal::new(HashMap::<i64, bool>::new());
726 const OWN_LINKS: i64 = -1;
727 const OWN_LENDS: i64 = -2;
728 let (load_err, set_load_err) = signal(Option::<String>::None);
729 let (others_err, set_others_err) = signal(Option::<String>::None);
730 let load_seq = StoredValue::new(0u32);
731 let load = move || {
732 let Some(seq) = load_seq.try_get_value().map(|s| s + 1) else {
733 return;
734 };
735 load_seq.set_value(seq);
736 set_load_err.set(None);
737 spawn_local(async move {
738 let latest = move || load_seq.try_get_value() == Some(seq);
739 let r = api::pim_own_shares().await;
740 if !latest() {
741 return;
742 }
743 match r {
744 Ok(v) => {
745 let _ = set_own.try_set(Some(v));
746 }
747 Err(e) if own.try_with_untracked(|o| o.is_some()) == Some(true) => {
748 show_error(toast, e.to_string())
749 }
750 Err(e) => {
751 let _ = set_load_err.try_set(Some(e.to_string()));
752 }
753 }
754 if admin {
755 let r = api::admin_pim_links().await;
756 if !latest() {
757 return;
758 }
759 match r {
760 Ok(v) => {
761 let v = v.into_iter().filter(|f| f.owner_id != my_id).collect();
762 let _ = set_others.try_set(v);
763 let _ = set_others_err.try_set(None);
764 }
765 Err(e) => {
766 let _ = set_others_err.try_set(Some(e.to_string()));
767 }
768 }
769 }
770 });
771 };
772 load();
773 let glyph = |kind: api::PimCollectionKind| match kind {
774 api::PimCollectionKind::Calendar => IconName::Calendar,
775 api::PimCollectionKind::Addressbook => IconName::Contacts,
776 };
777 // Own feeds end through the collection's route, other owners' through
778 // the admin route.
779 let feed_row = move |f: &AdminPimLink| {
780 let url = crate::router::server_url(&f.link.path);
781 let calendar = f.kind == api::PimCollectionKind::Calendar;
782 let subscribe = calendar.then(|| webcal(&url)).flatten();
783 let (id, cid, mine) = (f.link.id, f.collection_id, f.owner_id == my_id);
784 let mut meta = Vec::new();
785 // Address book feeds have no detail level.
786 if calendar {
787 meta.push(if f.link.busy_only {
788 i18n::t(i18n::k::PIM_LINK_BUSY).to_string()
789 } else {
790 i18n::t(i18n::k::PIM_LINK_FULL).to_string()
791 });
792 }
793 if let Some(e) = &f.link.expires_at {
794 meta.push(i18n::t_fmt(i18n::k::META_EXPIRES, &local_date_time(e)));
795 }
796 meta.push(i18n::t_fmt(
797 i18n::k::META_CREATED,
798 &local_date_time(&f.link.created_at),
799 ));
800 if f.link.has_password {
801 meta.push(i18n::t(i18n::k::SHARE_PROTECTED).to_string());
802 }
803 view! {
804 <div class="share-item">
805 <div class="share-item-main">
806 <div class="share-item-name">
807 {icon_svg(glyph(f.kind), "ic-row")}
808 <span>{f.collection_name.clone()}</span>
809 </div>
810 <div class="share-item-meta muted">{meta.join(" ยท ")}</div>
811 </div>
812 <div class="share-item-actions">
813 {copy_button(
814 "btn btn-sm",
815 None,
816 i18n::t(i18n::k::COPY),
817 url,
818 i18n::k::LINK_COPIED,
819 toast,
820 )}
821 {subscribe.map(|w| view! {
822 <a class="btn btn-sm" href=w>{i18n::t(i18n::k::PIM_SUBSCRIBE)}</a>
823 })}
824 <button
825 class="btn btn-sm btn-danger"
826 on:click=move |_| {
827 spawn_local(async move {
828 let done = if mine {
829 api::pim_delete_link(cid, id).await
830 } else {
831 api::admin_delete_pim_link(id).await
832 };
833 match done {
834 Ok(_) => {
835 let _ = set_own.try_update(|o| {
836 if let Some(o) = o {
837 o.links.retain(|l| l.link.id != id);
838 }
839 });
840 let _ = set_others.try_update(|o| o.retain(|l| l.link.id != id));
841 show(toast, i18n::t(i18n::k::PIM_LINK_REVOKED).to_string());
842 }
843 Err(e) => show_error(toast, e.to_string()),
844 }
845 load();
846 });
847 }
848 >
849 {icon_svg(IconName::Delete, "ic-btn")}
850 {i18n::t(i18n::k::DELETE)}
851 </button>
852 </div>
853 </div>
854 }
855 .into_any()
856 };
857 let lend_row = move |l: &PimLend| {
858 let (cid, uid, who) = (l.collection_id, l.share.user_id, l.share.user_name.clone());
859 let modes: &[PimShareMode] = match l.kind {
860 api::PimCollectionKind::Calendar => {
861 &[PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule]
862 }
863 api::PimCollectionKind::Addressbook => &[PimShareMode::Ro, PimShareMode::Rw],
864 };
865 let current = l.share.mode;
866 view! {
867 <div class="share-item">
868 <div class="share-item-main">
869 <div class="share-item-name">
870 {icon_svg(glyph(l.kind), "ic-row")}
871 <span>{l.collection_name.clone()}</span>
872 </div>
873 <div class="share-item-meta muted">{l.share.user_name.clone()}</div>
874 </div>
875 <div class="share-item-actions">
876 <select
877 class="pim-mode"
878 aria-label=i18n::tr(i18n::k::PIM_ACCESS)
879 on:change=move |ev| {
880 let Some(m) = PimShareMode::from_wire(&event_target_value(&ev)) else {
881 return;
882 };
883 let who = who.clone();
884 spawn_local(async move {
885 if let Err(e) = api::pim_share(cid, &who, m).await {
886 show_error(toast, e.to_string());
887 }
888 // Also on failure, so the select shows the real mode.
889 load();
890 });
891 }
892 >
893 {modes
894 .iter()
895 .map(|&m| view! {
896 <option value=m.as_str() selected=m == current>{mode_label(m)}</option>
897 })
898 .collect::<Vec<_>>()}
899 </select>
900 <button
901 class="btn btn-sm btn-danger"
902 on:click=move |_| {
903 spawn_local(async move {
904 match api::pim_unshare(cid, uid).await {
905 Ok(_) => {
906 let _ = set_own.try_update(|o| {
907 if let Some(o) = o {
908 o.lends.retain(|l| {
909 (l.collection_id, l.share.user_id) != (cid, uid)
910 });
911 }
912 });
913 show(toast, i18n::t(i18n::k::PIM_UNSHARED).to_string());
914 }
915 Err(e) => show_error(toast, e.to_string()),
916 }
917 load();
918 });
919 }
920 >
921 {icon_svg(IconName::Delete, "ic-btn")}
922 {i18n::t(i18n::k::PIM_UNSHARE)}
923 </button>
924 </div>
925 </div>
926 }
927 .into_any()
928 };
929 let group = move |gid: i64, title: String, warn: bool, rows: Vec<AnyView>| {
930 share_group(open, gid, gid < 0, title, warn, (), rows)
931 };
932 view! {
933 <div class="view-header feeds-header">
934 <h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_NAV)}</h3>
935 <p class="muted view-hint">{i18n::tr(i18n::k::PIM_OWN_HINT)}</p>
936 </div>
937 {move || {
938 let Some(own) = own.get() else {
939 return match load_err.get() {
940 Some(msg) => retry_card(msg, Callback::new(move |_| load())).into_any(),
941 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
942 };
943 };
944 let others = others.get();
945 let others_err = others_err
946 .get()
947 .map(|msg| retry_card(msg, Callback::new(move |_| load())));
948 if own.links.is_empty() && own.lends.is_empty() && others.is_empty() {
949 return view! {
950 {others_err}
951 <p class="muted">{i18n::tr(i18n::k::PIM_OWN_EMPTY)}</p>
952 }
953 .into_any();
954 }
955 let mut sections = Vec::new();
956 if !own.links.is_empty() {
957 let rows = own.links.iter().map(feed_row).collect();
958 sections.push(group(OWN_LINKS, i18n::t(i18n::k::PIM_OWN_LINKS).to_string(), false, rows));
959 }
960 if !own.lends.is_empty() {
961 let rows = own.lends.iter().map(lend_row).collect();
962 sections.push(group(OWN_LENDS, i18n::t(i18n::k::PIM_OWN_LENDS).to_string(), false, rows));
963 }
964 // Other owners' feeds, by name, collapsed.
965 let mut by_owner: BTreeMap<(String, i64), Vec<AdminPimLink>> = BTreeMap::new();
966 for f in others {
967 by_owner.entry((f.owner_name.to_lowercase(), f.owner_id)).or_default().push(f);
968 }
969 for ((_, gid), items) in by_owner {
970 let title = i18n::t_fmt(i18n::k::PIM_FEEDS_OF, &items[0].owner_name);
971 let rows = items.iter().map(feed_row).collect();
972 sections.push(group(gid, title, !items[0].owner_active, rows));
973 }
974 view! { {others_err} <div class="share-groups">{sections}</div> }.into_any()
975 }}
976 }
977}
978