//! Calendars and address books: the collection list beside each view, the //! dialog that manages one collection, the client setup help and the import //! of an `.ics` or `.vcf` file. use std::collections::HashSet; use api_types::{CreatePimCollection, CreatePimLink, UpdatePimCollection}; use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::{ self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimPreview, PimShareInfo, PimShareMode, }; use crate::components::icon::{Icon, icon_svg}; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show, show_error}; use crate::i18n::{self, k}; use crate::icons::IconName; use crate::router::{Location, Section, navigate, server_url}; use crate::util::{copy_to_clipboard, format_date, storage_get, storage_set}; use crate::views::dialogs::{ConfirmDialog, Dialog}; use crate::views::shares::expiry_to_rfc3339; /// Colors offered for a collection; a custom one comes from the color input. const PALETTE: [&str; 8] = [ "#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#64748b", ]; /// The collections the lists do not show, by id. const HIDDEN_KEY: &str = "fbng.pim-hidden"; /// Collection ids hidden in the calendar and contact views. pub fn load_hidden() -> HashSet { storage_get(HIDDEN_KEY) .unwrap_or_default() .split(',') .filter_map(|s| s.parse().ok()) .collect() } fn save_hidden(ids: &HashSet) { let list: Vec = ids.iter().map(i64::to_string).collect(); storage_set(HIDDEN_KEY, &list.join(",")); } /// A collection's color for a style attribute. Colors of lent collections /// come from other clients, so anything but a hex color falls back. pub fn safe_color(c: Option<&str>) -> String { match c.and_then(|c| c.strip_prefix('#')) { Some(h) if [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()) => { format!("#{h}") } _ => "var(--accent)".to_string(), } } /// The name to show: generated collections have translated names. pub fn display_name(c: &PimCollectionInfo) -> String { match (c.generated, c.kind) { (true, PimCollectionKind::Calendar) => i18n::t(k::PIM_BIRTHDAYS).to_string(), (true, PimCollectionKind::Addressbook) => i18n::t(k::PIM_DIRECTORY).to_string(), _ => c.name.clone(), } } /// Whether the signed-in user may add objects to a collection. pub fn writable(c: &PimCollectionInfo) -> bool { !c.generated && c.mode != Some(PimShareMode::Ro) } fn mode_label(m: PimShareMode) -> &'static str { match m { PimShareMode::Ro => i18n::t(k::PIM_MODE_RO), PimShareMode::Rw => i18n::t(k::PIM_MODE_RW), PimShareMode::RwSchedule => i18n::t(k::PIM_MODE_SCHEDULE), } } /// The browser's IANA time zone, for dates the server resolves. pub fn browser_tz() -> String { let opts = js_sys::Intl::DateTimeFormat::new(&js_sys::Array::new(), &js_sys::Object::new()) .resolved_options(); js_sys::Reflect::get(&opts, &"timeZone".into()) .ok() .and_then(|v| v.as_string()) .unwrap_or_else(|| "UTC".to_string()) } /// The calendar or contacts section: the collection list, and the view of /// what the visible collections hold. #[component] pub fn PimView( kind: PimCollectionKind, me: ReadSignal>, set_dialog: WriteSignal>, ) -> impl IntoView { let (collections, set_collections) = signal(Option::>::None); let (load_err, set_load_err) = signal(Option::::None); let hidden = RwSignal::new(load_hidden()); let reload = Callback::new(move |_| { set_load_err.set(None); spawn_local(async move { match api::pim_collections().await { Ok(v) => set_collections.set(Some(v)), Err(e) => set_load_err.set(Some(e.to_string())), } }); }); reload.run(()); let (glyph, title, hint) = match kind { PimCollectionKind::Calendar => (IconName::Calendar, k::PIM_CALENDAR, k::PIM_CAL_HINT), PimCollectionKind::Addressbook => { (IconName::Contacts, k::PIM_CONTACTS, k::PIM_CONTACTS_HINT) } }; view! {
} } #[component] fn CollectionList( kind: PimCollectionKind, collections: ReadSignal>>, load_err: ReadSignal>, hidden: RwSignal>, reload: Callback<()>, me: ReadSignal>, set_dialog: WriteSignal>, ) -> impl IntoView { let (managing, set_managing) = signal(Option::::None); let (creating, set_creating) = signal(false); let (connecting, set_connecting) = signal(false); let calendars = kind == PimCollectionKind::Calendar; let (title, new_label) = match calendars { true => (k::PIM_CALENDARS, k::PIM_NEW_CALENDAR), false => (k::PIM_ADDRESS_BOOKS, k::PIM_NEW_ADDRESS_BOOK), }; let row = move |c: PimCollectionInfo| { let id = c.id; let is_hidden = move || hidden.with(|h| h.contains(&id)); let toggle = move |_| { hidden.update(|h| { if !h.remove(&id) { h.insert(id); } save_hidden(h); }); }; let note = match (c.generated, c.mode) { (true, _) => Some(i18n::t(k::PIM_AUTO).to_string()), (false, Some(_)) => Some(c.owner.clone()), _ => None, }; let name = display_name(&c); let color = safe_color(c.color.as_deref()); let info = c.clone(); view! {
} }; view! { {move || managing.get().map(|info| view! { })} } } /// The palette plus a custom color. `None` clears the color. #[component] fn ColorPicker(color: RwSignal>) -> impl IntoView { view! {
{PALETTE .iter() .map(|&c| { view! { } }) .collect::>()} = 7) .map(|c| c[..7].to_string()) .unwrap_or_else(|| "#3b82f6".to_string()) }) } on:input=move |ev| color.set(Some(event_target_value(&ev))) />
} } #[component] fn NewCollectionDialog( kind: PimCollectionKind, on_close: Callback<()>, on_created: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let (name, set_name) = signal(String::new()); let color = RwSignal::new(Some(PALETTE[0].to_string())); let (description, set_description) = signal(String::new()); let (busy, set_busy) = signal(false); let title = match kind { PimCollectionKind::Calendar => k::PIM_NEW_CALENDAR, PimCollectionKind::Addressbook => k::PIM_NEW_ADDRESS_BOOK, }; let submit = move |ev: web_sys::SubmitEvent| { ev.prevent_default(); let n = name.get().trim().to_string(); if n.is_empty() || busy.get() { return; } set_busy.set(true); let body = CreatePimCollection { kind, name: n, color: color.get(), description: Some(description.get()).filter(|d| !d.trim().is_empty()), components: Vec::new(), }; spawn_local(async move { match api::pim_create_collection(body).await { Ok(_) => { show(toast, i18n::t(k::PIM_CREATED)); on_created.run(()); on_close.run(()); } Err(e) => { show_error(toast, e.to_string()); set_busy.set(false); } } }); }; view! {
{i18n::tr(k::PIM_COLOR)}
} } /// A read-only URL with a copy button. fn url_row(url: String, toast: ToastMsg) -> impl IntoView { let copy = url.clone(); view! { } } /// Everything about one collection: its settings, who it is lent to, its /// public links, export and deletion. #[component] fn CollectionDialog( info: PimCollectionInfo, me: ReadSignal>, set_dialog: WriteSignal>, on_close: Callback<()>, on_changed: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let own = info.mode.is_none() && !info.generated; let url = server_url(&info.url); let lent_note = info.mode.map(|m| { format!( "{} · {}", i18n::t_fmt(k::PIM_SHARED_BY, &info.owner), mode_label(m) ) }); let generated_note = info.generated.then(|| i18n::t(k::PIM_GENERATED_HINT)); view! { {lent_note.map(|n| view! {

