Calendar and contacts UI: review fixes

- Reads and callbacks after an await use try_* variants, so leaving a
  view during a request no longer traps in wasm
- Event detail keeps its "When" row after a reload with ?open=
- Invitations refetch has a sequence guard; URL years outside
  1000-9999 fall back to the current month
- vCard dates and collection colors are checked before byte slicing
- Contact rows mark the selection per row instead of rebuilding the
  list; the search box follows the URL on Back and Forward
- A failed share-mode change reloads the list; doc comments moved to
  their items

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit9ea2dc716ce2b8e8e83bb3c80fd55f0f7125e5ae
Parentfc5f7d1
4 files changed, 109 insertions(+), 65 deletions(-)
▾Mweb/src/views/admin.rs
@@ -1052,8 +1052,8 @@ fn RoomForm(
match result {
Ok(_) => {
show(toast, i18n::t(i18n::k::SAVED));
on_saved.run(());
close.run(());
let _ = on_saved.try_run(());
let _ = close.try_run(());
}
Err(e) => {
show_error(toast, e.to_string());
▾Mweb/src/views/calendar.rs
@@ -371,6 +371,13 @@ enum CalView {
Invitations,
}
/// `YYYY-MM` from the URL. Outside 1000–9999 JS dates and the grid disagree.
fn parse_month(ym: &str) -> Option<(i32, u32)> {
let (y, m) = ym.split_once('-')?;
let (y, m) = (y.parse().ok()?, m.parse().ok()?);
((1000..=9999).contains(&y) && (1..=12).contains(&m)).then_some((y, m))
}
impl CalView {
fn from_path(path: &[String]) -> Self {
match path.first().map(String::as_str) {
@@ -378,11 +385,8 @@ impl CalView {
Some("invitations") => CalView::Invitations,
_ => path
.get(1)
.and_then(|ym| {
let (y, m) = ym.split_once('-')?;
let (y, m) = (y.parse().ok()?, m.parse().ok()?);
(1..=12).contains(&m).then_some(CalView::Month(y, m))
})
.and_then(|ym| parse_month(ym))
.map(|(y, m)| CalView::Month(y, m))
.unwrap_or_else(|| {
let (y, m, _) = civil_from_days(today());
CalView::Month(y, m)
@@ -517,8 +521,8 @@ pub fn CalendarMain(
&browser_tz(),
)
.await;
if month_seq.get_value() == seq {
month.set(Some(r.map_err(|e| e.to_string())));
if month_seq.try_get_value() == Some(seq) {
let _ = month.try_set(Some(r.map_err(|e| e.to_string())));
}
});
});
@@ -541,18 +545,23 @@ pub fn CalendarMain(
&browser_tz(),
)
.await;
if agenda_seq.get_value() == seq {
agenda.set(Some(r.map_err(|e| e.to_string())));
if agenda_seq.try_get_value() == Some(seq) {
let _ = agenda.try_set(Some(r.map_err(|e| e.to_string())));
}
});
});
let invitations = RwSignal::new(Option::<Result<Vec<PimInvitation>, String>>::None);
let invitations_seq = StoredValue::new(0u32);
Effect::new(move |_| {
tick.track();
let seq = invitations_seq.get_value() + 1;
invitations_seq.set_value(seq);
spawn_local(async move {
let r = api::pim_invitations(&browser_tz()).await;
let _ = invitations.try_set(Some(r.map_err(|e| e.to_string())));
if invitations_seq.try_get_value() == Some(seq) {
let _ = invitations.try_set(Some(r.map_err(|e| e.to_string())));
}
});
});
let refresh = Callback::new(move |_| tick.update(|t| *t += 1));
@@ -731,23 +740,20 @@ pub fn CalendarMain(
.map(span_of)
})
};
month
.with_untracked(find)
.or_else(|| agenda.with_untracked(find))
.or_else(|| {
invitations.with_untracked(|i| {
i.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|l| {
l.iter().find(|v| {
v.collection_id == o.collection_id
&& v.name == o.name
&& v.recurrence_id == o.recurrence_id
})
month.with(find).or_else(|| agenda.with(find)).or_else(|| {
invitations.with(|i| {
i.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|l| {
l.iter().find(|v| {
v.collection_id == o.collection_id
&& v.name == o.name
&& v.recurrence_id == o.recurrence_id
})
.map(|v| span_of(&inv_instance(v)))
})
})
.map(|v| span_of(&inv_instance(v)))
})
})
};
view! {
<div class="cal">
@@ -755,7 +761,11 @@ pub fn CalendarMain(
{body}
</div>
{move || opened.get().map(|o| {
let span = times(&o);
// Reactive: after a reload the lists arrive after the dialog opens.
let span = {
let o = o.clone();
Signal::derive(move || times(&o))
};
view! {
<EventDialog
target=o
@@ -1056,7 +1066,8 @@ fn reply(
match api::pim_reply(body).await {
Ok(_) => {
show(toast, i18n::t(k::PIM_REPLIED));
done.run(());
// The dialog or view may be gone by now.
let _ = done.try_run(());
}
Err(e) => show_error(toast, e.to_string()),
}
@@ -1262,7 +1273,7 @@ pub fn linkified(text: &str) -> impl IntoView + use<> {
#[component]
fn EventDialog(
target: Opened,
span: Option<Span>,
span: Signal<Option<Span>>,
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
on_replied: Callback<()>,
on_close: Callback<()>,
@@ -1331,7 +1342,7 @@ fn EventDialog(
ps,
Callback::new(move |_| {
load();
on_replied.run(());
let _ = on_replied.try_run(());
}),
);
};
@@ -1347,7 +1358,7 @@ fn EventDialog(
<p class="cal-flag">{i18n::t(k::PIM_CANCELLED)}</p>
})}
<dl class="cal-facts">
{span.map(|s| view! {
{move || span.get().map(|s| view! {
<dt>{i18n::t(k::PIM_WHEN)}</dt>
<dd>{fmt_span(&s)}</dd>
})}
@@ -1449,6 +1460,15 @@ fn EventDialog(
mod tests {
use super::*;
#[test]
fn url_years_are_clamped() {
assert_eq!(parse_month("2026-10"), Some((2026, 10)));
assert_eq!(parse_month("9999-12"), Some((9999, 12)));
for bad in ["0050-01", "-3-01", "10000-01", "2026-13", "x-01"] {
assert_eq!(parse_month(bad), None, "{bad}");
}
}
fn day(y: i32, m: u32, d: u32) -> i64 {
days_from_civil(y, m, d)
}
▾Mweb/src/views/contacts.rs
@@ -35,6 +35,10 @@ fn initials(name: &str) -> String {
/// `--0315`. `None` when it is none of these.
pub fn vcard_date(s: &str) -> Option<(Option<i32>, u32, u32)> {
let s = s.split('T').next()?.replace('-', "");
// Byte slicing below needs ASCII; the value comes from any client.
if !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let (year, md) = match s.len() {
8 => (Some(s[..4].parse().ok()?), &s[4..]),
4 => (None, &s[..]),
@@ -70,8 +74,6 @@ fn fmt_vcard_date(s: &str) -> String {
}
}
/// Initials, covered by the photo when there is one. A photo that fails to
/// load hides itself and leaves the initials.
/// vCard TYPE values in the UI language. Unknown ones stay as written.
fn type_label(t: &str) -> String {
let key = match t.to_ascii_lowercase().as_str() {
@@ -85,6 +87,8 @@ fn type_label(t: &str) -> String {
i18n::t(key).to_string()
}
/// Initials, covered by the photo when there is one. A photo that fails to
/// load hides itself and leaves the initials.
fn avatar(name: &str, photo: Option<String>, class: &'static str) -> impl IntoView + use<> {
view! {
<span class=format!("ct-avatar ct-initials {class}") aria-hidden="true">
@@ -126,6 +130,14 @@ pub fn ContactsMain(
})));
};
// Back and Forward change the query from outside the box.
Effect::new(move |_| {
let q = query.get();
if input.get_untracked().trim() != q {
input.set(q);
}
});
let contacts = RwSignal::new(Option::<Result<Vec<PimContact>, String>>::None);
let seq = StoredValue::new(0u32);
Effect::new(move |_| {
@@ -135,7 +147,7 @@ pub fn ContactsMain(
seq.set_value(n);
spawn_local(async move {
let r = api::pim_contacts(&q).await;
if seq.get_value() == n {
if seq.try_get_value() == Some(n) {
let _ = contacts.try_set(Some(r.map_err(|e| e.to_string())));
}
});
@@ -154,14 +166,13 @@ pub fn ContactsMain(
Ok(PimObjectDetail::Event(_)) => Err(i18n::t(k::PIM_NOT_CONTACT).to_string()),
Err(e) => Err(e.to_string()),
};
if opened.get_untracked() == Some((id, name)) {
if opened.try_get_untracked() == Some(Some((id, name))) {
let _ = detail.try_set(Some(r));
}
});
});
let list = move || {
let open = opened.get();
match contacts.get() {
None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(),
@@ -194,7 +205,8 @@ pub fn ContactsMain(
.map(|c| {
let book = colors.get(&c.collection_id).cloned();
let key = (c.collection_id, c.name.clone());
let selected = open.as_ref() == Some(&key);
// Per row, so opening a contact does not rebuild the list.
let selected = move || opened.with(|o| o.as_ref() == Some(&key));
let param = format!("{}/{}", c.collection_id, c.name);
let meta: Vec<String> = [c.org.clone(), c.email.clone(), c.phone.clone()]
.into_iter()
@@ -203,8 +215,8 @@ pub fn ContactsMain(
view! {
<button
class="ct-row"
class:ct-sel=selected
aria-current=selected.then_some("true")
class:ct-sel=selected.clone()
aria-current=move || selected().then_some("true")
on:click=move |_| router::set_open(&param)
>
{avatar(
@@ -373,6 +385,9 @@ mod tests {
);
assert_eq!(vcard_date("sometime"), None);
assert_eq!(vcard_date("1980-13-01"), None);
// Multi-byte values must not panic on byte slicing.
assert_eq!(vcard_date("1980年3"), None);
assert_eq!(vcard_date("--03é1"), None);
assert_eq!(initials("anna maria berg"), "AM");
assert_eq!(initials("ümit"), "Ü");
assert_eq!(initials("Atrium (4th floor)"), "AF");
▾Mweb/src/views/pim.rs
@@ -123,8 +123,6 @@ pub fn browser_tz() -> String {
.unwrap_or_else(|| "UTC".to_string())
}
/// The calendar or contacts section: the collection list, and the view of
/// what the visible collections hold.
/// A readable form of the precondition an import skip names.
fn skip_reason(reason: &str) -> String {
let key = match reason {
@@ -139,6 +137,8 @@ fn skip_reason(reason: &str) -> String {
i18n::t(key).to_string()
}
/// The calendar or contacts section: the collection list, and the view of
/// what the visible collections hold.
#[component]
pub fn PimView(
kind: PimCollectionKind,
@@ -152,8 +152,12 @@ pub fn PimView(
set_load_err.set(None);
spawn_local(async move {
match api::pim_collections().await {
Ok(v) => set_collections.set(Some(v)),
Err(e) => set_load_err.set(Some(e.to_string())),
Ok(v) => {
let _ = set_collections.try_set(Some(v));
}
Err(e) => {
let _ = set_load_err.try_set(Some(e.to_string()));
}
}
});
});
@@ -438,10 +442,12 @@ fn ColorPicker(color: RwSignal<Option<String>>) -> impl IntoView {
aria-label=i18n::tr(k::PIM_CUSTOM_COLOR)
prop:value=move || {
color.with(|v| {
v.as_deref()
.filter(|c| c.len() >= 7)
.map(|c| c[..7].to_string())
.unwrap_or_else(|| "#3b82f6".to_string())
// `safe_color` guarantees ASCII hex before slicing.
let c = safe_color(v.as_deref());
match c.len() >= 7 && c.starts_with('#') {
true => c[..7].to_string(),
false => "#3b82f6".to_string(),
}
})
}
on:input=move |ev| color.set(Some(event_target_value(&ev)))
@@ -486,12 +492,12 @@ fn NewCollectionDialog(
match api::pim_create_collection(body).await {
Ok(_) => {
show(toast, i18n::t(k::PIM_CREATED));
on_created.run(());
on_close.run(());
let _ = on_created.try_run(());
let _ = on_close.try_run(());
}
Err(e) => {
show_error(toast, e.to_string());
set_busy.set(false);
let _ = set_busy.try_set(false);
}
}
});
@@ -632,11 +638,11 @@ fn GeneralSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl Int
match api::pim_update_collection(id, body).await {
Ok(_) => {
show(toast, i18n::t(k::SAVED));
on_changed.run(());
let _ = on_changed.try_run(());
}
Err(e) => show_error(toast, i18n::t_fmt(k::PIM_SAVE_ERR, &e.to_string())),
}
set_busy.set(false);
let _ = set_busy.try_set(false);
});
};
view! {
@@ -718,11 +724,12 @@ fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
match api::pim_share(id, &who, m).await {
Ok(s) => {
show(toast, i18n::t_fmt(k::PIM_SHARED_OK, &s.user_name));
set_user.set(String::new());
load();
let _ = set_user.try_set(String::new());
}
Err(e) => show_error(toast, e.to_string()),
}
// Also on failure, so a changed select shows the real mode again.
load();
});
};
let add = move |ev: web_sys::SubmitEvent| {
@@ -859,7 +866,9 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
let load = move || {
spawn_local(async move {
match api::pim_links(id).await {
Ok(v) => set_links.set(Some(v)),
Ok(v) => {
let _ = set_links.try_set(Some(v));
}
Err(e) => show_error(toast, e.to_string()),
}
});
@@ -880,12 +889,12 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
spawn_local(async move {
match api::pim_create_link(id, body).await {
Ok(_) => {
set_password.set(String::new());
let _ = set_password.try_set(String::new());
load();
}
Err(e) => show_error(toast, e.to_string()),
}
set_busy.set(false);
let _ = set_busy.try_set(false);
});
};
let link_row = move |l: PimLinkInfo| {
@@ -1079,14 +1088,14 @@ fn ImportSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl Into
spawn_local(async move {
match api::pim_import(id, file).await {
Ok(r) => {
set_result.set(Some(r));
on_changed.run(());
let _ = set_result.try_set(Some(r));
let _ = on_changed.try_run(());
}
Err(e) => show_error(toast, e.to_string()),
}
set_busy.set(false);
let _ = set_busy.try_set(false);
// The same file again must fire `change` again.
if let Some(el) = input.get_untracked() {
if let Some(Some(el)) = input.try_get_untracked() {
el.set_value("");
}
});
@@ -1184,7 +1193,7 @@ fn ImportNewDialog(
match api::pim_import_new(kind, &n, &color, file).await {
Ok(r) => {
if r.collection.is_some() {
on_created.run(());
let _ = on_created.try_run(());
}
let _ = set_done.try_set(Some(r));
}
@@ -1278,8 +1287,8 @@ fn DeleteSection(
false => i18n::t(k::DELETED),
},
);
on_changed.run(());
on_close.run(());
let _ = on_changed.try_run(());
let _ = on_close.try_run(());
}
Err(e) => show_error(toast, e.to_string()),
}