Skip to main content

max / goingson

Give mark-all-read a control, and describe it The command, its API binding and its surgical thread helper all existed; nothing in the markup reached any of them. A button in the email filter row does now, resolved by dispatch.js against the existing `emails.markAllRead` export, so no JS changed. The bulk bar's "Mark Read" sits directly below the new control and acts on the selection instead of on everything, so it reads "Mark Selected Read" now. Two adjacent buttons cannot both be "mark read". The described screen takes the act as `POST /emails/read-all`, offered from the band whatever the unread count says, which is what the filter row does. It ignores the view it was sent from because the command does: `mark_all_read` takes a user and nothing else. The toast carries the count, so clicking twice tells the user something the JS's fixed "All emails marked as read!" does not. The module header's record of why the port skipped this is deleted. It stopped being true.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 01:06 UTC
Signed with PGP, not checked
Commit: 4044d8447d51cb33b7ea47cd766820918fe3b9eb
Parent: 320fc3d
3 files changed, +92 insertions, -7 deletions
@@ -533,10 +533,11 @@
533 533 <option value="">All labels</option>
534 534 </select>
535 535 <span id="email-count" class="filter-count" aria-live="polite"></span>
536 + <button class="button button--sm" data-act="emails.markAllRead">Mark all read</button>
536 537 </div>
537 538 <div id="email-bulk-actions" class="bulk-actions-bar hidden" role="toolbar" aria-label="Bulk email actions">
538 539 <span id="email-bulk-count" class="bulk-count">0 selected</span>
539 - <button class="button button--sm" data-act="bulk.markEmailsRead">Mark Read</button>
540 + <button class="button button--sm" data-act="bulk.markEmailsRead">Mark Selected Read</button>
540 541 <button class="button button--sm" data-act="bulk.archiveEmails">Archive</button>
541 542 <button class="button button--sm" data-act="bulk.snoozeEmails">Snooze</button>
542 543 <button class="button button--sm button--danger" data-act="bulk.deleteEmails">Delete</button>
@@ -23,6 +23,7 @@
23 23 //! - `GET /emails` — the list, under `?folder=`, `?label=`, `?archived=`,
24 24 //! `?shown=`.
25 25 //! - `GET /emails/list` — the list alone, which is what a filter swaps.
26 + //! - `POST /emails/read-all` — every message read, whatever the view.
26 27 //! - `GET /emails/{id}` — the thread, read.
27 28 //! - `POST /emails/{id}/read` — read or unread, under `read`.
28 29 //! - `POST /emails/{id}/archive` — archive or unarchive, under `on`.
@@ -52,12 +53,6 @@
52 53 //! Sharing hit on the settings screen.
53 54 //! - **Search.** `search.js` is its own screen and its own port. The box on this
54 55 //! screen calls into it.
55 - //!
56 - //! One omission is not ours: `mark_all_emails_read` is a command with a
57 - //! `GoingsOn.emails.markAllRead` binding and **no control anywhere in the
58 - //! markup**. Nothing described here reaches it because nothing shipped reaches
59 - //! it either. Recorded rather than quietly given a button, which would make the
60 - //! described screen offer something the screen it stands in for does not.
61 56
62 57 // Handlers take their request by value because `quasi_router::Handler` is a
63 58 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
@@ -543,6 +538,15 @@
543 538 ]));
544 539 }
545 540 band = band.extend(filters(state, view)?);
541 + // Offered whatever the count says, which is what the filter row does. The
542 + // gate the rest of this file applies — say it rather than offer it when the
543 + // control leads nowhere — does not catch here: marking an already-read
544 + // mailbox read is a write that succeeds and changes nothing, not a button
545 + // that calls something the described screen cannot reach.
546 + band = band.with(Node::act(
547 + "Mark all read",
548 + view.carry(Action::post("/emails/read-all")),
549 + ));
546 550
547 551 let thread_pane = match open {
548 552 Some(id) => thread_slot(state, id, view)?,
@@ -890,6 +894,35 @@
890 894 wrote(state, &view, read.then_some(id))
891 895 }
892 896
897 + /// Every message read, whatever is being looked at.
898 + ///
899 + /// The one write on this screen that ignores the view it was sent from.
900 + /// `mark_all_read` takes a user and nothing else, and the shipped control is the
901 + /// same: `emails.js:markAllRead` calls the command and then clears unread across
902 + /// the streamed threads without consulting a filter. Reading only the filtered
903 + /// set would be a different feature, and inventing it here would make the
904 + /// described screen do something the screen it stands in for does not. The view
905 + /// is still carried, because the answer is the same page the user was on.
906 + fn read_all(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
907 + let view = View::of(&request);
908 + let marked = state
909 + .emails
910 + .mark_all_read(DESKTOP_USER_ID)
911 + .map_err(|error| RouteError::internal(error.to_string()))?;
912 +
913 + // The count, because it is the only thing that distinguishes a mailbox that
914 + // had unread mail from one that did not. The JS says "All emails marked as
915 + // read!" either way, which tells a user who clicked it twice nothing.
916 + Ok(wrote(state, &view, None)?.toast(
917 + makeover_layout::Tone::Success,
918 + match marked {
919 + 0 => "Nothing was unread".to_owned(),
920 + 1 => "1 email marked read".to_owned(),
921 + many => format!("{many} emails marked read"),
922 + },
923 + ))
924 + }
925 +
893 926 /// Archive, or bring it back.
894 927 ///
895 928 /// # The fifth finding
@@ -1162,6 +1195,7 @@
1162 1195 // not have to know that to be sure `list` never arrives as an id.
1163 1196 .get("/emails/list", list_only)
1164 1197 .get("/emails", index)
1198 + .post("/emails/read-all", read_all)
1165 1199 .get("/emails/{id}", thread)
1166 1200 .post("/emails/{id}/read", set_read)
1167 1201 .post("/emails/{id}/archive", set_archived)
@@ -353,6 +353,56 @@
353 353 assert!(page.contains("Unread"));
354 354 }
355 355
356 + #[tokio::test]
357 + async fn the_screen_offers_mark_all_read_the_way_the_filter_row_does() {
358 + let state = state().await;
359 + add(&state, message("One"));
360 +
361 + // Offered before it is needed and after it stops being, because the shipped
362 + // control is: the button in `.email-filter-row` is not gated on the count.
363 + assert!(screen(&state, Params::new()).contains("Mark all read"));
364 + post(&state, "/emails/read-all", Params::new());
365 + assert!(screen(&state, Params::new()).contains("Mark all read"));
366 + }
367 +
368 + #[tokio::test]
369 + async fn mark_all_read_ignores_the_filter_it_was_sent_from() {
370 + let state = state().await;
371 + let inbox = add(&state, message("In the inbox"));
372 + let mut filed = message("Filed elsewhere");
373 + filed.source_folder = Some("Archive".to_owned());
374 + let filed = add(&state, filed);
375 +
376 + // Sent from a folder that holds one of the two. The other is still marked,
377 + // because the command takes a user and nothing else.
378 + let response = viewing_post(
379 + &state,
380 + "/emails/read-all",
381 + Params::new(),
382 + Params::new().with("folder", "Archive"),
383 + );
384 +
385 + assert_eq!(notice(&response), "2 emails marked read");
386 + assert!(read(&state, inbox.id));
387 + assert!(read(&state, filed.id));
388 + // The answer is the view it was sent from, not an unfiltered inbox.
389 + let page = html(response);
390 + assert!(page.contains("Filed elsewhere"));
391 + assert!(!page.contains("In the inbox"));
392 + }
393 +
394 + #[tokio::test]
395 + async fn mark_all_read_says_so_when_nothing_was_unread() {
396 + let state = state().await;
397 + add(&state, message("Seen already"));
398 + post(&state, "/emails/read-all", Params::new());
399 +
400 + // The JS reports success identically both times, which tells a user who
401 + // clicked twice nothing.
402 + let response = post(&state, "/emails/read-all", Params::new());
403 + assert_eq!(notice(&response), "Nothing was unread");
404 + }
405 +
356 406 #[tokio::test]
357 407 async fn archiving_takes_it_out_of_the_view_it_was_in() {
358 408 let state = state().await;