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