contacts.rs
⎇
Raw
1//! The contacts section's main area: a searchable list of the visible
2//! address books and the details of one contact.
3
4use std::collections::HashSet;
5
6use leptos::prelude::*;
7use wasm_bindgen_futures::spawn_local;
8
9use crate::api::{
10 self, PimCollectionInfo, PimCollectionKind, PimContact, PimContactDetail, PimObjectDetail,
11};
12use crate::components::icon::icon_svg;
13use crate::i18n::{self, k};
14use crate::icons::IconName;
15use crate::router::{self, Location, Section};
16use crate::util::intl;
17use crate::views::calendar::linkified;
18use crate::views::pim::{browser_tz, display_name, safe_color};
19
20/// The open contact, as the URL names it: `{collection}/{name}`.
21fn 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.
27fn 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.
38pub 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
61fn 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.
87fn 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.
101fn 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]
123pub 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(&param)
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)]
410mod 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