{n}

})} {generated_note.map(|n| view! {

{n}

})} {own.then(|| view! { })}

{i18n::tr(k::PIM_ADDRESS)}

{url_row(url, toast)}
{own.then(|| view! { })} {own.then(|| view! { })} {(!info.generated).then(|| view! { })}
} } #[component] fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl IntoView { let toast = use_context::().expect("toast context"); let id = info.id; let calendar = info.kind == PimCollectionKind::Calendar; let (name, set_name) = signal(info.name.clone()); let color = RwSignal::new(info.color.clone()); let (description, set_description) = signal(info.description.clone().unwrap_or_default()); let (transparent, set_transparent) = signal(info.transparent); let (busy, set_busy) = signal(false); let save = move |ev: web_sys::SubmitEvent| { ev.prevent_default(); if busy.get() || name.get().trim().is_empty() { return; } set_busy.set(true); let body = UpdatePimCollection { name: Some(name.get()), color: Some(color.get().unwrap_or_default()), description: Some(description.get()), transparent: calendar.then(|| transparent.get()), }; spawn_local(async move { match api::pim_update_collection(id, body).await { Ok(_) => { show(toast, i18n::t(k::SAVED)); on_changed.run(()); } Err(e) => show_error(toast, i18n::t_fmt(k::PIM_SAVE_ERR, &e.to_string())), } set_busy.set(false); }); }; view! {

{i18n::tr(k::PIM_GENERAL)}

