//! The contacts section's main area: a searchable list of the visible //! address books and the details of one contact. use std::collections::HashSet; use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::{ self, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail, PimObjectDetail, }; use crate::components::icon::icon_svg; use crate::i18n::{self, k}; use crate::icons::IconName; use crate::router::{self, Location}; use crate::util::intl; use crate::views::calendar::linkified; use crate::views::pim::{browser_tz, display_name, safe_color}; /// The open contact, as the URL names it: `{collection}/{name}`. fn parse_open(s: &str) -> Option<(i64, String)> { let (id, name) = s.split_once('/')?; Some((id.parse().ok()?, name.to_string())) } /// Up to two initials for a contact without photo. fn initials(name: &str) -> String { // Words that start with a digit or bracket, like "(4th", give no letter. name.split_whitespace() .filter_map(|w| w.chars().next().filter(|c| c.is_alphabetic())) .take(2) .flat_map(char::to_uppercase) .collect() } /// A vCard date: `1980-03-15`, `19800315`, or without year `--03-15` and /// `--0315`. `None` when it is none of these. pub fn vcard_date(s: &str) -> Option<(Option, u32, u32)> { let s = s.split('T').next()?.replace('-', ""); // Byte slicing below needs ASCII; the value comes from any client. if !s.bytes().all(|b| b.is_ascii_digit()) { return None; } let (year, md) = match s.len() { 8 => (Some(s[..4].parse().ok()?), &s[4..]), 4 => (None, &s[..]), _ => return None, }; let (m, d) = (md[..2].parse().ok()?, md[2..].parse().ok()?); // No year: February 29 is allowed. let leap = year.is_none_or(|y| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)); let days = match m { 2 if leap => 29, 2 => 28, 4 | 6 | 9 | 11 => 30, _ => 31, }; ((1..=12).contains(&m) && (1..=days).contains(&d)).then_some((year, m, d)) } fn fmt_vcard_date(s: &str) -> String { let Some((year, m, d)) = vcard_date(s) else { return s.to_string(); }; // `Date.UTC` maps years below 100 to 19xx, so set the year separately. A // leap year, so February 29 exists when the year is unknown. let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0)); date.set_utc_full_year_with_month_date(year.unwrap_or(2000) as u32, m as i32 - 1, d as i32); match year { Some(_) => intl( &date, &[ ("timeZone", "UTC"), ("day", "numeric"), ("month", "long"), ("year", "numeric"), ], ), None => intl( &date, &[("timeZone", "UTC"), ("day", "numeric"), ("month", "long")], ), } } /// vCard TYPE values in the UI language. Unknown ones stay as written. fn type_label(t: &str) -> String { let key = match t.to_ascii_lowercase().as_str() { "work" => k::CT_TYPE_WORK, "home" => k::CT_TYPE_HOME, "cell" | "mobile" => k::CT_TYPE_CELL, "fax" => k::CT_TYPE_FAX, "other" => k::CT_TYPE_OTHER, _ => return t.to_string(), }; i18n::t(key).to_string() } /// Initials, covered by the photo when there is one. A photo that fails to /// load hides itself and leaves the initials. fn avatar(name: &str, photo: Option, class: &'static str) -> impl IntoView + use<> { view! { } } #[component] pub fn ContactsMain( collections: ReadSignal>>, hidden: RwSignal>, loc: ReadSignal, ) -> impl IntoView { let query = Memo::new(move |_| loc.with(|l| l.search.clone())); let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(parse_open))); let input = RwSignal::new(query.get_untracked()); let timer = StoredValue::new_local(None::); let typed = move |v: String| { input.set(v.clone()); // One request per pause in typing, and no history entry per key. timer.set_value(Some(gloo_timers::callback::Timeout::new(300, move || { let mut l = loc.get_untracked(); l.search = v.trim().to_string(); router::replace(&l); }))); }; // Back and Forward change the query from outside the box. Effect::new(move |_| { let q = query.get(); if input.get_untracked().trim() != q { input.set(q); } }); let contacts = RwSignal::new(Option::, String>>::None); let seq = StoredValue::new(0u32); Effect::new(move |_| { let q = query.get(); let ids = collections.with(|c| { hidden.with(|h| { c.as_ref().map(|c| { c.iter() .filter(|c| c.kind == PimCollectionKind::Addressbook && !h.contains(&c.id)) .map(|c| c.id) .collect::>() }) }) }); let Some(ids) = ids else { return }; let n = seq.get_value() + 1; seq.set_value(n); spawn_local(async move { let r = api::pim_contacts(&q, &ids).await; if seq.try_get_value() != Some(n) { return; } // A hidden book's contact stays open when it comes from a link. if let (Ok(list), Some((id, name))) = (&r, opened.get_untracked()) && !hidden.with_untracked(|h| h.contains(&id)) && !list.iter().any(|c| c.collection_id == id && c.name == name) { router::close_open(); } let _ = contacts.try_set(Some(r.map_err(|e| e.to_string()))); }); }); let detail = RwSignal::new(Option::>::None); Effect::new(move |_| { let Some((id, name)) = opened.get() else { detail.set(None); return; }; detail.set(None); spawn_local(async move { let r = match api::pim_object(id, &name, None, &browser_tz()).await { Ok(PimObjectDetail::Contact(c)) => Ok(c), Ok(PimObjectDetail::Event(_)) => Err(i18n::t(k::PIM_NOT_CONTACT).to_string()), Err(e) => Err(e.to_string()), }; if opened.try_get_untracked() == Some(Some((id, name))) { let _ = detail.try_set(Some(r)); } }); }); let list = move || { match contacts.get() { None => view! {

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

}.into_any(), Some(Err(e)) => view! {
{e}
}.into_any(), Some(Ok(all)) => { let rows: Vec<_> = hidden.with(|h| { all.into_iter() .filter(|c| !h.contains(&c.collection_id)) .collect() }); if rows.is_empty() { let msg = match query.get().is_empty() { true => k::PIM_NO_CONTACTS, false => k::PIM_NO_MATCH, }; return view! {
{icon_svg(IconName::Contacts, "empty-glyph")}

{i18n::t(msg)}

} .into_any(); } let colors: std::collections::HashMap = collections.with(|c| { c.iter() .flatten() .map(|c| (c.id, safe_color(c.color.as_deref()))) .collect() }); rows.into_iter() .map(|c| { let book = colors.get(&c.collection_id).cloned(); let key = (c.collection_id, c.name.clone()); // Per row, so opening a contact does not rebuild the list. let selected = move || opened.with(|o| o.as_ref() == Some(&key)); let param = format!("{}/{}", c.collection_id, c.name); let meta: Vec = [c.org.clone(), c.email.clone(), c.phone.clone()] .into_iter() .flatten() .collect(); view! { } }) .collect::>() .into_any() } } }; let book = move |id: i64| { collections.with(|c| { c.iter() .flatten() .find(|c| c.id == id) .map(|c| (display_name(c), safe_color(c.color.as_deref()))) }) }; let pane = move || match (opened.get(), detail.get()) { (None, _) => view! {
{icon_svg(IconName::Contacts, "empty-glyph")}

{i18n::t(k::PIM_PICK_CONTACT)}

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

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

}.into_any(), (Some(_), Some(Err(e))) => view! {
{e}
}.into_any(), (Some(_), Some(Ok(c))) => { let sub: Vec = [c.title.clone(), c.org.clone()] .into_iter() .flatten() .collect(); let head_photo = avatar(&c.full_name, c.photo_url.clone(), "ct-avatar-lg"); let labeled = |key: &'static str, items: Vec, href: fn(&str) -> Option| { (!items.is_empty()).then(|| view! {
{i18n::t(key)}
{items .into_iter() .map(|l| { let label = l.label.as_deref().map(type_label); let value = match href(&l.value) { Some(h) => { // mailto: and tel: open an app, not a tab. let tab = h.starts_with("http").then_some("_blank"); view! { {l.value.clone()} } .into_any() } None => view! { {l.value.clone()} }.into_any(), }; view! {
{value} {label.map(|t| view! { {t} })}
} }) .collect::>()}
}) }; let web = |v: &str| { (v.starts_with("https://") || v.starts_with("http://")).then(|| v.to_string()) }; let collection = book(c.collection_id); view! {
{head_photo}

{c.full_name.clone()}

{(!sub.is_empty()).then(|| view! {

{sub.join(" · ")}

})}
{labeled(k::PIM_EMAIL, c.emails.clone(), |v| { let addr = String::from(js_sys::encode_uri_component(v)).replace("%40", "@"); Some(format!("mailto:{addr}")) })} {labeled(k::PIM_PHONE, c.phones.clone(), |v| { Some(format!("tel:{}", v.chars().filter(|c| !c.is_whitespace()).collect::())) })} {labeled(k::PIM_POSTAL, c.addresses.clone(), |_| None)} {labeled(k::PIM_WEBSITE, c.urls.clone(), web)} {c.birthday.clone().map(|b| view! {
{i18n::t(k::PIM_BIRTHDAY)}
{fmt_vcard_date(&b)}
})} {c.anniversary.clone().map(|b| view! {
{i18n::t(k::PIM_ANNIVERSARY)}
{fmt_vcard_date(&b)}
})} {(!c.members.is_empty()).then(|| view! {
{i18n::t(k::PIM_MEMBERS)}
{c.members.join(", ")}
})} {collection.map(|(n, color)| view! {
{i18n::t(k::PIM_ADDRESS_BOOK)}
" "{n}
})}
{c.note.clone().filter(|n| !n.trim().is_empty()).map(|n| view! {

{i18n::t(k::PIM_NOTE)}

{linkified(&n)}
})} } .into_any() } }; view! {
{list}
{pane}
} } #[cfg(test)] mod tests { use super::*; #[test] fn vcard_dates() { assert_eq!(vcard_date("1980-03-15"), Some((Some(1980), 3, 15))); assert_eq!(vcard_date("19800315"), Some((Some(1980), 3, 15))); assert_eq!(vcard_date("--03-15"), Some((None, 3, 15))); assert_eq!(vcard_date("--0229"), Some((None, 2, 29))); assert_eq!( vcard_date("1980-03-15T00:00:00Z"), Some((Some(1980), 3, 15)) ); assert_eq!(vcard_date("sometime"), None); assert_eq!(vcard_date("1980-13-01"), None); assert_eq!(vcard_date("1980-04-31"), None); assert_eq!(vcard_date("1981-02-29"), None); assert_eq!(vcard_date("1900-02-29"), None); assert_eq!(vcard_date("2000-02-29"), Some((Some(2000), 2, 29))); // Multi-byte values must not panic on byte slicing. assert_eq!(vcard_date("1980年3"), None); assert_eq!(vcard_date("--03é1"), None); assert_eq!(initials("anna maria berg"), "AM"); assert_eq!(initials("ümit"), "Ü"); assert_eq!(initials("Atrium (4th floor)"), "AF"); assert_eq!(parse_open("3/a/b.vcf"), Some((3, "a/b.vcf".to_string()))); } }