contacts.rs
| 1 | //! The contacts section's main area: a searchable list of the visible |
| 2 | //! address books and the details of one contact. |
| 3 | |
| 4 | use std::collections::HashSet; |
| 5 | |
| 6 | use leptos::prelude::*; |
| 7 | use wasm_bindgen_futures::spawn_local; |
| 8 | |
| 9 | use crate::api::{self, PimCollectionInfo, PimContact, PimContactDetail, PimObjectDetail}; |
| 10 | use crate::components::icon::icon_svg; |
| 11 | use crate::i18n::{self, k}; |
| 12 | use crate::icons::IconName; |
| 13 | use crate::router::{self, Location}; |
| 14 | use crate::util::intl; |
| 15 | use crate::views::calendar::linkified; |
| 16 | use crate::views::pim::{browser_tz, display_name, safe_color}; |
| 17 | |
| 18 | /// The open contact, as the URL names it: `{collection}/{name}`. |
| 19 | fn parse_open(s: &str) -> Option<(i64, String)> { |
| 20 | let (id, name) = s.split_once('/')?; |
| 21 | Some((id.parse().ok()?, name.to_string())) |
| 22 | } |
| 23 | |
| 24 | /// Up to two initials for a contact without photo. |
| 25 | fn initials(name: &str) -> String { |
| 26 | name.split_whitespace() |
| 27 | .filter_map(|w| w.chars().next()) |
| 28 | .take(2) |
| 29 | .flat_map(char::to_uppercase) |
| 30 | .collect() |
| 31 | } |
| 32 | |
| 33 | /// A vCard date: `1980-03-15`, `19800315`, or without year `--03-15` and |
| 34 | /// `--0315`. `None` when it is none of these. |
| 35 | pub fn vcard_date(s: &str) -> Option<(Option<i32>, u32, u32)> { |
| 36 | let s = s.split('T').next()?.replace('-', ""); |
| 37 | let (year, md) = match s.len() { |
| 38 | 8 => (Some(s[..4].parse().ok()?), &s[4..]), |
| 39 | 4 => (None, &s[..]), |
| 40 | _ => return None, |
| 41 | }; |
| 42 | let (m, d) = (md[..2].parse().ok()?, md[2..].parse().ok()?); |
| 43 | ((1..=12).contains(&m) && (1..=31).contains(&d)).then_some((year, m, d)) |
| 44 | } |
| 45 | |
| 46 | fn fmt_vcard_date(s: &str) -> String { |
| 47 | let Some((year, m, d)) = vcard_date(s) else { |
| 48 | return s.to_string(); |
| 49 | }; |
| 50 | // A leap year, so February 29 exists when the year is unknown. |
| 51 | let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64( |
| 52 | js_sys::Date::utc(f64::from(year.unwrap_or(2000)), f64::from(m - 1)) |
| 53 | + f64::from(d - 1) * 86_400_000.0, |
| 54 | )); |
| 55 | match year { |
| 56 | Some(_) => intl( |
| 57 | &date, |
| 58 | &[ |
| 59 | ("timeZone", "UTC"), |
| 60 | ("day", "numeric"), |
| 61 | ("month", "long"), |
| 62 | ("year", "numeric"), |
| 63 | ], |
| 64 | ), |
| 65 | None => intl( |
| 66 | &date, |
| 67 | &[("timeZone", "UTC"), ("day", "numeric"), ("month", "long")], |
| 68 | ), |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /// Initials, covered by the photo when there is one. A photo that fails to |
| 73 | /// load hides itself and leaves the initials. |
| 74 | fn avatar(name: &str, photo: Option<String>, class: &'static str) -> impl IntoView + use<> { |
| 75 | view! { |
| 76 | <span class=format!("ct-avatar ct-initials {class}") aria-hidden="true"> |
| 77 | {initials(name)} |
| 78 | {photo.map(|src| view! { |
| 79 | <img |
| 80 | class="ct-photo" |
| 81 | src=src |
| 82 | alt="" |
| 83 | loading="lazy" |
| 84 | on:error=|ev| { |
| 85 | let _ = event_target::<web_sys::HtmlElement>(&ev) |
| 86 | .style() |
| 87 | .set_property("display", "none"); |
| 88 | } |
| 89 | /> |
| 90 | })} |
| 91 | </span> |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | #[component] |
| 96 | pub fn ContactsMain( |
| 97 | collections: ReadSignal<Option<Vec<PimCollectionInfo>>>, |
| 98 | hidden: RwSignal<HashSet<i64>>, |
| 99 | loc: ReadSignal<Location>, |
| 100 | ) -> impl IntoView { |
| 101 | let query = Memo::new(move |_| loc.with(|l| l.search.clone())); |
| 102 | let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(parse_open))); |
| 103 | let input = RwSignal::new(query.get_untracked()); |
| 104 | let timer = StoredValue::new_local(None::<gloo_timers::callback::Timeout>); |
| 105 | let typed = move |v: String| { |
| 106 | input.set(v.clone()); |
| 107 | // One request per pause in typing, and no history entry per key. |
| 108 | timer.set_value(Some(gloo_timers::callback::Timeout::new(300, move || { |
| 109 | let mut l = loc.get_untracked(); |
| 110 | l.search = v.trim().to_string(); |
| 111 | router::replace(&l); |
| 112 | }))); |
| 113 | }; |
| 114 | |
| 115 | let contacts = RwSignal::new(Option::<Result<Vec<PimContact>, String>>::None); |
| 116 | let seq = StoredValue::new(0u32); |
| 117 | Effect::new(move |_| { |
| 118 | collections.track(); |
| 119 | let q = query.get(); |
| 120 | let n = seq.get_value() + 1; |
| 121 | seq.set_value(n); |
| 122 | spawn_local(async move { |
| 123 | let r = api::pim_contacts(&q).await; |
| 124 | if seq.get_value() == n { |
| 125 | let _ = contacts.try_set(Some(r.map_err(|e| e.to_string()))); |
| 126 | } |
| 127 | }); |
| 128 | }); |
| 129 | |
| 130 | let detail = RwSignal::new(Option::<Result<PimContactDetail, String>>::None); |
| 131 | Effect::new(move |_| { |
| 132 | let Some((id, name)) = opened.get() else { |
| 133 | detail.set(None); |
| 134 | return; |
| 135 | }; |
| 136 | detail.set(None); |
| 137 | spawn_local(async move { |
| 138 | let r = match api::pim_object(id, &name, None, &browser_tz()).await { |
| 139 | Ok(PimObjectDetail::Contact(c)) => Ok(c), |
| 140 | Ok(PimObjectDetail::Event(_)) => Err(i18n::t(k::PIM_NOT_CONTACT).to_string()), |
| 141 | Err(e) => Err(e.to_string()), |
| 142 | }; |
| 143 | if opened.get_untracked() == Some((id, name)) { |
| 144 | let _ = detail.try_set(Some(r)); |
| 145 | } |
| 146 | }); |
| 147 | }); |
| 148 | |
| 149 | let list = move || { |
| 150 | let open = opened.get(); |
| 151 | match contacts.get() { |
| 152 | None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(), |
| 153 | Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(), |
| 154 | Some(Ok(all)) => { |
| 155 | let rows: Vec<_> = hidden.with(|h| { |
| 156 | all.into_iter() |
| 157 | .filter(|c| !h.contains(&c.collection_id)) |
| 158 | .collect() |
| 159 | }); |
| 160 | if rows.is_empty() { |
| 161 | let msg = match query.get().is_empty() { |
| 162 | true => k::PIM_NO_CONTACTS, |
| 163 | false => k::PIM_NO_MATCH, |
| 164 | }; |
| 165 | return view! { |
| 166 | <div class="empty-state"> |
| 167 | {icon_svg(IconName::Contacts, "empty-glyph")} |
| 168 | <p class="muted">{i18n::t(msg)}</p> |
| 169 | </div> |
| 170 | } |
| 171 | .into_any(); |
| 172 | } |
| 173 | rows.into_iter() |
| 174 | .map(|c| { |
| 175 | let key = (c.collection_id, c.name.clone()); |
| 176 | let selected = open.as_ref() == Some(&key); |
| 177 | let param = format!("{}/{}", c.collection_id, c.name); |
| 178 | let meta: Vec<String> = [c.org.clone(), c.email.clone(), c.phone.clone()] |
| 179 | .into_iter() |
| 180 | .flatten() |
| 181 | .collect(); |
| 182 | view! { |
| 183 | <button |
| 184 | class="ct-row" |
| 185 | class:ct-sel=selected |
| 186 | aria-current=selected.then_some("true") |
| 187 | on:click=move |_| router::set_open(¶m) |
| 188 | > |
| 189 | {avatar( |
| 190 | &c.full_name, |
| 191 | c.has_photo.then(|| api::pim_photo_url(c.collection_id, &c.name)), |
| 192 | "", |
| 193 | )} |
| 194 | <span class="ct-row-main"> |
| 195 | <span class="ct-name"> |
| 196 | <span class="ct-name-text">{c.full_name.clone()}</span> |
| 197 | {c.is_group.then(|| view! { <span class="badge">{i18n::t(k::PIM_GROUP)}</span> })} |
| 198 | </span> |
| 199 | {(!meta.is_empty()).then(|| view! { <span class="ct-meta">{meta.join(" · ")}</span> })} |
| 200 | </span> |
| 201 | </button> |
| 202 | } |
| 203 | }) |
| 204 | .collect::<Vec<_>>() |
| 205 | .into_any() |
| 206 | } |
| 207 | } |
| 208 | }; |
| 209 | |
| 210 | let book = move |id: i64| { |
| 211 | collections.with(|c| { |
| 212 | c.iter() |
| 213 | .flatten() |
| 214 | .find(|c| c.id == id) |
| 215 | .map(|c| (display_name(c), safe_color(c.color.as_deref()))) |
| 216 | }) |
| 217 | }; |
| 218 | let pane = move || match (opened.get(), detail.get()) { |
| 219 | (None, _) => view! { |
| 220 | <div class="empty-state ct-placeholder"> |
| 221 | {icon_svg(IconName::Contacts, "empty-glyph")} |
| 222 | <p class="muted">{i18n::t(k::PIM_PICK_CONTACT)}</p> |
| 223 | </div> |
| 224 | } |
| 225 | .into_any(), |
| 226 | (Some(_), None) => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(), |
| 227 | (Some(_), Some(Err(e))) => view! { <div class="card error-card">{e}</div> }.into_any(), |
| 228 | (Some(_), Some(Ok(c))) => { |
| 229 | let sub: Vec<String> = [c.title.clone(), c.org.clone()] |
| 230 | .into_iter() |
| 231 | .flatten() |
| 232 | .collect(); |
| 233 | let head_photo = avatar(&c.full_name, c.photo_url.clone(), "ct-avatar-lg"); |
| 234 | let labeled = |key: &'static str, |
| 235 | items: Vec<api_types::PimLabeled>, |
| 236 | href: fn(&str) -> Option<String>| { |
| 237 | (!items.is_empty()).then(|| view! { |
| 238 | <dt>{i18n::t(key)}</dt> |
| 239 | <dd> |
| 240 | {items |
| 241 | .into_iter() |
| 242 | .map(|l| { |
| 243 | let label = l.label.clone(); |
| 244 | let value = match href(&l.value) { |
| 245 | Some(h) => { |
| 246 | // mailto: and tel: open an app, not a tab. |
| 247 | let tab = h.starts_with("http").then_some("_blank"); |
| 248 | view! { |
| 249 | <a href=h target=tab rel="noopener noreferrer">{l.value.clone()}</a> |
| 250 | } |
| 251 | .into_any() |
| 252 | } |
| 253 | None => view! { <span class="cal-pre">{l.value.clone()}</span> }.into_any(), |
| 254 | }; |
| 255 | view! { |
| 256 | <div class="ct-value"> |
| 257 | {value} |
| 258 | {label.map(|t| view! { <span class="muted ct-label">{t}</span> })} |
| 259 | </div> |
| 260 | } |
| 261 | }) |
| 262 | .collect::<Vec<_>>()} |
| 263 | </dd> |
| 264 | }) |
| 265 | }; |
| 266 | let web = |v: &str| { |
| 267 | (v.starts_with("https://") || v.starts_with("http://")).then(|| v.to_string()) |
| 268 | }; |
| 269 | let collection = book(c.collection_id); |
| 270 | view! { |
| 271 | <button class="btn btn-sm ct-back" on:click=move |_| router::close_open()> |
| 272 | {icon_svg(IconName::ChevronRight, "ic-btn flip")} |
| 273 | {i18n::t(k::PIM_BACK)} |
| 274 | </button> |
| 275 | <div class="ct-head"> |
| 276 | {head_photo} |
| 277 | <div class="ct-head-text"> |
| 278 | <h2 class="ct-title">{c.full_name.clone()}</h2> |
| 279 | {(!sub.is_empty()).then(|| view! { <p class="muted">{sub.join(" · ")}</p> })} |
| 280 | </div> |
| 281 | </div> |
| 282 | <dl class="cal-facts"> |
| 283 | {labeled(k::PIM_EMAIL, c.emails.clone(), |v| Some(format!("mailto:{v}")))} |
| 284 | {labeled(k::PIM_PHONE, c.phones.clone(), |v| { |
| 285 | Some(format!("tel:{}", v.chars().filter(|c| !c.is_whitespace()).collect::<String>())) |
| 286 | })} |
| 287 | {labeled(k::PIM_POSTAL, c.addresses.clone(), |_| None)} |
| 288 | {labeled(k::PIM_WEBSITE, c.urls.clone(), web)} |
| 289 | {c.birthday.clone().map(|b| view! { |
| 290 | <dt>{i18n::t(k::PIM_BIRTHDAY)}</dt> |
| 291 | <dd>{fmt_vcard_date(&b)}</dd> |
| 292 | })} |
| 293 | {c.anniversary.clone().map(|b| view! { |
| 294 | <dt>{i18n::t(k::PIM_ANNIVERSARY)}</dt> |
| 295 | <dd>{fmt_vcard_date(&b)}</dd> |
| 296 | })} |
| 297 | {(!c.members.is_empty()).then(|| view! { |
| 298 | <dt>{i18n::t(k::PIM_MEMBERS)}</dt> |
| 299 | <dd>{c.members.join(", ")}</dd> |
| 300 | })} |
| 301 | {collection.map(|(n, color)| view! { |
| 302 | <dt>{i18n::t(k::PIM_ADDRESS_BOOK)}</dt> |
| 303 | <dd><span class="cal-dot" style=format!("--c: {color}")></span>" "{n}</dd> |
| 304 | })} |
| 305 | </dl> |
| 306 | {c.note.clone().filter(|n| !n.trim().is_empty()).map(|n| view! { |
| 307 | <h3 class="pim-section-title">{i18n::t(k::PIM_NOTE)}</h3> |
| 308 | <div class="cal-desc cal-pre">{linkified(&n)} |
| 309 | </div> |
| 310 | })} |
| 311 | } |
| 312 | .into_any() |
| 313 | } |
| 314 | }; |
| 315 | |
| 316 | view! { |
| 317 | <div class="ct" class:ct-has-open=move || opened.get().is_some()> |
| 318 | <div class="ct-list"> |
| 319 | <div class="ct-search"> |
| 320 | {icon_svg(IconName::Search, "ct-search-icon")} |
| 321 | <input |
| 322 | type="search" |
| 323 | placeholder=i18n::tr(k::PIM_SEARCH_CONTACTS) |
| 324 | aria-label=i18n::tr(k::PIM_SEARCH_CONTACTS) |
| 325 | prop:value=move || input.get() |
| 326 | on:input=move |ev| typed(event_target_value(&ev)) |
| 327 | /> |
| 328 | </div> |
| 329 | <div class="ct-rows">{list}</div> |
| 330 | </div> |
| 331 | <div class="ct-detail">{pane}</div> |
| 332 | </div> |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | #[cfg(test)] |
| 337 | mod tests { |
| 338 | use super::*; |
| 339 | |
| 340 | #[test] |
| 341 | fn vcard_dates() { |
| 342 | assert_eq!(vcard_date("1980-03-15"), Some((Some(1980), 3, 15))); |
| 343 | assert_eq!(vcard_date("19800315"), Some((Some(1980), 3, 15))); |
| 344 | assert_eq!(vcard_date("--03-15"), Some((None, 3, 15))); |
| 345 | assert_eq!(vcard_date("--0229"), Some((None, 2, 29))); |
| 346 | assert_eq!( |
| 347 | vcard_date("1980-03-15T00:00:00Z"), |
| 348 | Some((Some(1980), 3, 15)) |
| 349 | ); |
| 350 | assert_eq!(vcard_date("sometime"), None); |
| 351 | assert_eq!(vcard_date("1980-13-01"), None); |
| 352 | assert_eq!(initials("anna maria berg"), "AM"); |
| 353 | assert_eq!(initials("ümit"), "Ü"); |
| 354 | assert_eq!(parse_open("3/a/b.vcf"), Some((3, "a/b.vcf".to_string()))); |
| 355 | } |
| 356 | } |
| 357 |