{i18n::tr(k::PIM_COLOR)}
{calendar.then(|| view! { })}
} } #[component] fn SharingSection(info: PimCollectionInfo) -> impl IntoView { let toast = use_context::().expect("toast context"); let id = info.id; let calendar = info.kind == PimCollectionKind::Calendar; let modes: Vec = match calendar { true => vec![PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule], false => vec![PimShareMode::Ro, PimShareMode::Rw], }; let (shares, set_shares) = signal(Option::>::None); let (user, set_user) = signal(String::new()); let (mode, set_mode) = signal(PimShareMode::Ro); let load = move || { spawn_local(async move { match api::pim_shares(id).await { Ok(v) => set_shares.set(Some(v)), Err(e) => show_error(toast, e.to_string()), } }); }; load(); let set = move |who: String, m: PimShareMode| { spawn_local(async move { match api::pim_share(id, &who, m).await { Ok(s) => { show(toast, i18n::t_fmt(k::PIM_SHARED_OK, &s.user_name)); set_user.set(String::new()); load(); } Err(e) => show_error(toast, e.to_string()), } }); }; let add = move |ev: web_sys::SubmitEvent| { ev.prevent_default(); let who = user.get().trim().to_string(); if !who.is_empty() { set(who, mode.get()); } }; let mode_options = move |current: PimShareMode| { modes .iter() .map(|&m| { view! { } }) .collect::>() }; let mode_options_add = mode_options.clone(); view! {

{i18n::tr(k::PIM_SHARING)}

{i18n::tr(k::PIM_SHARING_HINT)}

{move || match shares.get() { None => view! {

{i18n::tr(k::LOADING)}

}.into_any(), Some(list) if list.is_empty() => { view! {

{i18n::tr(k::PIM_NO_SHARES)}

}.into_any() } Some(list) => { let options = mode_options.clone(); view! {
{list .into_iter() .map(|s| { let (uid, who) = (s.user_id, s.user_name.clone()); view! {
{s.user_name.clone()}
} }) .collect::>()}
} .into_any() } }}
} } #[component] fn LinksSection(info: PimCollectionInfo) -> impl IntoView { let toast = use_context::().expect("toast context"); let id = info.id; let calendar = info.kind == PimCollectionKind::Calendar; let (links, set_links) = signal(Option::>::None); let (busy_only, set_busy_only) = signal(false); let (choice, set_choice) = signal("never".to_string()); let (custom, set_custom) = signal(String::new()); let (password, set_password) = signal(String::new()); let (busy, set_busy) = signal(false); let load = move || { spawn_local(async move { match api::pim_links(id).await { Ok(v) => set_links.set(Some(v)), Err(e) => show_error(toast, e.to_string()), } }); }; load(); let create = move |ev: web_sys::SubmitEvent| { ev.prevent_default(); if busy.get() { return; } set_busy.set(true); let pw = password.get().trim().to_string(); let body = CreatePimLink { busy_only: busy_only.get(), expires_at: expiry_to_rfc3339(&choice.get(), &custom.get()), password: (!pw.is_empty()).then_some(pw), }; spawn_local(async move { match api::pim_create_link(id, body).await { Ok(_) => { set_password.set(String::new()); load(); } Err(e) => show_error(toast, e.to_string()), } set_busy.set(false); }); }; let link_row = move |l: PimLinkInfo| { let full = server_url(&l.path); // `webcal://` opens the calendar app; the transport stays HTTP(S). let webcal = full .strip_prefix("https://") .or_else(|| full.strip_prefix("http://")) .map(|rest| format!("webcal://{rest}")); let mut meta = vec![if l.busy_only { i18n::t(k::PIM_LINK_BUSY).to_string() } else { i18n::t(k::PIM_LINK_FULL).to_string() }]; if let Some(e) = &l.expires_at { meta.push(i18n::t_fmt(k::META_EXPIRES, &format_date(e))); } if l.has_password { meta.push(i18n::t(k::SHARE_PROTECTED).to_string()); } let link_id = l.id; let copy = full.clone(); view! { } }; view! {

{i18n::tr(k::PIM_LINKS)}

{move || match calendar { true => i18n::t(k::PIM_LINKS_HINT), false => i18n::t(k::PIM_LINKS_HINT_CARDS), }}

