Skip to main content

max / goingson

Declare the mail screen Wave 14, third of four files. emails.rs leaves the queue. Four reads hoisted: `Listing`, `Band`, `Thread` and the message, attachment and sender rows under it. The thread pane was doing the most work of any assembler in the tree, weaving a load, a thread query, a contact lookup, an attachment parse and the snooze clock through one function; each of those is now a value the description reads. One production earned, in quasi@23c219d: `more` on a list. This is `Rest`'s first consumer with a remainder it actually knows. Three things the form settled. A row's acts are a `Vec<Node>` shape placed with `beside Actions include`, which is what `Row::act` already is, so the open thread and the list row share the eight without either spelling them twice. Three empty states became a local enum the description dispatches on, which is what wave 13 found for the task list. And a screen's detail pane takes an `Option`, because a document holds regions and cannot hold a dispatch: the pane says "Nothing selected" itself rather than the screen choosing between two panes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 19:49 UTC
Signed with PGP, not checked
Commit: 4e18abd3a7385e13d60297122a0dcf526c9e8f9c
Parent: 69f0a9d
1 file changed, +263 insertions, -180 deletions
@@ -54,8 +54,10 @@
54 54 BodyFormat, Email, EmailId, EmailSource, EmailThread, Validate as _, date_utils, email_compose,
55 55 event_from_email, task_from_email,
56 56 };
57 - use quasi_router::screen::{Act, Choice, Consult, Field, Figure, Rest, Row, Tag};
58 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
57 + use makeover_layout::Tone;
58 + use quasi_declare::declare;
59 + use quasi_router::screen::{Choice, Consult, Figure, Rest, Tag};
60 + use quasi_router::{Action, Response, RouteError, Router};
59 61
60 62 use crate::commands::get_snooze_options;
61 63 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -265,75 +267,85 @@
265 267 .map_err(|error| RouteError::internal(error.to_string()))
266 268 }
267 269
268 - /// One thread as a row.
269 - ///
270 - /// # Selection
271 - ///
272 - /// A screen names the set with
273 - /// [`Screen::selecting`](quasi_router::Screen::selecting), a row says what its
274 - /// tick contributes with [`Row::ticking`], and a control says it runs over the
275 - /// whole of it with [`Act::over`], which sends every ticked value under
276 - /// [`Node::TICKED`]. So the row ticks under its own id and [`bulk`] is the bar.
277 - ///
278 - /// The tick state itself stays where it was put. A renderer holds which rows
279 - /// are ticked, the running count and the clearing; this screen holds only
280 - /// whether the rows *arrive* ticked, which is [`View::ticked`] and is select-all.
281 - ///
282 - /// Selection must not survive a filter change, or bulk actions target rows the
283 - /// user can no longer see. A filter here is an address, so a different view is
284 - /// a different page and the ticks a user made do not travel. The half that does
285 - /// need saying is `ticked=all`, which rides on the address: [`filters`] drops
286 - /// it, so "all" can never quietly come to mean a different all.
287 - ///
288 - /// # What the row does have
289 - ///
290 - /// The context menu's seven items are the row's actions, plainly. `components.js`
291 - /// hides them behind a right-click and a kebab and `contextMenus.showEmail`
292 - /// rebuilds them from four `data-email-*` attributes on the element; described,
293 - /// they are what the row offers, and whether that becomes a menu, a swipe or a
294 - /// trailing button strip is the renderer's business.
295 - fn row_for(thread: &EmailThread, view: &View, open: Option<EmailId>) -> Row {
296 - let email = &thread.most_recent_email;
297 - let mut row = Row::new(&email.subject)
298 - .secondary(&email.from)
299 - .meta(email.received_formatted());
270 + declare! {
271 + ///
272 + /// # Selection
273 + ///
274 + /// A screen names the set with
275 + /// [`Screen::selecting`](quasi_router::Screen::selecting), a row says what its
276 + /// tick contributes with [`Row::ticking`], and a control says it runs over the
277 + /// whole of it with [`Act::over`], which sends every ticked value under
278 + /// [`Node::TICKED`]. So the row ticks under its own id and [`bulk`] is the bar.
279 + ///
280 + /// The tick state itself stays where it was put. A renderer holds which rows
281 + /// are ticked, the running count and the clearing; this screen holds only
282 + /// whether the rows *arrive* ticked, which is [`View::ticked`] and is select-all.
283 + ///
284 + /// Selection must not survive a filter change, or bulk actions target rows the
285 + /// user can no longer see. A filter here is an address, so a different view is
286 + /// a different page and the ticks a user made do not travel. The half that does
287 + /// need saying is `ticked=all`, which rides on the address: [`filters`] drops
288 + /// it, so "all" can never quietly come to mean a different all.
289 + ///
290 + /// # What the row does have
291 + ///
292 + /// The context menu's seven items are the row's actions, plainly. `components.js`
293 + /// hides them behind a right-click and a kebab and `contextMenus.showEmail`
294 + /// rebuilds them from four `data-email-*` attributes on the element; described,
295 + /// they are what the row offers, and whether that becomes a menu, a swipe or a
296 + /// trailing button strip is the renderer's business.
297 + shape row_for(thread: &EmailThread, view: &View, open: Option<EmailId>) -> Row;
298 +
299 + row &thread.most_recent_email.subject {
300 + secondary &thread.most_recent_email.from;
301 + meta thread.most_recent_email.received_formatted();
302 +
303 + // The unread badge is on the thread and not on the message: `has_unread`
304 + // is true when any message in it is unread, which is what the JS's
305 + // `unread` class on the row means.
306 + token Tag::badge("Unread").tone(Tone::Info) when thread.has_unread;
300 307
301 - // The unread badge is on the thread and not on the message: `has_unread` is
302 - // true when any message in it is unread, which is what the JS's `unread`
303 - // class on the row means.
304 - if thread.has_unread {
305 - row = row.token(Tag::badge("Unread").tone(makeover_layout::Tone::Info));
306 - }
307 - if thread.thread_count > 1 {
308 308 // The JS draws the bare number in a `thread-badge` and puts "N messages
309 309 // in thread" in a `title`, which is the tooltip carrying the meaning and
310 310 // the badge carrying a digit. A description has no tooltip to hide the
311 311 // noun in, and does not need one.
312 - row = row.token(Tag::badge(format!("{} messages", thread.thread_count)));
313 - }
314 - for label in &email.labels {
315 - row = row.token(Tag::badge(label));
316 - }
317 - if email.is_snoozed() {
318 - row = row.token(
319 - Tag::badge(match snoozed_until(email) {
320 - Some(when) => format!("Snoozed until {when}"),
321 - None => "Snoozed".to_owned(),
322 - })
323 - .tone(makeover_layout::Tone::Warning),
324 - );
325 - }
312 + token Tag::badge("{thread.thread_count} messages") when thread.thread_count over 1;
326 313
327 - row.current = open == Some(email.id);
328 - // The tick joins the screen's set under the message's own id, which is what
329 - // the bar acts on. `emails.js` gathers the same ids from the checkboxes by
330 - // hand (`SelectionManager.setItems`, over `mostRecentEmail.id`).
331 - row = row.ticking(email.id.to_string(), view.ticked);
332 - row.activate = Some(view.carry(Action::get(format!("/emails/{}", email.id))));
333 - for act in row_acts(email, view) {
334 - row = row.act(act);
314 + for label in thread.most_recent_email.labels.iter() {
315 + token Tag::badge(label);
316 + }
317 +
318 + token Tag::badge(snooze_word(&thread.most_recent_email)).tone(Tone::Warning)
319 + when thread.most_recent_email.is_snoozed();
320 +
321 + current is_open(thread, open);
322 + activate to doing view.carry(Action::get("/emails/{thread.most_recent_email.id}"));
323 +
324 + // The tick joins the screen's set under the message's own id, which is
325 + // what the bar acts on. `emails.js` gathers the same ids from the
326 + // checkboxes by hand (`SelectionManager.setItems`, over
327 + // `mostRecentEmail.id`).
328 + ticking thread.most_recent_email.id.to_string() view.ticked;
329 +
330 + // `Row::act` is a part in `Actions`, which is what `beside` says here:
331 + // the same eight acts the open thread offers, placed on the row.
332 + for act in row_acts(&thread.most_recent_email, view) {
333 + beside Actions include act;
334 + }
335 335 }
336 - row
336 + }
337 +
338 + /// Whether this thread is the one the detail pane is showing.
339 + fn is_open(thread: &EmailThread, open: Option<EmailId>) -> bool {
340 + open == Some(thread.most_recent_email.id)
341 + }
342 +
343 + /// What the snoozed badge reads.
344 + fn snooze_word(email: &Email) -> String {
345 + snoozed_until(email).map_or_else(
346 + || "Snoozed".to_owned(),
347 + |when| format!("Snoozed until {when}"),
348 + )
337 349 }
338 350
339 351 /// When a snoozed email comes back, said the way the list says it.
@@ -347,134 +359,213 @@
347 359 .map(|until| date_utils::format_relative_future(until, Utc::now()))
348 360 }
349 361
350 - /// What a row offers, which is what the context menu offers.
351 - ///
352 - /// Read/unread and archive/unarchive are one route each with a param rather than
353 - /// two addresses, for the reason the weekly review's focus toggle gives: the
354 - /// caller always knows which way it is going, and a route that read the current
355 - /// state and flipped it would race a second window.
356 - fn row_acts(email: &Email, view: &View) -> Vec<Act> {
357 - let at = |suffix: &str| view.carry(Action::post(format!("/emails/{}/{suffix}", email.id)));
362 + declare! {
363 + /// What a row offers, which is what the context menu offers.
364 + ///
365 + /// Read/unread and archive/unarchive are one route each with a param rather
366 + /// than two addresses, for the reason the weekly review's focus toggle
367 + /// gives: the caller always knows which way it is going, and a route that
368 + /// read the current state and flipped it would race a second window.
369 + shape row_acts(email: &Email, view: &View) -> Vec<Node>;
358 370
359 - let mut acts = vec![
360 - if email.is_read {
361 - Act::new("Mark unread", at("read").with("read", "false"))
362 - } else {
363 - Act::new("Mark read", at("read").with("read", "true"))
364 - },
365 - if email.is_archived {
366 - Act::new("Unarchive", at("archive").with("archived", "false")).key("a")
367 - } else {
368 - Act::new("Archive", at("archive").with("archived", "true")).key("a")
369 - },
370 - Act::new("Create task", at("task")).key("t"),
371 - Act::new("Create event", at("event")).key("e"),
372 - ];
371 + act "Mark unread" to doing view.carry(Action::post("/emails/{email.id}/read"))
372 + with "read" "false"
373 + when email.is_read;
373 374
374 - if email.is_snoozed() {
375 - acts.push(Act::new("Unsnooze", at("snooze").with("clear", "true")));
375 + act "Mark read" to doing view.carry(Action::post("/emails/{email.id}/read"))
376 + with "read" "true"
377 + unless email.is_read;
378 +
379 + act "Unarchive" to doing view.carry(Action::post("/emails/{email.id}/archive"))
380 + with "archived" "false"
381 + when email.is_archived {
382 + key "a";
376 383 }
377 384
378 - acts.push(
379 - Act::new("Delete", at("delete"))
380 - .tone(makeover_layout::Tone::Danger)
381 - .confirm("Are you sure you want to delete this email? This cannot be undone."),
382 - );
383 - acts
385 + act "Archive" to doing view.carry(Action::post("/emails/{email.id}/archive"))
386 + with "archived" "true"
387 + unless email.is_archived {
388 + key "a";
389 + }
390 +
391 + act "Create task" to doing view.carry(Action::post("/emails/{email.id}/task")) {
392 + key "t";
393 + }
394 +
395 + act "Create event" to doing view.carry(Action::post("/emails/{email.id}/event")) {
396 + key "e";
397 + }
398 +
399 + act "Unsnooze" to doing view.carry(Action::post("/emails/{email.id}/snooze"))
400 + with "clear" "true"
401 + when email.is_snoozed();
402 +
403 + act "Delete" to doing view.carry(Action::post("/emails/{email.id}/delete")) {
404 + tone Danger;
405 + confirm "Are you sure you want to delete this email? This cannot be undone.";
406 + }
407 + }
408 +
409 + /// The list, as the pane draws it.
410 + struct Listing {
411 + /// The filters and the page it is being looked at through.
412 + view: View,
413 + /// Which message is open, so its row reads as current.
414 + open: Option<EmailId>,
415 + /// The page of threads.
416 + threads: Vec<EmailThread>,
417 + /// How many the query holds in all, which is the other half of `more`.
418 + total: i64,
419 + /// Whether the view is narrowed, which decides which empty state applies.
420 + filtered: bool,
421 + /// Whether an account is set up at all, which decides between the other two.
422 + configured: bool,
384 423 }
385 424
386 425 /// The list, under the filters it is being looked at through.
387 - ///
388 - /// # The second finding, which is a confirmation rather than a gap
389 - ///
390 - /// **[`Rest`] gets its first consumer with a remainder it actually knows.**
391 - ///
392 - /// `list_threaded` returns `(threads, total)` in one call, so the count line
393 - /// can read "X of N" and the description carries both numbers.
394 - ///
395 - /// Windowing rows a renderer already holds is a performance technique rather
396 - /// than a fact about the data, and `Rest` is not it.
397 - fn list(state: &AppState, view: &View, open: Option<EmailId>) -> Result<Node, RouteError> {
426 + fn listing(state: &AppState, view: &View, open: Option<EmailId>) -> Result<Listing, RouteError> {
398 427 let (threads, total) = threads(state, view)?;
399 -
400 - if threads.is_empty() {
428 + Ok(Listing {
401 429 // A filtered view that finds nothing is empty because of the filter,
402 430 // whatever else is true, so that answer comes first and carries the way
403 - // out. `emails.js` has no filter-specific empty state at all — it asks
404 - // `getEmailAccountsCache().length` and picks one of two — so a folder
431 + // out. `emails.js` has no filter-specific empty state at all -- it asks
432 + // `getEmailAccountsCache().length` and picks one of two -- so a folder
405 433 // holding no mail tells the user to set up an account they already have.
406 - if view.folder.is_some() || view.label.is_some() {
407 - return Ok(
408 - Node::empty("No mail matching this filter.").offering(Act::new(
409 - "Clear filters",
410 - View {
411 - archived: view.archived,
412 - ..View::default()
413 - }
414 - .list(),
415 - )),
416 - );
417 - }
418 -
434 + filtered: view.folder.is_some() || view.label.is_some(),
419 435 // The remaining two are the JS's, and which one shows is a question
420 436 // about accounts rather than about mail. The repository answers it
421 437 // without a cache.
422 - let configured = !state
438 + configured: !state
423 439 .email_accounts
424 440 .list_by_user(DESKTOP_USER_ID)
425 441 .map_err(|error| RouteError::internal(error.to_string()))?
426 - .is_empty();
427 - return Ok(if configured {
428 - Node::empty("No emails yet.")
429 - } else {
430 - // The JS offers "Add Account" here and this does not: adding one is
431 - // OAuth and a network round trip, so the described screen says the
432 - // sentence and stops rather than growing a control that leads
433 - // nowhere. The settings port drew the same line.
434 - Node::empty("Set up an email account to get started.")
435 - });
442 + .is_empty(),
443 + view: view.clone(),
444 + open,
445 + threads,
446 + total,
447 + })
448 + }
449 +
450 + impl Listing {
451 + /// Whether the page is the whole of it.
452 + fn all_shown(&self) -> bool {
453 + self.total <= i64::try_from(self.threads.len()).unwrap_or(i64::MAX)
436 454 }
437 455
438 - let rows: Vec<Row> = threads
439 - .iter()
440 - .map(|thread| row_for(thread, view, open))
441 - .collect();
442 -
443 - let shown = i64::try_from(rows.len()).unwrap_or(i64::MAX);
444 - let more = (total > shown).then(|| {
445 - // The window and the total, rather than the subtraction of the two.
446 - // `Rest` derives what is left, and it is the pair this function already
447 - // had in hand: the old shape took `remaining` and threw the more
448 - // informative halves away on the way in.
456 + /// What is left over, and the address that fetches it.
457 + ///
458 + /// The window and the total rather than the subtraction of the two: `Rest`
459 + /// derives what is left, and it is the pair `list_threaded` already hands
460 + /// back in one call.
461 + fn rest(&self) -> Rest {
449 462 Rest::more(
450 - usize::try_from(shown).unwrap_or(usize::MAX),
463 + self.threads.len(),
451 464 View {
452 - shown: view.shown + PAGE,
453 - ..view.clone()
465 + shown: self.view.shown + PAGE,
466 + ..self.view.clone()
454 467 }
455 468 .list(),
456 469 )
457 - .of(usize::try_from(total).unwrap_or(usize::MAX))
458 - });
470 + .of(usize::try_from(self.total).unwrap_or(usize::MAX))
471 + }
459 472
460 - Ok(Node::List { rows, more })
473 + /// The view with every filter cleared, which is the way out of an empty
474 + /// filtered list.
475 + fn unfiltered(&self) -> Action {
476 + View {
477 + archived: self.view.archived,
478 + ..View::default()
479 + }
480 + .list()
481 + }
461 482 }
462 483
463 - /// The two filters, and the archive switch.
464 - ///
465 - /// Fields with a [`Field::consults`] rather than a strip of options: the folder
466 - /// set is whatever the server has and can be any length, and a strip is a shape
467 - /// for a handful. The JS reaches the same conclusion by using a `<select>`, and
468 - /// `14612ed8` is what lets a control call a route without a form around it.
469 - ///
470 - /// A question and not a write (`aeb44860`): narrowing the list puts nothing in
471 - /// the database, and the member that says so is the one that means "ask a route
472 - /// about this value". [`Consult::at_once`] because a select is at its next
473 - /// value or its last one and has nothing to wait out.
474 - ///
475 - /// Each carries the *other* filters in its action and supplies its own value,
476 - /// which is what keeps picking a label from resetting the folder.
477 - fn filters(state: &AppState, view: &View) -> Result<Vec<Node>, RouteError> {
484 + declare! {
485 + /// The list, and what to say when it is empty.
486 + ///
487 + /// # The second finding, which is a confirmation rather than a gap
488 + ///
489 + /// **`Rest` gets its first consumer with a remainder it actually knows.**
490 + /// `list_threaded` returns `(threads, total)` in one call, so the count line
491 + /// can read "X of N" and the description carries both numbers.
492 + ///
493 + /// Windowing rows a renderer already holds is a performance technique rather
494 + /// than a fact about the data, and `Rest` is not it.
495 + ///
496 + /// The JS offers "Add Account" on the third empty state and this does not:
497 + /// adding one is OAuth and a network round trip, so the described screen
498 + /// says the sentence and stops rather than growing a control that leads
499 + /// nowhere. The settings port drew the same line.
500 + shape list(listing: &Listing) -> Node;
501 +
502 + given listing.nothing() {
503 + Nothing::Filtered -> empty "No mail matching this filter." {
504 + offering "Clear filters" to doing listing.unfiltered();
505 + }
506 + Nothing::NoAccount -> empty "Set up an email account to get started.";
507 + Nothing::NoMail -> empty "No emails yet.";
508 + otherwise -> list {
509 + for thread in listing.threads.iter() {
510 + include row_for(thread, &listing.view, listing.open);
511 + }
512 +
513 + more listing.rest() unless listing.all_shown();
514 + }
515 + }
516 + }
517 +
518 + /// Why the list has nothing in it, or that it has something.
519 + enum Nothing {
520 + /// The filter matched nothing, whatever else is true.
521 + Filtered,
522 + /// No account is set up, so no mail has ever arrived.
523 + NoAccount,
524 + /// An account is set up and the mailbox is empty.
525 + NoMail,
526 + /// There are rows.
527 + Some,
528 + }
529 +
530 + impl Listing {
531 + /// Which of the four this is.
532 + const fn nothing(&self) -> Nothing {
533 + if !self.threads.is_empty() {
534 + Nothing::Some
535 + } else if self.filtered {
536 + Nothing::Filtered
537 + } else if self.configured {
538 + Nothing::NoMail
539 + } else {
540 + Nothing::NoAccount
541 + }
542 + }
543 + }
544 +
545 + /// The two filters, the archive switch and the count over them.
546 + struct Band {
547 + /// The view every control on the band carries.
548 + view: View,
549 + /// The base every control writes from: a filter change is a new page of
550 + /// results, so `shown` goes back to one page. Carrying it would ask for 400
551 + /// rows of a folder holding nine.
552 + ///
553 + /// `ticked` goes with it, and for the sharper reason `row_for` records:
554 + /// carrying select-all through a filter change is exactly what
555 + /// `emails.js`'s charter rule forbids, since "everything" would silently
556 + /// come to mean a different everything.
557 + base: View,
558 + /// The folders the server has, with "all" at the head. Empty means the
559 + /// control is not drawn at all.
560 + folders: Vec<Choice>,
561 + /// The labels, on the same terms.
562 + labels: Vec<Choice>,
563 + /// How many are unread, which the band says only when there are any.
564 + unread: i64,
565 + }
566 +
567 + /// The band, read once.
568 + fn band(state: &AppState, view: &View) -> Result<Band, RouteError> {
478 569 let folders = state
479 570 .emails
480 571 .list_folders(DESKTOP_USER_ID)
@@ -484,186 +575,299 @@
484 575 .list_labels(DESKTOP_USER_ID)
485 576 .map_err(|error| RouteError::internal(error.to_string()))?;
486 577
487 - // A filter change is a new page of results, so `shown` goes back to one
488 - // page. Carrying it would ask for 400 rows of a folder holding nine.
489 - //
490 - // `ticked` goes with it, and for the sharper reason `row_for` records:
491 - // carrying select-all through a filter change is exactly what `emails.js`'s
492 - // charter rule forbids, since "everything" would silently come to mean a
493 - // different everything.
494 - let base = View {
495 - shown: PAGE,
496 - ticked: false,
497 - ..view.clone()
578 + let offered = |all: &str, values: Vec<String>| -> Vec<Choice> {
579 + if values.is_empty() {
580 + return Vec::new();
581 + }
582 + let mut options = vec![Choice::new("", all)];
583 + options.extend(values.iter().map(|value| Choice::new(value, value)));
584 + options
498 585 };
499 586
500 - let mut out = Vec::new();
501 -
502 - if !folders.is_empty() {
503 - let mut options = vec![Choice::new("", "All folders")];
Lines truncated