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