1use super::*;
2
3impl App {
4 pub fn tick(&mut self) {
5 self.input.check_timeout();
6 self.process_pending_preview_read();
7 }
8
9 pub fn apply(&mut self, action: Action) {
10 self.status_message = None;
12
13 match action {
14 Action::OpenMailboxScreen => {
15 if self.accounts_page.onboarding_required {
16 self.screen = Screen::Accounts;
17 self.accounts_page.onboarding_modal_open =
18 self.accounts_page.accounts.is_empty() && !self.accounts_page.form.visible;
19 return;
20 }
21 self.screen = Screen::Mailbox;
22 self.active_pane = if self.layout_mode == LayoutMode::ThreePane {
23 ActivePane::MailList
24 } else {
25 self.active_pane
26 };
27 }
28 Action::OpenSearchScreen => {
29 self.pending_preview_read = None;
30 self.screen = Screen::Search;
31 if self.search_page.has_session() {
32 self.search_page.editing = false;
33 self.auto_preview_search();
34 } else {
35 self.search_page.editing = true;
36 self.search_page.query = self.search_bar.query.clone();
37 self.search_page.mode = self.search_bar.mode;
38 self.search_page.sort = SortOrder::DateDesc;
39 if !self.search_page.query.is_empty() {
40 self.trigger_live_search();
41 }
42 }
43 }
44 Action::OpenRulesScreen => {
45 self.pending_preview_read = None;
46 self.screen = Screen::Rules;
47 self.rules_page.refresh_pending = true;
48 }
49 Action::OpenDiagnosticsScreen => {
50 self.pending_preview_read = None;
51 self.screen = Screen::Diagnostics;
52 self.diagnostics_page.refresh_pending = true;
53 }
54 Action::OpenAccountsScreen => {
55 self.pending_preview_read = None;
56 self.screen = Screen::Accounts;
57 self.accounts_page.refresh_pending = true;
58 }
59 Action::RefreshAccounts => {
60 self.accounts_page.refresh_pending = true;
61 }
62 Action::OpenAccountFormNew => {
63 self.accounts_page.form = AccountFormState::default();
64 self.accounts_page.form.visible = true;
65 self.accounts_page.onboarding_modal_open = false;
66 self.refresh_account_form_derived_fields();
67 self.screen = Screen::Accounts;
68 }
69 Action::SaveAccountForm => {
70 let is_default = self
71 .selected_account()
72 .is_some_and(|account| account.is_default)
73 || self.accounts_page.accounts.is_empty();
74 self.accounts_page.last_result = None;
75 self.accounts_page.form.last_result = None;
76 self.pending_account_save = Some(self.account_form_data(is_default));
77 self.accounts_page.status = Some("Saving account...".into());
78 }
79 Action::TestAccountForm => {
80 let account = if self.accounts_page.form.visible {
81 self.account_form_data(false)
82 } else if let Some(account) = self.selected_account_config() {
83 account
84 } else {
85 self.accounts_page.status = Some("No editable account selected.".into());
86 return;
87 };
88 self.accounts_page.last_result = None;
89 self.accounts_page.form.last_result = None;
90 self.pending_account_test = Some(account);
91 self.accounts_page.status = Some("Testing account...".into());
92 }
93 Action::ReauthorizeAccountForm => {
94 let account = self.account_form_data(false);
95 self.accounts_page.last_result = None;
96 self.accounts_page.form.last_result = None;
97 self.pending_account_authorize = Some((account, true));
98 self.accounts_page.status = Some("Authorizing Gmail account...".into());
99 }
100 Action::SetDefaultAccount => {
101 if let Some(key) = self
102 .selected_account()
103 .and_then(|account| account.key.clone())
104 {
105 self.pending_account_set_default = Some(key);
106 self.accounts_page.status = Some("Setting default account...".into());
107 } else {
108 self.accounts_page.status =
109 Some("Runtime-only account cannot be set default from TUI.".into());
110 }
111 }
112 Action::MoveDown => {
113 if self.screen == Screen::Search {
114 if self.search_page.selected_index + 1 < self.search_row_count() {
115 self.search_page.selected_index += 1;
116 self.ensure_search_visible();
117 self.auto_preview_search();
118 }
119 self.maybe_load_more_search_results();
120 return;
121 }
122 if self.selected_index + 1 < self.mail_row_count() {
123 self.selected_index += 1;
124 }
125 self.ensure_visible();
126 self.update_visual_selection();
127 self.auto_preview();
128 }
129 Action::MoveUp => {
130 if self.screen == Screen::Search {
131 if self.search_page.selected_index > 0 {
132 self.search_page.selected_index -= 1;
133 self.ensure_search_visible();
134 self.auto_preview_search();
135 }
136 return;
137 }
138 if self.selected_index > 0 {
139 self.selected_index -= 1;
140 }
141 self.ensure_visible();
142 self.update_visual_selection();
143 self.auto_preview();
144 }
145 Action::JumpTop => {
146 if self.screen == Screen::Search {
147 self.search_page.selected_index = 0;
148 self.search_page.scroll_offset = 0;
149 self.auto_preview_search();
150 return;
151 }
152 self.selected_index = 0;
153 self.scroll_offset = 0;
154 self.auto_preview();
155 }
156 Action::JumpBottom => {
157 if self.screen == Screen::Search {
158 if self.search_page.has_more {
159 self.search_page.load_to_end = true;
160 self.load_more_search_results();
161 } else if self.search_row_count() > 0 {
162 self.search_page.selected_index = self.search_row_count() - 1;
163 self.ensure_search_visible();
164 self.auto_preview_search();
165 }
166 return;
167 }
168 if self.mail_row_count() > 0 {
169 self.selected_index = self.mail_row_count() - 1;
170 }
171 self.ensure_visible();
172 self.auto_preview();
173 }
174 Action::PageDown => {
175 if self.screen == Screen::Search {
176 let page = self.visible_height.max(1);
177 self.search_page.selected_index = (self.search_page.selected_index + page)
178 .min(self.search_row_count().saturating_sub(1));
179 self.ensure_search_visible();
180 self.auto_preview_search();
181 self.maybe_load_more_search_results();
182 return;
183 }
184 let page = self.visible_height.max(1);
185 self.selected_index =
186 (self.selected_index + page).min(self.mail_row_count().saturating_sub(1));
187 self.ensure_visible();
188 self.auto_preview();
189 }
190 Action::PageUp => {
191 if self.screen == Screen::Search {
192 let page = self.visible_height.max(1);
193 self.search_page.selected_index =
194 self.search_page.selected_index.saturating_sub(page);
195 self.ensure_search_visible();
196 self.auto_preview_search();
197 return;
198 }
199 let page = self.visible_height.max(1);
200 self.selected_index = self.selected_index.saturating_sub(page);
201 self.ensure_visible();
202 self.auto_preview();
203 }
204 Action::ViewportTop => {
205 self.selected_index = self.scroll_offset;
206 self.auto_preview();
207 }
208 Action::ViewportMiddle => {
209 let visible_height = 20;
210 self.selected_index = (self.scroll_offset + visible_height / 2)
211 .min(self.mail_row_count().saturating_sub(1));
212 self.auto_preview();
213 }
214 Action::ViewportBottom => {
215 let visible_height = 20;
216 self.selected_index = (self.scroll_offset + visible_height)
217 .min(self.mail_row_count().saturating_sub(1));
218 self.auto_preview();
219 }
220 Action::CenterCurrent => {
221 let visible_height = 20;
222 self.scroll_offset = self.selected_index.saturating_sub(visible_height / 2);
223 }
224 Action::SwitchPane => {
225 if self.screen == Screen::Search {
226 self.search_page.active_pane = match self.search_page.active_pane {
227 SearchPane::Results => SearchPane::Preview,
228 SearchPane::Preview => SearchPane::Results,
229 };
230 return;
231 }
232 self.active_pane = match (self.layout_mode, self.active_pane) {
233 (LayoutMode::ThreePane, ActivePane::Sidebar) => ActivePane::MailList,
235 (LayoutMode::ThreePane, ActivePane::MailList) => ActivePane::MessageView,
236 (LayoutMode::ThreePane, ActivePane::MessageView) => ActivePane::Sidebar,
237 (_, ActivePane::Sidebar) => ActivePane::MailList,
239 (_, ActivePane::MailList) => ActivePane::Sidebar,
240 (_, ActivePane::MessageView) => ActivePane::Sidebar,
241 };
242 }
243 Action::OpenSelected => {
244 if let Some(pending) = self.pending_bulk_confirm.take() {
245 if let Some(effect) = pending.optimistic_effect.as_ref() {
246 self.apply_local_mutation_effect(effect);
247 }
248 self.queue_mutation(pending.request, pending.effect, pending.status_message);
249 self.clear_selection();
250 return;
251 }
252 if self.screen == Screen::Search {
253 if let Some(env) = self.selected_search_envelope().cloned() {
254 self.open_envelope(env);
255 self.search_page.active_pane = SearchPane::Preview;
256 }
257 return;
258 }
259 if self.mailbox_view == MailboxView::Subscriptions {
260 if let Some(entry) = self.selected_subscription_entry().cloned() {
261 self.open_envelope(entry.envelope);
262 self.layout_mode = LayoutMode::ThreePane;
263 self.active_pane = ActivePane::MessageView;
264 }
265 return;
266 }
267 if let Some(row) = self.selected_mail_row() {
268 self.open_envelope(row.representative);
269 self.layout_mode = LayoutMode::ThreePane;
270 self.active_pane = ActivePane::MessageView;
271 }
272 }
273 Action::Back => match self.active_pane {
274 _ if self.screen != Screen::Mailbox => {
275 self.screen = Screen::Mailbox;
276 }
277 ActivePane::MessageView => {
278 self.apply(Action::CloseMessageView);
279 }
280 ActivePane::MailList => {
281 if !self.selected_set.is_empty() {
282 self.apply(Action::ClearSelection);
283 } else if self.search_active {
284 self.apply(Action::CloseSearch);
285 } else if self.active_label.is_some() {
286 self.apply(Action::ClearFilter);
287 } else if self.layout_mode == LayoutMode::ThreePane {
288 self.apply(Action::CloseMessageView);
289 }
290 }
291 ActivePane::Sidebar => {}
292 },
293 Action::QuitView => {
294 self.should_quit = true;
295 }
296 Action::ClearSelection => {
297 self.clear_selection();
298 self.status_message = Some("Selection cleared".into());
299 }
300 Action::OpenSearch => {
302 if self.search_active {
303 self.search_bar.activate_existing();
304 } else {
305 self.search_bar.activate();
306 }
307 }
308 Action::SubmitSearch => {
309 if self.screen == Screen::Search {
310 self.search_page.editing = false;
311 self.search_bar.query = self.search_page.query.clone();
312 self.trigger_live_search();
313 } else {
314 self.search_bar.deactivate();
315 if !self.search_bar.query.is_empty() {
316 self.search_active = true;
317 self.trigger_live_search();
318 }
319 self.active_pane = ActivePane::MailList;
321 }
322 }
323 Action::CycleSearchMode => {
324 self.search_bar.cycle_mode();
325 if self.screen == Screen::Search {
326 self.search_page.mode = self.search_bar.mode;
327 }
328 if self.screen == Screen::Search || self.search_bar.active {
329 self.trigger_live_search();
330 }
331 }
332 Action::CloseSearch => {
333 self.search_bar.deactivate();
334 self.search_active = false;
335 Self::bump_search_session_id(&mut self.mailbox_search_session_id);
336 self.envelopes = self.all_mail_envelopes();
338 self.selected_index = 0;
339 self.scroll_offset = 0;
340 }
341 Action::NextSearchResult => {
342 if self.search_active && self.selected_index + 1 < self.envelopes.len() {
343 self.selected_index += 1;
344 self.ensure_visible();
345 self.auto_preview();
346 }
347 }
348 Action::PrevSearchResult => {
349 if self.search_active && self.selected_index > 0 {
350 self.selected_index -= 1;
351 self.ensure_visible();
352 self.auto_preview();
353 }
354 }
355 Action::GoToInbox => {
357 if let Some(label) = self.labels.iter().find(|l| l.name == "INBOX") {
358 self.apply(Action::SelectLabel(label.id.clone()));
359 } else {
360 self.desired_system_mailbox = Some("INBOX".into());
361 }
362 }
363 Action::GoToStarred => {
364 if let Some(label) = self.labels.iter().find(|l| l.name == "STARRED") {
365 self.apply(Action::SelectLabel(label.id.clone()));
366 } else {
367 self.desired_system_mailbox = Some("STARRED".into());
368 }
369 }
370 Action::GoToSent => {
371 if let Some(label) = self.labels.iter().find(|l| l.name == "SENT") {
372 self.apply(Action::SelectLabel(label.id.clone()));
373 } else {
374 self.desired_system_mailbox = Some("SENT".into());
375 }
376 }
377 Action::GoToDrafts => {
378 if let Some(label) = self.labels.iter().find(|l| l.name == "DRAFT") {
379 self.apply(Action::SelectLabel(label.id.clone()));
380 } else {
381 self.desired_system_mailbox = Some("DRAFT".into());
382 }
383 }
384 Action::GoToAllMail => {
385 self.mailbox_view = MailboxView::Messages;
386 self.apply(Action::ClearFilter);
387 }
388 Action::OpenSubscriptions => {
389 self.mailbox_view = MailboxView::Subscriptions;
390 self.active_label = None;
391 self.pending_active_label = None;
392 self.pending_label_fetch = None;
393 self.pending_preview_read = None;
394 self.desired_system_mailbox = None;
395 self.search_active = false;
396 self.screen = Screen::Mailbox;
397 self.active_pane = ActivePane::MailList;
398 self.selected_index = self
399 .selected_index
400 .min(self.subscriptions_page.entries.len().saturating_sub(1));
401 self.scroll_offset = 0;
402 if self.subscriptions_page.entries.is_empty() {
403 self.pending_subscriptions_refresh = true;
404 }
405 self.auto_preview();
406 }
407 Action::GoToLabel => {
408 self.mailbox_view = MailboxView::Messages;
409 self.apply(Action::ClearFilter);
410 }
411 Action::OpenTab1 => {
412 self.apply(Action::OpenMailboxScreen);
413 }
414 Action::OpenTab2 => {
415 self.apply(Action::OpenSearchScreen);
416 }
417 Action::OpenTab3 => {
418 self.apply(Action::OpenRulesScreen);
419 }
420 Action::OpenTab4 => {
421 self.apply(Action::OpenAccountsScreen);
422 }
423 Action::OpenTab5 => {
424 self.apply(Action::OpenDiagnosticsScreen);
425 }
426 Action::OpenCommandPalette => {
428 self.command_palette.toggle();
429 }
430 Action::CloseCommandPalette => {
431 self.command_palette.visible = false;
432 }
433 Action::SyncNow => {
435 self.queue_mutation(
436 Request::SyncNow { account_id: None },
437 MutationEffect::RefreshList,
438 "Syncing...".into(),
439 );
440 }
441 Action::OpenMessageView => {
443 if self.screen == Screen::Search {
444 if let Some(env) = self.selected_search_envelope().cloned() {
445 self.open_envelope(env);
446 self.search_page.active_pane = SearchPane::Preview;
447 }
448 return;
449 }
450 if self.mailbox_view == MailboxView::Subscriptions {
451 if let Some(entry) = self.selected_subscription_entry().cloned() {
452 self.open_envelope(entry.envelope);
453 self.layout_mode = LayoutMode::ThreePane;
454 }
455 } else if let Some(row) = self.selected_mail_row() {
456 self.open_envelope(row.representative);
457 self.layout_mode = LayoutMode::ThreePane;
458 }
459 }
460 Action::CloseMessageView => {
461 if self.screen == Screen::Search {
462 self.search_page.active_pane = SearchPane::Results;
463 return;
464 }
465 self.close_attachment_panel();
466 self.layout_mode = LayoutMode::TwoPane;
467 self.active_pane = ActivePane::MailList;
468 self.pending_preview_read = None;
469 self.viewing_envelope = None;
470 self.viewed_thread = None;
471 self.viewed_thread_messages.clear();
472 self.thread_selected_index = 0;
473 self.pending_thread_fetch = None;
474 self.in_flight_thread_fetch = None;
475 self.message_scroll_offset = 0;
476 self.body_view_state = BodyViewState::Empty { preview: None };
477 }
478 Action::ToggleMailListMode => {
479 if self.mailbox_view == MailboxView::Subscriptions {
480 return;
481 }
482 self.mail_list_mode = match self.mail_list_mode {
483 MailListMode::Threads => MailListMode::Messages,
484 MailListMode::Messages => MailListMode::Threads,
485 };
486 self.selected_index = self
487 .selected_index
488 .min(self.mail_row_count().saturating_sub(1));
489 }
490 Action::RefreshRules => {
491 self.rules_page.refresh_pending = true;
492 if let Some(id) = self.selected_rule().and_then(|rule| rule["id"].as_str()) {
493 self.pending_rule_detail = Some(id.to_string());
494 }
495 }
496 Action::ToggleRuleEnabled => {
497 if let Some(rule) = self.selected_rule().cloned() {
498 let mut updated = rule.clone();
499 if let Some(enabled) = updated.get("enabled").and_then(|v| v.as_bool()) {
500 updated["enabled"] = serde_json::Value::Bool(!enabled);
501 self.pending_rule_upsert = Some(updated);
502 self.rules_page.status = Some(if enabled {
503 "Disabling rule...".into()
504 } else {
505 "Enabling rule...".into()
506 });
507 }
508 }
509 }
510 Action::DeleteRule => {
511 if let Some(rule_id) = self
512 .selected_rule()
513 .and_then(|rule| rule["id"].as_str())
514 .map(ToString::to_string)
515 {
516 self.pending_rule_delete = Some(rule_id.clone());
517 self.rules_page.status = Some(format!("Deleting {rule_id}..."));
518 }
519 }
520 Action::ShowRuleHistory => {
521 self.rules_page.panel = RulesPanel::History;
522 self.pending_rule_history = self
523 .selected_rule()
524 .and_then(|rule| rule["id"].as_str())
525 .map(ToString::to_string);
526 }
527 Action::ShowRuleDryRun => {
528 self.rules_page.panel = RulesPanel::DryRun;
529 self.pending_rule_dry_run = self
530 .selected_rule()
531 .and_then(|rule| rule["id"].as_str())
532 .map(ToString::to_string);
533 }
534 Action::OpenRuleFormNew => {
535 self.rules_page.form = RuleFormState {
536 visible: true,
537 enabled: true,
538 priority: "100".to_string(),
539 active_field: 0,
540 ..RuleFormState::default()
541 };
542 self.rules_page.panel = RulesPanel::Form;
543 }
544 Action::OpenRuleFormEdit => {
545 if let Some(rule_id) = self
546 .selected_rule()
547 .and_then(|rule| rule["id"].as_str())
548 .map(ToString::to_string)
549 {
550 self.pending_rule_form_load = Some(rule_id);
551 }
552 }
553 Action::SaveRuleForm => {
554 self.rules_page.status = Some("Saving rule...".into());
555 self.pending_rule_form_save = true;
556 }
557 Action::RefreshDiagnostics => {
558 self.diagnostics_page.refresh_pending = true;
559 }
560 Action::GenerateBugReport => {
561 self.diagnostics_page.status = Some("Generating bug report...".into());
562 self.pending_bug_report = true;
563 }
564 Action::EditConfig => {
565 self.pending_config_edit = true;
566 self.status_message = Some("Opening config in editor...".into());
567 }
568 Action::OpenLogs => {
569 self.pending_log_open = true;
570 self.status_message = Some("Opening log file in editor...".into());
571 }
572 Action::OpenDiagnosticsPaneDetails => {
573 self.pending_diagnostics_details = Some(self.diagnostics_page.active_pane());
574 self.status_message = Some("Opening diagnostics details...".into());
575 }
576 Action::SelectLabel(label_id) => {
577 self.mailbox_view = MailboxView::Messages;
578 self.pending_label_fetch = Some(label_id);
579 self.pending_active_label = self.pending_label_fetch.clone();
580 self.desired_system_mailbox = None;
581 self.active_pane = ActivePane::MailList;
582 self.screen = Screen::Mailbox;
583 }
584 Action::SelectSavedSearch(query, mode) => {
585 self.mailbox_view = MailboxView::Messages;
586 if self.screen == Screen::Search {
587 self.search_page.query = query.clone();
588 self.search_page.editing = false;
589 self.search_page.mode = mode;
590 self.search_page.sort = SortOrder::DateDesc;
591 self.search_page.active_pane = SearchPane::Results;
592 self.search_bar.query = query.clone();
593 self.search_bar.mode = mode;
594 self.trigger_live_search();
595 } else {
596 self.search_active = true;
597 self.active_pane = ActivePane::MailList;
598 self.search_bar.query = query.clone();
599 self.search_bar.mode = mode;
600 self.trigger_live_search();
601 }
602 }
603 Action::ClearFilter => {
604 self.mailbox_view = MailboxView::Messages;
605 self.active_label = None;
606 self.pending_active_label = None;
607 self.pending_preview_read = None;
608 self.desired_system_mailbox = None;
609 self.search_active = false;
610 self.envelopes = self.all_mail_envelopes();
611 self.selected_index = 0;
612 self.scroll_offset = 0;
613 }
614
615 Action::Compose => {
617 let mut seen = std::collections::HashMap::new();
619 for env in &self.all_envelopes {
620 seen.entry(env.from.email.clone()).or_insert_with(|| {
621 crate::ui::compose_picker::Contact {
622 name: env.from.name.clone().unwrap_or_default(),
623 email: env.from.email.clone(),
624 }
625 });
626 }
627 let mut contacts: Vec<_> = seen.into_values().collect();
628 contacts.sort_by(|a, b| a.email.to_lowercase().cmp(&b.email.to_lowercase()));
629 self.compose_picker.open(contacts);
630 }
631 Action::Reply => {
632 if let Some(env) = self.context_envelope() {
633 self.pending_compose = Some(ComposeAction::Reply {
634 message_id: env.id.clone(),
635 });
636 }
637 }
638 Action::ReplyAll => {
639 if let Some(env) = self.context_envelope() {
640 self.pending_compose = Some(ComposeAction::ReplyAll {
641 message_id: env.id.clone(),
642 });
643 }
644 }
645 Action::Forward => {
646 if let Some(env) = self.context_envelope() {
647 self.pending_compose = Some(ComposeAction::Forward {
648 message_id: env.id.clone(),
649 });
650 }
651 }
652 Action::Archive => {
653 let ids = self.mutation_target_ids();
654 if !ids.is_empty() {
655 let effect = remove_from_list_effect(&ids);
656 self.queue_or_confirm_bulk_action(
657 "Archive messages",
658 bulk_message_detail("archive", ids.len()),
659 Request::Mutation(MutationCommand::Archive {
660 message_ids: ids.clone(),
661 }),
662 effect,
663 None,
664 "Archiving...".into(),
665 ids.len(),
666 );
667 }
668 }
669 Action::MarkReadAndArchive => {
670 let ids = self.mutation_target_ids();
671 if !ids.is_empty() {
672 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
673 flags.insert(MessageFlags::READ);
674 flags
675 });
676 self.queue_or_confirm_bulk_action(
677 "Mark messages as read and archive",
678 bulk_message_detail("mark as read and archive", ids.len()),
679 Request::Mutation(MutationCommand::ReadAndArchive {
680 message_ids: ids.clone(),
681 }),
682 remove_from_list_effect(&ids),
683 (!updates.is_empty())
684 .then_some(MutationEffect::UpdateFlagsMany { updates }),
685 format!(
686 "Marking {} {} as read and archiving...",
687 ids.len(),
688 pluralize_messages(ids.len())
689 ),
690 ids.len(),
691 );
692 }
693 }
694 Action::Trash => {
695 let ids = self.mutation_target_ids();
696 if !ids.is_empty() {
697 let effect = remove_from_list_effect(&ids);
698 self.queue_or_confirm_bulk_action(
699 "Delete messages",
700 bulk_message_detail("delete", ids.len()),
701 Request::Mutation(MutationCommand::Trash {
702 message_ids: ids.clone(),
703 }),
704 effect,
705 None,
706 "Trashing...".into(),
707 ids.len(),
708 );
709 }
710 }
711 Action::Spam => {
712 let ids = self.mutation_target_ids();
713 if !ids.is_empty() {
714 let effect = remove_from_list_effect(&ids);
715 self.queue_or_confirm_bulk_action(
716 "Mark as spam",
717 bulk_message_detail("mark as spam", ids.len()),
718 Request::Mutation(MutationCommand::Spam {
719 message_ids: ids.clone(),
720 }),
721 effect,
722 None,
723 "Marking as spam...".into(),
724 ids.len(),
725 );
726 }
727 }
728 Action::Star => {
729 let ids = self.mutation_target_ids();
730 if !ids.is_empty() {
731 let starred = if ids.len() == 1 {
733 if let Some(env) = self.context_envelope() {
734 !env.flags.contains(MessageFlags::STARRED)
735 } else {
736 true
737 }
738 } else {
739 true
740 };
741 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
742 if starred {
743 flags.insert(MessageFlags::STARRED);
744 } else {
745 flags.remove(MessageFlags::STARRED);
746 }
747 flags
748 });
749 let optimistic_effect = (!updates.is_empty())
750 .then_some(MutationEffect::UpdateFlagsMany { updates });
751 let verb = if starred { "star" } else { "unstar" };
752 let status = if starred {
753 format!(
754 "Starring {} {}...",
755 ids.len(),
756 pluralize_messages(ids.len())
757 )
758 } else {
759 format!(
760 "Unstarring {} {}...",
761 ids.len(),
762 pluralize_messages(ids.len())
763 )
764 };
765 self.queue_or_confirm_bulk_action(
766 if starred {
767 "Star messages"
768 } else {
769 "Unstar messages"
770 },
771 bulk_message_detail(verb, ids.len()),
772 Request::Mutation(MutationCommand::Star {
773 message_ids: ids.clone(),
774 starred,
775 }),
776 MutationEffect::StatusOnly(if starred {
777 format!("Starred {} {}", ids.len(), pluralize_messages(ids.len()))
778 } else {
779 format!("Unstarred {} {}", ids.len(), pluralize_messages(ids.len()))
780 }),
781 optimistic_effect,
782 status,
783 ids.len(),
784 );
785 }
786 }
787 Action::MarkRead => {
788 let ids = self.mutation_target_ids();
789 if !ids.is_empty() {
790 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
791 flags.insert(MessageFlags::READ);
792 flags
793 });
794 self.queue_or_confirm_bulk_action(
795 "Mark messages as read",
796 bulk_message_detail("mark as read", ids.len()),
797 Request::Mutation(MutationCommand::SetRead {
798 message_ids: ids.clone(),
799 read: true,
800 }),
801 MutationEffect::StatusOnly(format!(
802 "Marked {} {} as read",
803 ids.len(),
804 pluralize_messages(ids.len())
805 )),
806 (!updates.is_empty())
807 .then_some(MutationEffect::UpdateFlagsMany { updates }),
808 format!(
809 "Marking {} {} as read...",
810 ids.len(),
811 pluralize_messages(ids.len())
812 ),
813 ids.len(),
814 );
815 }
816 }
817 Action::MarkUnread => {
818 let ids = self.mutation_target_ids();
819 if !ids.is_empty() {
820 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
821 flags.remove(MessageFlags::READ);
822 flags
823 });
824 self.queue_or_confirm_bulk_action(
825 "Mark messages as unread",
826 bulk_message_detail("mark as unread", ids.len()),
827 Request::Mutation(MutationCommand::SetRead {
828 message_ids: ids.clone(),
829 read: false,
830 }),
831 MutationEffect::StatusOnly(format!(
832 "Marked {} {} as unread",
833 ids.len(),
834 pluralize_messages(ids.len())
835 )),
836 (!updates.is_empty())
837 .then_some(MutationEffect::UpdateFlagsMany { updates }),
838 format!(
839 "Marking {} {} as unread...",
840 ids.len(),
841 pluralize_messages(ids.len())
842 ),
843 ids.len(),
844 );
845 }
846 }
847 Action::ApplyLabel => {
848 if let Some((_, ref label_name)) = self.pending_label_action.take() {
849 let ids = self.mutation_target_ids();
851 if !ids.is_empty() {
852 self.queue_or_confirm_bulk_action(
853 "Apply label",
854 format!(
855 "You are about to apply '{}' to {} {}.",
856 label_name,
857 ids.len(),
858 pluralize_messages(ids.len())
859 ),
860 Request::Mutation(MutationCommand::ModifyLabels {
861 message_ids: ids.clone(),
862 add: vec![label_name.clone()],
863 remove: vec![],
864 }),
865 MutationEffect::ModifyLabels {
866 message_ids: ids.clone(),
867 add: vec![label_name.clone()],
868 remove: vec![],
869 status: format!("Applied label '{}'", label_name),
870 },
871 None,
872 format!("Applying label '{}'...", label_name),
873 ids.len(),
874 );
875 }
876 } else {
877 self.label_picker
879 .open(self.labels.clone(), LabelPickerMode::Apply);
880 }
881 }
882 Action::MoveToLabel => {
883 if let Some((_, ref label_name)) = self.pending_label_action.take() {
884 let ids = self.mutation_target_ids();
886 if !ids.is_empty() {
887 self.queue_or_confirm_bulk_action(
888 "Move messages",
889 format!(
890 "You are about to move {} {} to '{}'.",
891 ids.len(),
892 pluralize_messages(ids.len()),
893 label_name
894 ),
895 Request::Mutation(MutationCommand::Move {
896 message_ids: ids.clone(),
897 target_label: label_name.clone(),
898 }),
899 remove_from_list_effect(&ids),
900 None,
901 format!("Moving to '{}'...", label_name),
902 ids.len(),
903 );
904 }
905 } else {
906 self.label_picker
908 .open(self.labels.clone(), LabelPickerMode::Move);
909 }
910 }
911 Action::Unsubscribe => {
912 if let Some(env) = self.context_envelope() {
913 if matches!(env.unsubscribe, UnsubscribeMethod::None) {
914 self.status_message =
915 Some("No unsubscribe option found for this message".into());
916 } else {
917 let sender_email = env.from.email.clone();
918 let archive_message_ids = self
919 .all_envelopes
920 .iter()
921 .filter(|candidate| {
922 candidate.account_id == env.account_id
923 && candidate.from.email.eq_ignore_ascii_case(&sender_email)
924 })
925 .map(|candidate| candidate.id.clone())
926 .collect();
927 self.pending_unsubscribe_confirm = Some(PendingUnsubscribeConfirm {
928 message_id: env.id.clone(),
929 account_id: env.account_id.clone(),
930 sender_email,
931 method_label: unsubscribe_method_label(&env.unsubscribe).to_string(),
932 archive_message_ids,
933 });
934 }
935 }
936 }
937 Action::ConfirmUnsubscribeOnly => {
938 if let Some(pending) = self.pending_unsubscribe_confirm.take() {
939 self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
940 message_id: pending.message_id,
941 archive_message_ids: Vec::new(),
942 sender_email: pending.sender_email,
943 });
944 self.status_message = Some("Unsubscribing...".into());
945 }
946 }
947 Action::ConfirmUnsubscribeAndArchiveSender => {
948 if let Some(pending) = self.pending_unsubscribe_confirm.take() {
949 self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
950 message_id: pending.message_id,
951 archive_message_ids: pending.archive_message_ids,
952 sender_email: pending.sender_email,
953 });
954 self.status_message = Some("Unsubscribing and archiving sender...".into());
955 }
956 }
957 Action::CancelUnsubscribe => {
958 self.pending_unsubscribe_confirm = None;
959 self.status_message = Some("Unsubscribe cancelled".into());
960 }
961 Action::Snooze => {
962 if self.snooze_panel.visible {
963 if let Some(env) = self.context_envelope() {
964 let wake_at = resolve_snooze_preset(
965 snooze_presets()[self.snooze_panel.selected_index],
966 &self.snooze_config,
967 );
968 self.queue_mutation(
969 Request::Snooze {
970 message_id: env.id.clone(),
971 wake_at,
972 },
973 MutationEffect::StatusOnly(format!(
974 "Snoozed until {}",
975 wake_at
976 .with_timezone(&chrono::Local)
977 .format("%a %b %e %H:%M")
978 )),
979 "Snoozing...".into(),
980 );
981 }
982 self.snooze_panel.visible = false;
983 } else if self.context_envelope().is_some() {
984 self.snooze_panel.visible = true;
985 self.snooze_panel.selected_index = 0;
986 } else {
987 self.status_message = Some("No message selected".into());
988 }
989 }
990 Action::OpenInBrowser => {
991 if let Some(env) = self.context_envelope() {
992 let url = format!(
993 "https://mail.google.com/mail/u/0/#inbox/{}",
994 env.provider_id
995 );
996 #[cfg(target_os = "macos")]
997 let _ = std::process::Command::new("open").arg(&url).spawn();
998 #[cfg(target_os = "linux")]
999 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1000 self.status_message = Some("Opened in browser".into());
1001 }
1002 }
1003
1004 Action::ToggleReaderMode => {
1006 if let BodyViewState::Ready { .. } = self.body_view_state {
1007 self.reader_mode = !self.reader_mode;
1008 if let Some(env) = self.viewing_envelope.clone() {
1009 self.body_view_state = self.resolve_body_view_state(&env);
1010 }
1011 }
1012 }
1013 Action::ToggleSignature => {
1014 self.signature_expanded = !self.signature_expanded;
1015 }
1016
1017 Action::ToggleSelect => {
1019 if let Some(env) = self.context_envelope() {
1020 let id = env.id.clone();
1021 if self.selected_set.contains(&id) {
1022 self.selected_set.remove(&id);
1023 } else {
1024 self.selected_set.insert(id);
1025 }
1026 if self.screen == Screen::Search {
1028 if self.search_page.selected_index + 1 < self.search_row_count() {
1029 self.search_page.selected_index += 1;
1030 self.ensure_search_visible();
1031 self.auto_preview_search();
1032 self.maybe_load_more_search_results();
1033 }
1034 } else if self.selected_index + 1 < self.mail_row_count() {
1035 self.selected_index += 1;
1036 self.ensure_visible();
1037 self.auto_preview();
1038 }
1039 let count = self.selected_set.len();
1040 self.status_message = Some(format!("{count} selected"));
1041 }
1042 }
1043 Action::VisualLineMode => {
1044 if self.visual_mode {
1045 self.visual_mode = false;
1047 self.visual_anchor = None;
1048 self.status_message = Some("Visual mode off".into());
1049 } else {
1050 self.visual_mode = true;
1051 self.visual_anchor = Some(if self.screen == Screen::Search {
1052 self.search_page.selected_index
1053 } else {
1054 self.selected_index
1055 });
1056 if let Some(env) = self.context_envelope() {
1058 self.selected_set.insert(env.id.clone());
1059 }
1060 self.status_message = Some("-- VISUAL LINE --".into());
1061 }
1062 }
1063 Action::PatternSelect(pattern) => {
1064 let envelopes = if self.screen == Screen::Search {
1065 &self.search_page.results
1066 } else {
1067 &self.envelopes
1068 };
1069 match pattern {
1070 PatternKind::All => {
1071 self.selected_set = envelopes.iter().map(|e| e.id.clone()).collect();
1072 }
1073 PatternKind::None => {
1074 self.selected_set.clear();
1075 self.visual_mode = false;
1076 self.visual_anchor = None;
1077 }
1078 PatternKind::Read => {
1079 self.selected_set = envelopes
1080 .iter()
1081 .filter(|e| e.flags.contains(MessageFlags::READ))
1082 .map(|e| e.id.clone())
1083 .collect();
1084 }
1085 PatternKind::Unread => {
1086 self.selected_set = envelopes
1087 .iter()
1088 .filter(|e| !e.flags.contains(MessageFlags::READ))
1089 .map(|e| e.id.clone())
1090 .collect();
1091 }
1092 PatternKind::Starred => {
1093 self.selected_set = envelopes
1094 .iter()
1095 .filter(|e| e.flags.contains(MessageFlags::STARRED))
1096 .map(|e| e.id.clone())
1097 .collect();
1098 }
1099 PatternKind::Thread => {
1100 if let Some(env) = self.context_envelope() {
1101 let tid = env.thread_id.clone();
1102 self.selected_set = envelopes
1103 .iter()
1104 .filter(|e| e.thread_id == tid)
1105 .map(|e| e.id.clone())
1106 .collect();
1107 }
1108 }
1109 }
1110 let count = self.selected_set.len();
1111 self.status_message = Some(format!("{count} selected"));
1112 }
1113
1114 Action::AttachmentList => {
1116 if self.attachment_panel.visible {
1117 self.close_attachment_panel();
1118 } else {
1119 self.open_attachment_panel();
1120 }
1121 }
1122 Action::OpenLinks => {
1123 self.open_url_modal();
1124 }
1125 Action::ToggleFullscreen => {
1126 if self.layout_mode == LayoutMode::FullScreen {
1127 self.layout_mode = LayoutMode::ThreePane;
1128 } else if self.viewing_envelope.is_some() {
1129 self.layout_mode = LayoutMode::FullScreen;
1130 }
1131 }
1132 Action::ExportThread => {
1133 if let Some(env) = self.context_envelope() {
1134 self.pending_export_thread = Some(env.thread_id.clone());
1135 self.status_message = Some("Exporting thread...".into());
1136 } else {
1137 self.status_message = Some("No message selected".into());
1138 }
1139 }
1140 Action::Help => {
1141 self.help_modal_open = !self.help_modal_open;
1142 if self.help_modal_open {
1143 self.help_scroll_offset = 0;
1144 }
1145 }
1146 Action::Noop => {}
1147 }
1148 }
1149}