diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab4521..b3bff8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,8 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git + ~/.cargo/bin target - key: ${{ runner.os }}-cargo + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} # vim: set sw=2 et : diff --git a/Cargo.lock b/Cargo.lock index 3d7343a..d17885c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2020,6 +2020,7 @@ dependencies = [ "futures 0.3.13", "gluon", "hex 0.4.3", + "indexmap", "inotify", "log", "mailparse", @@ -3224,6 +3225,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" dependencies = [ + "indexmap", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 3c36dbf..137172b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,9 +44,10 @@ tokio = { version = "1.3.0", features = ["full"] } tokio-rustls = "0.22.0" tokio-stream = { version = "0.1.4", features = ["sync"] } tokio-util = { version = "0.6.4", features = ["full"] } -toml = "0.5.8" +toml = { version = "0.5.8", features = ["preserve_order"] } webpki-roots = "0.21.0" xdg = "2.2.0" +indexmap = "1.6.2" [dependencies.panorama-imap] path = "imap" diff --git a/src/mail/client.rs b/src/mail/client.rs index eef9288..7a28fc1 100644 --- a/src/mail/client.rs +++ b/src/mail/client.rs @@ -79,6 +79,7 @@ pub async fn sync_main( debug!("select response: {:?}", select); if let (Some(exists), Some(uidvalidity)) = (select.exists, select.uid_validity) { + // figure out which uids don't exist locally yet let new_uids = stream::iter(1..exists).map(Ok).try_filter_map(|uid| { mail_store.try_identify_email(&acct_name, &folder, uid, uidvalidity, None) // invert the option to only select uids that haven't been downloaded diff --git a/src/mail/store.rs b/src/mail/store.rs index c1c75c0..4131a0d 100644 --- a/src/mail/store.rs +++ b/src/mail/store.rs @@ -6,10 +6,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Error, Result}; +use chrono::{DateTime, Local}; use futures::{ future::{self, FutureExt, TryFutureExt}, stream::{StreamExt, TryStreamExt}, }; +use indexmap::IndexMap; use panorama_imap::response::AttributeValue; use sha2::{Digest, Sha256}; use sqlx::{ @@ -19,7 +21,7 @@ use sqlx::{ }; use tokio::{ fs, - sync::{broadcast, RwLock}, + sync::{broadcast, watch, RwLock}, task::JoinHandle, }; @@ -37,6 +39,10 @@ pub struct MailStore { config: Arc>>, inner: Arc>>, handle: Arc>, + store_out_tx: Arc>>, + + /// A receiver for listening to updates to the mail store + pub store_out_rx: watch::Receiver>, } #[derive(Debug)] @@ -44,12 +50,16 @@ pub struct MailStore { struct MailStoreInner { pool: SqlitePool, mail_dir: PathBuf, - accounts: HashMap>, + accounts: IndexMap>, } #[derive(Clone, Debug)] +#[non_exhaustive] /// Probably an event about new emails? i forgot -pub struct EmailUpdateInfo {} +pub enum MailStoreUpdate { + /// The list of accounts has been updated (probably as a result of a config update) + AccountListUpdate(()), +} impl MailStore { /// Creates a new MailStore @@ -60,9 +70,14 @@ impl MailStore { let inner = Arc::new(RwLock::new(None)); let inner2 = inner.clone(); + let (store_out_tx, store_out_rx) = watch::channel(None); + let store_out_tx = Arc::new(store_out_tx); + let store_out_tx2 = store_out_tx.clone(); + let listener = async move { while let Ok(()) = config_watcher.changed().await { let new_config = config_watcher.borrow().clone(); + let fut = future::try_join( async { let mut write = config2.write().await; @@ -77,13 +92,14 @@ impl MailStore { Ok(()) }, ); + match fut.await { - Ok(_) => {} + Ok(_) => store_out_tx2.send(Some(MailStoreUpdate::AccountListUpdate(()))), Err(e) => { error!("during mail loop: {}", e); panic!(); } - } + }; } }; let handle = tokio::spawn(listener); @@ -92,9 +108,14 @@ impl MailStore { config, inner, handle: Arc::new(handle), + store_out_tx, + store_out_rx, } } + /// Nuke all messages with an invalid UIDVALIDITY + pub async fn nuke_old_uidvalidity(&self, current: usize) {} + /// Given a UID and optional message-id try to identify a particular message pub async fn try_identify_email( &self, @@ -265,11 +286,11 @@ impl MailStore { /// Return a map of the accounts that are currently being tracked as well as a reference to the /// account handles themselves - pub async fn list_accounts(&self) -> HashMap> { + pub async fn list_accounts(&self) -> IndexMap> { let read = self.inner.read().await; - let inner = match &*read { + let inner = match read.as_ref() { Some(v) => v, - None => return HashMap::new(), + None => return IndexMap::new(), }; inner.accounts.clone() @@ -365,6 +386,11 @@ impl AccountRef { .bind(folder) .fetch(&self.pool) .map_ok(|(date, subject): (String, String)| EmailMetadata { + date: Some( + DateTime::parse_from_rfc3339(&date) + .unwrap() + .with_timezone(&Local), + ), subject, ..EmailMetadata::default() }) diff --git a/src/ui/mail_store.rs b/src/ui/mail_store.rs deleted file mode 100644 index d67ef9f..0000000 --- a/src/ui/mail_store.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use parking_lot::RwLock; - -use crate::mail::{EmailMetadata, MailEvent}; - -/// UI's view of the currently-known mail-related state of all accounts. -#[derive(Clone, Debug, Default)] -pub struct MailStore { - accounts: Arc>>>>, -} - -impl MailStore { - pub fn handle_mail_event(&self, evt: MailEvent) { - let acct_name = evt.acct_name().to_owned(); - - { - let accounts = self.accounts.read(); - let contains_key = accounts.contains_key(&acct_name); - std::mem::drop(accounts); - - if !contains_key { - let mut accounts = self.accounts.write(); - accounts.insert( - acct_name.clone(), - Arc::new(RwLock::new(MailAccountState::default())), - ); - } - } - - let accounts = self.accounts.read(); - if let Some(lock) = accounts.get(&acct_name) { - let mut state = lock.write(); - state.update(evt); - } - } - - pub fn iter_accts(&self) -> Vec { - self.accounts.read().keys().cloned().collect() - } - - pub fn folders_of(&self, acct_name: impl AsRef) -> Option> { - let accounts = self.accounts.read(); - let lock = accounts.get(acct_name.as_ref())?; - let state = lock.read(); - Some(state.folders.clone()) - } - - pub fn messages_of(&self, acct_name: impl AsRef) -> Option> { - let accounts = self.accounts.read(); - let lock = accounts.get(acct_name.as_ref())?; - let state = lock.read(); - let mut msgs = Vec::new(); - for uid in state.message_uids.iter() { - if let Some(meta) = state.message_map.get(uid) { - msgs.push(meta.clone()); - } - } - Some(msgs) - } -} - -#[derive(Debug, Default)] -pub struct MailAccountState { - pub folders: Vec, - pub message_uids: Vec, - pub message_map: HashMap, -} - -impl MailAccountState { - pub fn update(&mut self, evt: MailEvent) { - match evt { - MailEvent::FolderList(_, new_folders) => self.folders = new_folders, - MailEvent::MessageUids(_, new_uids) => self.message_uids = new_uids, - - MailEvent::UpdateUid(_, uid, attrs) => { - let meta = EmailMetadata::from_attrs(attrs); - let uid = meta.uid.unwrap_or(uid); - self.message_map.insert(uid, meta); - } - MailEvent::NewUid(_, uid) => { - debug!("new msg!"); - self.message_uids.push(uid); - } - _ => {} - } - // debug!("mail store updated! {:?}", self); - } -} diff --git a/src/ui/mail_view.rs b/src/ui/mail_view.rs index 0235bea..48c9054 100644 --- a/src/ui/mail_view.rs +++ b/src/ui/mail_view.rs @@ -19,9 +19,12 @@ use panorama_tui::{ widgets::*, }, }; -use tokio::task::JoinHandle; +use tokio::{sync::RwLock, task::JoinHandle}; -use crate::mail::{store::AccountRef, EmailMetadata}; +use crate::mail::{ + store::{AccountRef, MailStoreUpdate}, + EmailMetadata, +}; use super::{FrameType, HandlesInput, InputResult, MailStore, TermType, Window, UI}; @@ -29,11 +32,17 @@ use super::{FrameType, HandlesInput, InputResult, MailStore, TermType, Window, U /// A singular UI view of a list of mail pub struct MailView { pub mail_store: MailStore, - pub current_account: Option>, - pub current_folder: Option, pub message_list: TableState, pub selected: Arc, pub change: Arc, + current: Arc>>, + mail_store_listener: JoinHandle<()>, +} + +#[derive(Debug)] +struct Current { + account: Arc, + folder: Option, } impl HandlesInput for MailView { @@ -60,7 +69,7 @@ impl Window for MailView { String::from("email") } - async fn draw(&self, f: &mut FrameType<'_, '_>, area: Rect, ui: &UI) { + async fn draw(&self, f: &mut FrameType<'_, '_>, area: Rect, ui: &UI) -> Result<()> { let chunks = Layout::default() .direction(Direction::Horizontal) .margin(0) @@ -89,30 +98,27 @@ impl Window for MailView { .highlight_symbol(">>"); let mut rows = vec![]; - if let Some(acct_ref) = self.current_account.as_ref() { - let messages = acct_ref.get_newest_n_messages("INBOX", chunks[1].height as usize); - } - - for (acct_name, acct_ref) in accts.iter() { - let result: Option> = None; // self.mail_store.messages_of(acct); - if let Some(messages) = result { - for meta in messages { - let mut row = Row::new(vec![ - String::from(if meta.unread { "\u{2b24}" } else { "" }), - meta.uid.map(|u| u.to_string()).unwrap_or_default(), - meta.date.map(|d| humanize_timestamp(d)).unwrap_or_default(), - meta.from.clone(), - meta.subject.clone(), - ]); - if meta.unread { - row = row.style( - Style::default() - .fg(Color::LightCyan) - .add_modifier(Modifier::BOLD), - ); - } - rows.push(row); + if let Some(current) = self.current.read().await.as_ref() { + let messages = current + .account + .get_newest_n_messages("INBOX", chunks[1].height as usize) + .await?; + for meta in messages.iter() { + let mut row = Row::new(vec![ + String::from(if meta.unread { "\u{2b24}" } else { "" }), + meta.uid.map(|u| u.to_string()).unwrap_or_default(), + meta.date.map(|d| humanize_timestamp(d)).unwrap_or_default(), + meta.from.clone(), + meta.subject.clone(), + ]); + if meta.unread { + row = row.style( + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + ); } + rows.push(row); } } @@ -133,6 +139,8 @@ impl Window for MailView { f.render_widget(dirlist, chunks[0]); f.render_widget(table, chunks[1]); + + Ok(()) } async fn update(&mut self) { @@ -174,21 +182,42 @@ fn humanize_timestamp(date: DateTime) -> String { impl MailView { pub fn new(mail_store: MailStore) -> Self { + let current = Arc::new(RwLock::new(None)); + let current2 = current.clone(); + + let mut listener = mail_store.store_out_rx.clone(); + let mail_store2 = mail_store.clone(); + let mail_store_listener = tokio::spawn(async move { + while let Ok(()) = listener.changed().await { + let updated = listener.borrow().clone(); + debug!("new update from mail store: {:?}", updated); + + // TODO: maybe do the processing of updates somewhere else? + // in case events get missed + match updated { + Some(MailStoreUpdate::AccountListUpdate(_)) => { + // TODO: maybe have a default account? + let accounts = mail_store2.list_accounts().await; + if let Some((acct_name, acct_ref)) = accounts.iter().next() { + let mut write = current2.write().await; + *write = Some(Current { + account: acct_ref.clone(), + folder: None, + }) + } + } + _ => {} + } + } + }); + MailView { mail_store, - current_account: None, - current_folder: None, + current, message_list: TableState::default(), selected: Arc::new(AtomicU32::default()), change: Arc::new(AtomicI8::default()), - } - } - - pub async fn set_current_account(&mut self, name: impl AsRef) { - let name = name.as_ref(); - let accounts = self.mail_store.list_accounts().await; - if let Some(acct_ref) = accounts.get(name) { - self.current_account = Some(acct_ref.clone()); + mail_store_listener, } } @@ -219,5 +248,4 @@ impl MailView { // self.message_list.select(Some(len - 1)); // } } - } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b1689c8..4350d9d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -102,7 +102,7 @@ pub async fn run_ui2(params: UiParams) -> Result<()> { term.pre_draw()?; { let mut frame = term.get_frame(); - ui.draw(&mut frame).await; + ui.draw(&mut frame).await?; } term.post_draw()?; @@ -148,7 +148,7 @@ pub struct UI { } impl UI { - async fn draw(&mut self, f: &mut FrameType<'_, '_>) { + async fn draw(&mut self, f: &mut FrameType<'_, '_>) -> Result<()> { let chunks = Layout::default() .direction(Direction::Vertical) .margin(0) @@ -179,10 +179,12 @@ impl UI { let visible = self.window_layout.visible_windows(chunks[0]); for (layout_id, area) in visible.into_iter() { if let Some(window) = self.windows.get(&layout_id) { - window.draw(f, area, self).await; + window.draw(f, area, self).await?; debug!("drew {:?} {:?}", layout_id, area); } } + + Ok(()) } fn open_window(&mut self, window: impl Window) { diff --git a/src/ui/windows.rs b/src/ui/windows.rs index 00d8041..882a28c 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; +use anyhow::Result; use futures::future::Future; use panorama_tui::tui::layout::Rect; @@ -12,7 +13,7 @@ pub trait Window: HandlesInput { fn name(&self) -> String; /// Main draw function - async fn draw(&self, f: &mut FrameType<'_, '_>, area: Rect, ui: &UI); + async fn draw(&self, f: &mut FrameType<'_, '_>, area: Rect, ui: &UI) -> Result<()>; // async fn draw(&self, f: FrameType, area: Rect, ui: Rc); /// Update function