//! 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, PimShareCandidate, 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, local_date_time, storage_get, storage_set}; use crate::views::calendar::CalendarMain; use crate::views::contacts::ContactsMain; use crate::views::dialogs::ConfirmDialog; use crate::views::shares::expiry_to_rfc3339; /// Colors offered for a collection; a custom one comes from the color input. /// No gray: the server paints its generated collections gray. const PALETTE: [&str; 8] = [ "#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#ec4899", "#f97316", ]; /// The first palette color no listed collection uses, so a new one stands /// out. A collection without color shows the first palette color. fn next_color(collections: &[PimCollectionInfo]) -> String { let used: HashSet = collections .iter() .filter(|c| !c.generated) .map(|c| { c.color .as_deref() .unwrap_or(PALETTE[0]) .to_ascii_lowercase() }) .collect(); PALETTE .iter() .find(|p| !used.contains(**p)) .unwrap_or(&PALETTE[used.len() % PALETTE.len()]) .to_string() } /// The collections the lists do not show, by id. const HIDDEN_KEY: &str = "dovenest.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(), } } /// Shows only `id` of the collections `ids`. On the only shown one, shows /// them all again. Collections of the other kind keep their state. fn solo(hidden: &mut HashSet, ids: &[i64], id: i64) { let alone = !hidden.contains(&id) && ids.iter().all(|o| *o == id || hidden.contains(o)); for &o in ids { if alone || o == id { hidden.remove(&o); } else { hidden.insert(o); } } } /// 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) } pub 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), } } /// `webcal://` opens the calendar app; the transport stays HTTP(S). pub fn webcal(url: &str) -> Option { url.strip_prefix("https://") .or_else(|| url.strip_prefix("http://")) .map(|rest| format!("webcal://{rest}")) } /// 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()) } /// A readable form of the precondition an import skip names. fn skip_reason(reason: &str) -> String { let key = match reason { "valid-calendar-data" | "valid-address-data" | "valid-calendar-object-resource" => { k::PIM_SKIP_INVALID } "supported-calendar-component" => k::PIM_SKIP_COMPONENT, "unique-scheduling-object-resource" => k::PIM_SKIP_UID, "max-resource-size" => k::PIM_SKIP_SIZE, _ => return reason.to_string(), }; i18n::t(key).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>, loc: ReadSignal, ) -> 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_seq = StoredValue::new(0u32); let reload = Callback::new(move |_| { set_load_err.set(None); let seq = reload_seq.get_value() + 1; reload_seq.set_value(seq); spawn_local(async move { let r = api::pim_collections().await; if reload_seq.try_get_value() != Some(seq) { return; } match r { Ok(v) => { let _ = set_collections.try_set(Some(v)); } Err(e) => { let _ = set_load_err.try_set(Some(e.to_string())); } } }); }); reload.run(()); // An open event or contact must belong to a shown collection. Only a // change of the shown set closes it, so a link to a hidden one still opens. Effect::new(move |prev: Option<()>| { let h = hidden.get(); if prev.is_none() { return; } let open = loc.with_untracked(|l| { l.open .as_deref() .and_then(|o| o.split('/').next()?.parse::().ok()) }); if open.is_some_and(|id| h.contains(&id)) { crate::router::close_open(); } }); let main = match kind { PimCollectionKind::Calendar => { let week_start = Signal::derive(move || { me.with(|m| { m.as_ref() .and_then(|m| m.user.as_ref()) .map_or(1, |u| u32::from(u.week_start)) }) }); view! {