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::{ |
| 10 | self, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail, PimObjectDetail, |
| 11 | }; |
| 12 | use crate::components::icon::icon_svg; |
| 13 | use crate::i18n::{self, k}; |
| 14 | use crate::icons::IconName; |
| 15 | use crate::router::{self, Location, Section}; |
| 16 | use crate::util::intl; |
| 17 | use crate::views::calendar::linkified; |
| 18 | use crate::views::pim::{browser_tz, display_name, safe_color}; |
| 19 | |
| 20 | /// The open contact, as the URL names it: `{collection}/{name}`. |
| 21 | fn parse_open(s: &str) -> Option<(i64, String)> { |
| 22 | let (id, name) = s.split_once('/')?; |
| 23 | Some((id.parse().ok()?, name.to_string())) |
| 24 | } |
| 25 | |
| 26 | /// Up to two initials for a contact without photo. |
| 27 | fn initials(name: &str) -> String { |
| 28 | // Words that start with a digit or bracket, like "(4th", give no letter. |
| 29 | name.split_whitespace() |
| 30 | .filter_map(|w| w.chars().next().filter(|c| c.is_alphabetic())) |
| 31 | .take(2) |
| 32 | .flat_map(char::to_uppercase) |
| 33 | .collect() |
| 34 | } |
| 35 | |
| 36 | /// A vCard date: `1980-03-15`, `19800315`, or without year `--03-15` and |
| 37 | /// `--0315`. `None` when it is none of these. |
| 38 | pub fn vcard_date(s: &str) -> Option<(Option<i32>, u32, u32)> { |
| 39 | let s = s.split('T').next()?.replace('-', ""); |
| 40 | // Byte slicing below needs ASCII; the value comes from any client. |
| 41 | if !s.bytes().all(|b| b.is_ascii_digit()) { |
| 42 | return None; |
| 43 | } |
| 44 | let (year, md) = match s.len() { |
| 45 | 8 => (Some(s[..4].parse().ok()?), &s[4..]), |
| 46 | 4 => (None, &s[..]), |
| 47 | _ => return None, |
| 48 | }; |
| 49 | let (m, d) = (md[..2].parse().ok()?, md[2..].parse().ok()?); |
| 50 | // No year: February 29 is allowed. |
| 51 | let leap = year.is_none_or(|y| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)); |
| 52 | let days = match m { |
| 53 | 2 if leap => 29, |
| 54 | 2 => 28, |
| 55 | 4 | 6 | 9 | 11 => 30, |
| 56 | _ => 31, |
| 57 | }; |
| 58 | ((1..=12).contains(&m) && (1..=days).contains(&d)).then_some((year, m, d)) |
| 59 | } |
| 60 | |
| 61 | fn fmt_vcard_date(s: &str) -> String { |
| 62 | let Some((year, m, d)) = vcard_date(s) else { |
| 63 | return s.to_string(); |
| 64 | }; |
| 65 | // `Date.UTC` maps years below 100 to 19xx, so set the year separately. A |
| 66 | // leap year, so February 29 exists when the year is unknown. |
| 67 | let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0)); |
| 68 | date.set_utc_full_year_with_month_date(year.unwrap_or(2000) as u32, m as i32 - 1, d as i32); |
| 69 | match year { |
| 70 | Some(_) => intl( |
| 71 | &date, |
| 72 | &[ |
| 73 | ("timeZone", "UTC"), |
| 74 | ("day", "numeric"), |
| 75 | ("month", "long"), |
| 76 | ("year", "numeric"), |
| 77 | ], |
| 78 | ), |
| 79 | None => intl( |
| 80 | &date, |
| 81 | &[("timeZone", "UTC"), ("day", "numeric"), ("month", "long")], |
| 82 | ), |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// vCard TYPE values in the UI language. Unknown ones stay as written. |
| 87 | fn type_label(t: &str) -> String { |
| 88 | let key = match t.to_ascii_lowercase().as_str() { |
| 89 | "work" => k::CT_TYPE_WORK, |
| 90 | "home" => k::CT_TYPE_HOME, |
| 91 | "cell" | "mobile" => k::CT_TYPE_CELL, |
| 92 | "fax" => k::CT_TYPE_FAX, |
| 93 | "other" => k::CT_TYPE_OTHER, |
| 94 | _ => return t.to_string(), |
| 95 | }; |
| 96 | i18n::t(key).to_string() |
| 97 | } |
| 98 | |
| 99 | /// Initials, covered by the photo when there is one. A photo that fails to |
| 100 | /// load hides itself and leaves the initials. |
| 101 | fn avatar(name: &str, photo: Option<String>, class: &'static str) -> impl IntoView + use<> { |
| 102 | view! { |
| 103 | <span class=format!("ct-avatar ct-initials {class}") aria-hidden="true"> |
| 104 | {initials(name)} |
| 105 | {photo.map(|src| view! { |
| 106 | <img |
| 107 | class="ct-photo" |
| 108 | src=src |
| 109 | alt="" |
| 110 | loading="lazy" |
| 111 | on:error=|ev| { |
| 112 | let _ = event_target::<web_sys::HtmlElement>(&ev) |
| 113 | .style() |
| 114 | .set_property("display", "none"); |
| 115 | } |
| 116 | /> |
| 117 | })} |
| 118 | </span> |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | #[component] |
| 123 | pub fn ContactsMain( |
| 124 | collections: ReadSignal<Option<Vec<PimCollectionInfo>>>, |
| 125 | hidden: RwSignal<HashSet<i64>>, |
| 126 | loc: ReadSignal<Location>, |
| 127 | ) -> impl IntoView { |
| 128 | let query = Memo::new(move |_| loc.with(|l| l.search.clone())); |
| 129 | let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(parse_open))); |
| 130 | let input = RwSignal::new(query.get_untracked()); |
| 131 | let timer = StoredValue::new_local(None::<gloo_timers::callback::Timeout>); |
| 132 | let typed = move |v: String| { |
| 133 | input.set(v.clone()); |
| 134 | // One request per pause in typing, and no history entry per key. |
| 135 | timer.set_value(Some(gloo_timers::callback::Timeout::new(300, move || { |
| 136 | let mut l = loc.get_untracked(); |
| 137 | if l.section != Section::Contacts || l.search == v.trim() { |
| 138 | return; |
| 139 | } |
| 140 | l.search = v.trim().to_string(); |
| 141 | // Back from the open contact would restore the old query. |
| 142 | router::replace_unmarked(&l); |
| 143 | }))); |
| 144 | }; |
| 145 | |
| 146 | // Back and Forward change the query from outside the box. A pending |
| 147 | // timer would undo that. |
| 148 | Effect::new(move |_| { |
| 149 | let q = query.get(); |
| 150 | if input.get_untracked().trim() != q { |
| 151 | timer.set_value(None); |
| 152 | input.set(q); |
| 153 | } |
| 154 | }); |
| 155 | |
| 156 | let contacts = RwSignal::new(Option::<Result<Vec<PimContact>, String>>::None); |
| 157 | let seq = StoredValue::new(0u32); |
| 158 | Effect::new(move |_| { |
| 159 | let q = query.get(); |
| 160 | let ids = collections.with(|c| { |
| 161 | hidden.with(|h| { |
| 162 | c.as_ref().map(|c| { |
| 163 | c.iter() |
| 164 | .filter(|c| c.kind == PimCollectionKind::Addressbook && !h.contains(&c.id)) |
| 165 | .map(|c| c.id) |
| 166 | .collect::<Vec<_>>() |
| 167 | }) |
| 168 | }) |
| 169 | }); |
| 170 | let Some(ids) = ids else { return }; |
| 171 | let n = seq.get_value() + 1; |
| 172 | seq.set_value(n); |
| 173 | spawn_local(async move { |
| 174 | let r = api::pim_contacts(&q, &ids).await; |
| 175 | if seq.try_get_value() != Some(n) { |
| 176 | return; |
| 177 | } |
| 178 | // A hidden book's contact stays open when it comes from a link. |
| 179 | if let (Ok(list), Some((id, name))) = (&r, opened.get_untracked()) |
| 180 | && !hidden.with_untracked(|h| h.contains(&id)) |
| 181 | && !list.iter().any(|c| c.collection_id == id && c.name == name) |
| 182 | { |
| 183 | // Not Back: that entry holds the query from before the typing. |
| 184 | router::clear_open(); |
| 185 | } |
| 186 | let _ = contacts.try_set(Some(r.map_err(|e| e.to_string()))); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | let detail = RwSignal::new(Option::<Result<PimContactDetail, String>>::None); |
| 191 | Effect::new(move |_| { |
| 192 | let Some((id, name)) = opened.get() else { |
| 193 | detail.set(None); |
| 194 | return; |
| 195 | }; |
| 196 | detail.set(None); |
| 197 | spawn_local(async move { |
| 198 | let r = match api::pim_object(id, &name, None, &browser_tz()).await { |
| 199 | Ok(PimObjectDetail::Contact(c)) => Ok(c), |
| 200 | Ok(PimObjectDetail::Event(_)) => Err(i18n::t(k::PIM_NOT_CONTACT).to_string()), |
| 201 | Err(e) => Err(e.to_string()), |
| 202 | }; |
| 203 | if opened.try_get_untracked() == Some(Some((id, name))) { |
| 204 | let _ = detail.try_set(Some(r)); |
| 205 | } |
| 206 | }); |
| 207 | }); |
| 208 | |
| 209 | let list = move || { |
| 210 | match contacts.get() { |
| 211 | None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(), |
| 212 | Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(), |
| 213 | Some(Ok(all)) => { |
| 214 | let rows: Vec<_> = hidden.with(|h| { |
| 215 | all.into_iter() |
| 216 | .filter(|c| !h.contains(&c.collection_id)) |
| 217 | .collect() |
| 218 | }); |
| 219 | if rows.is_empty() { |
| 220 | let msg = match query.get().is_empty() { |
| 221 | true => k::PIM_NO_CONTACTS, |
| 222 | false => k::PIM_NO_MATCH, |
| 223 | }; |
| 224 | return view! { |
| 225 | <div class="empty-state"> |
| 226 | {icon_svg(IconName::Contacts, "empty-glyph")} |
| 227 | <p class="muted">{i18n::t(msg)}</p> |
| 228 | </div> |
| 229 | } |
| 230 | .into_any(); |
| 231 | } |
| 232 | let colors: std::collections::HashMap<i64, String> = collections.with(|c| { |
| 233 | c.iter() |
| 234 | .flatten() |
| 235 | .map(|c| (c.id, safe_color(c.color.as_deref()))) |
| 236 | .collect() |
| 237 | }); |
| 238 | rows.into_iter() |
| 239 | .map(|c| { |
| 240 | let book = colors.get(&c.collection_id).cloned(); |
| 241 | let key = (c.collection_id, c.name.clone()); |
| 242 | // Per row, so opening a contact does not rebuild the list. |
| 243 | let selected = move || opened.with(|o| o.as_ref() == Some(&key)); |
| 244 | let param = format!("{}/{}", c.collection_id, c.name); |
| 245 | let meta: Vec<String> = [c.org.clone(), c.email.clone(), c.phone.clone()] |
| 246 | .into_iter() |
| 247 | .flatten() |
| 248 | .collect(); |
| 249 | view! { |
| 250 | <button |
| 251 | class="ct-row" |
| 252 | class:ct-sel=selected.clone() |
| 253 | aria-current=move || selected().then_some("true") |
| 254 | on:click=move |_| router::set_open(¶m) |
| 255 | > |
| 256 | {avatar( |
| 257 | &c.full_name, |
| 258 | c.has_photo.then(|| api::pim_photo_url(c.collection_id, &c.name)), |
| 259 | "", |
| 260 | )} |
| 261 | <span class="ct-row-main"> |
| 262 | <span class="ct-name"> |
| 263 | <span class="ct-name-text">{c.full_name.clone()}</span> |
| 264 | {c.is_group.then(|| view! { <span class="badge">{i18n::t(k::PIM_GROUP)}</span> })} |
| 265 | </span> |
| 266 | {(!meta.is_empty()).then(|| view! { <span class="ct-meta">{meta.join(" · ")}</span> })} |
| 267 | </span> |
| 268 | {book.map(|b| view! { |
| 269 | <span class="ct-book" style=format!("--swatch: {b}") aria-hidden="true"></span> |
| 270 | })} |
| 271 | </button> |
| 272 | } |
| 273 | }) |
| 274 | .collect::<Vec<_>>() |
| 275 | .into_any() |
| 276 | } |
| 277 | } |
| 278 | }; |
| 279 | |
| 280 | let book = move |id: i64| { |
| 281 | collections.with(|c| { |
| 282 | c.iter() |
| 283 | .flatten() |
| 284 | .find(|c| c.id == id) |
| 285 | .map(|c| (display_name(c), safe_color(c.color.as_deref()))) |
| 286 | }) |
| 287 | }; |
| 288 | let pane = move || match (opened.get(), detail.get()) { |
| 289 | (None, _) => view! { |
| 290 | <div class="empty-state ct-placeholder"> |
| 291 | {icon_svg(IconName::Contacts, "empty-glyph")} |
| 292 | <p class="muted">{i18n::t(k::PIM_PICK_CONTACT)}</p> |
| 293 | </div> |
| 294 | } |
| 295 | .into_any(), |
| 296 | (Some(_), None) => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(), |
| 297 | (Some(_), Some(Err(e))) => view! { <div class="card error-card">{e}</div> }.into_any(), |
| 298 | (Some(_), Some(Ok(c))) => { |
| 299 | let sub: Vec<String> = [c.title.clone(), c.org.clone()] |
| 300 | .into_iter() |
| 301 | .flatten() |
| 302 | .collect(); |
| 303 | let head_photo = avatar(&c.full_name, c.photo_url.clone(), "ct-avatar-lg"); |
| 304 | let labeled = |key: &'static str, |
| 305 | items: Vec<api_types::PimLabeled>, |
| 306 | href: fn(&str) -> Option<String>| { |
| 307 | (!items.is_empty()).then(|| view! { |
| 308 | <dt>{i18n::t(key)}</dt> |
| 309 | <dd> |
| 310 | {items |
| 311 | .into_iter() |
| 312 | .map(|l| { |
| 313 | let label = l.label.as_deref().map(type_label); |
| 314 | let value = match href(&l.value) { |
| 315 | Some(h) => { |
| 316 | // mailto: and tel: open an app, not a tab. |
| 317 | let tab = h.starts_with("http").then_some("_blank"); |
| 318 | view! { |
| 319 | <a href=h target=tab rel="noopener noreferrer">{l.value.clone()}</a> |
| 320 | } |
| 321 | .into_any() |
| 322 | } |
| 323 | None => view! { <span class="cal-pre">{l.value.clone()}</span> }.into_any(), |
| 324 | }; |
| 325 | view! { |
| 326 | <div class="ct-value"> |
| 327 | {value} |
| 328 | {label.map(|t| view! { <span class="muted ct-label">{t}</span> })} |
| 329 | </div> |
| 330 | } |
| 331 | }) |
| 332 | .collect::<Vec<_>>()} |
| 333 | </dd> |
| 334 | }) |
| 335 | }; |
| 336 | let web = |v: &str| { |
| 337 | (v.starts_with("https://") || v.starts_with("http://")).then(|| v.to_string()) |
| 338 | }; |
| 339 | let collection = book(c.collection_id); |
| 340 | view! { |
| 341 | <button class="btn btn-sm ct-back" on:click=move |_| router::close_open()> |
| 342 | {icon_svg(IconName::ChevronRight, "ic-btn flip")} |
| 343 | {i18n::t(k::PIM_BACK)} |
| 344 | </button> |
| 345 | <div class="ct-head"> |
| 346 | {head_photo} |
| 347 | <div class="ct-head-text"> |
| 348 | <h2 class="ct-title">{c.full_name.clone()}</h2> |
| 349 | {(!sub.is_empty()).then(|| view! { <p class="muted">{sub.join(" · ")}</p> })} |
| 350 | </div> |
| 351 | </div> |
| 352 | <dl class="cal-facts"> |
| 353 | {labeled(k::PIM_EMAIL, c.emails.clone(), |v| { |
| 354 | let addr = String::from(js_sys::encode_uri_component(v)).replace("%40", "@"); |
| 355 | Some(format!("mailto:{addr}")) |
| 356 | })} |
| 357 | {labeled(k::PIM_PHONE, c.phones.clone(), |v| { |
| 358 | Some(format!("tel:{}", v.chars().filter(|c| !c.is_whitespace()).collect::<String>())) |
| 359 | })} |
| 360 | {labeled(k::PIM_POSTAL, c.addresses.clone(), |_| None)} |
| 361 | {labeled(k::PIM_WEBSITE, c.urls.clone(), web)} |
| 362 | {c.birthday.clone().map(|b| view! { |
| 363 | <dt>{i18n::t(k::PIM_BIRTHDAY)}</dt> |
| 364 | <dd>{fmt_vcard_date(&b)}</dd> |
| 365 | })} |
| 366 | {c.anniversary.clone().map(|b| view! { |
| 367 | <dt>{i18n::t(k::PIM_ANNIVERSARY)}</dt> |
| 368 | <dd>{fmt_vcard_date(&b)}</dd> |
| 369 | })} |
| 370 | {(!c.members.is_empty()).then(|| view! { |
| 371 | <dt>{i18n::t(k::PIM_MEMBERS)}</dt> |
| 372 | <dd>{c.members.join(", ")}</dd> |
| 373 | })} |
| 374 | {collection.map(|(n, color)| view! { |
| 375 | <dt>{i18n::t(k::PIM_ADDRESS_BOOK)}</dt> |
| 376 | <dd><span class="cal-dot" style=format!("--c: {color}")></span>" "{n}</dd> |
| 377 | })} |
| 378 | </dl> |
| 379 | {c.note.clone().filter(|n| !n.trim().is_empty()).map(|n| view! { |
| 380 | <h3 class="pim-section-title">{i18n::t(k::PIM_NOTE)}</h3> |
| 381 | <div class="cal-desc cal-pre">{linkified(&n)} |
| 382 | </div> |
| 383 | })} |
| 384 | } |
| 385 | .into_any() |
| 386 | } |
| 387 | }; |
| 388 | |
| 389 | view! { |
| 390 | <div class="ct" class:ct-has-open=move || opened.get().is_some()> |
| 391 | <div class="ct-list"> |
| 392 | <div class="ct-search"> |
| 393 | {icon_svg(IconName::Search, "ct-search-icon")} |
| 394 | <input |
| 395 | type="search" |
| 396 | placeholder=i18n::tr(k::PIM_SEARCH_CONTACTS) |
| 397 | aria-label=i18n::tr(k::PIM_SEARCH_CONTACTS) |
| 398 | prop:value=move || input.get() |
| 399 | on:input=move |ev| typed(event_target_value(&ev)) |
| 400 | /> |
| 401 | </div> |
| 402 | <div class="ct-rows">{list}</div> |
| 403 | </div> |
| 404 | <div class="ct-detail">{pane}</div> |
| 405 | </div> |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | #[cfg(test)] |
| 410 | mod tests { |
| 411 | use super::*; |
| 412 | |
| 413 | #[test] |
| 414 | fn vcard_dates() { |
| 415 | assert_eq!(vcard_date("1980-03-15"), Some((Some(1980), 3, 15))); |
| 416 | assert_eq!(vcard_date("19800315"), Some((Some(1980), 3, 15))); |
| 417 | assert_eq!(vcard_date("--03-15"), Some((None, 3, 15))); |
| 418 | assert_eq!(vcard_date("--0229"), Some((None, 2, 29))); |
| 419 | assert_eq!( |
| 420 | vcard_date("1980-03-15T00:00:00Z"), |
| 421 | Some((Some(1980), 3, 15)) |
| 422 | ); |
| 423 | assert_eq!(vcard_date("sometime"), None); |
| 424 | assert_eq!(vcard_date("1980-13-01"), None); |
| 425 | assert_eq!(vcard_date("1980-04-31"), None); |
| 426 | assert_eq!(vcard_date("1981-02-29"), None); |
| 427 | assert_eq!(vcard_date("1900-02-29"), None); |
| 428 | assert_eq!(vcard_date("2000-02-29"), Some((Some(2000), 2, 29))); |
| 429 | // Multi-byte values must not panic on byte slicing. |
| 430 | assert_eq!(vcard_date("1980年3"), None); |
| 431 | assert_eq!(vcard_date("--03é1"), None); |
| 432 | assert_eq!(initials("anna maria berg"), "AM"); |
| 433 | assert_eq!(initials("ümit"), "Ü"); |
| 434 | assert_eq!(initials("Atrium (4th floor)"), "AF"); |
| 435 | assert_eq!(parse_open("3/a/b.vcf"), Some((3, "a/b.vcf".to_string()))); |
| 436 | } |
| 437 | } |
| 438 |