Skip to main content

max / goingson

31.1 KB · 801 lines History Blame Raw
1 //! Settings, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # The shape
6 //!
7 //! - `GET /settings` — Appearance.
8 //! - `GET /settings/{section}` — one section.
9 //! - `POST /settings/config/{key}` — write one config key, under `value`.
10 //!
11 //! The section is an address rather than module state, per decision 2. Unlike
12 //! the projects filters and the weekly review's week it needs no carrying: a
13 //! write answers with the section it happened in, and the handler knows which
14 //! that is from the key.
15 //!
16 //! Appearance, Notifications and Planning & Review are `user_config` keys: rows
17 //! in a table this app owns, declared as a closed set in
18 //! [`crate::config_key::CONFIG`]. So the whole of this screen's writing is one
19 //! route, `POST /settings/config/{key}`, refused by the same `ensure_known` the
20 //! Tauri command is refused by. A screen that is a key/value editor should read
21 //! as one.
22 //!
23 //! Email, Sync, Sharing and About are sections of their own; see [`email`],
24 //! [`sync`], [`sharing`] and [`about`]. Import & Export is a screen of its own
25 //! at `/data`; see [`Section::at`].
26 //!
27 //! # A host fact belongs on `AppState`
28 //!
29 //! A route handler is `fn(&AppState, Request)`, so a section reading the host
30 //! rather than the app has nothing to read. The general answer is not quasi's
31 //! to give: `S` is the app's own state, and a host fact a described screen
32 //! needs is a host fact the app resolves at startup and holds. The theme search
33 //! path is
34 //! [`AppState::theme_dirs`](crate::state::AppState::theme_dirs); the version
35 //! and the platform are held the same way.
36 //!
37 //! What does not yield to it is a fact that is only true at the moment it is
38 //! asked, such as whether biometry is enrolled. That is filed. A save dialog is
39 //! not one of them: the route answers with the file and the host puts it
40 //! somewhere, so the control never asks.
41 //!
42 //! The admin half of sharing stays host-bound. The member half is here, because
43 //! being added to a group takes no write from the person being added.
44
45 // Handlers take their request by value because `quasi_router::Handler` is a
46 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
47 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
48 #![allow(clippy::needless_pass_by_value)]
49
50 use std::collections::HashMap;
51
52 use quasi_declare::declare;
53 use quasi_notifs::pane;
54 use quasi_router::layout::{Contrast, ThemeVariant};
55 use quasi_router::screen::{Choice, ThemeChoice};
56 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Slot};
57
58 use crate::notifs::NOTIFS;
59
60 use crate::commands::{all_config, write_config};
61 use crate::state::AppState;
62
63 pub(crate) mod about;
64 pub(crate) mod email;
65 pub(crate) mod sharing;
66 pub(crate) mod sync;
67
68 #[cfg(test)]
69 mod tests;
70
71 /// The region a section's contents answer at.
72 ///
73 /// Named once because several things aim at it: the screen that builds it, and
74 /// every section that answers itself back after a write.
75 const SECTION_REGION: &str = "settings-content";
76
77 /// A section of the screen: its address, its heading, and the keys it writes.
78 struct Section {
79 /// The last path segment, and what the sidebar sends.
80 slug: &'static str,
81 /// What the section is called.
82 title: &'static str,
83 /// Where the row goes, when that is not a section of this screen.
84 ///
85 /// `None` for the ordinary case, which is `/settings/{slug}` and a pane
86 /// this module draws.
87 ///
88 /// `Some` is a row that navigates away, and Import & Export is the one: it
89 /// is [`super::data`], a whole screen at `/data`. Serving `/data` under
90 /// `/settings/data` instead would move a finished screen for the sake of
91 /// the sidebar's shape.
92 ///
93 /// What it costs: the sidebar belongs to this screen, so following such a
94 /// row leaves it behind and `/data` draws its own band and no nav.
95 ///
96 /// Two consequences hold wherever this is read. A row that leaves is never
97 /// [`Row::current`], because this screen is not showing it. And
98 /// [`section_of`] does not answer for one, so `/settings/data` is a 404
99 /// rather than Appearance drawn under somebody else's name.
100 at: Option<&'static str>,
101 }
102
103 /// The sections that are described, in the sidebar's order.
104 ///
105 /// A section that cannot be drawn is absent rather than disabled: a control
106 /// that is drawn and does nothing is worse than a control that is not drawn.
107 const SECTIONS: [Section; 8] = [
108 Section {
109 slug: "appearance",
110 title: "Appearance",
111 at: None,
112 },
113 Section {
114 slug: "notifications",
115 title: "Notifications",
116 at: None,
117 },
118 Section {
119 slug: "planning",
120 title: "Planning & Review",
121 at: None,
122 },
123 // See `email`: the account is app data; only the OAuth handshake is
124 // host-bound.
125 Section {
126 slug: "email",
127 title: "Email",
128 at: None,
129 },
130 // Described in full as `super::data` and not a section of this screen; see
131 // `Section::at`.
132 Section {
133 slug: "data",
134 title: "Import & Export",
135 at: Some("/data"),
136 },
137 // Host facts, held on `AppState` at startup. See `about`.
138 Section {
139 slug: "about",
140 title: "About",
141 at: None,
142 },
143 // Local per command, whatever the file's await count suggests; see `sync`.
144 Section {
145 slug: "sync",
146 title: "Sync",
147 at: None,
148 },
149 // Reads the local group directory synckit writes down. See `sharing`.
150 Section {
151 slug: "sharing",
152 title: "Sharing",
153 at: None,
154 },
155 ];
156
157 /// What the app falls back to when a key has never been written.
158 ///
159 /// Stated once here rather than at each read. The JS states each of these
160 /// inline at its own call site — `|| 'system'`, `|| '15'`, `|| '9'` — which is
161 /// how `event_lead_minutes` came to have its default written in three places.
162 fn default_for(key: &str) -> &'static str {
163 match key {
164 "theme" => "system",
165 "event_lead_minutes" => "15",
166 "work_start_hour" => "9",
167 "work_end_hour" => "17",
168 // Both nudges are on unless someone turned them off, which is what the
169 // JS's `!== 'disabled'` says the long way round.
170 _ => "enabled",
171 }
172 }
173
174 /// The value a key currently holds.
175 fn value_of<'a>(config: &'a HashMap<String, String>, key: &str) -> &'a str {
176 config.get(key).map_or_else(
177 || {
178 // Borrowed from a `'static`, which outlives `'a`.
179 default_for(key)
180 },
181 String::as_str,
182 )
183 }
184
185 declare! {
186 /// A select over a fixed set of values, holding the one in force.
187 ///
188 /// It writes as soon as it changes and stands on its own: [`Field::writes`]
189 /// on a field in a panel, which is what finding `14612ed8` was closed for
190 /// and what this screen is the first consumer of. The JS says the same
191 /// thing with `data-change` on a bare `<select>` with no form around it;
192 /// wrapping these in a [`Node::Form`] would describe a submit button that
193 /// does not exist.
194 shape choice_field(
195 config: &HashMap<String, String>,
196 key: &'static str,
197 label: &str,
198 choices: Vec<Choice>,
199 ) -> Field;
200
201 field Select key label {
202 options choices;
203 value value_of(config, key);
204 writes Action::post("/settings/config/{key}");
205 }
206 }
207
208 /// The themes on offer, in the order the resolver read them out.
209 ///
210 /// A supplier because the mapping is a closure. It hands back [`ThemeChoice`],
211 /// which is not a vocabulary type, so it costs the population nothing.
212 ///
213 /// # Nothing here sorts, and nothing here groups
214 ///
215 /// [`makeover::theme_options`] returns the list already ordered by variant and
216 /// then by measured contrast, which is the order the renderer reads the groups
217 /// out of. Re-sorting here would be this app deciding a question it cannot see
218 /// the inputs to, and it is the duplication that put three different orders in
219 /// three apps in the first place.
220 ///
221 /// The list is read through [`AppState::theme_dirs`], which is the whole reason
222 /// the Appearance section exists at all — see the module header.
223 fn theme_choices(app: &AppState) -> Vec<ThemeChoice> {
224 makeover::theme_options(&app.theme_dirs)
225 .into_iter()
226 .map(|theme| {
227 ThemeChoice::new(
228 theme.id,
229 theme.name,
230 variant_of(theme.variant),
231 tier_of(theme.contrast),
232 )
233 })
234 .collect()
235 }
236
237 declare! {
238 /// The theme picker.
239 ///
240 /// A described theme picker rather than a select of grouped options:
241 /// [`Field::theme`]'s kind, whose entries carry their variant and their
242 /// measured contrast tier as values rather than as prose. The grouping
243 /// comes back, and the tier arrives with it — a fact this app never had,
244 /// because deriving it means resolving every theme's colours and this
245 /// screen only ever had the names.
246 ///
247 /// `makeover::FOLLOW` rather than a literal: the sentinel this app stores
248 /// is the one the crate that resolves it reads back, and spelling it here
249 /// is how the two drift.
250 shape theme_field(app: &AppState, config: &HashMap<String, String>) -> Field;
251
252 field Theme "theme" "Theme" {
253 themes theme_choices(app);
254 following Choice::new(makeover::FOLLOW, "Follow System");
255 value value_of(config, "theme");
256 writes Action::post("/settings/config/theme");
257 }
258 }
259
260 /// `makeover`'s variant as the description layer's own.
261 ///
262 /// The seam the layering costs, and it is three arms. `makeover-layout` takes
263 /// no dependencies by charter, so it cannot name `makeover::Variant`, and a
264 /// renderer that groups a picker needs the groups as values. Exhaustive rather
265 /// than wildcarded on purpose: a fourth ambient mode should stop the build here
266 /// rather than quietly file itself under dark.
267 fn variant_of(variant: makeover::Variant) -> ThemeVariant {
268 match variant {
269 makeover::Variant::Light => ThemeVariant::Light,
270 makeover::Variant::Dark => ThemeVariant::Dark,
271 makeover::Variant::HighContrast => ThemeVariant::HighContrast,
272 }
273 }
274
275 /// `makeover`'s measured tier as the description layer's own.
276 ///
277 /// [`variant_of`]'s twin, for its reason and with its exhaustiveness.
278 fn tier_of(tier: makeover::ContrastTier) -> Contrast {
279 match tier {
280 makeover::ContrastTier::Low => Contrast::Low,
281 makeover::ContrastTier::Standard => Contrast::Standard,
282 makeover::ContrastTier::High => Contrast::High,
283 }
284 }
285
286 declare! {
287 /// Appearance.
288 ///
289 /// # The second finding
290 ///
291 /// **Import and Export are absent because a file dialog is not an
292 /// address.** `themes.importTheme` opens a native open-dialog and
293 /// `themes.exportTheme` a native save-dialog, and both then call a command
294 /// with the path the user picked. `FieldKind::File` covers picking a file
295 /// to *submit*, which is the import half and would work here if the write
296 /// route existed; the export half is a control that asks the host where to
297 /// put something and then acts, and nothing in the vocabulary names that.
298 ///
299 /// Left out rather than dangled, to the standard the contacts port set.
300 /// Filed on quasicoherent alongside the About section's host facts, because
301 /// it is the same finding wearing different clothes: the description can
302 /// say what to do and cannot reach what the host knows.
303 ///
304 /// # There is no hint any more, because there is nothing left to apologise for
305 ///
306 /// There was one, and it said a named theme took effect the next time
307 /// GoingsOn started. That was true while the sheet held one theme and the
308 /// document linked it once. It holds every theme now, keyed by a root
309 /// attribute (`super::theming`), and picking one sets the attribute, so
310 /// every choice on this control lands at once and none of them is worth
311 /// explaining.
312 ///
313 /// Deleted rather than reworded. The remaining sentence would have said
314 /// that Follow System follows the system, which the option's own label
315 /// says.
316 shape appearance(app: &AppState, config: &HashMap<String, String>) -> Vec<Node>;
317
318 section "Appearance";
319 include theme_field(app, config);
320 }
321
322 /// The lead times the Events tab indicator offers.
323 ///
324 /// The default is marked in the label because the control cannot otherwise say
325 /// which value it would hold if nobody had chosen. The JS marks the same one
326 /// the same way.
327 fn lead_choices() -> Vec<Choice> {
328 [5, 10, 15, 30, 60]
329 .into_iter()
330 .map(|minutes| {
331 let label = if minutes == 60 {
332 "1 hour".to_owned()
333 } else {
334 format!("{minutes} minutes")
335 };
336 let label = if minutes == 15 {
337 format!("{label} (default)")
338 } else {
339 label
340 };
341 Choice::new(minutes.to_string(), label)
342 })
343 .collect()
344 }
345
346 declare! {
347 /// Notifications.
348 ///
349 /// Two halves, and the point of the section is that they are two.
350 ///
351 /// The first is generated: [`quasi_notifs::pane`] emits a control per
352 /// declared kind straight from [`NOTIFS`], so adding a kind adds its
353 /// settings and there is no list here to keep in step. That replaced a
354 /// hand-built section, which is what task `07830eb5` was for. Every
355 /// generated control writes to one route under its own key, and the
356 /// section heading the generator emits per category is why nothing is
357 /// added around it: "Reminders" is the category GoingsOn declared.
358 ///
359 /// The shipped JavaScript screen renders the same half from the same
360 /// registry, over [`crate::commands::list_notification_kinds`], because
361 /// this screen is behind the `quasi` feature and a pane nobody can reach is
362 /// not somewhere onboarding can point (`b6c634fb`). It writes the same
363 /// generated keys, so the flip deletes it rather than migrating it.
364 ///
365 /// The second is `event_lead_minutes`, which stays hand-written because it
366 /// is not a notification setting at all: it colours a dot on the Events
367 /// tab. It sat alone under this heading before the generated half arrived,
368 /// and the risk the adoption had to avoid was folding it into the
369 /// event-reminder kind by name-similarity. Generated keys are dotted and
370 /// this one is not, so they cannot collide in the store; what they could
371 /// still do is read alike in the pane, which is what the sub-heading and
372 /// the hint are for.
373 ///
374 /// It sat in a shape of its own while this one had to `extend` a second
375 /// list onto its own; a panel says both halves in one body, so that shape
376 /// is gone.
377 shape notifications(config: &HashMap<String, String>) -> Vec<Node>;
378
379 section "Notifications";
380 include pane::pane(&NOTIFS, config, &Action::post("/settings/notifications"));
381
382 section "Events tab";
383 field Select "event_lead_minutes" "Event indicator lead time" {
384 options lead_choices();
385 value value_of(config, "event_lead_minutes");
386 hint "How far in advance the Events tab dot turns yellow. This is the \
387 indicator, not a notification.";
388 writes Action::post("/settings/config/event_lead_minutes");
389 }
390 }
391
392 /// Every hour of the day, as the clock writes it.
393 ///
394 /// Twelve-hour with AM and PM. A description that emitted `09:00` would be
395 /// answering a question about the user's locale that nothing here asked.
396 fn hour_choices() -> Vec<Choice> {
397 (0..24)
398 .map(|hour| {
399 let label = match hour {
400 0 => "12:00 AM".to_owned(),
401 1..=11 => format!("{hour}:00 AM"),
402 12 => "12:00 PM".to_owned(),
403 _ => format!("{}:00 PM", hour - 12),
404 };
405 Choice::new(hour.to_string(), label)
406 })
407 .collect()
408 }
409
410 /// Whether a switch of this kind is on.
411 fn on_off() -> Vec<Choice> {
412 vec![
413 Choice::new("enabled", "Enabled (default)"),
414 Choice::new("disabled", "Disabled"),
415 ]
416 }
417
418 declare! {
419 /// Planning and review.
420 ///
421 /// **Two controls answering one question are two controls.** Work hours is
422 /// a start and an end, and the description has one label per field, so it
423 /// says "Work day starts" and "Work day ends" rather than pairing them into
424 /// a row. A group of fields inside a form is furniture, which the
425 /// vocabulary declines to state.
426 shape planning(config: &HashMap<String, String>) -> Vec<Node>;
427
428 section "Planning & Review";
429
430 include choice_field(config, "work_start_hour", "Work day starts", hour_choices())
431 .hint("Controls when plan and review nudge dots appear.");
432 include choice_field(config, "work_end_hour", "Work day ends", hour_choices());
433 include choice_field(config, "plan_nudges", "Plan nudges", on_off());
434 include choice_field(config, "review_nudges", "Review nudges", on_off());
435 }
436
437 /// The section under this slug, or 404.
438 ///
439 /// A row that navigates away is in [`SECTIONS`] for the sidebar's sake and is
440 /// not a section of this screen, so it does not answer here. Without that,
441 /// `/settings/data` would fall through [`screen`]'s match and draw Appearance
442 /// under the Import & Export heading.
443 fn section_of(slug: &str) -> Result<&'static Section, RouteError> {
444 SECTIONS
445 .iter()
446 .find(|section| section.at.is_none() && section.slug == slug)
447 .ok_or_else(|| RouteError::not_found("no such settings section"))
448 }
449
450 /// Which section a config key belongs to, so a write can answer with it.
451 ///
452 /// A match on the key rather than a param the control carries: the section a
453 /// setting sits in is a fact about the setting, and threading it through every
454 /// action would be the screen telling the handler something the handler already
455 /// knows. An unknown key never reaches here — [`write_config`] refuses it first.
456 fn section_for_key(key: &str) -> &'static str {
457 match key {
458 "event_lead_minutes" => "notifications",
459 "work_start_hour" | "work_end_hour" | "plan_nudges" | "review_nudges" => "planning",
460 _ => "appearance",
461 }
462 }
463
464 /// Where a sidebar row leads.
465 ///
466 /// A section of this screen is `/settings/{slug}`; a row that navigates away
467 /// carries its own address. See [`Section::at`].
468 fn section_path(section: &Section) -> String {
469 section
470 .at
471 .map_or_else(|| format!("/settings/{}", section.slug), ToOwned::to_owned)
472 }
473
474 /// Whether this row is the one the pane is showing.
475 ///
476 /// `current` and not `selected`: this is the app's own pointer at what the pane
477 /// is showing rather than a tick the reader made. A row that leaves is never
478 /// current; see [`Section::at`].
479 fn is_showing(item: &Section, section: &Section) -> bool {
480 item.at.is_none() && item.slug == section.slug
481 }
482
483 /// The section the screen is showing, and the body it draws.
484 struct Showing {
485 section: &'static Section,
486 body: Vec<Node>,
487 }
488
489 /// The section under this slug, and the body it draws.
490 ///
491 /// The read is the handler's, which is what converting this screen meant: four
492 /// of the seven sections read the database or the sync client, two of them
493 /// fallibly, and a description says what is on the screen rather than fetching
494 /// it.
495 fn read(state: &AppState, slug: &str) -> Result<Showing, RouteError> {
496 let section = section_of(slug)?;
497 let config = all_config(state).map_err(|error| RouteError::internal(error.to_string()))?;
498 let body = match section.slug {
499 "notifications" => notifications(&config),
500 "planning" => planning(&config),
501 "email" => email::pane(&email::accounts(state)?),
502 "about" => about::pane(state),
503 "sync" => sync::pane(state),
504 "sharing" => sharing::sharing_pane(&sharing::read(state)?),
505 _ => appearance(state, &config),
506 };
507 Ok(Showing { section, body })
508 }
509
510 declare! {
511 /// The whole screen, showing one section.
512 ///
513 /// `Sidebar` for what it is named for: the sidebar is the section nav.
514 pub(super) shape screen(section: &Section, body: Vec<Node>) -> Screen;
515
516 screen sidebar_content "Settings" {
517 at_place super::shell::SETTINGS;
518
519 region "settings-nav" as Sidebar {
520 list {
521 for item in SECTIONS.iter() {
522 row item.title {
523 current is_showing(item, section);
524 activate to get section_path(item);
525 }
526 }
527 }
528 }
529
530 region SECTION_REGION as Pane {
531 extend body;
532 }
533 }
534 }
535
536 /// One section, drawn and answered with.
537 pub(super) fn showing(state: &AppState, slug: &str) -> Result<Response, RouteError> {
538 let showing = read(state, slug)?;
539 Ok(screen(showing.section, showing.body).into())
540 }
541
542 /// Appearance, which is where the screen opens.
543 fn index(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
544 showing(state, "appearance")
545 }
546
547 /// Turn the launch-time update check on or off.
548 ///
549 /// Its own route rather than a `user_config` key, because the value is not in
550 /// the database: `preferences.json` is read before a connection exists, which
551 /// is what `commands::preferences` is for. See [`about`].
552 fn set_update_check(
553 state: &AppState,
554 request: quasi_router::Request,
555 ) -> Result<Response, RouteError> {
556 let on = request.payload.get(about::UPDATE_CHECK).unwrap_or_default() != "disabled";
557 let said = about::write(state, on)?;
558 Ok(Response::fragment(
559 SECTION_REGION,
560 Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(about::pane(state))),
561 )
562 .toast(quasi_router::layout::Tone::Success, said))
563 }
564
565 /// The Sync section, answered back after a write to it.
566 ///
567 /// Its own three routes rather than arms of the config writer, because these
568 /// are `sync_state` rows read by the sync engine rather than `user_config`
569 /// keys. See [`sync`].
570 fn sync_pane(state: &AppState, said: String) -> Result<Response, RouteError> {
571 Ok(Response::fragment(
572 SECTION_REGION,
573 Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(sync::pane(state))),
574 )
575 .toast(quasi_router::layout::Tone::Success, said))
576 }
577
578 /// The Sharing pane, re-read, with a word about what just happened.
579 ///
580 /// Whole-pane rather than a narrower region, because every one of these writes
581 /// lands in the queue list and one of them lands in the group list too.
582 fn sharing_pane(state: &AppState, said: &str) -> Result<Response, RouteError> {
583 Ok(Response::fragment(
584 SECTION_REGION,
585 Node::Region(
586 Slot::new(SECTION_REGION, RegionKind::Pane)
587 .extend(sharing::sharing_pane(&sharing::read(state)?)),
588 ),
589 )
590 .toast(quasi_router::layout::Tone::Success, said))
591 }
592
593 /// Queue a new group.
594 fn queue_group(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
595 let said = sharing::create_group(state, &request)?;
596 sharing_pane(state, said)
597 }
598
599 /// Queue an add-member.
600 fn queue_member(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
601 let said = sharing::add_member(state, &request)?;
602 sharing_pane(state, said)
603 }
604
605 /// Take a queued admin write back out.
606 fn cancel_queued(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
607 let said = sharing::cancel(state, &request)?;
608 sharing_pane(state, said)
609 }
610
611 /// Queue a fresh invite code.
612 fn queue_invite(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
613 let said = sharing::create_invite(state, &request)?;
614 sharing_pane(state, said)
615 }
616
617 /// Queue a revoke, from the group's list or from the confirmations section.
618 fn queue_revoke(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
619 let said = sharing::revoke_invite(state, &request)?;
620 sharing_pane(state, said)
621 }
622
623 /// Queue a confirm: admit the holder of the key on this invitation.
624 fn queue_confirm(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
625 let said = sharing::confirm_invite(state, &request)?;
626 sharing_pane(state, said)
627 }
628
629 /// Queue a read of what a pasted code leads to.
630 fn queue_preview(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
631 let said = sharing::preview_invite(state, &request)?;
632 sharing_pane(state, said)
633 }
634
635 /// Queue an accept of the code this device is holding an answer about.
636 fn queue_accept(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
637 let said = sharing::accept_invite(state)?;
638 sharing_pane(state, said)
639 }
640
641 /// Forget the previewed code. Local, so nothing is queued.
642 fn drop_preview(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
643 let said = sharing::dismiss_preview(state)?;
644 sharing_pane(state, said)
645 }
646
647 /// Turn automatic syncing on or off.
648 fn set_sync_auto(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
649 let on = request.payload.get(sync::AUTO_SYNC).unwrap_or_default() != "disabled";
650 let said = sync::set_auto(state, on)?;
651 sync_pane(state, said.to_owned())
652 }
653
654 /// Set how often the scheduler syncs.
655 fn set_sync_interval(
656 state: &AppState,
657 request: quasi_router::Request,
658 ) -> Result<Response, RouteError> {
659 let minutes = request
660 .payload
661 .get(sync::INTERVAL)
662 .unwrap_or_default()
663 .parse()
664 .unwrap_or(5);
665 let said = sync::set_interval(state, minutes)?;
666 sync_pane(state, said)
667 }
668
669 /// Forget the stored token and stop syncing.
670 fn disconnect_sync(
671 state: &AppState,
672 _request: quasi_router::Request,
673 ) -> Result<Response, RouteError> {
674 let said = sync::disconnect(state)?;
675 sync_pane(state, said.to_owned())
676 }
677
678 /// One section.
679 fn section(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
680 let slug = request
681 .captures
682 .get("section")
683 .ok_or_else(|| RouteError::not_found("no section"))?;
684 showing(state, slug)
685 }
686
687 /// Write one config key.
688 ///
689 /// One route for every control on the screen, because every control on the
690 /// screen writes one `user_config` row. The closed set is [`write_config`]'s to
691 /// enforce, so a key the spec does not declare is refused here exactly as it is
692 /// refused through the command, and the screen does not carry a second list of
693 /// what it is willing to name.
694 ///
695 /// A refusal is an [`Internal`](quasi_router::Class::Internal) rather than the
696 /// field carrying its own error, and rather than the 400 that is not there to
697 /// reach for. `RouteError` has four classes and none of them is "the caller
698 /// asked for something malformed", which is a real gap in general and the right
699 /// answer here: every value this route can receive was put on the control by
700 /// this screen, so an undeclared key or a missing value is a bug in the
701 /// description and not in what the user chose. `Field::error` is for the other
702 /// case, which this screen does not have — nothing here is typed.
703 fn set(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
704 let key = request
705 .captures
706 .get("key")
707 .ok_or_else(|| RouteError::not_found("no config key"))?
708 .to_owned();
709 let value = request
710 .payload
711 .get(Node::SELECTED)
712 .ok_or_else(|| RouteError::internal("the control sent no value"))?;
713
714 write_config(state, &key, value).map_err(|error| RouteError::internal(error.to_string()))?;
715
716 // Answer with the section the setting lives in, re-read, for the reason
717 // every other port re-reads: the write is the database's to confirm.
718 showing(state, section_for_key(&key))
719 }
720
721 /// Write whatever the generated notifications pane sent.
722 ///
723 /// One route for the whole pane rather than one per key, because that is the
724 /// shape [`quasi_notifs::pane`] emits: every generated control carries the same
725 /// write action and sends its value under its own generated key, so the handler
726 /// reads a name it never had to be told. The hand-written half of the section
727 /// keeps [`set`], since its key is in the path.
728 ///
729 /// A checkbox that is off sends nothing at all -- that is how HTML submits one,
730 /// and the description follows it -- so a key the payload does not carry is a
731 /// key the reader just turned off. Iterating the registry rather than the
732 /// payload is what makes that readable: every declared toggle is written on
733 /// every post, as `true` or as `false`, so a stored value is never the absence
734 /// of one.
735 fn set_notifications(
736 state: &AppState,
737 request: quasi_router::Request,
738 ) -> Result<Response, RouteError> {
739 for kind in NOTIFS.kinds() {
740 let key = quasi_notifs::config::enabled_key(kind.id);
741 let on = request
742 .payload
743 .get(key.as_str())
744 .is_some_and(|v| v == "true");
745 write_config(state, &key, &on.to_string())
746 .map_err(|error| RouteError::internal(error.to_string()))?;
747
748 // A knob is a value rather than a presence, so an absent one was not
749 // sent and is left alone. No kind declares one today; this is here so
750 // that adding one to the registry needs no change to this route, which
751 // is the whole promise of the generated pane.
752 for knob in kind.options {
753 let key = quasi_notifs::config::knob_key(kind.id, knob.id);
754 if let Some(value) = request.payload.get(key.as_str()) {
755 write_config(state, &key, value)
756 .map_err(|error| RouteError::internal(error.to_string()))?;
757 }
758 }
759 }
760
761 showing(state, "notifications")
762 }
763
764 /// The settings screen's routes.
765 #[must_use]
766 pub fn routes(router: Router<AppState>) -> Router<AppState> {
767 let router = router
768 .get("/settings", index)
769 .post("/settings/config/{key}", set)
770 .post("/settings/notifications", set_notifications);
771 // Above the section capture, so `email` and `about` are literals rather
772 // than section names that happen to match.
773 let router = email::routes(router)
774 .post("/settings/about/update-check", set_update_check)
775 .post("/settings/sync/auto", set_sync_auto)
776 .post("/settings/sync/interval", set_sync_interval)
777 .post("/settings/sync/disconnect", disconnect_sync)
778 // Every one of these is a local insert. What talks to the server is
779 // `group_queue`'s drainer; see `sharing`.
780 .post("/settings/sharing/groups", queue_group)
781 .post("/settings/sharing/members", queue_member)
782 .post("/settings/sharing/queue/{id}/cancel", cancel_queued)
783 // Unlike `email` and `about` above, these need no ordering care: the
784 // literals are four segments and the captured pair is six, so nothing
785 // here can swallow anything else. Grouped for reading, not for
786 // precedence.
787 .post("/settings/sharing/invites/preview", queue_preview)
788 .post("/settings/sharing/invites/accept", queue_accept)
789 .post("/settings/sharing/invites/dismiss", drop_preview)
790 .post("/settings/sharing/invites", queue_invite)
791 .post(
792 "/settings/sharing/invites/{group_id}/{invitation_id}/revoke",
793 queue_revoke,
794 )
795 .post(
796 "/settings/sharing/invites/{group_id}/{invitation_id}/confirm",
797 queue_confirm,
798 );
799 router.get("/settings/{section}", section)
800 }
801