diff --git a/Cargo.lock b/Cargo.lock index 4653771..0684677 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1825,6 +1825,7 @@ dependencies = [ "panorama-imap", "panorama-smtp", "parking_lot", + "quoted_printable", "serde", "structopt", "tokio 1.2.0", @@ -1852,6 +1853,7 @@ dependencies = [ "parking_lot", "pest", "pest_derive", + "quoted_printable", "tokio 1.2.0", "tokio-rustls", "tokio-util", @@ -2099,6 +2101,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b080c5db639b292ac79cbd34be0cfc5d36694768d8341109634d90b86930e2" + [[package]] name = "rand" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index cc322ff..addef20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ tui = { version = "0.14.0", default-features = false, features = ["crossterm"] } webpki-roots = "0.21.0" xdg = "2.2.0" downcast-rs = "1.2.0" +quoted_printable = "0.4.2" [dependencies.panorama-imap] path = "imap" diff --git a/imap/Cargo.toml b/imap/Cargo.toml index c15fbe1..a7017f6 100644 --- a/imap/Cargo.toml +++ b/imap/Cargo.toml @@ -21,6 +21,7 @@ parking_lot = "0.11.1" # pest_derive = { path = "../../pest/derive" } pest = { git = "https://github.com/iptq/pest", rev = "6a4d3a3d10e42a3ee605ca979d0fcdac97a83a99" } pest_derive = { git = "https://github.com/iptq/pest", rev = "6a4d3a3d10e42a3ee605ca979d0fcdac97a83a99" } +quoted_printable = "0.4.2" tokio = { version = "1.1.1", features = ["full"] } tokio-rustls = "0.22.0" tokio-util = { version = "0.6.3" } diff --git a/notes.md b/notes.md index 854a2d9..89e9daa 100644 --- a/notes.md +++ b/notes.md @@ -1,3 +1,17 @@ +design ideas +--- + +- instead of dumb search with `/`, have like an omnibar with recency info built in? + - this requires some kind of cache and text search +- tmux-like windows + - maybe some of the familiar commands? `` for split for ex, +- gluon for scripting language + - hook into some global keybinds/hooks struct +- transparent self-updates?? this could work with some kind of deprecation scheme for the config files + - for ex: v1 has `{ x: Int }`, v2 has `{ [deprecated] x: Int, x2: Float }` and v3 has `{ x2: Float }` + this means v1 -> v2 upgrade can be done automatically but because there are _any_ pending deprecated values being used + it's not allowed to automatically upgrade to v3 + imap routine --- diff --git a/src/mail/metadata.rs b/src/mail/metadata.rs new file mode 100644 index 0000000..85f29b5 --- /dev/null +++ b/src/mail/metadata.rs @@ -0,0 +1,65 @@ +use chrono::{DateTime, Local}; +use panorama_imap::response::*; + +/// A record that describes the metadata of an email as it appears in the UI list +#[derive(Debug, Default)] +pub struct EmailMetadata { + /// UID if the message has one + pub uid: Option, + + /// Whether or not this message is unread + pub unread: Option, + + /// Date + pub date: Option>, + + /// Sender + pub from: String, + + /// Subject + pub subject: String, +} + +impl EmailMetadata { + /// Construct an EmailMetadata from a list of attributes retrieved from the server + pub fn from_attrs(attrs: Vec) -> Self { + let mut meta = EmailMetadata::default(); + + for attr in attrs { + match attr { + AttributeValue::Uid(new_uid) => meta.uid = Some(new_uid), + AttributeValue::InternalDate(new_date) => { + meta.date = Some(new_date.with_timezone(&Local)); + } + AttributeValue::Envelope(Envelope { + subject: new_subject, + from: new_from, + .. + }) => { + if let Some(new_from) = new_from { + meta.from = new_from + .iter() + .filter_map(|addr| addr.name.to_owned()) + .collect::>() + .join(", "); + } + if let Some(new_subject) = new_subject { + // TODO: probably shouldn't insert quoted-printable here + // but this is just the most convenient for it to look right at the moment + // there's probably some header that indicates it's quoted-printable + // MIME? + use quoted_printable::ParseMode; + let new_subject = + quoted_printable::decode(new_subject.as_bytes(), ParseMode::Robust) + .unwrap(); + let new_subject = String::from_utf8(new_subject).unwrap(); + meta.subject = new_subject; + } + } + _ => {} + } + } + + meta + } +} diff --git a/src/mail/mod.rs b/src/mail/mod.rs index b4e7638..37ab10c 100644 --- a/src/mail/mod.rs +++ b/src/mail/mod.rs @@ -1,5 +1,7 @@ //! Mail +mod metadata; + use anyhow::Result; use futures::{ future::FutureExt, @@ -22,6 +24,8 @@ use tokio_stream::wrappers::WatchStream; use crate::config::{Config, ConfigWatcher, ImapAuth, MailAccountConfig, TlsMethod}; +pub use self::metadata::EmailMetadata; + /// Command sent to the mail thread by something else (i.e. UI) #[derive(Debug)] #[non_exhaustive] @@ -156,7 +160,7 @@ async fn imap_main(acct: MailAccountConfig, mail2ui_tx: UnboundedSender>(); + let message_uids = message_uids.into_iter().take(30).collect::>(); let _ = mail2ui_tx.send(MailEvent::MessageUids(message_uids.clone())); // TODO: make this happen concurrently with the main loop? diff --git a/src/ui/colon_prompt.rs b/src/ui/colon_prompt.rs index 00155fb..ca86f51 100644 --- a/src/ui/colon_prompt.rs +++ b/src/ui/colon_prompt.rs @@ -32,12 +32,20 @@ impl HandlesInput for ColonPrompt { let mut b = [0; 2]; self.value += c.encode_utf8(&mut b); } + KeyCode::Enter => { + let cmd = self.value.clone(); + self.value.clear(); + debug!("executing colon command: {:?}", cmd); + return Ok(InputResult::Pop); + } KeyCode::Backspace => { let mut new_len = self.value.len(); if new_len > 0 { new_len -= 1; + self.value.truncate(new_len); + } else { + return Ok(InputResult::Pop); } - self.value.truncate(new_len); } _ => {} } diff --git a/src/ui/input.rs b/src/ui/input.rs index b0962ea..618df8e 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -1,7 +1,7 @@ use std::any::Any; use std::fmt::Debug; use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicI8, Ordering}, Arc, }; @@ -31,13 +31,15 @@ pub enum InputResult { } #[derive(Debug)] -pub struct BaseInputHandler(pub Arc); +pub struct BaseInputHandler(pub Arc, pub Arc); impl HandlesInput for BaseInputHandler { fn handle_key(&mut self, term: TermType, evt: KeyEvent) -> Result { let KeyEvent { code, .. } = evt; match code { KeyCode::Char('q') => self.0.store(true, Ordering::Relaxed), + KeyCode::Char('j') => self.1.store(1, Ordering::Relaxed), + KeyCode::Char('k') => self.1.store(-1, Ordering::Relaxed), KeyCode::Char(':') => { let colon_prompt = Box::new(ColonPrompt::init(term)); return Ok(InputResult::Push(colon_prompt)); diff --git a/src/ui/mail_tab.rs b/src/ui/mail_tab.rs index 437622d..d6b8b0b 100644 --- a/src/ui/mail_tab.rs +++ b/src/ui/mail_tab.rs @@ -1,4 +1,8 @@ use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicI8, Ordering}, + Arc, +}; use chrono::{DateTime, Datelike, Duration, Local}; use chrono_humanize::HumanTime; @@ -11,6 +15,8 @@ use tui::{ widgets::*, }; +use crate::mail::EmailMetadata; + use super::FrameType; #[derive(Default)] @@ -20,13 +26,7 @@ pub struct MailTabState { pub message_map: HashMap, pub messages: Vec, pub message_list: TableState, -} - -#[derive(Debug)] -pub struct EmailMetadata { - pub date: DateTime, - pub from: String, - pub subject: String, + pub change: Arc, } fn humanize_timestamp(date: DateTime) -> String { @@ -78,6 +78,22 @@ impl MailTabState { .constraints([Constraint::Length(20), Constraint::Max(5000)]) .split(area); + // make the change + if self + .change + .compare_exchange(-1, 0, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.move_up(); + } + if self + .change + .compare_exchange(1, 0, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.move_down(); + } + // folder list let items = self .folders @@ -86,23 +102,31 @@ impl MailTabState { .collect::>(); let dirlist = List::new(items) - .block(Block::default().borders(Borders::NONE)) + .block(Block::default().borders(Borders::NONE).title(Span::styled( + "ur mom", + Style::default().add_modifier(Modifier::BOLD), + ))) .style(Style::default().fg(Color::White)) .highlight_style(Style::default().add_modifier(Modifier::ITALIC)) .highlight_symbol(">>"); // message list table - let rows = self + let mut metas = self .message_uids .iter() - .map(|id| { - let meta = self.message_map.get(id); + .filter_map(|id| self.message_map.get(id)) + .collect::>(); + metas.sort_by_key(|m| m.date); + let rows = metas + .iter() + .rev() + .map(|meta| { Row::new(vec![ "".to_owned(), - id.to_string(), - meta.map(|m| humanize_timestamp(m.date)).unwrap_or_default(), - meta.map(|m| m.from.clone()).unwrap_or_default(), - meta.map(|m| m.subject.clone()).unwrap_or_default(), + 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(), ]) }) .collect::>(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 83d4348..ef1ae3c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -34,11 +34,11 @@ use tui::{ Frame, Terminal, }; -use crate::mail::MailEvent; +use crate::mail::{EmailMetadata, MailEvent}; use self::colon_prompt::ColonPrompt; use self::input::{BaseInputHandler, HandlesInput, InputResult}; -use self::mail_tab::{EmailMetadata, MailTabState}; +use self::mail_tab::MailTabState; pub(crate) type FrameType<'a, 'b> = Frame<'a, CrosstermBackend<&'b mut Stdout>>; pub(crate) type TermType<'a, 'b> = &'b mut Terminal>; @@ -60,8 +60,10 @@ pub async fn run_ui( // state stack for handling inputs let should_exit = Arc::new(AtomicBool::new(false)); - let mut input_states: Vec> = - vec![Box::new(BaseInputHandler(should_exit.clone()))]; + let mut input_states: Vec> = vec![Box::new(BaseInputHandler( + should_exit.clone(), + mail_tab.change.clone(), + ))]; while !should_exit.load(Ordering::Relaxed) { term.draw(|f| { @@ -76,7 +78,7 @@ pub async fn run_ui( .split(f.size()); // this is the title bar - let titles = vec!["OSU mail"].into_iter().map(Spans::from).collect(); + let titles = vec!["panorama mail"].into_iter().map(Spans::from).collect(); let tabs = Tabs::new(titles); f.render_widget(tabs, chunks[0]); @@ -130,21 +132,6 @@ pub async fn run_ui( } } - // if let Event::Key(KeyEvent { code, .. }) = event { - // match code { - // // KeyCode::Char('q') => break, - // KeyCode::Char('j') => mail_tab.move_down(), - // KeyCode::Char('k') => mail_tab.move_up(), - // KeyCode::Char(':') => { - // let rect = term.size()?; - // term.set_cursor(1, rect.height - 1)?; - // term.show_cursor()?; - // colon_prompt = Some(ColonPrompt::default()); - // } - // _ => {} - // } - // } - Some(event) } else { None @@ -161,37 +148,10 @@ pub async fn run_ui( MailEvent::MessageList(new_messages) => mail_tab.messages = new_messages, MailEvent::MessageUids(new_uids) => mail_tab.message_uids = new_uids, - MailEvent::UpdateUid(_, attrs) => { - let mut uid = None; - let mut date = None; - let mut from = String::new(); - let mut subject = String::new(); - for attr in attrs { - match attr { - AttributeValue::Uid(new_uid) => uid = Some(new_uid), - AttributeValue::InternalDate(new_date) => { - date = Some(new_date.with_timezone(&Local)); - } - AttributeValue::Envelope(Envelope { - subject: new_subject, from: new_from, .. - }) => { - if let Some(new_from) = new_from { - from = new_from.iter() - .filter_map(|addr| addr.name.to_owned()) - .collect::>() - .join(", "); - } - if let Some(new_subject) = new_subject { - subject = new_subject; - } - } - _ => {} - } - } - if let (Some(uid), Some(date)) = (uid, date) { - let meta = EmailMetadata {date ,from, subject }; - mail_tab.message_map.insert(uid, meta); - } + MailEvent::UpdateUid(uid, attrs) => { + let meta = EmailMetadata::from_attrs(attrs); + let uid = meta.uid.unwrap_or(uid); + mail_tab.message_map.insert(uid, meta); } MailEvent::NewUid(uid) => { debug!("new msg!");