Something went wrong. Try again.
A one-stop-shop capsuleer manager for EVE Online pod.aaronmallen.dev
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902pub mod compose;mod draft;mod folder_pane;mod labels;mod loaders;mod markup;mod message_list;mod outbox_indicator;mod read_state;mod reading_pane;mod shell;mod snooze;mod switcher;mod triage;
use std::collections::HashMap;
use iced::{Element, Point, Task, widget::text_editor};
pub use self::loaders::{FolderPaneData, OutboxIndicator, RosterPilot};use self::{ labels::LabelDraft, loaders::{FolderLabel, MessageLabel}, message_list::MessageRow,};use crate::{ features::shell::window_state::{self, UiState}, store::{ Database, images, model::{ CharacterMail, DraftInput, MailCursor, character_mail_view::{MailRender, UnifiedMail}, mail_overlay_state::MailOverlayState, }, repo::mail, }, ui::{ components::{ entity_search::EntityRef, resizable_pane::{self, PaneDrag}, }, load_epoch::LoadEpoch, },};
/// Resolves an i18n key to a process-lifetime `&'static str`.////// The active locale is fixed for the run, so each key's localized value is interned once and/// handed back as `&'static str`. This lets borrowing widgets (`text_input`, `MultiSelect`, the/// shared `empty_state`) carry a localized placeholder/title whose lifetime outlives the rendered/// element without threading owned strings through every render helper.pub(super) fn tr_static(key: &'static str) -> &'static str { use std::{ collections::HashMap, sync::{Mutex, OnceLock}, };
static INTERNED: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new(); let cache = INTERNED.get_or_init(|| Mutex::new(HashMap::new())); let mut guard = cache.lock().expect("mail i18n intern cache poisoned"); if let Some(value) = guard.get(key) { return value; } let leaked: &'static str = Box::leak(t!(key).into_owned().into_boxed_str()); guard.insert(key, leaked); leaked}
pub const FOLDER_PANE_KEY: &str = "mail.folder";
pub const MESSAGE_LIST_PANE_KEY: &str = "mail.message_list";
const FOLDER_PANE_DEFAULT_WIDTH: f32 = 240.0;
const MESSAGE_LIST_PANE_DEFAULT_WIDTH: f32 = 380.0;
const FOLDER_PANE_MIN_WIDTH: f32 = 80.0;
const MESSAGE_LIST_PANE_MIN_WIDTH: f32 = resizable_pane::MIN_PANE_WIDTH;
pub const EMPTY_MAIL_SELECTION: i64 = 0;
pub const RECIPIENT_SEARCH_MIN_CHARS: usize = 3;
const LIST_SCROLL_THRESHOLD: f32 = 0.85;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub enum Folder { Label(i64), Standard(StandardFolder), #[default] Unified,}
#[derive(Clone, Debug, Default)]pub struct Loaded { drafts: Vec<draft::DraftRow>, folder: Folder, folder_data: FolderPaneData, folder_pane_width: f32, headers: Vec<CharacterMail>, message_list_pane_width: f32, messages: Vec<MessageRow>, messages_has_more: bool, outbox_indicator: OutboxIndicator, overlays: HashMap<i64, MailOverlayState>, roster: Vec<RosterPilot>, scope: Scope, unified: Vec<UnifiedMail>, unified_unread: i64,}
#[derive(Clone, Debug)]pub enum Message { Archive(i64), ComposeBodyChanged(text_editor::Action), ComposeBold, ComposeCcCommitted, ComposeCcInput(String), ComposeCcPicked(EntityRef), ComposeCcRemoved(usize), ComposeCcSearched { generation: u64, results: Vec<EntityRef>, }, ComposeCcShown, ComposeDiscarded, ComposeFromChanged(i64), ComposeFromToggled, ComposeItalic, ComposeLinkInsert, ComposeLinkKindSelected(compose::LinkKind), ComposeLinkPicked(EntityRef), ComposeLinkSearchInput(String), ComposeLinkSearched { generation: u64, results: Vec<EntityRef>, }, ComposeLinkToggled, ComposeLinkUrlChanged(String), ComposeOpened, ComposeSend, ComposeSent(Result<(), String>), ComposeSubjectChanged(String), ComposeToCommitted, ComposeToInput(String), ComposeToPicked(EntityRef), ComposeToRemoved(usize), ComposeToSearched { generation: u64, results: Vec<EntityRef>, }, Delete(i64), DraftDeleted(i64), DraftLoaded(Box<Option<crate::store::model::MailDraft>>), DraftOpened(i64), DraftRowsLoaded(Vec<draft::DraftRow>), DraftSaved(Option<i64>), DropTargetEntered(DropTarget), DropTargetLeft(DropTarget), FolderPaneDragEnd, FolderPaneDragStart, FolderPaneDragged(f32), FolderSelected(Folder), Forward(i64), LabelColorPicked(String), LabelDeleteCancelled, LabelDeleteConfirmed, LabelDeleteRequested(i64), LabelDragMoved(Point), LabelDropReleased, LabelModalClosed, LabelModalOpened, LabelModalSubmitted, LabelNameChanged(String), LabelPickerClosed, LabelPickerOpened(i64), LabelRowMenuOpened(i64), LabelToggled(i64, i64), LabelsWritten, ListPaneDragEnd, ListPaneDragStart, ListPaneDragged(f32), ListScrolled { absolute: f32, relative: f32, }, Loaded(Box<Loaded>), MarkedRead, MessagesPageLoaded { epoch: u64, rows: Vec<MessageRow>, }, OutboxDismiss(i64), OutboxRefreshed(Box<OutboxIndicator>), OutboxRetry(i64), OverlayWritten, PaneSettled(&'static str, f32), PickerToggled, ReauthRequested(i64), RenderLoaded { mail_id: i64, render: Box<Option<ReadingRender>>, }, Reply(i64), ReplyAll(i64), ScopeSelected(Scope), SearchChanged(String), SearchPageLoaded { query: String, rows: Vec<MessageRow>, }, Selected(i64), SnoozeCalendarBack, // Constructed only by handler-routing tests; the set-time arm is wired but not yet triggered from the UI. #[cfg_attr( not(test), expect( dead_code, reason = "Set-time dispatch arm is wired; awaiting the snooze-calendar UI to emit it." ) )] SnoozeCalendarChip(u32, u32), SnoozeCalendarConfirmed, SnoozeCalendarDaySelected(i32, u32, u32), SnoozeCalendarHourDown, SnoozeCalendarHourUp, SnoozeCalendarMinuteDown, SnoozeCalendarMinuteUp, SnoozeCalendarNextMonth, SnoozeCalendarOpened, SnoozeCalendarPrevMonth, SnoozeMenuToggled, SnoozePreset(snooze::Preset), ToggleStar(i64), Trash(i64), Unsnooze(i64),}
impl Message { pub fn loads_data(&self) -> bool { matches!( self, Message::Loaded(_) | Message::MessagesPageLoaded { .. } | Message::RenderLoaded { .. } | Message::SearchPageLoaded { .. } ) }}
#[derive(Clone, Debug, Eq, PartialEq)]pub struct ReadingRender { is_starred: bool, labels: Vec<MessageLabel>, mail: MailRender, sender_portrait: images::ImageState,}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum Scope { Character(i64),}
impl Default for Scope { fn default() -> Self { Scope::Character(EMPTY_MAIL_SELECTION) }}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum StandardFolder { Archive, Drafts, Inbox, Sent, Snoozed, Starred, Trash,}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum DropTarget { Label(i64), StandardFolder(StandardFolder),}
#[derive(Debug)]pub struct State { active: Scope, all_messages: Vec<MessageRow>, cursor: Option<Point>, drafts: Vec<draft::DraftRow>, dragging_mail: Option<i64>, drop_target: Option<DropTarget>, folder: Folder, folder_data: FolderPaneData, folder_pane: PaneDrag, headers: Vec<CharacterMail>, label_modal: Option<LabelDraft>, label_picker: Option<LabelPicker>, list_scroll_offset: f32, message_list_pane: PaneDrag, messages: Vec<MessageRow>, messages_cursor: Option<MailCursor>, messages_has_more: bool, messages_loading: bool, messages_page_epoch: LoadEpoch, outbox_indicator: OutboxIndicator, overlays: HashMap<i64, MailOverlayState>, pending_label_delete: Option<i64>, picker_open: bool, render: Option<ReadingRender>, roster: Vec<RosterPilot>, search: String, search_cursor: Option<MailCursor>, search_has_more: bool, search_loading: bool, selected: Option<i64>, snooze_calendar: Option<snooze::Calendar>, snooze_menu: SnoozeMenu, unified: Vec<UnifiedMail>, unified_unread: i64,}
impl State { pub fn new(active: i64) -> Self { State { active: Scope::Character(active), all_messages: Vec::new(), cursor: None, drafts: Vec::new(), dragging_mail: None, drop_target: None, folder: Folder::default(), folder_data: FolderPaneData::default(), folder_pane: PaneDrag::with_min_width( FOLDER_PANE_DEFAULT_WIDTH, FOLDER_PANE_MIN_WIDTH, crate::ui::style::spacing::layout::WINDOW_DEFAULT_WIDTH, ), headers: Vec::new(), label_modal: None, label_picker: None, list_scroll_offset: 0.0, message_list_pane: PaneDrag::with_min_width( MESSAGE_LIST_PANE_DEFAULT_WIDTH, MESSAGE_LIST_PANE_MIN_WIDTH, crate::ui::style::spacing::layout::WINDOW_DEFAULT_WIDTH, ), messages: Vec::new(), messages_cursor: None, messages_has_more: false, messages_loading: false, messages_page_epoch: LoadEpoch::default(), outbox_indicator: OutboxIndicator::default(), overlays: HashMap::new(), pending_label_delete: None, picker_open: false, render: None, roster: Vec::new(), search: String::new(), search_cursor: None, search_has_more: false, search_loading: false, selected: None, snooze_calendar: None, snooze_menu: SnoozeMenu::Closed, unified: Vec::new(), unified_unread: 0, } }
pub fn with_restored_panes(mut self, ui: &UiState) -> Self { let host_width = ui.host_width("main", crate::ui::style::spacing::layout::WINDOW_DEFAULT_WIDTH); self.folder_pane = restore_pane( ui, FOLDER_PANE_KEY, FOLDER_PANE_DEFAULT_WIDTH, FOLDER_PANE_MIN_WIDTH, host_width, ); self.message_list_pane = restore_pane( ui, MESSAGE_LIST_PANE_KEY, MESSAGE_LIST_PANE_DEFAULT_WIDTH, MESSAGE_LIST_PANE_MIN_WIDTH, host_width, ); self }
pub fn set_pane_host_width(&mut self, host_width: f32) { self.folder_pane.set_host_width(host_width); self.message_list_pane.set_host_width(host_width); }
pub fn active(&self) -> Scope { self.active }
pub fn stale_images(&self) -> Vec<(images::ImageKind, i64)> { let roster = self.roster.iter().map(|pilot| &pilot.portrait); let messages = self.messages.iter().map(|row| &row.sender_portrait); let all_messages = self.all_messages.iter().map(|row| &row.sender_portrait); let render = self.render.iter().map(|render| &render.sender_portrait);
roster .chain(messages) .chain(all_messages) .chain(render) .filter_map(images::ImageState::stale_key) .filter(|(_, id)| *id > 0) .collect() }
pub fn roster(&self) -> &[RosterPilot] { &self.roster }
pub(super) fn scope_gate(&self) -> Option<(i64, &str, Vec<&'static str>)> { let Scope::Character(id) = self.active; let pilot = self.roster.iter().find(|pilot| pilot.id == id)?; let required = crate::features::shell::registry::descriptor(crate::config::Feature::Mail).scopes; let missing = crate::ui::components::forbidden::missing_scopes(pilot.granted_scopes.as_deref(), required); if missing.is_empty() { return None; } Some((id, pilot.name.as_str(), missing)) }
pub fn unified_unread(&self) -> i64 { self.unified_unread }
pub fn picker_open(&self) -> bool { self.picker_open }
pub fn folder(&self) -> Folder { self.folder }
pub fn folder_data(&self) -> &FolderPaneData { &self.folder_data }
pub fn selected(&self) -> Option<i64> { self.selected }
pub fn render(&self) -> Option<&ReadingRender> { self.render.as_ref() }
pub fn search(&self) -> &str { &self.search }
pub fn messages(&self) -> &[MessageRow] { &self.messages }
pub(super) fn drafts(&self) -> &[draft::DraftRow] { &self.drafts }
pub fn default_from(&self) -> Option<i64> { let Scope::Character(id) = self.active; Some(id) }
pub fn reply_seed(&self, mail_id: i64, kind: compose::Kind) -> Option<compose::Seed> { let render = self.render.as_ref()?; if render.mail.header.mail_id() != mail_id { return None; } Some(compose::Seed::Reply { kind, render: Box::new(render.mail.clone()), }) }
pub fn list_scroll_offset(&self) -> f32 { self.list_scroll_offset }
pub fn all_messages(&self) -> &[MessageRow] { &self.all_messages }
pub fn outbox_indicator(&self) -> &OutboxIndicator { &self.outbox_indicator }
pub fn folder_pane_width(&self) -> f32 { self.folder_pane.width() }
pub fn message_list_pane_width(&self) -> f32 { self.message_list_pane.width() }
pub fn snooze_presets_open(&self) -> bool { self.snooze_menu == SnoozeMenu::Presets }
pub fn snooze_calendar(&self) -> Option<&snooze::Calendar> { if self.snooze_menu == SnoozeMenu::Calendar { self.snooze_calendar.as_ref() } else { None } }
pub fn open_mail_snoozed(&self) -> bool { self .selected .and_then(|mail_id| self.overlay_for(mail_id)) .map(|o| o.is_snoozed()) .unwrap_or(false) }
pub(super) fn label_modal(&self) -> Option<&LabelDraft> { self.label_modal.as_ref() }
pub(super) fn pending_label_delete(&self) -> Option<&FolderLabel> { let label_id = self.pending_label_delete?; self.folder_data.labels.iter().find(|label| label.label_id == label_id) }
pub(super) fn dragging_mail(&self) -> Option<i64> { self.dragging_mail }
pub(super) fn drop_target(&self) -> Option<DropTarget> { self.drop_target }
pub(super) fn label_picker_view(&self) -> Option<(i64, Option<Point>, Vec<i64>)> { let picker = self.label_picker?; Some((picker.mail_id, picker.anchor, self.applied_label_ids(picker.mail_id))) }
fn applied_label_ids(&self, mail_id: i64) -> Vec<i64> { if let Some(render) = self.render.as_ref() && render.mail.header.mail_id() == mail_id { return render.mail.label_ids.clone(); } self .messages .iter() .chain(self.all_messages.iter()) .find(|row| row.mail_id == mail_id) .map(|row| row.label_ids.clone()) .unwrap_or_default() }
fn character_for(&self, mail_id: i64) -> Option<i64> { if let Some(render) = self.render.as_ref() && render.mail.header.mail_id() == mail_id { return Some(render.mail.header.character_id()); } if let Some(row) = self.messages.iter().find(|r| r.mail_id == mail_id) { return Some(row.character_id); } let Scope::Character(id) = self.active; Some(id) }
fn overlay_for(&self, mail_id: i64) -> Option<&MailOverlayState> { self.overlays.get(&mail_id) }}
impl Default for State { fn default() -> Self { Self::new(EMPTY_MAIL_SELECTION) }}
#[derive(Clone, Copy, Debug, PartialEq)]struct LabelPicker { anchor: Option<Point>, mail_id: i64,}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]enum SnoozeMenu { Calendar, #[default] Closed, Presets,}
fn restore_pane(ui: &UiState, key: &str, default: f32, min: f32, host_width: f32) -> PaneDrag { PaneDrag::from_store_with_min(ui, key, default, min, host_width)}
pub async fn persist_pending_draft(db: Database, id: Option<i64>, input: DraftInput) { let _ = draft::persist(db, id, input).await;}
pub async fn delete_draft(db: Database, id: i64) { draft::delete(db, id).await;}
pub fn load(db: &Database, character: i64) -> Task<Message> { Task::perform( load_mail(db.clone(), Scope::Character(character), Folder::Unified), |loaded| Message::Loaded(Box::new(loaded)), )}
pub fn reload(db: &Database, scope: Scope) -> Task<Message> { Task::perform(load_mail(db.clone(), scope, Folder::Unified), |loaded| { Message::Loaded(Box::new(loaded)) })}
async fn load_render(db: Database, character_id: i64, mail_id: i64) -> Option<ReadingRender> { let mail = mail::mail(&db, character_id, mail_id).await.ok().flatten()?; let is_starred = mail::overlay_state(&db, character_id, mail_id) .await .map(|overlay| overlay.is_starred) .unwrap_or(false); let sender_portrait = loaders::resolve_sender_portrait(mail.header.from_id()); let labels = loaders::resolve_message_labels(&db, character_id, &mail.label_ids).await; Some(ReadingRender { is_starred, labels, mail, sender_portrait, })}
fn reload_for(db: &Database, scope: Scope, folder: Folder) -> Task<Message> { Task::perform(load_mail(db.clone(), scope, folder), |loaded| { Message::Loaded(Box::new(loaded)) })}
fn cursor_of(row: &MessageRow) -> MailCursor { MailCursor::new(row.timestamp.clone(), row.mail_id)}
fn start_search(state: &mut State, db: &Database) -> Task<Message> { state.search_loading = true; let (db, scope, folder, needle) = (db.clone(), state.active, state.folder, state.search.clone()); Task::perform( async move { let rows = message_list::load_search_page(&db, scope, folder, &needle, None).await; (needle, rows) }, |(query, rows)| Message::SearchPageLoaded { query, rows, }, )}
fn update_pagination(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::ListScrolled { absolute, relative, } => { state.list_scroll_offset = absolute; if relative < LIST_SCROLL_THRESHOLD { return Task::none(); } if state.search.trim().is_empty() { load_more_messages(state, db) } else { load_more_search(state, db) } } Message::MessagesPageLoaded { epoch, rows, } => { // Drop a page captured against a folder/scope the user has since left, so it can't append // foreign rows to the current list. if !state.messages_page_epoch.matches(epoch) { return Task::none(); } state.messages_loading = false; state.messages_has_more = rows.len() as i64 == message_list::MESSAGE_PAGE_SIZE; if let Some(last) = rows.last() { state.messages_cursor = Some(cursor_of(last)); } state.messages.extend(rows); Task::none() } Message::SearchPageLoaded { query, rows, } => { // Drop a page whose query the user has since changed; its `all_messages` // accumulator was already cleared by the newer `SearchChanged`. if query != state.search { return Task::none(); } state.search_loading = false; state.search_has_more = rows.len() as i64 == message_list::MESSAGE_PAGE_SIZE; if let Some(last) = rows.last() { state.search_cursor = Some(cursor_of(last)); } state.all_messages.extend(rows); Task::none() } _ => Task::none(), }}
fn load_more_messages(state: &mut State, db: &Database) -> Task<Message> { if state.messages_loading || !state.messages_has_more { return Task::none(); } let Some(cursor) = state.messages_cursor.clone() else { return Task::none(); }; state.messages_loading = true; let epoch = state.messages_page_epoch.current(); let (db, scope, folder) = (db.clone(), state.active, state.folder); Task::perform( async move { message_list::load_messages_page(&db, scope, folder, cursor).await }, move |rows| Message::MessagesPageLoaded { epoch, rows, }, )}
fn load_more_search(state: &mut State, db: &Database) -> Task<Message> { if state.search_loading || !state.search_has_more { return Task::none(); } let Some(cursor) = state.search_cursor.clone() else { return Task::none(); }; state.search_loading = true; let (db, scope, folder, needle) = (db.clone(), state.active, state.folder, state.search.clone()); Task::perform( async move { let rows = message_list::load_search_page(&db, scope, folder, &needle, Some(cursor)).await; (needle, rows) }, |(query, rows)| Message::SearchPageLoaded { query, rows, }, )}
fn triage_write<Fut>(state: &State, db: &Database, mail_id: i64, op: fn(Database, i64, i64) -> Fut) -> Task<Message>where Fut: std::future::Future<Output = ()> + Send + 'static,{ let Some(character_id) = state.character_for(mail_id) else { return Task::none(); }; Task::perform(op(db.clone(), character_id, mail_id), |()| Message::OverlayWritten)}
pub fn update(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::Loaded(loaded) => handle_loaded(state, *loaded, db), Message::ScopeSelected(_) | Message::PickerToggled | Message::FolderSelected(_) | Message::SearchChanged(_) | Message::RenderLoaded { .. } => update_navigation(state, message, db), Message::FolderPaneDragStart | Message::FolderPaneDragged(_) | Message::FolderPaneDragEnd | Message::ListPaneDragStart | Message::ListPaneDragged(_) | Message::ListPaneDragEnd | Message::PaneSettled(..) => update_pane_drag(state, message), Message::ListScrolled { .. } | Message::MessagesPageLoaded { .. } | Message::SearchPageLoaded { .. } => update_pagination(state, message, db), Message::Selected(mail_id) => handle_message_selected(state, mail_id, db), Message::MarkedRead => reload_for(db, state.active, state.folder), Message::ToggleStar(mail_id) => triage_write(state, db, mail_id, triage::toggle_star), Message::Archive(mail_id) => triage_write(state, db, mail_id, triage::archive), Message::Delete(mail_id) => triage_write(state, db, mail_id, triage::delete), Message::Trash(mail_id) => triage_write(state, db, mail_id, triage::trash), Message::OverlayWritten => reload_for(db, state.active, state.folder),
Message::SnoozeMenuToggled | Message::SnoozePreset(_) | Message::SnoozeCalendarOpened | Message::SnoozeCalendarBack | Message::SnoozeCalendarConfirmed | Message::Unsnooze(_) => update_snooze(state, message, db), Message::SnoozeCalendarPrevMonth | Message::SnoozeCalendarNextMonth | Message::SnoozeCalendarDaySelected(..) | Message::SnoozeCalendarHourUp | Message::SnoozeCalendarHourDown | Message::SnoozeCalendarMinuteUp | Message::SnoozeCalendarMinuteDown | Message::SnoozeCalendarChip(..) => update_snooze_calendar(state, message),
// Compose now lives in detached windows: the app layer intercepts every compose/reply/forward // and per-window compose message before it reaches here, so these arms are unreachable in practice // and kept only to satisfy the match. Message::Reply(_) | Message::ReplyAll(_) | Message::Forward(_) | Message::ComposeOpened | Message::ComposeToInput(_) | Message::ComposeCcInput(_) | Message::ComposeToSearched { .. } | Message::ComposeCcSearched { .. } | Message::ComposeToCommitted | Message::ComposeCcCommitted | Message::ComposeToPicked(_) | Message::ComposeCcPicked(_) | Message::ComposeToRemoved(_) | Message::ComposeCcRemoved(_) | Message::ComposeCcShown | Message::ComposeSubjectChanged(_) | Message::ComposeBodyChanged(_) | Message::ComposeBold | Message::ComposeItalic | Message::ComposeLinkToggled | Message::ComposeLinkKindSelected(_) | Message::ComposeLinkUrlChanged(_) | Message::ComposeLinkSearchInput(_) | Message::ComposeLinkSearched { .. } | Message::ComposeLinkPicked(_) | Message::ComposeLinkInsert | Message::ComposeFromChanged(_) | Message::ComposeFromToggled | Message::ComposeDiscarded | Message::ComposeSend | Message::ComposeSent(_) | Message::DraftLoaded(_) => Task::none(), Message::DraftDeleted(_) | Message::DraftOpened(_) | Message::DraftRowsLoaded(_) | Message::DraftSaved(_) => { update_drafts(state, message, db) } Message::DropTargetEntered(_) | Message::DropTargetLeft(_) | Message::LabelColorPicked(_) | Message::LabelDeleteCancelled | Message::LabelDeleteConfirmed | Message::LabelDeleteRequested(_) | Message::LabelDragMoved(_) | Message::LabelDropReleased | Message::LabelModalClosed | Message::LabelModalOpened | Message::LabelModalSubmitted | Message::LabelNameChanged(_) | Message::LabelPickerClosed | Message::LabelPickerOpened(_) | Message::LabelRowMenuOpened(_) | Message::LabelToggled(..) | Message::LabelsWritten => update_labels(state, message, db), Message::OutboxRetry(_) | Message::OutboxDismiss(_) | Message::OutboxRefreshed(_) => { update_outbox(state, message, db) } Message::ReauthRequested(_) => Task::none(), }}
fn update_navigation(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::ScopeSelected(scope) => { state.messages_page_epoch.next(); state.active = scope; state.folder = Folder::Unified; state.selected = None; state.render = None; state.picker_open = false; state.snooze_menu = SnoozeMenu::Closed; state.snooze_calendar = None; state.label_modal = None; state.label_picker = None; state.pending_label_delete = None; state.dragging_mail = None; state.drop_target = None; Task::none() } Message::PickerToggled => { state.picker_open = !state.picker_open; Task::none() } Message::FolderSelected(folder) => { state.messages_page_epoch.next(); state.folder = folder; state.selected = None; state.render = None; state.snooze_menu = SnoozeMenu::Closed; state.snooze_calendar = None; state.label_picker = None; reload_for(db, state.active, folder) } Message::SearchChanged(query) => { state.search = query; state.list_scroll_offset = 0.0; state.all_messages.clear(); state.search_cursor = None; state.search_has_more = false; state.search_loading = false; if state.search.trim().is_empty() { Task::none() } else { start_search(state, db) } } Message::RenderLoaded { mail_id, render, } => { // A render that completes after the selection moved on (or was cleared) belongs to a mail we are no longer // showing; dropping it stops a stale body from landing under the current selection. if state.selected == Some(mail_id) { state.render = *render; } Task::none() } _ => Task::none(), }}
fn update_outbox(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::OutboxRetry(id) => Task::perform(read_state::retry_outbox(db.clone(), id), |indicator| { Message::OutboxRefreshed(Box::new(indicator)) }), Message::OutboxDismiss(id) => Task::perform(read_state::dismiss_outbox(db.clone(), id), |indicator| { Message::OutboxRefreshed(Box::new(indicator)) }), Message::OutboxRefreshed(indicator) => { state.outbox_indicator = *indicator; Task::none() } _ => Task::none(), }}
fn handle_loaded(state: &mut State, loaded: Loaded, db: &Database) -> Task<Message> { let Loaded { drafts, folder, folder_data, folder_pane_width, headers, message_list_pane_width, messages, messages_has_more, outbox_indicator, overlays, roster, scope, unified, unified_unread, } = loaded; state.outbox_indicator = outbox_indicator; state.roster = roster; state.unified = unified; state.unified_unread = unified_unread; if !state.folder_pane.is_active() { state.folder_pane.set_ratio_from_store(folder_pane_width); } if !state.message_list_pane.is_active() { state.message_list_pane.set_ratio_from_store(message_list_pane_width); } if scope == state.active && folder == state.folder { // A fresh folder load supersedes any in-flight scroll page captured against the prior list. state.messages_page_epoch.next(); state.drafts = drafts; state.folder_data = folder_data; state.headers = headers; state.overlays = overlays; state.messages = messages; state.messages_cursor = state.messages.last().map(cursor_of); state.messages_has_more = messages_has_more; state.messages_loading = false; state.list_scroll_offset = 0.0; if let Some(selected) = state.selected && !state.messages.iter().any(|row| row.mail_id == selected) { state.selected = None; state.render = None; } state.all_messages.clear(); state.search_cursor = None; state.search_has_more = false; state.search_loading = false; if state.search.trim().is_empty() { Task::none() } else { start_search(state, db) } } else { reload_for(db, state.active, state.folder) }}
fn update_pane_drag(state: &mut State, message: Message) -> Task<Message> { match message { Message::FolderPaneDragStart => { state.folder_pane.start(); Task::none() } Message::FolderPaneDragged(x) => { state.folder_pane.drag_to(x); Task::none() } Message::FolderPaneDragEnd => { state.folder_pane.end(); Task::done(Message::PaneSettled(FOLDER_PANE_KEY, state.folder_pane.ratio())) } Message::ListPaneDragStart => { state.message_list_pane.start(); Task::none() } Message::ListPaneDragged(x) => { state.message_list_pane.drag_to(x); Task::none() } Message::ListPaneDragEnd => { state.message_list_pane.end(); Task::done(Message::PaneSettled( MESSAGE_LIST_PANE_KEY, state.message_list_pane.ratio(), )) } _ => Task::none(), }}
fn handle_message_selected(state: &mut State, mail_id: i64, db: &Database) -> Task<Message> { state.selected = Some(mail_id); state.snooze_menu = SnoozeMenu::Closed; state.snooze_calendar = None; state.label_picker = None; // A row press both selects and arms a potential drag-to-label; the drag is a no-op unless released over a label target. state.dragging_mail = Some(mail_id); state.drop_target = None; let Some(character_id) = state .messages .iter() .find(|r| r.mail_id == mail_id) .map(|r| r.character_id) else { state.render = None; return Task::none(); }; let render = Task::perform(load_render(db.clone(), character_id, mail_id), move |render| { Message::RenderLoaded { mail_id, render: Box::new(render), } }); match read_state::open_target(state, mail_id) { Some((character_id, mail_id)) => Task::batch([ render, Task::perform(read_state::mark_read_on_open(db.clone(), character_id, mail_id), |()| { Message::MarkedRead }), ]), None => render, }}
fn update_snooze(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::SnoozeMenuToggled => { state.snooze_menu = if state.snooze_menu == SnoozeMenu::Closed { SnoozeMenu::Presets } else { SnoozeMenu::Closed }; state.snooze_calendar = None; Task::none() } Message::SnoozePreset(preset) => { state.snooze_menu = SnoozeMenu::Closed; let Some(mail_id) = state.selected else { return Task::none(); }; let Some(character_id) = state.character_for(mail_id) else { return Task::none(); }; let until = preset.resolve(chrono::Utc::now()).to_rfc3339(); Task::perform(snooze::snooze_until(db.clone(), character_id, mail_id, until), |()| { Message::OverlayWritten }) } Message::SnoozeCalendarOpened => { state.snooze_menu = SnoozeMenu::Calendar; state.snooze_calendar = Some(snooze::Calendar::open(chrono::Utc::now())); Task::none() } Message::SnoozeCalendarBack => { state.snooze_menu = SnoozeMenu::Presets; state.snooze_calendar = None; Task::none() } Message::SnoozeCalendarConfirmed => { let until = state.snooze_calendar.and_then(|c| c.resolved()).map(|d| d.to_rfc3339()); state.snooze_menu = SnoozeMenu::Closed; state.snooze_calendar = None; let (Some(until), Some(mail_id)) = (until, state.selected) else { return Task::none(); }; let Some(character_id) = state.character_for(mail_id) else { return Task::none(); }; Task::perform(snooze::snooze_until(db.clone(), character_id, mail_id, until), |()| { Message::OverlayWritten }) } Message::Unsnooze(mail_id) => { state.snooze_menu = SnoozeMenu::Closed; state.snooze_calendar = None; let Some(character_id) = state.character_for(mail_id) else { return Task::none(); }; Task::perform(snooze::unsnooze(db.clone(), character_id, mail_id), |()| { Message::OverlayWritten }) } _ => Task::none(), }}
fn update_snooze_calendar(state: &mut State, message: Message) -> Task<Message> { let Some(cal) = state.snooze_calendar.as_mut() else { return Task::none(); }; match message { Message::SnoozeCalendarPrevMonth => cal.prev_month(), Message::SnoozeCalendarNextMonth => cal.next_month(), Message::SnoozeCalendarDaySelected(year, month0, day) => cal.select_day(year, month0, day), Message::SnoozeCalendarHourUp => cal.hour_up(), Message::SnoozeCalendarHourDown => cal.hour_down(), Message::SnoozeCalendarMinuteUp => cal.minute_up(), Message::SnoozeCalendarMinuteDown => cal.minute_down(), Message::SnoozeCalendarChip(hour, minute) => cal.set_time(hour, minute), _ => {} } Task::none()}
fn reload_drafts(state: &State, db: &Database) -> Task<Message> { let Scope::Character(character_id) = state.active; Task::perform(draft::load_rows(db.clone(), character_id), Message::DraftRowsLoaded)}
fn update_drafts(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::DraftDeleted(id) => { state.drafts.retain(|row| row.id != id); let reload = reload_for(db, state.active, state.folder); Task::perform(draft::delete(db.clone(), id), |()| ()) .discard() .chain(reload) } Message::DraftOpened(id) => { let db = db.clone(); Task::perform(async move { mail::draft(&db, id).await.ok().flatten() }, |row| { Message::DraftLoaded(Box::new(row)) }) } Message::DraftRowsLoaded(rows) => { state.drafts = rows; Task::none() } Message::DraftSaved(_) => reload_drafts(state, db), _ => Task::none(), }}
fn update_labels(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::LabelModalOpened => { state.label_modal = Some(LabelDraft::blank()); state.label_picker = None; Task::none() } Message::LabelModalClosed => { state.label_modal = None; Task::none() } Message::LabelNameChanged(value) => { if let Some(draft) = state.label_modal.as_mut() { draft.name = value.chars().take(labels::NAME_MAX_CHARS).collect(); } Task::none() } Message::LabelColorPicked(hex) => { if let Some(draft) = state.label_modal.as_mut() { draft.color = hex; } Task::none() } Message::LabelModalSubmitted => { let Some(draft) = state.label_modal.as_ref() else { return Task::none(); }; if !draft.can_create() { return Task::none(); } let draft = draft.clone(); state.label_modal = None; let Scope::Character(character_id) = state.active; let temp_id = labels::temp_label_id(); Task::perform(labels::enqueue_create(db.clone(), character_id, temp_id, draft), |()| { Message::LabelsWritten }) } Message::LabelPickerOpened(mail_id) => { state.label_modal = None; state.snooze_menu = SnoozeMenu::Closed; state.label_picker = Some(LabelPicker { anchor: None, mail_id, }); Task::none() } Message::LabelRowMenuOpened(mail_id) => { state.selected = Some(mail_id); state.label_modal = None; state.label_picker = Some(LabelPicker { anchor: state.cursor, mail_id, }); Task::none() } Message::LabelPickerClosed => { state.label_picker = None; Task::none() } Message::LabelToggled(mail_id, label_id) => { let Some(character_id) = state.character_for(mail_id) else { return Task::none(); }; Task::perform( labels::enqueue_toggle(db.clone(), character_id, mail_id, label_id), |()| Message::LabelsWritten, ) } Message::LabelDeleteRequested(label_id) => { state.pending_label_delete = Some(label_id); Task::none() } Message::LabelDeleteCancelled => { state.pending_label_delete = None; Task::none() } Message::LabelDeleteConfirmed => { let Some(label_id) = state.pending_label_delete.take() else { return Task::none(); }; let Scope::Character(character_id) = state.active; Task::perform(labels::enqueue_delete(db.clone(), character_id, label_id), |()| { Message::LabelsWritten }) } Message::LabelDragMoved(point) => { state.cursor = Some(point); Task::none() } Message::DropTargetEntered(_) | Message::DropTargetLeft(_) | Message::LabelDropReleased => { update_label_drag(state, message, db) } Message::LabelsWritten => reload_after_label_write(state, db), _ => Task::none(), }}
fn update_label_drag(state: &mut State, message: Message, db: &Database) -> Task<Message> { match message { Message::DropTargetEntered(target) => { if state.dragging_mail.is_some() { state.drop_target = Some(target); } Task::none() } Message::DropTargetLeft(target) => { if state.drop_target == Some(target) { state.drop_target = None; } Task::none() } Message::LabelDropReleased => { let drop = state.dragging_mail.zip(state.drop_target); state.dragging_mail = None; state.drop_target = None; let Some((mail_id, target)) = drop else { return Task::none(); }; let Some(character_id) = state.character_for(mail_id) else { return Task::none(); }; match target { DropTarget::Label(label_id) => Task::perform( labels::enqueue_assign(db.clone(), character_id, mail_id, label_id), |()| Message::LabelsWritten, ), DropTarget::StandardFolder(folder) => { Task::perform(triage::move_to_box(db.clone(), character_id, mail_id, folder), |()| { Message::LabelsWritten }) } } } _ => Task::none(), }}
fn reload_after_label_write(state: &State, db: &Database) -> Task<Message> { let folder = reload_for(db, state.active, state.folder); let Some(render) = state.render.as_ref() else { return folder; }; let mail_id = render.mail.header.mail_id(); let character_id = render.mail.header.character_id(); let render = Task::perform(load_render(db.clone(), character_id, mail_id), move |render| { Message::RenderLoaded { mail_id, render: Box::new(render), } }); Task::batch([folder, render])}
pub fn view(state: &State) -> Element<'_, Message> { shell::shell(state)}
pub fn escape_dismiss(state: &State) -> Option<Message> { shell::escape_dismiss(state)}
pub fn subscription(state: &State) -> iced::Subscription<Message> { if state.folder_pane.is_active() { return iced::event::listen_with(|event, _status, _id| { resizable_pane::drag_event(event, Message::FolderPaneDragged, Message::FolderPaneDragEnd) }); } if state.message_list_pane.is_active() { return iced::event::listen_with(|event, _status, _id| { resizable_pane::drag_event(event, Message::ListPaneDragged, Message::ListPaneDragEnd) }); } if state.dragging_mail.is_some() { return iced::event::listen_with(|event, _status, _id| { matches!( event, iced::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) ) .then_some(Message::LabelDropReleased) }); } iced::Subscription::none()}
async fn load_mail(db: Database, scope: Scope, folder: Folder) -> Loaded { let roster = loaders::load_roster(&db).await; let unified = loaders::load_unified(&db).await; let unified_unread = loaders::load_unified_unread(&db).await;
let Scope::Character(scope_id) = scope;
let folder_data = match folder { Folder::Unified => loaders::load_folder_pane_unified(&db, &roster).await, _ => loaders::load_folder_pane(&db, scope_id).await, };
let first_page = message_list::load_first_page(&db, scope, folder).await; let outbox_indicator = loaders::load_outbox_indicator(&db).await; let drafts = draft::load_rows(db.clone(), scope_id).await;
let (headers, overlays) = match folder { Folder::Unified => (Vec::new(), HashMap::new()), _ => { let headers = loaders::load_headers(&db, scope_id).await; let overlays = loaders::load_overlays(&db, scope_id).await; (headers, overlays) } };
let ui = window_state::load(); let folder_pane_width = ui .panes .get(FOLDER_PANE_KEY) .copied() .unwrap_or(FOLDER_PANE_DEFAULT_WIDTH); let message_list_pane_width = ui .panes .get(MESSAGE_LIST_PANE_KEY) .copied() .unwrap_or(MESSAGE_LIST_PANE_DEFAULT_WIDTH);
Loaded { drafts, folder, folder_data, folder_pane_width, headers, message_list_pane_width, messages: first_page.tail, messages_has_more: first_page.has_more, outbox_indicator, overlays, roster, scope, unified, unified_unread, }}
#[cfg(test)]mod tests { use super::*;
fn sample_render() -> crate::store::model::character_mail_view::MailRender { use crate::store::model::{ CharacterMail, CharacterMailBody, CharacterMailRecipient, character_mail_view::MailRender, }; MailRender { body: CharacterMailBody { body: "<p>Form up at Jita.</p>".to_owned(), character_id: 42, mail_id: 7, }, header: CharacterMail { character_id: 42, from_id: 95_000_001, from_name: "Vex Voronova".to_owned(), is_read: true, mail_id: 7, subject: Some("CTA tonight".to_owned()), timestamp: "2026-06-01T10:00:00Z".to_owned(), ..Default::default() }, label_ids: vec![8], recipients: vec![CharacterMailRecipient { character_id: 42, mail_id: 7, recipient_id: 42, recipient_name: "Vex Voronova".to_owned(), recipient_type: "character".to_owned(), }], recipients_display: "Vex Voronova".to_owned(), } }
mod scope_gate { use pretty_assertions::assert_eq;
use super::*;
fn pilot(id: i64, granted: Option<&str>) -> RosterPilot { RosterPilot { corp: "VEX".to_owned(), granted_scopes: granted.map(str::to_owned), id, name: "Vex".to_owned(), portrait: images::ImageState::Fresh("/cache/42.jpg".into()), unread: 0, } }
#[test] fn it_does_not_gate_when_the_active_character_has_the_mail_scopes() { let granted = crate::features::shell::registry::descriptor(crate::config::Feature::Mail) .scopes .join(" "); let mut state = State::new(42); state.roster = vec![pilot(42, Some(&granted))];
assert!(state.scope_gate().is_none()); }
#[test] fn it_does_not_gate_when_the_active_character_is_absent_from_the_roster() { let state = State::new(42);
assert!(state.scope_gate().is_none()); }
#[test] fn it_gates_when_the_active_character_lacks_the_mail_scopes() { let mut state = State::new(42); state.roster = vec![pilot(42, None)];
let gate = state.scope_gate().expect("missing scope should gate");
assert_eq!(gate.0, 42); assert!(!gate.2.is_empty()); } }
mod stale_images { use pretty_assertions::assert_eq;
use super::*; use crate::features::mail::message_list::{DayBucket, MessageRow, SenderKind};
fn message_row(mail_id: i64, sender_id: i64) -> MessageRow { MessageRow { bucket: DayBucket::Today, character_id: 42, is_read: true, is_starred: false, has_attachment: false, important: false, sender_kind: SenderKind::Character, label_ids: Vec::new(), labels: Vec::new(), mail_id, sender: "Vex".to_owned(), sender_id, sender_portrait: images::ImageState::Stale { id: sender_id, kind: images::ImageKind::CharacterPortrait, }, snippet: String::new(), subject: "S".to_owned(), time: "10:00".to_owned(), timestamp: "2026-06-01T10:00:00Z".to_owned(), } }
#[test] fn it_collects_stale_keys_from_the_roster_messages_and_open_render() { let mut state = State::new(42); state.roster = vec![RosterPilot { corp: "VEX".to_owned(), granted_scopes: None, id: 42, name: "Vex".to_owned(), portrait: images::ImageState::Stale { id: 42, kind: images::ImageKind::CharacterPortrait, }, unread: 0, }]; state.messages = vec![message_row(7, 95_000_001)]; state.render = Some(ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_002, kind: images::ImageKind::CharacterPortrait, }, });
let stale = state.stale_images();
assert!(stale.contains(&(images::ImageKind::CharacterPortrait, 42))); assert!(stale.contains(&(images::ImageKind::CharacterPortrait, 95_000_001))); assert!(stale.contains(&(images::ImageKind::CharacterPortrait, 95_000_002))); }
#[test] fn it_is_empty_for_a_fresh_default_state() { let state = State::new(42);
assert!(state.stale_images().is_empty()); }
#[test] fn it_omits_a_fresh_portrait_and_a_non_positive_sender_id() { let mut state = State::new(42); state.roster = vec![RosterPilot { corp: "VEX".to_owned(), granted_scopes: None, id: 42, name: "Vex".to_owned(), portrait: images::ImageState::Fresh("/cache/42.jpg".into()), unread: 0, }]; state.messages = vec![message_row(7, 0)];
assert_eq!(state.stale_images(), Vec::new()); } }
mod state { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_clamps_a_restored_folder_width_to_its_80px_minimum() { let mut ui = UiState::default(); ui.panes.insert(FOLDER_PANE_KEY.to_owned(), 40.0); ui.panes.insert(MESSAGE_LIST_PANE_KEY.to_owned(), 60.0);
let state = State::new(42).with_restored_panes(&ui);
assert_eq!(state.folder_pane_width(), FOLDER_PANE_MIN_WIDTH); assert_eq!(state.message_list_pane_width(), MESSAGE_LIST_PANE_MIN_WIDTH); }
#[test] fn it_falls_back_to_default_pane_widths_when_unsized() { let state = State::new(42).with_restored_panes(&UiState::default());
assert_eq!(state.folder_pane_width(), FOLDER_PANE_DEFAULT_WIDTH); assert_eq!(state.message_list_pane_width(), MESSAGE_LIST_PANE_DEFAULT_WIDTH); }
#[test] fn it_opens_scoped_to_the_starting_character() { let state = State::new(42);
assert_eq!(state.active(), Scope::Character(42)); assert_eq!(state.unified_unread(), 0); }
#[test] fn it_restores_pane_widths_from_the_keyed_store() { let mut ui = UiState::default(); ui.panes.insert(FOLDER_PANE_KEY.to_owned(), 300.0); ui.panes.insert(MESSAGE_LIST_PANE_KEY.to_owned(), 420.0);
let state = State::new(42).with_restored_panes(&ui);
assert_eq!(state.folder_pane_width(), 300.0); assert_eq!(state.message_list_pane_width(), 420.0); } }
mod update { use pretty_assertions::assert_eq;
use super::*; fn list_row(mail_id: i64, character_id: i64, is_read: bool) -> message_list::MessageRow { message_list::MessageRow { bucket: message_list::DayBucket::Today, character_id, is_read, is_starred: false, has_attachment: false, important: false, sender_kind: message_list::SenderKind::Character, label_ids: Vec::new(), labels: Vec::new(), mail_id, sender: "Vex".to_owned(), sender_id: 95_000_001, sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, snippet: String::new(), subject: "S".to_owned(), time: "10:00".to_owned(), timestamp: "2026-06-01T10:00:00Z".to_owned(), } }
#[tokio::test] async fn it_clamps_a_folder_drag_to_the_80px_minimum() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update(&mut state, Message::FolderPaneDragStart, &db); let _ = update(&mut state, Message::FolderPaneDragged(500.0), &db); let _ = update(&mut state, Message::FolderPaneDragged(0.0), &db); let _ = update(&mut state, Message::FolderPaneDragEnd, &db);
assert!((state.folder_pane_width() - FOLDER_PANE_MIN_WIDTH).abs() < 0.5); }
#[tokio::test] async fn it_clears_the_open_mail_when_a_reload_no_longer_lists_it() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.active = Scope::Character(42); state.folder = Folder::Standard(StandardFolder::Inbox); state.selected = Some(7); state.render = Some(ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, }); let loaded = Loaded { folder: Folder::Standard(StandardFolder::Inbox), messages: vec![list_row(8, 42, true)], scope: Scope::Character(42), ..Loaded::default() };
let _ = update(&mut state, Message::Loaded(Box::new(loaded)), &db);
assert!( state.selected().is_none(), "archiving the open mail drops it from the reload and clears the selection" ); assert!( state.render().is_none(), "the reading pane stops rendering the gone mail" ); }
#[tokio::test] async fn it_clears_the_open_render_when_the_folder_changes() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.render = Some(ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, });
let _ = update( &mut state, Message::FolderSelected(Folder::Standard(StandardFolder::Starred)), &db, );
assert!(state.render().is_none()); }
#[tokio::test] async fn it_clears_the_render_when_selecting_a_row_no_longer_in_the_list() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.render = Some(ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, });
let _ = update(&mut state, Message::Selected(999), &db);
assert_eq!(state.selected(), Some(999)); assert!(state.render().is_none()); }
#[tokio::test] async fn it_closes_the_dropdown_and_resets_the_folder_on_a_scope_selection() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.picker_open = true; state.folder = Folder::Standard(StandardFolder::Starred); state.selected = Some(7);
let _ = update(&mut state, Message::ScopeSelected(Scope::Character(42)), &db);
assert_eq!(state.active(), Scope::Character(42)); assert_eq!(state.folder(), Folder::Unified); assert!(!state.picker_open()); assert!(state.selected().is_none()); }
#[tokio::test] async fn it_confirms_a_calendar_snooze_and_unsnoozes() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.active = Scope::Character(42); state.selected = Some(7);
let _ = update(&mut state, Message::SnoozeCalendarOpened, &db); let _ = update(&mut state, Message::SnoozeCalendarConfirmed, &db); assert!(state.snooze_calendar().is_none());
let _ = update(&mut state, Message::Unsnooze(7), &db); assert!(!state.snooze_presets_open()); }
#[tokio::test] async fn it_dispatches_the_remaining_arms_without_panicking() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.active = Scope::Character(42); state.messages = vec![list_row(7, 42, true)]; state.selected = Some(7); state.render = Some(ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, });
for message in [ Message::PickerToggled, Message::MarkedRead, Message::OverlayWritten, Message::ToggleStar(7), Message::Archive(7), Message::Trash(7), Message::Reply(7), Message::ReplyAll(7), Message::Forward(7), Message::OutboxRetry(1), Message::OutboxDismiss(1), Message::SearchChanged("cta".to_owned()), Message::RenderLoaded { mail_id: 7, render: Box::new(None), }, Message::PaneSettled(FOLDER_PANE_KEY, 240.0), ] { let _ = update(&mut state, message, &db); }
assert_eq!(state.search(), "cta"); assert!(state.render().is_none()); }
#[tokio::test] async fn it_does_not_adopt_a_stale_scope_loads_scope_specific_picture() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.active = Scope::Character(42); state.folder_data = FolderPaneData { labels: vec![loaders::FolderLabel { color: None, label_id: 99, name: "Sentinel".to_owned(), unread: 7, }], ..FolderPaneData::default() }; let loaded = Loaded { scope: Scope::Character(0), folder: Folder::Unified, ..Loaded::default() };
let _ = update(&mut state, Message::Loaded(Box::new(loaded)), &db);
assert_eq!(state.folder_data().labels.len(), 1); assert_eq!(state.active(), Scope::Character(42)); }
#[tokio::test] async fn it_drops_a_render_for_a_mail_that_is_no_longer_selected() { let mut state = State::new(42); state.selected = Some(9); let db = crate::store::open_test().await.unwrap(); let render = ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, };
let _ = update( &mut state, Message::RenderLoaded { mail_id: 7, render: Box::new(Some(render)), }, &db, );
assert!( state.render().is_none(), "a render for mail 7 must not land while mail 9 is selected" ); }
#[tokio::test] async fn it_echoes_a_settled_folder_pane_width_for_the_app_to_persist() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update(&mut state, Message::FolderPaneDragStart, &db); let _ = update(&mut state, Message::FolderPaneDragged(500.0), &db); let _ = update(&mut state, Message::FolderPaneDragged(540.0), &db); let _ = update(&mut state, Message::FolderPaneDragEnd, &db);
assert_eq!(state.folder_pane_width(), FOLDER_PANE_DEFAULT_WIDTH + 40.0); }
#[tokio::test] async fn it_folds_a_refreshed_outbox_indicator_into_state() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); let indicator = OutboxIndicator { pending: 2, failed: Vec::new(), };
let _ = update(&mut state, Message::OutboxRefreshed(Box::new(indicator)), &db);
assert_eq!(state.outbox_indicator().pending, 2); }
#[tokio::test] async fn it_keeps_the_open_mail_when_a_reload_still_lists_it() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.active = Scope::Character(42); state.folder = Folder::Standard(StandardFolder::Inbox); state.selected = Some(7); let loaded = Loaded { folder: Folder::Standard(StandardFolder::Inbox), messages: vec![list_row(7, 42, true), list_row(8, 42, true)], scope: Scope::Character(42), ..Loaded::default() };
let _ = update(&mut state, Message::Loaded(Box::new(loaded)), &db);
assert_eq!( state.selected(), Some(7), "a reload that still lists the open mail keeps the selection" ); }
#[tokio::test] async fn it_opens_and_edits_the_snooze_calendar() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update(&mut state, Message::SnoozeCalendarOpened, &db); assert!(state.snooze_calendar().is_some()); for message in [ Message::SnoozeCalendarNextMonth, Message::SnoozeCalendarPrevMonth, Message::SnoozeCalendarHourUp, Message::SnoozeCalendarHourDown, Message::SnoozeCalendarMinuteUp, Message::SnoozeCalendarMinuteDown, Message::SnoozeCalendarChip(11, 0), Message::SnoozeCalendarDaySelected(2026, 5, 15), ] { let _ = update(&mut state, message, &db); } assert!(state.snooze_calendar().is_some()); let _ = update(&mut state, Message::SnoozeCalendarBack, &db); assert!(state.snooze_calendar().is_none()); assert!(state.snooze_presets_open()); }
#[tokio::test] async fn it_records_a_scope_selection() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update(&mut state, Message::ScopeSelected(Scope::Character(42)), &db);
assert_eq!(state.active(), Scope::Character(42)); }
#[tokio::test] async fn it_resolves_the_owning_character_for_an_action() { let mut state = State::new(42); state.active = Scope::Character(42); state.render = Some(ReadingRender { is_starred: false, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, }); assert_eq!(state.character_for(7), Some(42)); state.render = None; state.messages = vec![list_row(8, 43, true)]; assert_eq!(state.character_for(8), Some(43)); assert_eq!(state.character_for(999), Some(42)); }
#[tokio::test] async fn it_selects_a_row_and_clears_any_open_snooze_menu() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.messages = vec![list_row(7, 42, true)]; state.snooze_menu = SnoozeMenu::Presets;
let _ = update(&mut state, Message::Selected(7), &db);
assert_eq!(state.selected(), Some(7)); assert!(!state.snooze_presets_open()); }
#[tokio::test] async fn it_stores_a_landed_reading_pane_render() { let mut state = State::new(42); state.selected = Some(7); let db = crate::store::open_test().await.unwrap(); let render = ReadingRender { is_starred: true, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, };
let _ = update( &mut state, Message::RenderLoaded { mail_id: 7, render: Box::new(Some(render.clone())), }, &db, );
assert_eq!(state.render(), Some(&render)); }
#[tokio::test] async fn it_toggles_the_account_switcher_dropdown() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
assert!(!state.picker_open()); let _ = update(&mut state, Message::PickerToggled, &db); assert!(state.picker_open()); let _ = update(&mut state, Message::PickerToggled, &db); assert!(!state.picker_open()); }
#[tokio::test] async fn it_toggles_the_snooze_preset_menu() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update(&mut state, Message::SnoozeMenuToggled, &db); assert!(state.snooze_presets_open()); let _ = update(&mut state, Message::SnoozeMenuToggled, &db); assert!(!state.snooze_presets_open()); }
#[tokio::test] async fn it_writes_a_preset_snooze_for_the_open_mail_and_closes_the_menu() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.active = Scope::Character(42); state.selected = Some(7); state.snooze_menu = SnoozeMenu::Presets;
let _ = update(&mut state, Message::SnoozePreset(snooze::Preset::Tomorrow), &db); assert!(!state.snooze_presets_open());
state.selected = None; let _ = update(&mut state, Message::SnoozePreset(snooze::Preset::Tomorrow), &db); }
mod labels_dispatch { use pretty_assertions::assert_eq;
use super::*;
#[tokio::test] async fn it_assigns_the_label_when_releasing_onto_a_target() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.messages = vec![list_row(7, 42, true)]; state.dragging_mail = Some(7); state.drop_target = Some(DropTarget::Label(8));
let _ = update_labels(&mut state, Message::LabelDropReleased, &db);
assert!(state.dragging_mail.is_none()); assert!(state.drop_target.is_none()); }
#[tokio::test] async fn it_clears_drag_state_when_releasing_onto_a_standard_box() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.messages = vec![list_row(7, 42, true)]; state.dragging_mail = Some(7); state.drop_target = Some(DropTarget::StandardFolder(StandardFolder::Archive));
let _ = update_labels(&mut state, Message::LabelDropReleased, &db);
assert!(state.dragging_mail.is_none()); assert!(state.drop_target.is_none()); }
#[tokio::test] async fn it_cancels_a_pending_label_deletion() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.pending_label_delete = Some(8);
let _ = update_labels(&mut state, Message::LabelDeleteCancelled, &db);
assert!(state.pending_label_delete.is_none()); }
#[tokio::test] async fn it_clears_drag_state_when_releasing_with_no_target() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.dragging_mail = Some(7); state.drop_target = None;
let _ = update_labels(&mut state, Message::LabelDropReleased, &db);
assert!(state.dragging_mail.is_none()); assert!(state.drop_target.is_none()); }
#[tokio::test] async fn it_clears_only_the_matching_drop_target_on_leave() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.drop_target = Some(DropTarget::Label(8));
let _ = update_labels(&mut state, Message::DropTargetLeft(DropTarget::Label(9)), &db); assert_eq!(state.drop_target, Some(DropTarget::Label(8)));
let _ = update_labels(&mut state, Message::DropTargetLeft(DropTarget::Label(8)), &db); assert!(state.drop_target.is_none()); }
#[tokio::test] async fn it_closes_the_label_modal() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_modal = Some(LabelDraft::blank());
let _ = update_labels(&mut state, Message::LabelModalClosed, &db);
assert!(state.label_modal.is_none()); }
#[tokio::test] async fn it_closes_the_label_picker() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_picker = Some(LabelPicker { anchor: None, mail_id: 7, });
let _ = update_labels(&mut state, Message::LabelPickerClosed, &db);
assert!(state.label_picker.is_none()); }
#[tokio::test] async fn it_closes_the_modal_when_submitting_a_creatable_draft() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_modal = Some(LabelDraft { color: "#ff6600".to_owned(), name: "Fleet".to_owned(), });
let _ = update_labels(&mut state, Message::LabelModalSubmitted, &db);
assert!(state.label_modal.is_none()); }
#[tokio::test] async fn it_consumes_the_pending_label_on_a_delete_confirmation() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.pending_label_delete = Some(8);
let _ = update_labels(&mut state, Message::LabelDeleteConfirmed, &db);
assert!(state.pending_label_delete.is_none()); }
#[tokio::test] async fn it_drops_a_submission_for_an_uncreatable_draft() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_modal = Some(LabelDraft::blank());
let _ = update_labels(&mut state, Message::LabelModalSubmitted, &db);
assert!(state.label_modal.is_some()); }
#[tokio::test] async fn it_drops_a_submission_with_no_open_modal() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update_labels(&mut state, Message::LabelModalSubmitted, &db);
assert!(state.label_modal.is_none()); }
#[tokio::test] async fn it_ignores_a_delete_confirmation_with_nothing_pending() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update_labels(&mut state, Message::LabelDeleteConfirmed, &db);
assert!(state.pending_label_delete.is_none()); }
#[tokio::test] async fn it_ignores_a_name_change_with_no_open_modal() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update_labels(&mut state, Message::LabelNameChanged("Fleet".to_owned()), &db);
assert!(state.label_modal.is_none()); }
#[tokio::test] async fn it_marks_a_drop_target_only_while_dragging() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update_labels(&mut state, Message::DropTargetEntered(DropTarget::Label(8)), &db); assert!(state.drop_target.is_none());
state.dragging_mail = Some(7); let _ = update_labels(&mut state, Message::DropTargetEntered(DropTarget::Label(8)), &db); assert_eq!(state.drop_target, Some(DropTarget::Label(8))); }
#[tokio::test] async fn it_opens_a_blank_label_modal_and_clears_the_picker() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_picker = Some(LabelPicker { anchor: None, mail_id: 7, });
let _ = update_labels(&mut state, Message::LabelModalOpened, &db);
assert_eq!(state.label_modal, Some(LabelDraft::blank())); assert!(state.label_picker.is_none()); }
#[tokio::test] async fn it_opens_a_cursor_anchored_label_picker_from_a_row_menu() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.cursor = Some(Point::new(12.0, 34.0)); state.label_modal = Some(LabelDraft::blank());
let _ = update_labels(&mut state, Message::LabelRowMenuOpened(7), &db);
assert_eq!(state.selected, Some(7)); assert!(state.label_modal.is_none()); assert_eq!( state.label_picker, Some(LabelPicker { anchor: Some(Point::new(12.0, 34.0)), mail_id: 7, }) ); }
#[tokio::test] async fn it_opens_an_unanchored_label_picker() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_modal = Some(LabelDraft::blank()); state.snooze_menu = SnoozeMenu::Presets;
let _ = update_labels(&mut state, Message::LabelPickerOpened(7), &db);
assert!(state.label_modal.is_none()); assert_eq!(state.snooze_menu, SnoozeMenu::Closed); assert_eq!( state.label_picker, Some(LabelPicker { anchor: None, mail_id: 7, }) ); }
#[tokio::test] async fn it_records_a_pending_label_deletion() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update_labels(&mut state, Message::LabelDeleteRequested(8), &db);
assert_eq!(state.pending_label_delete, Some(8)); }
#[tokio::test] async fn it_records_a_picked_label_color() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_modal = Some(LabelDraft::blank());
let _ = update_labels(&mut state, Message::LabelColorPicked("#ff6600".to_owned()), &db);
assert_eq!( state.label_modal.as_ref().map(|d| d.color.clone()), Some("#ff6600".to_owned()) ); }
#[tokio::test] async fn it_reloads_after_a_label_write() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update_labels(&mut state, Message::LabelsWritten, &db); }
#[tokio::test] async fn it_toggles_a_label_for_a_known_mail() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.messages = vec![list_row(7, 42, true)];
let _ = update_labels(&mut state, Message::LabelToggled(7, 8), &db); }
#[tokio::test] async fn it_tracks_the_cursor_during_a_label_drag() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); let point = Point::new(5.0, 6.0);
let _ = update_labels(&mut state, Message::LabelDragMoved(point), &db);
assert_eq!(state.cursor, Some(point)); }
#[tokio::test] async fn it_truncates_the_label_name_to_the_max_length() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.label_modal = Some(LabelDraft::blank()); let oversized = "x".repeat(labels::NAME_MAX_CHARS + 10);
let _ = update_labels(&mut state, Message::LabelNameChanged(oversized), &db);
let name = state.label_modal.as_ref().map(|d| d.name.clone()).unwrap(); assert_eq!(name.chars().count(), labels::NAME_MAX_CHARS); } }
mod drafts { use pretty_assertions::assert_eq;
use super::*; use crate::store::{ self, model::{Alliance, Bloodline, Character, Corporation, Gender, Race}, repo::{character, mail}, };
async fn seed_character(db: &Database, id: i64) { let corp_id = 90_000_001; let alliance_id = 99_000_001; let alliance = Alliance::new(alliance_id, corp_id, id, "2003-01-01", "Test Alliance", "TST"); let race = Race::new(2, alliance_id, "A race.", "Caldari"); let mut corp = Corporation::new(corp_id, "Test Corp", "TSC"); corp.set_ceo_id(id); corp.set_creator_id(id); corp.set_member_count(1); corp.set_tax_rate(0.0); let bloodline = Bloodline::new(1, corp_id, 2, 3, "A bloodline.", 4, 5, "Civire", 4, 4); let character = Character::new(id, 1, corp_id, 2, "2003-05-12", Gender::Male, "Pilot"); character::insert_with_org(db, &character, &bloodline, &race, &corp, Some(&alliance), None) .await .unwrap(); }
fn typed_compose() -> compose::Draft { let mut draft = compose::Draft::blank(42); draft.subject = "CTA".to_owned(); draft.body = text_editor::Content::with_text("Form up."); draft.to.push(compose::Recipient::typed("Vex")); draft }
#[tokio::test] async fn it_persists_one_row_for_a_non_empty_compose_window() { let db = store::open_test().await.unwrap(); seed_character(&db, 42).await;
let (id, input) = typed_compose() .pending_save() .expect("a non-empty compose is worth saving"); draft::persist(db.clone(), id, input).await;
assert_eq!(mail::count_drafts_for_character(&db, 42).await.unwrap(), 1); }
#[tokio::test] async fn it_offers_no_pending_save_for_a_blank_compose() { assert!(compose::Draft::blank(42).pending_save().is_none()); }
#[tokio::test] async fn it_updates_the_same_row_when_re_saving_an_opened_draft() { let db = store::open_test().await.unwrap(); seed_character(&db, 42).await; let id = mail::upsert_draft(&db, None, &typed_compose().persist_input()) .await .unwrap(); let mut compose = typed_compose(); compose.set_id(Some(id)); compose.subject = "Edited".to_owned();
let (saved_id, input) = compose.pending_save().unwrap(); assert_eq!(saved_id, Some(id), "the persisted id is threaded back into the save"); draft::persist(db.clone(), saved_id, input).await;
assert_eq!(mail::count_drafts_for_character(&db, 42).await.unwrap(), 1); assert_eq!(mail::draft(&db, id).await.unwrap().unwrap().subject, "Edited"); }
#[tokio::test] async fn it_deletes_the_row_by_id_on_a_successful_send() { let db = store::open_test().await.unwrap(); seed_character(&db, 42).await; let id = mail::upsert_draft(&db, None, &typed_compose().persist_input()) .await .unwrap();
delete_draft(db.clone(), id).await; assert_eq!(mail::count_drafts_for_character(&db, 42).await.unwrap(), 0); }
#[tokio::test] async fn it_fills_a_compose_from_a_loaded_draft_row() { let db = store::open_test().await.unwrap(); seed_character(&db, 42).await; let id = mail::upsert_draft(&db, None, &typed_compose().persist_input()) .await .unwrap(); let row = mail::draft(&db, id).await.unwrap().unwrap();
let compose = compose::Draft::from_persisted(&row);
assert_eq!(compose.id, Some(id)); assert_eq!(compose.subject, "CTA"); assert_eq!(compose.body.text(), "Form up."); }
#[tokio::test] async fn it_drops_a_deleted_draft_from_the_in_memory_list() { let db = store::open_test().await.unwrap(); seed_character(&db, 42).await; let id = mail::upsert_draft(&db, None, &typed_compose().persist_input()) .await .unwrap(); let mut state = State::new(42); state.drafts = draft::load_rows(db.clone(), 42).await;
let _ = update(&mut state, Message::DraftDeleted(id), &db);
assert!(state.drafts().is_empty()); } }
mod pagination { use pretty_assertions::assert_eq;
use super::*;
#[tokio::test] async fn it_appends_a_loaded_search_page_into_the_search_accumulator() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.search = "cta".to_owned(); state.all_messages = vec![list_row(7, 42, true)]; state.search_loading = true;
let _ = update( &mut state, Message::SearchPageLoaded { query: "cta".to_owned(), rows: vec![list_row(8, 42, true)], }, &db, );
assert_eq!( state.all_messages().iter().map(|r| r.mail_id).collect::<Vec<_>>(), [7, 8] ); assert!(!state.search_loading); }
#[tokio::test] async fn it_appends_a_loaded_tail_page_and_advances_the_cursor() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.messages = vec![list_row(7, 42, true)]; state.messages_loading = true;
let epoch = state.messages_page_epoch.current(); let _ = update( &mut state, Message::MessagesPageLoaded { epoch, rows: vec![list_row(8, 42, true)], }, &db, );
assert_eq!( state.messages().iter().map(|r| r.mail_id).collect::<Vec<_>>(), [7, 8], "the new page is appended to the tail" ); assert!(!state.messages_loading); assert!( !state.messages_has_more, "a short page (under the page size) ends pagination" ); assert!( state.messages_cursor.is_some(), "the cursor advances to the last loaded row" ); }
#[tokio::test] async fn it_clears_search_paging_when_the_query_is_emptied() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.search = "cta".to_owned(); state.all_messages = vec![list_row(7, 42, true)]; state.search_loading = true;
let _ = update(&mut state, Message::SearchChanged(String::new()), &db);
assert_eq!(state.search(), ""); assert!(state.all_messages().is_empty()); assert!(!state.search_loading, "no search runs for an empty query"); }
#[tokio::test] async fn it_does_not_load_more_when_the_tail_is_exhausted() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.folder = Folder::Standard(StandardFolder::Inbox); state.messages = vec![list_row(7, 42, true)]; state.messages_cursor = Some(cursor_of(&state.messages[0])); state.messages_has_more = false;
let _ = update( &mut state, Message::ListScrolled { absolute: 9_000.0, relative: 0.99, }, &db, );
assert!(!state.messages_loading, "no page is requested past the last page"); }
#[tokio::test] async fn it_drops_a_messages_page_captured_before_a_folder_switch() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.messages = vec![list_row(7, 42, true)]; state.messages_loading = true;
let stale_epoch = state.messages_page_epoch.current(); let _ = update( &mut state, Message::FolderSelected(Folder::Standard(StandardFolder::Sent)), &db, );
let _ = update( &mut state, Message::MessagesPageLoaded { epoch: stale_epoch, rows: vec![list_row(8, 42, true)], }, &db, );
assert_eq!( state.messages().iter().map(|r| r.mail_id).collect::<Vec<_>>(), [7], "a page from the previous folder must not append foreign rows" ); }
#[tokio::test] async fn it_drops_a_search_page_from_a_superseded_query() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.search = "wormhole".to_owned(); state.search_loading = true;
let _ = update( &mut state, Message::SearchPageLoaded { query: "cta".to_owned(), rows: vec![list_row(8, 42, true)], }, &db, );
assert!( state.all_messages().is_empty(), "a page tagged with a stale query is discarded" ); assert!( state.search_loading, "the in-flight load for the current query is left untouched" ); }
#[tokio::test] async fn it_resets_search_paging_and_kicks_a_query_on_search_change() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.all_messages = vec![list_row(7, 42, true)]; state.search_cursor = Some(cursor_of(&state.all_messages[0]));
let _ = update(&mut state, Message::SearchChanged("cta".to_owned()), &db);
assert_eq!(state.search(), "cta"); assert!(state.all_messages().is_empty(), "the prior search results are cleared"); assert!( state.search_cursor.is_none(), "the search cursor resets for the new query" ); assert!(state.search_loading, "the first search page is requested"); }
#[tokio::test] async fn it_starts_a_tail_load_when_scrolling_past_the_threshold_with_more_pages() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap(); state.folder = Folder::Standard(StandardFolder::Inbox); state.messages = vec![list_row(7, 42, true)]; state.messages_cursor = Some(cursor_of(&state.messages[0])); state.messages_has_more = true;
let _ = update( &mut state, Message::ListScrolled { absolute: 9_000.0, relative: 0.99, }, &db, );
assert!( state.messages_loading, "a deep scroll with more pages requests the next" ); }
#[tokio::test] async fn it_stores_the_absolute_scroll_offset_for_windowing() { let mut state = State::new(42); let db = crate::store::open_test().await.unwrap();
let _ = update( &mut state, Message::ListScrolled { absolute: 1_234.0, relative: 0.2, }, &db, );
assert_eq!( state.list_scroll_offset(), 1_234.0, "the pixel offset is stored so the virtual list can window the body" ); assert!(!state.messages_loading, "a shallow scroll loads no further page"); } } }
mod view { use super::*; use crate::features::mail::message_list::{DayBucket, MessageRow, SenderKind};
fn populated_state() -> State { let mut state = State::new(42); state.active = Scope::Character(42); state.roster = vec![ RosterPilot { corp: "VEX".to_owned(), granted_scopes: None, id: 42, name: "Vex Voronova".to_owned(), portrait: images::ImageState::Stale { id: 42, kind: images::ImageKind::CharacterPortrait, }, unread: 3, }, RosterPilot { corp: "ALT".to_owned(), granted_scopes: None, id: 43, name: "Alt Pilot".to_owned(), portrait: images::ImageState::Stale { id: 43, kind: images::ImageKind::CharacterPortrait, }, unread: 0, }, ]; state.folder_data = FolderPaneData { labels: vec![loaders::FolderLabel { color: Some("#ff6600".to_owned()), label_id: 99, name: "Fleet".to_owned(), unread: 2, }], standard_counts: loaders::StandardFolderCounts { inbox: 3, starred: 1, ..loaders::StandardFolderCounts::default() }, }; state.messages = vec![ row(1, DayBucket::Today, true, false, false, &[]), row(2, DayBucket::Today, false, false, false, &["Fleet"]), row(3, DayBucket::Yesterday, false, true, false, &[]), row(4, DayBucket::ThisMonth, false, false, true, &["Ops", "Fleet"]), ]; state.unified_unread = 3; state.outbox_indicator = OutboxIndicator { pending: 1, failed: vec![loaders::FailedMutation { id: 7, kind: "mail.send".to_owned(), last_error: "ESI 520".to_owned(), }], }; state.render = Some(ReadingRender { is_starred: true, labels: Vec::new(), mail: sample_render(), sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, }); state.selected = Some(7); state }
fn row( mail_id: i64, bucket: DayBucket, is_read: bool, is_starred: bool, _unused: bool, labels: &[&str], ) -> MessageRow { MessageRow { bucket, character_id: 42, is_read, is_starred, has_attachment: false, important: false, sender_kind: SenderKind::Character, label_ids: Vec::new(), labels: labels .iter() .map(|name| MessageLabel { color: None, name: (*name).to_owned(), }) .collect(), mail_id, sender: "Vex Voronova".to_owned(), sender_id: 95_000_001, sender_portrait: images::ImageState::Stale { id: 95_000_001, kind: images::ImageKind::CharacterPortrait, }, snippet: "Form up at Jita.".to_owned(), subject: "CTA tonight".to_owned(), time: "10:00".to_owned(), timestamp: "2026-06-01T10:00:00Z".to_owned(), } }
#[test] fn it_filters_the_message_list_live_on_search() { let mut state = populated_state(); state.search = "nothing matches".to_owned(); let _el: Element<'_, Message> = view(&state); }
#[test] fn it_renders_every_folder_caption() { let mut state = populated_state(); for folder in [ Folder::Unified, Folder::Standard(StandardFolder::Archive), Folder::Standard(StandardFolder::Drafts), Folder::Standard(StandardFolder::Inbox), Folder::Standard(StandardFolder::Sent), Folder::Standard(StandardFolder::Snoozed), Folder::Standard(StandardFolder::Starred), Folder::Standard(StandardFolder::Trash), Folder::Label(99), Folder::Label(123), ] { state.folder = folder; let _el: Element<'_, Message> = view(&state); } }
#[test] fn it_renders_the_account_switcher_dropdown_overlay() { let mut state = populated_state(); state.picker_open = true; let _el: Element<'_, Message> = view(&state); }
#[test] fn it_renders_the_detached_compose_window_body() { let mut draft = compose::Draft::blank(42); draft.to.push(compose::Recipient::typed("Vex Voronova")); draft.show_cc = true; draft.cc.push(compose::Recipient::typed("Alt Pilot")); draft.subject = "CTA".to_owned(); draft.body = text_editor::Content::with_text("Form up."); draft.from_picker_open = true; draft.error = Some("enqueue failed".to_owned());
let _el: Element<'_, Message> = compose::view(&draft, &[]); }
#[test] fn it_renders_the_populated_shell_with_an_open_mail() { let state = populated_state(); let _el: Element<'_, Message> = view(&state); }
#[test] fn it_renders_the_snooze_calendar_overlay() { let mut state = populated_state(); state.snooze_menu = SnoozeMenu::Calendar; state.snooze_calendar = Some(snooze::Calendar::open(chrono::Utc::now())); let _el: Element<'_, Message> = view(&state); }
#[test] fn it_renders_the_snooze_presets_overlay() { let mut state = populated_state(); state.snooze_menu = SnoozeMenu::Presets; let _el: Element<'_, Message> = view(&state); }
#[test] fn it_renders_the_three_pane_shell() { let state = State::new(42); let _el: Element<'_, Message> = view(&state); } }}