Skip to main content

imsg_session/live/
mod.rs

1//! Live-query orchestration: `list`/`threads`/`get` read straight from the device and return
2//! lean models, with no store writes and no cursor advances.
3//!
4//! Both read arms (the in-process CLI path and the broker path) call these. `map_core` protocol
5//! types (`BMessage`, `MessageEntry`) are normalized here and never cross outward — only the lean
6//! models in [`models`] do.
7
8pub mod models;
9
10use std::collections::HashMap;
11
12use map_core::client::MapClient;
13use map_core::folders::Folder;
14use map_core::messages::MessageEntry;
15use map_core::{BMessage, MessageStatus};
16use tokio::io::{AsyncRead, AsyncWrite};
17
18use crate::fetch::{fetch_folder, list_folder};
19use crate::util::{datetime_to_ms, now_ms};
20use models::{Direction, LiveBody, LiveMessage, LiveThread};
21
22/// Client-side filters for a live [`list`], mirroring `store::list_messages` semantics.
23///
24/// Applied in memory over the device's listing window: the device returns its whole fixed
25/// window regardless of offset, so paging is meaningless against it.
26#[derive(Debug, Default)]
27pub struct ListFilter {
28    /// Keep only unread messages.
29    pub unread: bool,
30    /// Keep only messages whose resolved address equals this value exactly.
31    pub from: Option<String>,
32    /// Keep only messages at or after this epoch-millisecond datetime.
33    pub since_ms: Option<i64>,
34    /// Maximum rows after `offset`; `None` keeps the rest of the window.
35    pub limit: Option<u16>,
36    /// Rows to skip from the front of the newest-first window.
37    pub offset: u16,
38}
39
40/// Lists `folder` live and returns lean messages newest-first, after applying `filter`.
41///
42/// Fetches the device's full listing window (bodies included, an irreducible 1+N cost) then
43/// filters, sorts, and windows in memory. No store access, no cursor advance.
44///
45/// # Errors
46///
47/// Returns an error if any MAP listing or body fetch fails.
48pub async fn list<T: AsyncRead + AsyncWrite + Unpin>(
49    client: &mut MapClient<T>,
50    folder: Folder,
51    filter: &ListFilter,
52) -> anyhow::Result<Vec<LiveMessage>> {
53    let mut msgs: Vec<LiveMessage> = fetch_folder(client, folder, None, now_ms())
54        .await?
55        .into_iter()
56        .map(|m| LiveMessage {
57            handle: m.handle,
58            timestamp_ms: m.timestamp_ms,
59            address: m.address,
60            folder: m.folder,
61            read: m.read,
62            text: m.text,
63        })
64        .filter(|m| keep(m, filter))
65        .collect();
66    msgs.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms));
67    Ok(window(msgs, filter))
68}
69
70fn keep(m: &LiveMessage, f: &ListFilter) -> bool {
71    if f.unread && m.read {
72        return false;
73    }
74    if f.from.as_deref().is_some_and(|addr| addr != m.address) {
75        return false;
76    }
77    f.since_ms.is_none_or(|since| m.timestamp_ms >= since)
78}
79
80fn window(msgs: Vec<LiveMessage>, f: &ListFilter) -> Vec<LiveMessage> {
81    let rest = msgs.into_iter().skip(usize::from(f.offset));
82    match f.limit {
83        Some(n) => rest.take(usize::from(n)).collect(),
84        None => rest.collect(),
85    }
86}
87
88/// Aggregates Inbox and Sent listings into per-contact thread summaries, newest-first.
89///
90/// Listings only — no bodies are fetched. Mirrors `store::threads`: `total` counts all messages,
91/// `unread` counts received-and-unread, `latest_ms` is the max datetime, empty-address entries are
92/// dropped. Counts are approximate (device window, not full corpus). No store access.
93///
94/// # Errors
95///
96/// Returns an error if either folder listing fails.
97pub async fn threads<T: AsyncRead + AsyncWrite + Unpin>(
98    client: &mut MapClient<T>,
99) -> anyhow::Result<Vec<LiveThread>> {
100    let mut acc: HashMap<String, LiveThread> = HashMap::new();
101    for folder in [Folder::Inbox, Folder::Sent] {
102        for entry in list_folder(client, folder).await? {
103            accumulate(&mut acc, &entry);
104        }
105    }
106    let mut threads: Vec<LiveThread> = acc.into_values().collect();
107    threads.sort_by(|a, b| b.latest_ms.cmp(&a.latest_ms));
108    Ok(threads)
109}
110
111fn accumulate(acc: &mut HashMap<String, LiveThread>, entry: &MessageEntry) {
112    let address = peer_address(entry);
113    if address.is_empty() {
114        return;
115    }
116    let ts = datetime_to_ms(&entry.datetime).unwrap_or(0);
117    let t = acc.entry(address.clone()).or_insert_with(|| LiveThread {
118        address,
119        latest_ms: ts,
120        total: 0,
121        unread: 0,
122    });
123    t.total = t.total.saturating_add(1);
124    t.unread = t.unread.saturating_add(u32::from(!entry.sent && !entry.read));
125    if ts > t.latest_ms {
126        t.latest_ms = ts;
127    }
128}
129
130fn peer_address(entry: &MessageEntry) -> String {
131    if entry.sent {
132        entry.recipient_addressing.clone()
133    } else {
134        entry.sender_addressing.clone()
135    }
136}
137
138/// Fetches one message body live by `handle` and normalizes it to a [`LiveBody`].
139///
140/// `direction` is derived from the bMessage folder (containing `sent`/`outbox` ⇒ [`Direction::Sent`]);
141/// the address is the originator for received messages and the first recipient for sent. No
142/// timestamp — a `BMessage` carries no datetime. No store access.
143///
144/// # Errors
145///
146/// Returns an error if the MAP `GetMessage` fails.
147pub async fn get<T: AsyncRead + AsyncWrite + Unpin>(
148    client: &mut MapClient<T>,
149    handle: String,
150) -> anyhow::Result<LiveBody> {
151    let bmsg = client.get_message(&handle).await?;
152    Ok(to_live_body(handle, &bmsg))
153}
154
155fn to_live_body(handle: String, bmsg: &BMessage) -> LiveBody {
156    let direction = direction_of(bmsg.folder());
157    let address = match direction {
158        Direction::Sent => bmsg.envelope().recipients.first().map(|v| v.tel.clone()),
159        Direction::Received => bmsg.originator().map(|v| v.tel.clone()),
160    }
161    .unwrap_or_default();
162    LiveBody {
163        handle,
164        direction,
165        address,
166        folder: bmsg.folder().to_owned(),
167        read: matches!(bmsg.status(), MessageStatus::Read),
168        text: bmsg.envelope().body.text.clone(),
169    }
170}
171
172fn direction_of(folder: &str) -> Direction {
173    let f = folder.to_ascii_lowercase();
174    if f.contains("sent") || f.contains("outbox") {
175        Direction::Sent
176    } else {
177        Direction::Received
178    }
179}
180
181#[cfg(test)]
182mod tests;