{move || match links.get() { None => view! {

{i18n::tr(k::LOADING)}

}.into_any(), Some(list) if list.is_empty() => { view! {

{i18n::tr(k::PIM_NO_LINKS)}

}.into_any() } Some(list) => view! {
{list.into_iter().map(link_row).collect::>()}
} .into_any(), }}
} } /// A file name for an export: the collection name without path separators. fn export_file(info: &PimCollectionInfo) -> String { let ext = match info.kind { PimCollectionKind::Calendar => "ics", PimCollectionKind::Addressbook => "vcf", }; let base: String = display_name(info) .chars() .map(|c| { if matches!(c, '/' | '\\' | ':') { '_' } else { c } }) .collect(); format!("{}.{ext}", base.trim()) } #[component] fn ExportSection( info: PimCollectionInfo, me: ReadSignal>, set_dialog: WriteSignal>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let id = info.id; let file = export_file(&info); let download = { let file = file.clone(); move |_| api::trigger_download(&api::pim_export_url(id), &file) }; let save = move |_| { let roots: Vec<_> = me.with_untracked(|m| { m.as_ref() .map(|m| { m.roots .iter() .filter(|r| r.mode.is_writable()) .cloned() .collect() }) .unwrap_or_default() }); let Some(first) = roots.first().map(|r| r.id) else { show_error(toast, i18n::t(k::PIM_NO_WRITABLE_FOLDER)); return; }; let file = file.clone(); set_dialog.set(Some(Dialog::Picker { title: i18n::t(k::PIM_SAVE_TO_FOLDER).to_string(), confirm: i18n::t(k::PIM_SAVE_HERE).to_string(), roots, root: first, dir: String::new(), for_write: false, on_pick: Callback::new(move |(root, dir): (i64, String)| { let path = match dir.is_empty() { true => file.clone(), false => format!("{dir}/{file}"), }; spawn_local(async move { match api::pim_export_to_root(id, root, &path).await { Ok(_) => show(toast, i18n::t_fmt(k::PIM_EXPORTED, &path)), Err(e) => show_error(toast, e.to_string()), } }); }), })); }; view! {

{i18n::tr(k::PIM_EXPORT)}

} } #[component] fn DeleteSection( info: PimCollectionInfo, on_close: Callback<()>, on_changed: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let (confirm, set_confirm) = signal(false); let id = info.id; let lent = info.mode.is_some(); let name = display_name(&info); let run = Callback::new(move |_| { set_confirm.set(false); spawn_local(async move { match api::pim_delete_collection(id).await { Ok(_) => { show( toast, match lent { true => i18n::t(k::PIM_REMOVED), false => i18n::t(k::DELETED), }, ); on_changed.run(()); on_close.run(()); } Err(e) => show_error(toast, e.to_string()), } }); }); view! {
{match (lent, info.is_default) { (_, true) => view! {

{i18n::tr(k::PIM_DEFAULT_HINT)}

}.into_any(), (true, _) => view! { } .into_any(), (false, _) => view! { } .into_any(), }}
i18n::t(k::PIM_REMOVE_MSG).to_string(), false => i18n::t(k::PIM_DELETE_MSG).to_string(), } submit=match lent { true => i18n::t(k::PIM_REMOVE_FROM_LIST).to_string(), false => i18n::t(k::DELETE).to_string(), } danger=!lent on_submit=run on_close=Callback::new(move |_| set_confirm.set(false)) /> } } /// How to set up a calendar or contacts app against this server. #[component] fn ConnectDialog( me: ReadSignal>, collections: ReadSignal>>, on_close: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let user = me.with_untracked(|m| { m.as_ref() .and_then(|m| m.user.as_ref()) .map(|u| u.name.clone()) .unwrap_or_default() }); let app_passwords = move |_| { on_close.run(()); navigate(&Location { section: Section::Settings, path: vec!["security".to_string()], ..Location::root() }); }; view! {

{i18n::tr(k::PIM_SERVER_ADDRESS)}

{url_row(server_url("/"), toast)}

{i18n::tr(k::PIM_SERVER_ADDRESS_HINT)}

{url_row(server_url(&format!("{}/", api_types::PIM)), toast)}

{i18n::tr(k::PIM_USER)}

{url_row(user, toast)}

{i18n::tr(k::PIM_PASSWORD_HINT)}

{i18n::tr(k::PIM_CLIENTS)}

  • {i18n::tr(k::PIM_HINT_APPLE)}
  • {i18n::tr(k::PIM_HINT_DAVX5)}
  • {i18n::tr(k::PIM_HINT_THUNDERBIRD)}
  • {i18n::tr(k::PIM_HINT_GNOME)}

{i18n::tr(k::PIM_COLLECTION_URLS)}

