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.clone(),
663 Some(effect),
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 effect = remove_from_list_effect(&ids);
673 self.queue_or_confirm_bulk_action(
674 "Mark messages as read and archive",
675 bulk_message_detail("mark as read and archive", ids.len()),
676 Request::Mutation(MutationCommand::ReadAndArchive {
677 message_ids: ids.clone(),
678 }),
679 effect.clone(),
680 Some(effect),
681 format!(
682 "Marking {} {} as read and archiving...",
683 ids.len(),
684 pluralize_messages(ids.len())
685 ),
686 ids.len(),
687 );
688 }
689 }
690 Action::Trash => {
691 let ids = self.mutation_target_ids();
692 if !ids.is_empty() {
693 let effect = remove_from_list_effect(&ids);
694 self.queue_or_confirm_bulk_action(
695 "Delete messages",
696 bulk_message_detail("delete", ids.len()),
697 Request::Mutation(MutationCommand::Trash {
698 message_ids: ids.clone(),
699 }),
700 effect.clone(),
701 Some(effect),
702 "Trashing...".into(),
703 ids.len(),
704 );
705 }
706 }
707 Action::Spam => {
708 let ids = self.mutation_target_ids();
709 if !ids.is_empty() {
710 let effect = remove_from_list_effect(&ids);
711 self.queue_or_confirm_bulk_action(
712 "Mark as spam",
713 bulk_message_detail("mark as spam", ids.len()),
714 Request::Mutation(MutationCommand::Spam {
715 message_ids: ids.clone(),
716 }),
717 effect.clone(),
718 Some(effect),
719 "Marking as spam...".into(),
720 ids.len(),
721 );
722 }
723 }
724 Action::Star => {
725 let ids = self.mutation_target_ids();
726 if !ids.is_empty() {
727 let starred = if ids.len() == 1 {
729 if let Some(env) = self.context_envelope() {
730 !env.flags.contains(MessageFlags::STARRED)
731 } else {
732 true
733 }
734 } else {
735 true
736 };
737 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
738 if starred {
739 flags.insert(MessageFlags::STARRED);
740 } else {
741 flags.remove(MessageFlags::STARRED);
742 }
743 flags
744 });
745 let optimistic_effect = (!updates.is_empty())
746 .then_some(MutationEffect::UpdateFlagsMany { updates });
747 let verb = if starred { "star" } else { "unstar" };
748 let status = if starred {
749 format!(
750 "Starring {} {}...",
751 ids.len(),
752 pluralize_messages(ids.len())
753 )
754 } else {
755 format!(
756 "Unstarring {} {}...",
757 ids.len(),
758 pluralize_messages(ids.len())
759 )
760 };
761 self.queue_or_confirm_bulk_action(
762 if starred {
763 "Star messages"
764 } else {
765 "Unstar messages"
766 },
767 bulk_message_detail(verb, ids.len()),
768 Request::Mutation(MutationCommand::Star {
769 message_ids: ids.clone(),
770 starred,
771 }),
772 MutationEffect::StatusOnly(if starred {
773 format!("Starred {} {}", ids.len(), pluralize_messages(ids.len()))
774 } else {
775 format!("Unstarred {} {}", ids.len(), pluralize_messages(ids.len()))
776 }),
777 optimistic_effect,
778 status,
779 ids.len(),
780 );
781 }
782 }
783 Action::MarkRead => {
784 let ids = self.mutation_target_ids();
785 if !ids.is_empty() {
786 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
787 flags.insert(MessageFlags::READ);
788 flags
789 });
790 self.queue_or_confirm_bulk_action(
791 "Mark messages as read",
792 bulk_message_detail("mark as read", ids.len()),
793 Request::Mutation(MutationCommand::SetRead {
794 message_ids: ids.clone(),
795 read: true,
796 }),
797 MutationEffect::StatusOnly(format!(
798 "Marked {} {} as read",
799 ids.len(),
800 pluralize_messages(ids.len())
801 )),
802 (!updates.is_empty())
803 .then_some(MutationEffect::UpdateFlagsMany { updates }),
804 format!(
805 "Marking {} {} as read...",
806 ids.len(),
807 pluralize_messages(ids.len())
808 ),
809 ids.len(),
810 );
811 }
812 }
813 Action::MarkUnread => {
814 let ids = self.mutation_target_ids();
815 if !ids.is_empty() {
816 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
817 flags.remove(MessageFlags::READ);
818 flags
819 });
820 self.queue_or_confirm_bulk_action(
821 "Mark messages as unread",
822 bulk_message_detail("mark as unread", ids.len()),
823 Request::Mutation(MutationCommand::SetRead {
824 message_ids: ids.clone(),
825 read: false,
826 }),
827 MutationEffect::StatusOnly(format!(
828 "Marked {} {} as unread",
829 ids.len(),
830 pluralize_messages(ids.len())
831 )),
832 (!updates.is_empty())
833 .then_some(MutationEffect::UpdateFlagsMany { updates }),
834 format!(
835 "Marking {} {} as unread...",
836 ids.len(),
837 pluralize_messages(ids.len())
838 ),
839 ids.len(),
840 );
841 }
842 }
843 Action::ApplyLabel => {
844 if let Some((_, ref label_name)) = self.pending_label_action.take() {
845 let ids = self.mutation_target_ids();
847 if !ids.is_empty() {
848 self.queue_or_confirm_bulk_action(
849 "Apply label",
850 format!(
851 "You are about to apply '{}' to {} {}.",
852 label_name,
853 ids.len(),
854 pluralize_messages(ids.len())
855 ),
856 Request::Mutation(MutationCommand::ModifyLabels {
857 message_ids: ids.clone(),
858 add: vec![label_name.clone()],
859 remove: vec![],
860 }),
861 MutationEffect::ModifyLabels {
862 message_ids: ids.clone(),
863 add: vec![label_name.clone()],
864 remove: vec![],
865 status: format!("Applied label '{}'", label_name),
866 },
867 None,
868 format!("Applying label '{}'...", label_name),
869 ids.len(),
870 );
871 }
872 } else {
873 self.label_picker
875 .open(self.labels.clone(), LabelPickerMode::Apply);
876 }
877 }
878 Action::MoveToLabel => {
879 if let Some((_, ref label_name)) = self.pending_label_action.take() {
880 let ids = self.mutation_target_ids();
882 if !ids.is_empty() {
883 self.queue_or_confirm_bulk_action(
884 "Move messages",
885 format!(
886 "You are about to move {} {} to '{}'.",
887 ids.len(),
888 pluralize_messages(ids.len()),
889 label_name
890 ),
891 Request::Mutation(MutationCommand::Move {
892 message_ids: ids.clone(),
893 target_label: label_name.clone(),
894 }),
895 remove_from_list_effect(&ids),
896 None,
897 format!("Moving to '{}'...", label_name),
898 ids.len(),
899 );
900 }
901 } else {
902 self.label_picker
904 .open(self.labels.clone(), LabelPickerMode::Move);
905 }
906 }
907 Action::Unsubscribe => {
908 if let Some(env) = self.context_envelope() {
909 if matches!(env.unsubscribe, UnsubscribeMethod::None) {
910 self.status_message =
911 Some("No unsubscribe option found for this message".into());
912 } else {
913 let sender_email = env.from.email.clone();
914 let archive_message_ids = self
915 .all_envelopes
916 .iter()
917 .filter(|candidate| {
918 candidate.account_id == env.account_id
919 && candidate.from.email.eq_ignore_ascii_case(&sender_email)
920 })
921 .map(|candidate| candidate.id.clone())
922 .collect();
923 self.pending_unsubscribe_confirm = Some(PendingUnsubscribeConfirm {
924 message_id: env.id.clone(),
925 account_id: env.account_id.clone(),
926 sender_email,
927 method_label: unsubscribe_method_label(&env.unsubscribe).to_string(),
928 archive_message_ids,
929 });
930 }
931 }
932 }
933 Action::ConfirmUnsubscribeOnly => {
934 if let Some(pending) = self.pending_unsubscribe_confirm.take() {
935 self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
936 message_id: pending.message_id,
937 archive_message_ids: Vec::new(),
938 sender_email: pending.sender_email,
939 });
940 self.status_message = Some("Unsubscribing...".into());
941 }
942 }
943 Action::ConfirmUnsubscribeAndArchiveSender => {
944 if let Some(pending) = self.pending_unsubscribe_confirm.take() {
945 self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
946 message_id: pending.message_id,
947 archive_message_ids: pending.archive_message_ids,
948 sender_email: pending.sender_email,
949 });
950 self.status_message = Some("Unsubscribing and archiving sender...".into());
951 }
952 }
953 Action::CancelUnsubscribe => {
954 self.pending_unsubscribe_confirm = None;
955 self.status_message = Some("Unsubscribe cancelled".into());
956 }
957 Action::Snooze => {
958 if self.snooze_panel.visible {
959 if let Some(env) = self.context_envelope() {
960 let wake_at = resolve_snooze_preset(
961 snooze_presets()[self.snooze_panel.selected_index],
962 &self.snooze_config,
963 );
964 self.queue_mutation(
965 Request::Snooze {
966 message_id: env.id.clone(),
967 wake_at,
968 },
969 MutationEffect::StatusOnly(format!(
970 "Snoozed until {}",
971 wake_at
972 .with_timezone(&chrono::Local)
973 .format("%a %b %e %H:%M")
974 )),
975 "Snoozing...".into(),
976 );
977 }
978 self.snooze_panel.visible = false;
979 } else if self.context_envelope().is_some() {
980 self.snooze_panel.visible = true;
981 self.snooze_panel.selected_index = 0;
982 } else {
983 self.status_message = Some("No message selected".into());
984 }
985 }
986 Action::OpenInBrowser => {
987 if let Some(env) = self.context_envelope() {
988 let url = format!(
989 "https://mail.google.com/mail/u/0/#inbox/{}",
990 env.provider_id
991 );
992 #[cfg(target_os = "macos")]
993 let _ = std::process::Command::new("open").arg(&url).spawn();
994 #[cfg(target_os = "linux")]
995 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
996 self.status_message = Some("Opened in browser".into());
997 }
998 }
999
1000 Action::ToggleReaderMode => {
1002 if let BodyViewState::Ready { .. } = self.body_view_state {
1003 self.reader_mode = !self.reader_mode;
1004 if let Some(env) = self.viewing_envelope.clone() {
1005 self.body_view_state = self.resolve_body_view_state(&env);
1006 }
1007 }
1008 }
1009 Action::ToggleSignature => {
1010 self.signature_expanded = !self.signature_expanded;
1011 }
1012
1013 Action::ToggleSelect => {
1015 if let Some(env) = self.context_envelope() {
1016 let id = env.id.clone();
1017 if self.selected_set.contains(&id) {
1018 self.selected_set.remove(&id);
1019 } else {
1020 self.selected_set.insert(id);
1021 }
1022 if self.screen == Screen::Search {
1024 if self.search_page.selected_index + 1 < self.search_row_count() {
1025 self.search_page.selected_index += 1;
1026 self.ensure_search_visible();
1027 self.auto_preview_search();
1028 self.maybe_load_more_search_results();
1029 }
1030 } else if self.selected_index + 1 < self.mail_row_count() {
1031 self.selected_index += 1;
1032 self.ensure_visible();
1033 self.auto_preview();
1034 }
1035 let count = self.selected_set.len();
1036 self.status_message = Some(format!("{count} selected"));
1037 }
1038 }
1039 Action::VisualLineMode => {
1040 if self.visual_mode {
1041 self.visual_mode = false;
1043 self.visual_anchor = None;
1044 self.status_message = Some("Visual mode off".into());
1045 } else {
1046 self.visual_mode = true;
1047 self.visual_anchor = Some(if self.screen == Screen::Search {
1048 self.search_page.selected_index
1049 } else {
1050 self.selected_index
1051 });
1052 if let Some(env) = self.context_envelope() {
1054 self.selected_set.insert(env.id.clone());
1055 }
1056 self.status_message = Some("-- VISUAL LINE --".into());
1057 }
1058 }
1059 Action::PatternSelect(pattern) => {
1060 let envelopes = if self.screen == Screen::Search {
1061 &self.search_page.results
1062 } else {
1063 &self.envelopes
1064 };
1065 match pattern {
1066 PatternKind::All => {
1067 self.selected_set = envelopes.iter().map(|e| e.id.clone()).collect();
1068 }
1069 PatternKind::None => {
1070 self.selected_set.clear();
1071 self.visual_mode = false;
1072 self.visual_anchor = None;
1073 }
1074 PatternKind::Read => {
1075 self.selected_set = envelopes
1076 .iter()
1077 .filter(|e| e.flags.contains(MessageFlags::READ))
1078 .map(|e| e.id.clone())
1079 .collect();
1080 }
1081 PatternKind::Unread => {
1082 self.selected_set = envelopes
1083 .iter()
1084 .filter(|e| !e.flags.contains(MessageFlags::READ))
1085 .map(|e| e.id.clone())
1086 .collect();
1087 }
1088 PatternKind::Starred => {
1089 self.selected_set = envelopes
1090 .iter()
1091 .filter(|e| e.flags.contains(MessageFlags::STARRED))
1092 .map(|e| e.id.clone())
1093 .collect();
1094 }
1095 PatternKind::Thread => {
1096 if let Some(env) = self.context_envelope() {
1097 let tid = env.thread_id.clone();
1098 self.selected_set = envelopes
1099 .iter()
1100 .filter(|e| e.thread_id == tid)
1101 .map(|e| e.id.clone())
1102 .collect();
1103 }
1104 }
1105 }
1106 let count = self.selected_set.len();
1107 self.status_message = Some(format!("{count} selected"));
1108 }
1109
1110 Action::AttachmentList => {
1112 if self.attachment_panel.visible {
1113 self.close_attachment_panel();
1114 } else {
1115 self.open_attachment_panel();
1116 }
1117 }
1118 Action::OpenLinks => {
1119 self.open_url_modal();
1120 }
1121 Action::ToggleFullscreen => {
1122 if self.layout_mode == LayoutMode::FullScreen {
1123 self.layout_mode = LayoutMode::ThreePane;
1124 } else if self.viewing_envelope.is_some() {
1125 self.layout_mode = LayoutMode::FullScreen;
1126 }
1127 }
1128 Action::ExportThread => {
1129 if let Some(env) = self.context_envelope() {
1130 self.pending_export_thread = Some(env.thread_id.clone());
1131 self.status_message = Some("Exporting thread...".into());
1132 } else {
1133 self.status_message = Some("No message selected".into());
1134 }
1135 }
1136 Action::Help => {
1137 self.help_modal_open = !self.help_modal_open;
1138 if self.help_modal_open {
1139 self.help_scroll_offset = 0;
1140 }
1141 }
1142 Action::Noop => {}
1143 }
1144 }
1145}