1use crate::backend::{QtThread, load_config, tr};
23use jiff::Zoned;
24use sioul_core::areas::Area;
25use sioul_core::attention::{Attention, Level, Now, Output};
26use sioul_core::config::Config;
27use sioul_core::everywhere::People;
28use sioul_core::mailnote::{self, Fate, Ledger, Letter};
29use sioul_core::porch::{self, Triaged};
30use std::path::PathBuf;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34const GATHERED: Duration = Duration::from_secs(10);
36const AT_MOST: Duration = Duration::from_secs(60);
38
39#[derive(Clone)]
42pub(crate) struct Window {
43 #[cfg_attr(target_os = "android", allow(dead_code))]
45 pub(crate) qt: QtThread,
46 pub(crate) active: i64,
48}
49
50struct Batch {
52 letters: Vec<Letter>,
53 first: Option<Instant>,
54 last: Option<Instant>,
55 window: Option<Window>,
56 opens: bool,
58 waiting: bool,
60}
61
62static BATCH: Mutex<Batch> = Mutex::new(Batch { letters: Vec::new(), first: None, last: None, window: None, opens: false, waiting: false });
63
64fn add_to_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
67 let start = {
68 let Ok(mut batch) = BATCH.lock() else { return };
69 batch.letters.extend(letters);
70 batch.first.get_or_insert_with(Instant::now);
71 batch.last = Some(Instant::now());
72 batch.opens |= opens;
73 if window.is_some() {
74 batch.window = window;
75 }
76 !std::mem::replace(&mut batch.waiting, true)
77 };
78 if start {
79 std::thread::spawn(close_batch);
80 }
81}
82
83static MOMENT: Mutex<Option<String>> = Mutex::new(None);
85
86struct Seen {
91 config: Config,
92 senders: std::cell::OnceCell<porch::Senders>,
95 attention: Attention,
96 now: Now,
97 always: People,
99 may: bool,
101}
102
103impl Seen {
104 fn now(config: Config, now: &Zoned) -> Seen {
105 let mode = crate::hours::mode_at(now);
106 let moment = crate::hours::attention_at(&mode, now);
107 let may = config.reminders.mail && (!moment.holds.porch_rests || moment.reachable);
109 Seen { attention: Attention::of(&config), always: People::load(&People::default_path()), config, senders: std::cell::OnceCell::new(), now: moment, may }
110 }
111
112 fn senders(&self) -> &porch::Senders {
113 self.senders.get_or_init(|| porch::Senders::load(&self.config))
114 }
115
116 fn decided(&self, t: &Triaged) -> Output {
120 let area = t.card.account.as_deref().and_then(|id| self.config.account(id)).and_then(|a| a.area.as_deref()).and_then(Area::parse).unwrap_or(Area::WORK);
121 self.attention.mail(t, self.senders(), &self.always, area, &self.now)
122 }
123
124 fn fate(&self, t: &Triaged) -> Fate {
126 let out = self.decided(t);
127 match out {
128 _ if !self.may => Fate::Later,
129 out if out.told && out.pierce => Fate::Through,
130 out if out.told => Fate::Now,
131 out if out.level == Level::Never => Fate::Never,
132 _ => Fate::Later,
133 }
134 }
135
136 fn tells(&self, t: &Triaged) -> bool {
138 self.may && self.decided(t).told
139 }
140
141 fn moment(&self) -> String {
142 mailnote::moment(&self.now, self.may)
143 }
144
145 fn judge(&self, files: &[PathBuf], stamp: i64) -> Vec<Triaged> {
148 let (store, known) = self.world();
149 porch::judge(files, &self.config.mail_sources(), store.as_ref(), &known, self.senders(), stamp)
150 }
151
152 fn porch(&self, stamp: i64) -> Vec<Triaged> {
154 let (store, known) = self.world();
155 let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
156 porch::gather(&self.config.mail_sources(), store.as_ref(), &known, self.senders(), &state, stamp)
157 }
158
159 fn world(&self) -> (Option<sioul_core::projects::ProjectStore>, porch::SenderList) {
160 let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
161 let store = self.config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
162 (store, porch::KnownSenders::load(&self.config.known_senders_path()))
163 }
164}
165
166#[cfg_attr(not(target_os = "android"), allow(dead_code))]
170pub(crate) fn arrived(account: &str, new: &[PathBuf], first: bool) {
171 arrived_from(account, new, first, None);
172}
173
174pub(crate) fn arrived_in_window(window: Window, account: &str, new: &[PathBuf], first: bool) {
176 arrived_from(account, new, first, Some(window));
177}
178
179#[cfg_attr(not(target_os = "android"), allow(dead_code))]
184pub(crate) fn unfiltered(account: &str, files: &[PathBuf]) {
185 told(account, files, None, false);
186}
187
188pub(crate) fn unfiltered_in_window(window: Window, account: &str, files: &[PathBuf]) {
190 told(account, files, Some(window), false);
191}
192
193fn arrived_from(account: &str, new: &[PathBuf], first: bool, window: Option<Window>) {
194 if !first {
195 told(account, new, window, true);
196 }
197}
198
199fn told(_account: &str, files: &[PathBuf], window: Option<Window>, hold: bool) {
203 if files.is_empty() {
204 return;
205 }
206 let config = load_config();
207 if !config.reminders.mail {
208 return;
209 }
210 let now = Zoned::now();
211 let stamp = now.timestamp().as_second();
212 let newsletters = config.reminders.mail_newsletters;
213 let seen = Seen::now(config, &now);
214 let judged = seen.judge(files, stamp);
215 let arrivals = if hold { crate::filters::untold(&seen.config, judged, seen.senders()) } else { judged };
217 let path = Ledger::default_path();
218 let letters = sioul_core::filelock::with_lock(&path, || {
223 let mut ledger = Ledger::load(&path);
224 let sorted = mailnote::sort(&arrivals, &ledger, newsletters, |t| seen.fate(t));
225 for key in &sorted.never {
226 ledger.tell(key, stamp);
227 }
228 for key in sorted.later.iter().chain(sorted.now.iter().map(|l| &l.key)) {
229 ledger.wait(key, stamp);
230 }
231 ledger.forget_old(stamp);
232 if let Err(e) = ledger.save(&path) {
233 eprintln!("{e}");
234 }
235 sorted.now
236 });
237 if !letters.is_empty() {
238 add_to_batch(letters, window, false);
239 }
240}
241
242#[cfg_attr(not(target_os = "android"), allow(dead_code))]
247pub(crate) fn close_now() {
248 let (letters, window, opens) = {
249 let Ok(mut batch) = BATCH.lock() else { return };
250 batch.first = None;
251 batch.last = None;
252 (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
253 };
254 if !letters.is_empty() {
255 tell_batch(letters, window, opens);
256 }
257}
258
259fn close_batch() {
262 loop {
263 std::thread::sleep(Duration::from_secs(1));
264 let ready = {
265 let Ok(mut batch) = BATCH.lock() else { return };
266 let done = batch.last.is_none_or(|last| last.elapsed() >= GATHERED) || batch.first.is_some_and(|first| first.elapsed() >= AT_MOST);
267 done.then(|| {
268 batch.waiting = false;
269 batch.first = None;
270 batch.last = None;
271 (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
272 })
273 };
274 if let Some((letters, window, opens)) = ready {
275 if !letters.is_empty() {
277 tell_batch(letters, window, opens);
278 }
279 return;
280 }
281 }
282}
283
284fn tell_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
289 let now = Zoned::now();
290 let stamp = now.timestamp().as_second();
291 if !Seen::now(load_config(), &now).may {
292 return;
293 }
294 let mine = claim(letters, &[], stamp);
295 if mine.is_empty() || !ours(window.as_ref()) {
296 return;
297 }
298 let (title, body) = if opens { mailnote::opens(tr(), &mine) } else { mailnote::batch(tr(), &mine) };
299 show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
300}
301
302#[cfg_attr(not(target_os = "android"), allow(dead_code))]
306pub(crate) fn tick() {
307 tick_from(None);
308}
309
310pub(crate) fn tick_in_window(window: Window) {
312 std::thread::spawn(move || tick_from(Some(window)));
313}
314
315fn tick_from(window: Option<Window>) {
316 static BUSY: Mutex<()> = Mutex::new(());
317 let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
318 let config = load_config();
319 if !config.reminders.mail {
320 return;
321 }
322 let now = Zoned::now();
323 let stamp = now.timestamp().as_second();
324 let newsletters = config.reminders.mail_newsletters;
325 let seen = Seen::now(config, &now);
326 let moment = seen.moment();
327 let changed = MOMENT.lock().is_ok_and(|mut last| last.replace(moment.clone()).as_ref() != Some(&moment));
328 if !changed || !seen.may {
329 return;
330 }
331 let path = Ledger::default_path();
332 let ledger = Ledger::load(&path);
333 if ledger.waiting.is_empty() {
334 return;
335 }
336 let items = seen.porch(stamp);
337 let letters = mailnote::waited(&items, &ledger, newsletters, |t| seen.tells(t));
338 let gone = mailnote::gone_by(&items, &ledger, newsletters);
340 if window.is_some() {
343 claim(Vec::new(), &gone, stamp);
344 if !letters.is_empty() {
345 add_to_batch(letters, window, true);
346 }
347 return;
348 }
349 let mine = claim(letters, &gone, stamp);
350 if mine.is_empty() || !ours(window.as_ref()) {
351 return;
352 }
353 let (title, body) = mailnote::opens(tr(), &mine);
354 show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
355}
356
357fn claim(letters: Vec<Letter>, gone: &[String], stamp: i64) -> Vec<Letter> {
360 let path = Ledger::default_path();
361 sioul_core::filelock::with_lock(&path, || {
362 let mut ledger = Ledger::load(&path);
363 let mut seen = std::collections::BTreeSet::new();
364 let mine: Vec<Letter> = letters.into_iter().filter(|l| ledger.waiting.contains_key(&l.key) && seen.insert(l.key.clone())).collect();
365 for key in mine.iter().map(|l| &l.key).chain(gone) {
366 ledger.tell(key, stamp);
367 }
368 if mine.is_empty() && gone.is_empty() {
369 return mine;
370 }
371 match ledger.save(&path) {
372 Ok(()) => mine,
373 Err(e) => {
375 eprintln!("{e}");
376 Vec::new()
377 }
378 }
379 })
380}
381
382fn ours(window: Option<&Window>) -> bool {
385 match window {
386 Some(w) => crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, w.active, false).0.mine,
387 None => crate::share::looked("notices", sioul_sync::lease::Rule::FollowsYou).mine,
388 }
389}
390
391fn show(title: &str, body: &str, through: bool, window: Option<&Window>) {
396 #[cfg(target_os = "android")]
397 {
398 let _ = window;
399 crate::eventalarms::mail_note(title, body, through);
400 }
401 #[cfg(not(target_os = "android"))]
402 {
403 use cxx_qt_lib::QString;
404 let open = window.map(|w| {
405 let qt = w.qt.clone();
406 let action: Box<dyn FnOnce() + Send> = Box::new(move || {
407 let _ = qt.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
408 });
409 (tr().text("reminder-open", None), action)
410 });
411 if let Err(e) = sioul_sync::notify::mail(title, body, through, open) {
412 eprintln!("{e}");
413 }
414 }
415}