{move || { collections .get() .unwrap_or_default() .into_iter() .map(|c| { let url = server_url(&c.url); let copy = url.clone(); view! {
{display_name(&c)}
} }) .collect::>() }}
} } /// Whether a file name is one the import takes, and as what. pub fn import_kind(name: &str) -> Option { let lower = name.to_ascii_lowercase(); if lower.ends_with(".ics") || lower.ends_with(".ical") || lower.ends_with(".ifb") { Some(PimCollectionKind::Calendar) } else if lower.ends_with(".vcf") || lower.ends_with(".vcard") { Some(PimCollectionKind::Addressbook) } else { None } } /// Import a file of a root into one of the user's calendars or address /// books, after a look at what it holds. #[component] pub fn PimImportDialog( root_id: i64, path: String, name: String, on_close: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let kind = import_kind(&name).unwrap_or(PimCollectionKind::Calendar); let (targets, set_targets) = signal(Option::>::None); let (preview, set_preview) = signal(Option::>::None); let (target, set_target) = signal(Option::::None); let (busy, set_busy) = signal(false); let (result, set_result) = signal(Option::::None); { let path = path.clone(); spawn_local(async move { let tz = browser_tz(); match api::pim_preview(root_id, &path, &tz).await { Ok(p) => set_preview.set(Some(Ok(p))), Err(e) => set_preview.set(Some(Err(e.to_string()))), } match api::pim_collections().await { Ok(list) => { let list: Vec<_> = list .into_iter() .filter(|c| c.kind == kind && writable(c)) .collect(); set_target.set(list.first().map(|c| c.id)); set_targets.set(Some(list)); } Err(e) => { show_error(toast, e.to_string()); set_targets.set(Some(Vec::new())); } } }); } let run = move |_| { let (Some(id), false) = (target.get(), busy.get()) else { return; }; set_busy.set(true); let path = path.clone(); spawn_local(async move { match api::pim_import(id, root_id, &path).await { Ok(r) => set_result.set(Some(r)), Err(e) => show_error(toast, e.to_string()), } set_busy.set(false); }); }; let summary = move |p: &PimPreview| { let mut parts = vec![match p.kind { PimCollectionKind::Calendar => i18n::t_fmt(k::PIM_FILE_EVENTS, &p.total.to_string()), PimCollectionKind::Addressbook => { i18n::t_fmt(k::PIM_FILE_CONTACTS, &p.total.to_string()) } }]; if p.invalid > 0 { parts.push(i18n::t_fmt(k::PIM_FILE_INVALID, &p.invalid.to_string())); } parts.join(" · ") }; let sample = move |p: &PimPreview| { let names: Vec = match p.kind { PimCollectionKind::Calendar => p .events .iter() .take(5) .map(|e| { let when = e.start.as_deref().map(|s| match e.all_day { true => s[..10.min(s.len())].to_string(), false => format_date(s), }); let what = e.summary.clone().unwrap_or_else(|| "—".to_string()); match when { Some(w) => format!("{w} · {what}"), None => what, } }) .collect(), PimCollectionKind::Addressbook => p .contacts .iter() .take(5) .map(|c| match &c.email { Some(e) => format!("{} · {e}", c.full_name), None => c.full_name.clone(), }) .collect(), }; let shown = names.len(); let rest = p.total.saturating_sub(p.invalid).saturating_sub(shown); view! {
    {names.into_iter().map(|n| view! {
  • {n}
  • }).collect::>()} {(rest > 0).then(|| view! {
  • {i18n::t_fmt(k::AND_MORE, &rest.to_string())}
  • })}
} }; view! { {move || match result.get() { Some(r) => { let skipped = r.skipped.clone(); let more = r.skipped_total.saturating_sub(skipped.len()); view! { {(r.skipped_total > 0).then(|| view! {
    {skipped .into_iter() .map(|s| view! {
  • {format!("{} · {}", s.uid.unwrap_or_else(|| "—".to_string()), s.reason)}
  • }) .collect::>()} {(more > 0).then(|| view! {
  • {i18n::t_fmt(k::AND_MORE, &more.to_string())}
  • })}
})} } .into_any() } None => view! { {move || match preview.get() { None => view! {

{i18n::tr(k::LOADING)}

}.into_any(), Some(Err(e)) => view! {

{e}

}.into_any(), Some(Ok(p)) => view! { {sample(&p)} } .into_any(), }} {move || match targets.get() { None => None, Some(list) if list.is_empty() => Some(view! {

{i18n::tr(k::PIM_NO_TARGET)}

}.into_any()), Some(list) => Some(view! { }.into_any()), }} } .into_any(), }}
} }