Skip to main content

max / goingson

Describe the mail screen's selection and its five bulk verbs The first finding closes. quasi 0.37.0 named the set (Screen::selecting), what a row's tick contributes (Row::ticking), the control that runs over the whole of it (Act::over, sending values under Node::TICKED) and the question a verb asks before it fires (Act::asks), so the shipped bar's five buttons are sayable. Four run over the set: Mark read, Archive, Snooze, Delete, reaching /emails/list/{read,archive,snooze,delete}. The fifth, Select all, is an address rather than a verb, and its opposite is the same address without it. Snooze asks for the time with Act::asking over get_snooze_options, which is the modal bulk-actions.js raises for the same moment. No tick state app-side. View::ticked says only whether the rows arrive ticked; which rows are ticked, the running count and the clearing stay the renderer's. A filter change drops ticked=all, which is emails.js's charter rule holding on the address instead of by hand. no_row_offers_a_selection_because_nothing_describes_one is inverted rather than deleted, and the handlers are covered: the ticks read back, the answer clears them, a malformed id is dropped rather than failing the press, an empty set answers the screen, and a past snooze is refused.
Author: Max Johnson <me@maxj.phd> · 2026-08-20 17:43 UTC
Signed with PGP, not checked
Commit: d4cde8ca68468cb0f992f2a378b4b79d9f69bef0
Parent: d948954
2 files changed, +616 insertions, -26 deletions
@@ -24,6 +24,10 @@
24 24 //! `?shown=`.
25 25 //! - `GET /emails/list` — the list alone, which is what a filter swaps.
26 26 //! - `POST /emails/read-all` — every message read, whatever the view.
27 + //! - `POST /emails/list/read` — every ticked thread read.
28 + //! - `POST /emails/list/archive` — every ticked thread archived.
29 + //! - `POST /emails/list/snooze` — every ticked thread snoozed, under `until`.
30 + //! - `POST /emails/list/delete` — every ticked thread deleted.
27 31 //! - `GET /emails/{id}` — the thread, read.
28 32 //! - `POST /emails/{id}/read` — read or unread, under `read`.
29 33 //! - `POST /emails/{id}/archive` — archive or unarchive, under `on`.
@@ -85,6 +89,14 @@
85 89 /// The region one thread is drawn in.
86 90 const THREAD: &str = "emails-thread";
87 91
92 + /// The name of the set the list's ticks go into.
93 + ///
94 + /// The same word the task list uses for its own set. A selection is
95 + /// screen-scoped — [`Screen::selection`](quasi_router::Screen::selection) holds
96 + /// one name and [`Act::over`] names it back — so two screens sharing a spelling
97 + /// is a reader's convenience and not a shared set.
98 + const SELECTION: &str = "chosen";
99 +
88 100 /// The list as it was being looked at.
89 101 ///
90 102 /// `emails.js` holds this across four places — `emailPaging.baseFilters`,
@@ -103,6 +115,20 @@
103 115 label: Option<String>,
104 116 /// Whether archived mail is included.
105 117 archived: bool,
118 + /// Whether the rows arrive ticked.
119 + ///
120 + /// Select-all, and it is an address, for the reason
121 + /// [`super::task_list`]'s own `ticked` gives at length: a renderer could
122 + /// tick every box it drew, but a webview one would need a script this
123 + /// crate does not ship and a terminal a key it invents, and neither
124 + /// survives the fragment swap that replaces the boxes. Answering it from
125 + /// the server is one query against a local SQLite file and every host gets
126 + /// it.
127 + ///
128 + /// Only the arriving state. What the user ticks or unticks afterwards is
129 + /// the renderer's, which is the whole point of `5f2b8753`: this screen
130 + /// never holds which rows are ticked.
131 + ticked: bool,
106 132 /// How many threads are on screen.
107 133 ///
108 134 /// The JS appends: it holds what it has fetched and asks for the next 200
@@ -123,6 +149,11 @@
123 149 folder: text(&request.carried, "folder"),
124 150 label: text(&request.carried, "label"),
125 151 archived: matches!(request.carried.get("archived"), Some("1" | "true")),
152 + // `carried`, and the ticks themselves arrive under `ticked` in
153 + // `payload` ([`Node::TICKED`]). Two bags, so the select-all address
154 + // and the set it produces cannot be read as each other. Same
155 + // arrangement `View::carry` records for `folder` and `archived`.
156 + ticked: matches!(request.carried.get("ticked"), Some("all")),
126 157 // A hand-typed `shown` is clamped rather than refused: this is an
127 158 // address, and landing on the first page is a more useful answer
128 159 // than an error page. The ceiling is the one the JS's own paging
@@ -184,6 +215,11 @@
184 215 } else {
185 216 action
186 217 };
218 + let action = if self.ticked {
219 + action.carrying("ticked", "all")
220 + } else {
221 + action
222 + };
187 223 if self.shown == PAGE {
188 224 action
189 225 } else {
@@ -246,27 +282,33 @@
246 282
247 283 /// One thread as a row.
248 284 ///
249 - /// # The first finding
285 + /// # The first finding, closed
250 286 ///
251 - /// **Nothing describes a selection, or an action taken over one.**
287 + /// **Nothing described a selection, or an action taken over one.**
252 288 ///
253 - /// [`Row::selectable`] says a row is ticked, and that is the whole of it. The
254 - /// shipped screen has a `SelectionManager` with shift-range select, a bulk bar
255 - /// that appears at one selected and counts upward, and five actions that run
256 - /// over the set — mark read, archive, snooze, delete, select-all. A description
257 - /// can say each row is ticked and cannot say what the ticks are *for*, so the
258 - /// bar has nowhere to go and the five actions have no address to call.
289 + /// [`Row::selectable`] said a row was ticked and that was the whole of it, so
290 + /// the port described no ticks at all: the shipped screen's `SelectionManager`,
291 + /// its counting bulk bar and its five verbs had nothing to say themselves in.
259 292 ///
260 - /// Not worked around. The workaround is a route per bulk action taking a list of
261 - /// ids in a param, which is a real design and not one this port should invent on
262 - /// its own from one consumer's shape. Filed against quasicoherent.
293 + /// Closed by quasi 0.37.0. A screen names the set with
294 + /// [`Screen::selecting`](quasi_router::Screen::selecting), a row says what its
295 + /// tick contributes with [`Row::ticking`], and a control says it runs over the
296 + /// whole of it with [`Act::over`], which sends every ticked value under
297 + /// [`Node::TICKED`]. So the row ticks under its own id and [`bulk`] is the bar.
298 + ///
299 + /// The tick state itself stays where it was put. A renderer holds which rows
300 + /// are ticked, the running count and the clearing; this screen holds only
301 + /// whether the rows *arrive* ticked, which is [`View::ticked`] and is select-all.
263 302 ///
264 303 /// One part of it the port gets for free and is worth writing down: `emails.js`
265 304 /// carries a charter rule that selection clears on a filter change, "so bulk
266 305 /// actions can't target rows the user can no longer see", and it is enforced by
267 306 /// a `clearSelectionIfAny` call in each of the four filter handlers. A filter
268 307 /// here is an address. A different view is a different page, and there is no
269 - /// selection to carry into it, so the rule holds without anyone maintaining it.
308 + /// selection to carry into it, so the rule holds for the ticks a user made
309 + /// without anyone maintaining it. The one half that does need saying is
310 + /// `ticked=all`, which rides on the address: [`filters`] drops it, so "all" can
311 + /// never quietly come to mean a different all.
270 312 ///
271 313 /// # What the row does have
272 314 ///
@@ -308,6 +350,10 @@
308 350 }
309 351
310 352 row.current = open == Some(email.id);
353 + // The tick joins the screen's set under the message's own id, which is what
354 + // the bar acts on. `emails.js` gathers the same ids from the checkboxes by
355 + // hand (`SelectionManager.setItems`, over `mostRecentEmail.id`).
356 + row = row.ticking(email.id.to_string(), view.ticked);
311 357 row.activate = Some(view.carry(Action::get(format!("/emails/{}", email.id))));
312 358 for act in row_acts(email, view) {
313 359 row = row.act(act);
@@ -466,8 +512,14 @@
466 512
467 513 // A filter change is a new page of results, so `shown` goes back to one
468 514 // page. Carrying it would ask for 400 rows of a folder holding nine.
515 + //
516 + // `ticked` goes with it, and for the sharper reason `row_for` records:
517 + // carrying select-all through a filter change is exactly what `emails.js`'s
518 + // charter rule forbids, since "everything" would silently come to mean a
519 + // different everything.
469 520 let base = View {
470 521 shown: PAGE,
522 + ticked: false,
471 523 ..view.clone()
472 524 };
473 525
@@ -520,6 +572,85 @@
520 572 Ok(out)
521 573 }
522 574
575 + /// The controls over the selection.
576 + ///
577 + /// The shipped bar's five buttons, in its order: Mark Selected Read, Archive,
578 + /// Snooze, Delete, Select All. Four of them run over the set and the fifth is
579 + /// an address, which is the split [`View::ticked`] describes.
580 + ///
581 + /// Snooze is the one that needs a value before it can go, and it asks for it
582 + /// with [`Act::asking`] rather than through a form. `bulk-actions.js` opens a
583 + /// modal for the same moment (`openBulkSnoozeModal`), which is a whole screen
584 + /// raised to collect one time; described, it is the verb carrying the question
585 + /// it has to ask, and whether that becomes a popover, a line under the button
586 + /// or a prompt is the renderer's.
587 + ///
588 + /// The options are [`get_snooze_options`]'s, the same ones the open thread
589 + /// offers, computed from the local clock per render so "Later Today" stops
590 + /// being offered once it means nothing. No blank leading option, unlike the
591 + /// task list's snooze picker: that one writes on change and needed a resting
592 + /// state, and this one is answered by the press.
593 + ///
594 + /// # What is not here
595 + ///
596 + /// The count. The bar says "3 selected" and hides itself at zero, and neither
597 + /// is sayable here, because the ticks belong to the renderer until a press
598 + /// sends them. That is the right place for it — a renderer knows exactly how
599 + /// many boxes it drew ticked — and every renderer has drawn a control over an
600 + /// empty selection as disabled since 2026-08-16, which is the hiding half
601 + /// arrived at from the other side. The bar is always on screen, which is the
602 + /// honest version of not knowing.
603 + fn bulk(view: &View) -> Vec<Node> {
604 + let over = |suffix: &str| view.carry(Action::post(format!("/emails/list/{suffix}")));
605 +
606 + let mut out = vec![
607 + Node::Act(Act::new("Mark read", over("read")).over(SELECTION)),
608 + Node::Act(Act::new("Archive", over("archive")).over(SELECTION)),
609 + Node::Act(
610 + Act::new("Snooze", over("snooze"))
611 + .over(SELECTION)
612 + .asking(Field::select(
613 + "until",
614 + "Snooze until",
615 + get_snooze_options()
616 + .options
617 + .into_iter()
618 + .map(|option| Choice::new(option.time.to_rfc3339(), option.label))
619 + .collect(),
620 + )),
621 + ),
622 + Node::Act(
623 + Act::new("Delete", over("delete"))
624 + .tone(makeover_layout::Tone::Danger)
625 + .over(SELECTION)
626 + .confirm("Delete every selected email? This cannot be undone."),
627 + ),
628 + ];
629 +
630 + // Select-all is an address, so its opposite is the same address without it,
631 + // and it is only offered when there is something to clear.
632 + out.push(Node::act(
633 + "Select all",
634 + View {
635 + ticked: true,
636 + ..view.clone()
637 + }
638 + .list(),
639 + ));
640 + if view.ticked {
641 + out.push(Node::act(
642 + "Clear selection",
643 + View {
644 + ticked: false,
645 + ..view.clone()
646 + }
647 + .list(),
648 + ));
649 + }
650 +
651 + out
652 + }
653 +
523 654 /// The whole screen, with one thread open or none.
524 655 ///
525 656 /// Built here rather than inside each route for the reason the projects screen
@@ -547,6 +678,7 @@
547 678 "Mark all read",
548 679 view.carry(Action::post("/emails/read-all")),
549 680 ));
681 + band = band.extend(bulk(view));
550 682
551 683 let thread_pane = match open {
552 684 Some(id) => thread_slot(state, id, view)?,
@@ -554,6 +686,7 @@
554 686 };
555 687
556 688 Ok(Screen::list_detail("Emails", false)
689 + .selecting(SELECTION)
557 690 .with(band)
558 691 .with(Slot::new(LIST, RegionKind::Pane).with(list(state, view, open)?))
559 692 .with(thread_pane))
@@ -923,6 +1056,154 @@
923 1056 ))
924 1057 }
925 1058
1059 + /// Every message the user ticked, in the order they arrived.
1060 + ///
1061 + /// The ticks come back under one repeated name, [`Node::TICKED`], which is what
1062 + /// [`quasi_router::Params::get_all`] is for and why no delimiter had to be one
1063 + /// no id can contain.
1064 + ///
1065 + /// An id that does not parse is dropped rather than refused, on the task list's
1066 + /// reasoning: a bulk write is answered by the list it happened in, and failing
1067 + /// the whole press over one malformed value would lose the other thirty-nine.
1068 + /// The count in the toast is what the user actually gets, so a drop shows up as
1069 + /// a smaller number.
1070 + ///
1071 + /// An empty set is not an error either. Every renderer has drawn a control over
1072 + /// an empty selection as disabled since 2026-08-16, so the ordinary way to
1073 + /// arrive here with nothing is gone, and the ways that are left — a hand-typed
1074 + /// request, a webview host serving no selection script — deserve the unchanged
1075 + /// list rather than a 404. `bulk-actions.js` reaches the same place with its
1076 + /// `if (selectedIds.size === 0) return;`.
1077 + fn chosen(request: &quasi_router::Request) -> Vec<EmailId> {
1078 + request
1079 + .payload
1080 + .get_all(quasi_router::Node::TICKED)
1081 + .filter_map(|raw| uuid::Uuid::parse_str(raw.trim()).ok())
1082 + .map(EmailId::from)
1083 + .collect()
1084 + }
1085 +
1086 + /// `N emails` or `1 email`, for a toast that counts.
1087 + fn counted(n: usize) -> String {
1088 + if n == 1 {
1089 + "1 email".to_owned()
1090 + } else {
1091 + format!("{n} emails")
1092 + }
1093 + }
1094 +
1095 + /// The list after a bulk write, with nothing ticked.
1096 + ///
1097 + /// The ticks are cleared by answering a view that has none, which is
1098 + /// `bulk-actions.js`'s `clearSelection()` in each of its five paths arrived at
1099 + /// from the other side. Nothing to clear renderer-side either: the rows are
1100 + /// redrawn, and a row that comes back unticked is unticked.
1101 + fn bulk_wrote(
1102 + state: &AppState,
1103 + request: &quasi_router::Request,
1104 + message: String,
1105 + ) -> Result<Response, RouteError> {
1106 + let view = View {
1107 + ticked: false,
1108 + ..View::of(request)
1109 + };
1110 + Ok(wrote(state, &view, None)?.toast(makeover_layout::Tone::Success, message))
1111 + }
1112 +
1113 + /// Mark every ticked message read.
1114 + ///
1115 + /// One at a time through the same repository call a row's own Mark read takes,
1116 + /// which is what `bulk-actions.js` does with its `Promise.allSettled` over the
1117 + /// per-message API. A message that has gone since the list was drawn is skipped
1118 + /// rather than failing the press.
1119 + fn read_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1120 + let mut done = 0;
1121 + for id in chosen(&request) {
1122 + if state
1123 + .emails
1124 + .mark_read(id, DESKTOP_USER_ID)
1125 + .map_err(|error| RouteError::internal(error.to_string()))?
1126 + {
1127 + done += 1;
1128 + }
1129 + }
1130 + bulk_wrote(state, &request, format!("{} marked read.", counted(done)))
1131 + }
1132 +
1133 + /// Archive every ticked message.
1134 + ///
1135 + /// The local half only; see [`set_archived`] for why the IMAP half cannot be
1136 + /// described from here.
1137 + fn archive_chosen(
1138 + state: &AppState,
1139 + request: quasi_router::Request,
1140 + ) -> Result<Response, RouteError> {
1141 + let mut done = 0;
1142 + for id in chosen(&request) {
1143 + if state
1144 + .emails
1145 + .archive(id, DESKTOP_USER_ID)
1146 + .map_err(|error| RouteError::internal(error.to_string()))?
1147 + {
1148 + done += 1;
1149 + }
1150 + }
1151 + bulk_wrote(state, &request, format!("{} archived.", counted(done)))
1152 + }
1153 +
1154 + /// Snooze every ticked message until the time the verb asked for.
1155 + ///
1156 + /// The time arrives under its own [`Field::name`] because that is how
1157 + /// [`Act::asks`] sends it, so this reads `until` exactly as [`set_snooze`]
1158 + /// does, and refuses a past one for the same reason: the repository would take
1159 + /// it, `is_snoozed` would read false the moment it landed, and the user would
1160 + /// be told forty messages were hidden when none of them were.
1161 + fn snooze_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1162 + let until = request
1163 + .payload
1164 + .get("until")
1165 + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
1166 + .map(|when| when.with_timezone(&Utc))
1167 + .filter(|when| *when > Utc::now())
1168 + .ok_or_else(|| RouteError::not_found("not a time to snooze until"))?;
1169 +
1170 + let mut done = 0;
1171 + for id in chosen(&request) {
1172 + if state
1173 + .emails
1174 + .snooze(id, DESKTOP_USER_ID, until)
1175 + .map_err(|error| RouteError::internal(error.to_string()))?
1176 + .is_some()
1177 + {
1178 + done += 1;
1179 + }
1180 + }
1181 + bulk_wrote(
1182 + state,
1183 + &request,
1184 + format!(
1185 + "{} snoozed until {}.",
1186 + counted(done),
1187 + date_utils::format_relative_future(until, Utc::now())
1188 + ),
1189 + )
1190 + }
1191 +
1192 + /// Delete every ticked message.
1193 + fn delete_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1194 + let mut done = 0;
1195 + for id in chosen(&request) {
1196 + if state
1197 + .emails
1198 + .delete(id, DESKTOP_USER_ID)
1199 + .map_err(|error| RouteError::internal(error.to_string()))?
1200 + {
1201 + done += 1;
1202 + }
1203 + }
1204 + bulk_wrote(state, &request, format!("{} deleted.", counted(done)))
1205 + }
1206 +
926 1207 /// Archive, or bring it back.
927 1208 ///
928 1209 /// # The fifth finding
@@ -1196,6 +1477,14 @@
1196 1477 .get("/emails/list", list_only)
1197 1478 .get("/emails", index)
1198 1479 .post("/emails/read-all", read_all)
1480 + // Ahead of the `{id}` writes below for the same reason `/emails/list`
1481 + // is ahead of `/emails`: a literal segment outranks a capture, and a
1482 + // reader should not have to know that to be sure `list` never arrives
1483 + // as an id.
1484 + .post("/emails/list/read", read_chosen)
1485 + .post("/emails/list/archive", archive_chosen)
1486 + .post("/emails/list/snooze", snooze_chosen)
1487 + .post("/emails/list/delete", delete_chosen)
1199 1488 .get("/emails/{id}", thread)
1200 1489 .post("/emails/{id}/read", set_read)
1201 1490 .post("/emails/{id}/archive", set_archived)
@@ -11,8 +11,8 @@
11 11 use chrono::{Duration, Utc};
12 12 use goingson_core::{BodyFormat, Email, EmailId, NewEmailWithTracking};
13 13 use quasi_http::Serves as _;
14 - use quasi_router::screen::Row;
15 - use quasi_router::{Node, Outcome, Params, Request, Response};
14 + use quasi_router::screen::{Act, Row, Slot};
15 + use quasi_router::{Action, Node, Outcome, Params, Request, Response, Screen};
16 16
17 17 use super::super::router;
18 18 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -146,6 +146,56 @@
146 146 .unwrap_or_default()
147 147 }
148 148
149 + /// The band, which is where the filters and the bulk bar live.
150 + fn band(page: &Screen) -> &Slot {
151 + page.slots
152 + .iter()
153 + .find(|slot| slot.id == "emails-band")
154 + .expect("the screen has a band")
155 + }
156 +
157 + /// The controls the band offers, in the order it offers them.
158 + fn acts(page: &Screen) -> Vec<&Act> {
159 + band(page)
160 + .body
161 + .iter()
162 + .filter_map(|ranked| match &ranked.node {
163 + Node::Act(act) => Some(act),
164 + _ => None,
165 + })
166 + .collect()
167 + }
168 +
169 + /// What those controls are called.
170 + fn labels(page: &Screen) -> Vec<String> {
171 + acts(page).iter().map(|act| act.label.clone()).collect()
172 + }
173 +
174 + /// Every route a filter control leads to: the two selects and the chip.
175 + fn filter_actions(page: &Screen) -> Vec<Action> {
176 + band(page)
177 + .body
178 + .iter()
179 + .filter_map(|ranked| match &ranked.node {
180 + Node::Field(field) => field.changes.clone(),
181 + Node::Token(tag) => tag.action.clone(),
182 + _ => None,
183 + })
184 + .collect()
185 + }
186 +
187 + /// The rows the screen's list pane is holding.
188 + fn listed_rows(page: &Screen) -> Vec<Row> {
189 + page.slots
190 + .iter()
191 + .find(|slot| slot.id == super::LIST)
192 + .and_then(|slot| slot.body.first())
193 + .map_or_else(Vec::new, |ranked| match &ranked.node {
194 + Node::List { rows, .. } => rows.clone(),
195 + other => panic!("expected a list, got {other:?}"),
196 + })
197 + }
198 +
149 199 fn read(state: &AppState, id: EmailId) -> bool {
150 200 state
151 201 .emails
@@ -170,25 +220,276 @@
170 220 }
171 221
172 222 #[tokio::test]
173 - async fn no_row_offers_a_selection_because_nothing_describes_one() {
174 - // The first finding. `Row::selectable` says a row is ticked and there is no
175 - // way to say what a set of ticked rows is for, so the port describes none:
176 - // a tick with no bulk action behind it is a control that collects a value
177 - // nothing reads. The shipped screen has five bulk actions and a counting
178 - // bar.
179 - //
180 - // Closing the finding on quasicoherent is what changes this test.
223 + async fn every_row_offers_a_selection_and_says_what_its_tick_contributes() {
224 + // The first finding, inverted. It was written to fail loudly once the
225 + // vocabulary landed, and quasi 0.37.0 landed it: the screen names the set,
226 + // the row says what its tick contributes, and the bar acts on the whole of
227 + // it. A row that is tickable and contributes nothing is the dead affordance
228 + // `Row::ticking` exists to end, so both halves are asserted.
181 229 let state = state().await;
182 - add(&state, message("One"));
183 - add(&state, message("Two"));
230 + let one = add(&state, message("One"));
231 + let two = add(&state, message("Two"));
184 232
185 233 let listed = rows(get(&state, "/emails/list", Params::new()));
186 234
187 235 assert_eq!(listed.len(), 2);
188 236 assert!(
189 - listed.iter().all(|row| row.selected.is_none()),
190 - "a described row cannot be part of a selection yet",
237 + listed.iter().all(|row| row.selected == Some(false)),
238 + "a described row is tickable and arrives unticked",
191 239 );
240 + let values: Vec<String> = listed
241 + .iter()
242 + .map(|row| row.value.clone().expect("the tick contributes a value"))
243 + .collect();
244 + assert!(values.contains(&one.id.to_string()));
245 + assert!(values.contains(&two.id.to_string()));
246 + }
247 +
248 + #[tokio::test]
249 + async fn the_screen_names_the_set_and_five_controls_sit_over_it() {
250 + // The other half of the description: a set with no name is a tick with
251 + // nowhere to go, and `Act::over` is what makes a control read as a commit
252 + // control rather than as a second row action.
253 + let state = state().await;
254 + add(&state, message("One"));
255 +
256 + let Outcome::Screen(page) = get(&state, "/emails", Params::new()).outcome else {
257 + panic!("expected a screen");
258 + };
259 +
260 + assert_eq!(page.selection.as_deref(), Some(super::SELECTION));
261 +
262 + let over: Vec<&str> = acts(&page)
263 + .iter()
264 + .filter(|act| act.over.as_deref() == Some(super::SELECTION))
265 + .map(|act| act.label.as_str())
266 + .collect();
267 + assert_eq!(over, ["Mark read", "Archive", "Snooze", "Delete"]);
268 +
269 + // The fifth is the shipped bar's fifth, and it is an address rather than a
270 + // verb over the set: see `View::ticked`.
271 + assert!(labels(&page).iter().any(|label| label == "Select all"));
272 + assert!(
273 + !labels(&page).iter().any(|label| label == "Clear selection"),
274 + "nothing arrives ticked, so there is nothing to clear",
275 + );
276 + }
277 +
278 + #[tokio::test]
279 + async fn the_snooze_verb_asks_for_a_time_before_it_fires() {
280 + // `Act::asks`, and the reason it exists: `bulk-actions.js` raises a whole
281 + // modal to collect this one value. The options are the same ones the open
282 + // thread offers, so the count is whatever the local clock allows rather
283 + // than a number written here.
284 + let state = state().await;
285 + add(&state, message("One"));
286 +
287 + let Outcome::Screen(page) = get(&state, "/emails", Params::new()).outcome else {
288 + panic!("expected a screen");
289 + };
290 +
291 + let snooze = acts(&page)
292 + .into_iter()
293 + .find(|act| act.label == "Snooze")
294 + .expect("the bar offers Snooze");
295 + assert_eq!(snooze.asks.len(), 1);
296 + assert_eq!(snooze.asks[0].name, "until");
297 + assert!(
298 + !snooze.asks[0].options.is_empty(),
299 + "the question is a pick from the clock's own options",
300 + );
301 + }
302 +
303 + #[tokio::test]
304 + async fn select_all_is_an_address_and_a_filter_change_drops_it() {
305 + // The charter rule `emails.js` enforces by hand in each of its four filter
306 + // handlers, holding here because the ticks travel on the address and the
307 + // filter controls drop them.
308 + let state = state().await;
309 + add(&state, message("One"));
310 +
311 + let ticked = rows(get(
312 + &state,
313 + "/emails/list",
314 + Params::new().with("ticked", "all"),
315 + ));
316 + assert!(
317 + ticked.iter().all(|row| row.selected == Some(true)),
318 + "select-all is answered by the server, so the rows arrive ticked",
319 + );
320 +
321 + let Outcome::Screen(page) = get(&state, "/emails", Params::new().with("ticked", "all")).outcome
322 + else {
323 + panic!("expected a screen");
324 + };
325 + assert!(
326 + labels(&page).iter().any(|label| label == "Clear selection"),
327 + "with everything ticked there is something to clear",
328 + );
329 + // Every filter control leads to a view with nothing ticked.
330 + for action in filter_actions(&page) {
331 + assert_eq!(
332 + action.carried.get("ticked"),
333 + None,
334 + "a filter change cannot carry select-all: {action:?}",
335 + );
336 + }
337 + }
338 +
339 + #[tokio::test]
340 + async fn a_bulk_write_reads_the_ticks_and_answers_with_them_cleared() {
341 + let state = state().await;
342 + let one = add(&state, message("One"));
343 + let two = add(&state, message("Two"));
344 + let untouched = add(&state, message("Three"));
345 +
346 + let answer = viewing_post(
347 + &state,
348 + "/emails/list/read",
349 + Params::new()
350 + .with(Node::TICKED, one.id.to_string())
351 + .with(Node::TICKED, two.id.to_string()),
352 + Params::new().with("ticked", "all"),
353 + );
354 +
355 + assert!(read(&state, one.id));
356 + assert!(read(&state, two.id));
357 + assert!(!read(&state, untouched.id), "an unticked row is untouched");
358 + assert_eq!(notice(&answer), "2 emails marked read.");
359 +
360 + let Outcome::Screen(page) = answer.outcome else {
361 + panic!("expected a screen");
362 + };
363 + assert!(
364 + listed_rows(&page)
365 + .iter()
366 + .all(|row| row.selected == Some(false)),
367 + "the answer clears the ticks, select-all included",
368 + );
369 + }
370 +
371 + #[tokio::test]
372 + async fn archive_delete_and_snooze_each_run_over_the_set() {
373 + let state = state().await;
374 + let archived = add(&state, message("Archive me"));
375 + let deleted = add(&state, message("Delete me"));
376 + let snoozed = add(&state, message("Snooze me"));
377 +
378 + let answer = post(
379 + &state,
380 + "/emails/list/archive",
381 + Params::new().with(Node::TICKED, archived.id.to_string()),
382 + );
383 + assert_eq!(notice(&answer), "1 email archived.");
384 + assert!(
385 + state
386 + .emails
387 + .get_by_id(archived.id, DESKTOP_USER_ID)
388 + .unwrap()
389 + .unwrap()
390 + .is_archived
391 + );
392 +
393 + let answer = post(
394 + &state,
395 + "/emails/list/delete",
396 + Params::new().with(Node::TICKED, deleted.id.to_string()),
397 + );
398 + assert_eq!(notice(&answer), "1 email deleted.");
399 + assert!(
400 + state
401 + .emails
402 + .get_by_id(deleted.id, DESKTOP_USER_ID)
403 + .unwrap()
404 + .is_none()
405 + );
406 +
407 + let until = Utc::now() + Duration::hours(3);
408 + let answer = post(
409 + &state,
410 + "/emails/list/snooze",
411 + Params::new()
412 + .with(Node::TICKED, snoozed.id.to_string())
413 + .with("until", until.to_rfc3339()),
414 + );
415 + assert!(
416 + notice(&answer).starts_with("1 email snoozed until "),
417 + "the toast counts and says when: {}",
418 + notice(&answer),
419 + );
420 + assert!(
421 + state
422 + .emails
423 + .get_by_id(snoozed.id, DESKTOP_USER_ID)
424 + .unwrap()
425 + .unwrap()
426 + .is_snoozed()
427 + );
428 + }
429 +
430 + #[tokio::test]
431 + async fn a_bulk_write_over_nothing_answers_the_screen_rather_than_erroring() {
432 + // Every renderer draws a control over an empty selection as disabled, so
433 + // this is the hand-typed request rather than the ordinary path. It still
434 + // gets the list back: see `chosen`.
435 + let state = state().await;
436 + add(&state, message("One"));
437 +
438 + let answer = post(&state, "/emails/list/delete", Params::new());
439 +
440 + assert_eq!(notice(&answer), "0 emails deleted.");
441 + let Outcome::Screen(page) = answer.outcome else {
442 + panic!("expected a screen");
443 + };
444 + assert_eq!(listed_rows(&page).len(), 1);
445 + }
446 +
447 + #[tokio::test]
448 + async fn a_bulk_snooze_refuses_a_time_that_has_passed() {
449 + // `set_snooze`'s floor, applied to the set: the repository would take it,
450 + // `is_snoozed` would read false immediately, and the toast would claim
451 + // forty messages were hidden when none of them were.
452 + let state = state().await;
453 + let email = add(&state, message("One"));
454 +
455 + let answer = router().handle(
456 + &state,
457 + Request::post("/emails/list/snooze").sending(
458 + Params::new()
459 + .with(Node::TICKED, email.id.to_string())
460 + .with("until", (Utc::now() - Duration::hours(1)).to_rfc3339()),
461 + ),
462 + );
463 +
464 + assert!(answer.is_err(), "a past time is refused, not stored");
465 + assert!(
466 + !state
467 + .emails
468 + .get_by_id(email.id, DESKTOP_USER_ID)
469 + .unwrap()
470 + .unwrap()
471 + .is_snoozed()
472 + );
473 + }
474 +
475 + #[tokio::test]
476 + async fn a_ticked_id_that_is_not_an_id_is_dropped_rather_than_failing_the_press() {
477 + // The task list's rule, and the reason for it: failing the whole press over
478 + // one malformed value would lose the other thirty-nine, and the count in
479 + // the toast is what the user actually gets.
480 + let state = state().await;
481 + let email = add(&state, message("One"));
482 +
483 + let answer = post(
484 + &state,
485 + "/emails/list/read",
486 + Params::new()
487 + .with(Node::TICKED, "not-a-uuid")
488 + .with(Node::TICKED, email.id.to_string()),
489 + );
490 +
491 + assert_eq!(notice(&answer), "1 email marked read.");
492 + assert!(read(&state, email.id));
192 493 }
193 494
194 495 #[tokio::test]