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 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.
35pub 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
46fn 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.
74fn 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]
96pub 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(&param)
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)]
337mod 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