max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
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