Skip to main content

teams_cli/
render.rs

1//! Human text views for chats and messages, shared by the Graph and desktop backends.
2use jiff::{Timestamp, Zoned, tz::TimeZone};
3use serde_json::Value;
4use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
5
6use crate::graph::Page;
7
8/// The local clock and terminal width a view is rendered against.
9pub struct View {
10    pub now: Zoned,
11    pub width: usize,
12}
13
14impl View {
15    pub fn terminal() -> Self {
16        let width = crossterm::terminal::size().map_or(100, |(w, _)| usize::from(w));
17        Self {
18            now: Zoned::now().with_time_zone(TimeZone::system()),
19            width: width.clamp(40, 100),
20        }
21    }
22
23    fn local(&self, value: &Value) -> Option<Zoned> {
24        let ts: Timestamp = value.as_str()?.parse().ok()?;
25        Some(ts.to_zoned(self.now.time_zone().clone()))
26    }
27
28    /// Compact time for list rows: "14:03", "Mon 14:03", "12 Oct" or "12 Oct 2025".
29    fn short(&self, at: &Zoned) -> String {
30        let days = self.days_ago(at);
31        if days == 0 {
32            at.strftime("%H:%M").to_string()
33        } else if (1..7).contains(&days) {
34            at.strftime("%a %H:%M").to_string()
35        } else if at.year() == self.now.year() {
36            at.strftime("%-d %b").to_string()
37        } else {
38            at.strftime("%-d %b %Y").to_string()
39        }
40    }
41
42    fn day_header(&self, at: &Zoned) -> String {
43        match self.days_ago(at) {
44            0 => "Today".into(),
45            1 => "Yesterday".into(),
46            _ if at.year() == self.now.year() => at.strftime("%A %-d %B").to_string(),
47            _ => at.strftime("%A %-d %B %Y").to_string(),
48        }
49    }
50
51    fn days_ago(&self, at: &Zoned) -> i64 {
52        at.date()
53            .until(self.now.date())
54            .map_or(0, |span| i64::from(span.get_days()))
55    }
56}
57
58/// One chat as a list row: what the text view and the TUI ledger both show.
59pub struct ChatRow {
60    pub id: String,
61    pub title: String,
62    pub kind: Option<&'static str>,
63    pub members: usize,
64    pub time: String,
65    pub preview: Option<String>,
66    pub unread: bool,
67}
68
69impl ChatRow {
70    pub fn new(chat: &Value, view: &View, viewer: Option<&str>) -> Self {
71        let preview = &chat["lastMessagePreview"];
72        Self {
73            id: str_at(chat, &["id"]).unwrap_or_default().to_string(),
74            title: chat_title(chat, viewer),
75            kind: match str_at(chat, &["chatType"]) {
76                Some("group") => Some("group"),
77                Some("meeting") => Some("meeting"),
78                _ => None,
79            },
80            members: chat["members"].as_array().map_or(0, Vec::len),
81            time: view
82                .local(&preview["createdDateTime"])
83                .or_else(|| view.local(&chat["lastUpdatedDateTime"]))
84                .map(|at| view.short(&at))
85                .unwrap_or_default(),
86            preview: preview_text(chat, viewer),
87            unread: is_unread(chat, viewer),
88        }
89    }
90}
91
92/// A conversation laid out oldest first, before wrapping to a width.
93#[derive(Debug, PartialEq)]
94pub enum Entry {
95    Day(String),
96    Sender {
97        name: String,
98        time: Option<String>,
99        own: bool,
100    },
101    /// One source line of a message body; empty for a paragraph break.
102    Text(String),
103}
104
105pub fn conversation(items: &[Value], view: &View, viewer: Option<&str>) -> Vec<Entry> {
106    let mut items: Vec<(Option<Zoned>, &Value)> = items
107        .iter()
108        .filter(|m| matches!(str_at(m, &["messageType"]), None | Some("message")))
109        .map(|m| (view.local(&m["createdDateTime"]), m))
110        .collect();
111    // Services list newest first; a conversation reads oldest first.
112    items.sort_by_key(|(at, _)| at.as_ref().map(Zoned::timestamp));
113    let mut entries = Vec::new();
114    let mut last: Option<(String, Zoned)> = None;
115    for (at, message) in items {
116        let name = sender_name(message, viewer);
117        let own = viewer.is_some() && str_at(message, &["from", "user", "id"]) == viewer;
118        if let Some(at) = &at {
119            let new_day = last
120                .as_ref()
121                .is_none_or(|(_, prev)| prev.date() != at.date());
122            if new_day {
123                entries.push(Entry::Day(view.day_header(at)));
124            }
125            // Messages from one sender a few minutes apart read as one turn.
126            let continues = !new_day
127                && last.as_ref().is_some_and(|(who, prev)| {
128                    *who == name
129                        && prev.until(at).is_ok_and(|gap| {
130                            gap.total(jiff::Unit::Minute).unwrap_or(f64::MAX) < 5.0
131                        })
132                });
133            if !continues {
134                entries.push(Entry::Sender {
135                    name: name.clone(),
136                    time: Some(at.strftime("%H:%M").to_string()),
137                    own,
138                });
139            }
140            last = Some((name, at.clone()));
141        } else {
142            entries.push(Entry::Sender {
143                name,
144                time: None,
145                own,
146            });
147            last = None;
148        }
149        entries.extend(message_text(message).lines().map(|l| Entry::Text(l.into())));
150    }
151    entries
152}
153
154pub fn chats(page: &Page, view: &View, continuation: &str) -> String {
155    let viewer = page.viewer.as_deref();
156    let mut blocks = Vec::new();
157    for chat in &page.items {
158        let row = ChatRow::new(chat, view, viewer);
159        let mut title = row.title;
160        if let Some(kind) = row.kind {
161            title = format!("{title} · {kind}");
162        }
163        let time = row.time;
164        let room = view.width.saturating_sub(time.width() + 4).max(10);
165        let title = truncate(&title, room);
166        let gap = view
167            .width
168            .saturating_sub(title.width() + time.width() + 2)
169            .max(1);
170        let mut lines = vec![format!(
171            "{}{title}{}{time}",
172            if row.unread { "● " } else { "  " },
173            " ".repeat(gap)
174        )];
175        if let Some(text) = row.preview {
176            lines.push(format!("  {}", truncate(&text, view.width - 2)));
177        }
178        lines.push(format!("  {}", row.id));
179        blocks.push(lines.join("\n"));
180    }
181    if blocks.is_empty() {
182        return "No chats.".into();
183    }
184    let mut out = blocks.join("\n\n");
185    if page.truncated {
186        out.push_str(&format!(
187            "\n\nMore chats available. Continue with --cursor '{continuation}'"
188        ));
189    }
190    out
191}
192
193pub fn messages(page: &Page, view: &View, continuation: &str) -> String {
194    if page.items.is_empty() {
195        return "No messages.".into();
196    }
197    let mut lines = Vec::new();
198    if page.truncated {
199        lines.push(format!(
200            "Older messages available. Continue with --cursor '{continuation}'"
201        ));
202    }
203    for entry in conversation(&page.items, view, page.viewer.as_deref()) {
204        match entry {
205            Entry::Day(day) => {
206                if !lines.is_empty() {
207                    lines.push(String::new());
208                }
209                lines.push(format!("── {day} ──"));
210            }
211            Entry::Sender {
212                name,
213                time: Some(time),
214                ..
215            } => lines.push(format!("{name}  {time}")),
216            Entry::Sender { name, .. } => lines.push(name),
217            Entry::Text(line) if line.is_empty() => lines.push(String::new()),
218            Entry::Text(line) => lines.extend(
219                wrap(&line, view.width - 2)
220                    .into_iter()
221                    .map(|l| format!("  {l}")),
222            ),
223        }
224    }
225    lines.join("\n")
226}
227
228fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
229    path.iter()
230        .try_fold(value, |v, key| v.get(*key))
231        .and_then(Value::as_str)
232        .filter(|s| !s.trim().is_empty())
233}
234
235fn chat_title(chat: &Value, viewer: Option<&str>) -> String {
236    if let Some(topic) = str_at(chat, &["topic"]) {
237        return topic.trim().to_string();
238    }
239    let names: Vec<&str> = chat["members"]
240        .as_array()
241        .into_iter()
242        .flatten()
243        .filter(|m| viewer.is_none() || str_at(m, &["userId"]) != viewer)
244        .filter_map(|m| str_at(m, &["displayName"]))
245        .collect();
246    let members = chat["members"].as_array().map_or(&[][..], Vec::as_slice);
247    let only_viewer = viewer.is_some()
248        && !members.is_empty()
249        && members.iter().all(|m| str_at(m, &["userId"]) == viewer);
250    match names.as_slice() {
251        [] if only_viewer => "You".into(),
252        [] => match str_at(chat, &["chatType"]) {
253            Some("oneOnOne") => "(unknown person)".into(),
254            Some("meeting") => "(untitled meeting)".into(),
255            _ => "(untitled chat)".into(),
256        },
257        [one] => (*one).into(),
258        _ if names.len() <= 3 => names.join(", "),
259        _ => format!("{} +{}", names[..3].join(", "), names.len() - 3),
260    }
261}
262
263fn is_unread(chat: &Value, viewer: Option<&str>) -> bool {
264    let preview = &chat["lastMessagePreview"];
265    let sent = str_at(preview, &["createdDateTime"]).and_then(|t| t.parse::<Timestamp>().ok());
266    let read = str_at(chat, &["viewpoint", "lastMessageReadDateTime"])
267        .and_then(|t| t.parse::<Timestamp>().ok());
268    let from_viewer = viewer.is_some() && str_at(preview, &["from", "user", "id"]) == viewer;
269    matches!(str_at(preview, &["messageType"]), None | Some("message"))
270        && !from_viewer
271        && matches!((sent, read), (Some(sent), Some(read)) if sent > read)
272}
273
274fn preview_text(chat: &Value, viewer: Option<&str>) -> Option<String> {
275    let preview = &chat["lastMessagePreview"];
276    if preview.is_null() || str_at(preview, &["messageType"]).is_some_and(|t| t != "message") {
277        return None;
278    }
279    let text = message_text(preview)
280        .split_whitespace()
281        .collect::<Vec<_>>()
282        .join(" ");
283    let from_viewer = viewer.is_some() && str_at(preview, &["from", "user", "id"]) == viewer;
284    let author = if from_viewer {
285        Some("You")
286    } else if str_at(chat, &["chatType"]) == Some("oneOnOne") {
287        None
288    } else {
289        str_at(preview, &["from", "user", "displayName"])
290            .and_then(|name| name.split_whitespace().next())
291    };
292    Some(match author {
293        Some(author) => format!("{author}: {text}"),
294        None => text,
295    })
296}
297
298fn sender_name(message: &Value, viewer: Option<&str>) -> String {
299    let user = &message["from"]["user"];
300    if viewer.is_some() && str_at(user, &["id"]) == viewer {
301        return "You".into();
302    }
303    str_at(user, &["displayName"])
304        .or_else(|| str_at(message, &["from", "application", "displayName"]))
305        .unwrap_or("Unknown sender")
306        .to_string()
307}
308
309fn message_text(message: &Value) -> String {
310    if message["deleted"] == true
311        || str_at(message, &["deletedDateTime"]).is_some()
312        || message["isDeleted"] == true
313    {
314        return "(message deleted)".into();
315    }
316    let content = str_at(message, &["body", "content"]).unwrap_or_default();
317    let text = if str_at(message, &["body", "contentType"]) == Some("html") {
318        html_to_text(content)
319    } else {
320        content.trim().to_string()
321    };
322    let attachments = message["attachments"].as_array().map_or(0, Vec::len);
323    if !text.is_empty() {
324        text
325    } else if attachments > 0 {
326        format!(
327            "[{attachments} attachment{}]",
328            if attachments == 1 { "" } else { "s" }
329        )
330    } else {
331        "(no text)".into()
332    }
333}
334
335/// Reduces Teams message HTML to readable plain text.
336pub fn html_to_text(html: &str) -> String {
337    let mut out = String::new();
338    let mut quote = 0usize;
339    let mut skip = 0usize;
340    let mut rest = html;
341    while let Some(start) = rest.find('<') {
342        if skip == 0 {
343            push_text(&mut out, &decode_entities(&rest[..start]), quote);
344        }
345        let Some(end) = rest[start..].find('>') else {
346            rest = &rest[start..];
347            break;
348        };
349        let tag = &rest[start + 1..start + end];
350        rest = &rest[start + end + 1..];
351        let closing = tag.starts_with('/');
352        let name: String = tag
353            .trim_start_matches('/')
354            .chars()
355            .take_while(|c| c.is_ascii_alphanumeric())
356            .collect::<String>()
357            .to_ascii_lowercase();
358        match (name.as_str(), closing) {
359            ("style" | "script", false) if !tag.ends_with('/') => skip += 1,
360            ("style" | "script", true) => skip = skip.saturating_sub(1),
361            ("br", _) => {
362                newline(&mut out, quote);
363                // Teams formats its HTML with CRLF after <br>. That whitespace
364                // is not another visible line break; consecutive <br>s still are.
365                rest = rest.trim_start_matches([' ', '\t', '\r', '\n']);
366            }
367            ("p" | "div" | "tr" | "h1" | "h2" | "h3" | "pre" | "ul" | "ol", _) => {
368                block(&mut out, quote)
369            }
370            ("li", false) => {
371                block(&mut out, quote);
372                out.push_str("• ");
373            }
374            ("td" | "th", false) => out.push(' '),
375            ("blockquote", false) => {
376                quote += 1;
377                block(&mut out, quote);
378            }
379            ("blockquote", true) => {
380                quote = quote.saturating_sub(1);
381                newline(&mut out, quote);
382            }
383            ("at", false) => out.push('@'),
384            ("img" | "emoji", false) if skip == 0 => match attribute(tag, "alt") {
385                Some(alt) if name == "emoji" || tag.contains("emoji") => {
386                    out.push_str(&decode_entities(&alt))
387                }
388                _ if name == "img" => out.push_str("[image]"),
389                _ => {}
390            },
391            ("attachment", false) => out.push_str("[attachment]"),
392            _ => {}
393        }
394    }
395    if skip == 0 {
396        push_text(&mut out, &decode_entities(rest), quote);
397    }
398    let mut lines: Vec<String> = Vec::new();
399    for line in out.lines() {
400        let line = line.split_whitespace().collect::<Vec<_>>().join(" ");
401        let blank = line.chars().all(|c| c == '>' || c == ' ');
402        if blank && lines.last().is_none_or(|l: &String| l.is_empty()) {
403            continue;
404        }
405        lines.push(if blank { String::new() } else { line });
406    }
407    while lines.last().is_some_and(String::is_empty) {
408        lines.pop();
409    }
410    lines.join("\n")
411}
412
413/// Starts a new line unless the current one is still empty.
414fn block(out: &mut String, quote: usize) {
415    let current = out.rsplit('\n').next().unwrap_or_default();
416    if current.chars().any(|c| c != '>' && !c.is_whitespace()) {
417        newline(out, quote);
418    } else {
419        let start = out.len() - current.len();
420        out.truncate(start);
421        out.push_str(&"> ".repeat(quote));
422    }
423}
424
425fn newline(out: &mut String, quote: usize) {
426    out.push('\n');
427    out.push_str(&"> ".repeat(quote));
428}
429
430fn push_text(out: &mut String, text: &str, quote: usize) {
431    let mut parts = text.split('\n');
432    if let Some(first) = parts.next() {
433        out.push_str(first);
434    }
435    for part in parts {
436        newline(out, quote);
437        out.push_str(part);
438    }
439}
440
441fn attribute(tag: &str, name: &str) -> Option<String> {
442    let lower = tag.to_ascii_lowercase();
443    let mut from = 0;
444    while let Some(found) = lower[from..].find(name) {
445        let at = from + found;
446        from = at + name.len();
447        let before = lower[..at].chars().last();
448        let after = lower[from..].trim_start();
449        if before.is_some_and(char::is_whitespace) && after.starts_with('=') {
450            let value = tag[tag.len() - after.len() + 1..].trim_start();
451            let quote = value.chars().next()?;
452            return if quote == '"' || quote == '\'' {
453                value[1..].split(quote).next().map(String::from)
454            } else {
455                value.split_whitespace().next().map(String::from)
456            };
457        }
458    }
459    None
460}
461
462fn decode_entities(text: &str) -> String {
463    let mut out = String::with_capacity(text.len());
464    let mut rest = text;
465    while let Some(start) = rest.find('&') {
466        out.push_str(&rest[..start]);
467        rest = &rest[start..];
468        let decoded = rest[1..]
469            .find(';')
470            .filter(|end| *end <= 10)
471            .and_then(|end| {
472                let entity = &rest[1..=end];
473                let c = match entity {
474                    "amp" => Some('&'),
475                    "lt" => Some('<'),
476                    "gt" => Some('>'),
477                    "quot" => Some('"'),
478                    "apos" => Some('\''),
479                    "nbsp" => Some(' '),
480                    _ => entity
481                        .strip_prefix("#x")
482                        .or_else(|| entity.strip_prefix("#X"))
483                        .and_then(|hex| u32::from_str_radix(hex, 16).ok())
484                        .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok()))
485                        .and_then(char::from_u32),
486                }?;
487                Some((c, end + 2))
488            });
489        match decoded {
490            Some((c, len)) => {
491                out.push(c);
492                rest = &rest[len..];
493            }
494            None => {
495                out.push('&');
496                rest = &rest[1..];
497            }
498        }
499    }
500    out.push_str(rest);
501    out
502}
503
504pub(crate) fn truncate(text: &str, width: usize) -> String {
505    if text.width() <= width {
506        return text.into();
507    }
508    let mut out = String::new();
509    let mut used = 0;
510    for c in text.chars() {
511        let w = c.width().unwrap_or(0);
512        if used + w + 1 > width {
513            break;
514        }
515        used += w;
516        out.push(c);
517    }
518    out.push('…');
519    out
520}
521
522/// Greedy word wrap by display width; words longer than a line are split.
523pub(crate) fn wrap(text: &str, width: usize) -> Vec<String> {
524    let width = width.max(10);
525    let mut lines = Vec::new();
526    let prefix_len = text.len() - text.trim_start_matches("> ").len();
527    let (prefix, body) = text.split_at(prefix_len);
528    let room = width.saturating_sub(prefix.width()).max(10);
529    let mut line = String::new();
530    for word in body.split_whitespace() {
531        let mut word = word.to_string();
532        loop {
533            let need = if line.is_empty() {
534                word.width()
535            } else {
536                line.width() + 1 + word.width()
537            };
538            if need <= room {
539                if !line.is_empty() {
540                    line.push(' ');
541                }
542                line.push_str(&word);
543                break;
544            }
545            if !line.is_empty() {
546                lines.push(std::mem::take(&mut line));
547                continue;
548            }
549            let mut head = String::new();
550            let mut tail = word.chars();
551            while let Some(c) = tail.clone().next() {
552                if head.width() + c.width().unwrap_or(0) > room {
553                    break;
554                }
555                head.push(c);
556                tail.next();
557            }
558            lines.push(head);
559            word = tail.collect();
560        }
561    }
562    if !line.is_empty() || lines.is_empty() {
563        lines.push(line);
564    }
565    lines.into_iter().map(|l| format!("{prefix}{l}")).collect()
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use serde_json::json;
572
573    fn view() -> View {
574        View {
575            now: "2026-10-13T15:00:00+00:00[UTC]".parse().unwrap(),
576            width: 60,
577        }
578    }
579
580    fn page(items: Vec<Value>, viewer: Option<&str>) -> Page {
581        Page {
582            items,
583            next_cursor: None,
584            next_offset: None,
585            truncated: false,
586            source: None,
587            viewer: viewer.map(String::from),
588        }
589    }
590
591    #[test]
592    fn html_becomes_readable_text() {
593        let html = r#"<div><blockquote itemtype="http://schema.skype.com/Reply"><strong>Ann</strong><p>old  point</p></blockquote><p>Hi <at id="0">Bob</at>,&nbsp;5 &lt; 6 &amp; &#x1F600;</p><ul><li>one</li><li>two</li></ul><emoji alt="🙂" title="Smile"></emoji><img src="x"><br><style>p{}</style></div><p>a<br><br>b</p><p>c</p>"#;
594        assert_eq!(
595            html_to_text(html),
596            "> Ann\n> old point\n\nHi @Bob, 5 < 6 & 😀\n• one\n• two\n🙂[image]\na\n\nb\nc"
597        );
598        assert_eq!(html_to_text("<p></p><p>  </p>"), "");
599        assert_eq!(
600            html_to_text("<p>line one<br />\r\nline two</p>"),
601            "line one\nline two"
602        );
603        assert_eq!(decode_entities("a &bogus; &#65; &"), "a &bogus; A &");
604    }
605
606    #[test]
607    fn plain_text_entities_are_literal_in_conversations_and_previews() {
608        let content = "Use &amp; and &#65; literally";
609        let message = json!({"from": {"user": {"displayName": "Ann"}},
610            "body": {"contentType": "text", "content": content}});
611        let entries = conversation(std::slice::from_ref(&message), &view(), None);
612        assert!(entries.contains(&Entry::Text(content.into())));
613        let chat = json!({"id": "chat", "chatType": "oneOnOne", "lastMessagePreview": message});
614        assert_eq!(
615            ChatRow::new(&chat, &view(), None).preview.as_deref(),
616            Some(content)
617        );
618        assert_eq!(
619            message_text(&json!({"body": {"contentType": "html", "content": content}})),
620            "Use & and A literally"
621        );
622    }
623
624    #[test]
625    fn times_are_relative_to_the_local_day() {
626        let view = view();
627        let at = |s: &str| view.local(&json!(s)).unwrap();
628        assert_eq!(view.short(&at("2026-10-13T09:05:00Z")), "09:05");
629        assert_eq!(view.short(&at("2026-10-12T09:05:00.123Z")), "Mon 09:05");
630        assert_eq!(view.short(&at("2026-09-02T09:05:00Z")), "2 Sep");
631        assert_eq!(view.short(&at("2025-09-02T09:05:00Z")), "2 Sep 2025");
632        assert_eq!(view.day_header(&at("2026-10-12T23:00:00Z")), "Yesterday");
633        assert_eq!(
634            view.day_header(&at("2026-10-01T23:00:00Z")),
635            "Thursday 1 October"
636        );
637    }
638
639    #[test]
640    fn chat_titles_prefer_topic_then_other_members() {
641        let members = json!([
642            {"userId": "me", "displayName": "Sam"},
643            {"userId": "a", "displayName": "Ann Smith"},
644            {"userId": "b", "displayName": null}
645        ]);
646        let chat = json!({"topic": " Release ", "members": members});
647        assert_eq!(chat_title(&chat, Some("me")), "Release");
648        let chat = json!({"topic": null, "chatType": "oneOnOne", "members": members});
649        assert_eq!(chat_title(&chat, Some("me")), "Ann Smith");
650        assert_eq!(chat_title(&chat, None), "Sam, Ann Smith");
651        let chat = json!({"chatType": "oneOnOne", "members": []});
652        assert_eq!(chat_title(&chat, Some("me")), "(unknown person)");
653        let notes =
654            json!({"chatType": "oneOnOne", "members": [{"userId": "me", "displayName": "Sam"}]});
655        assert_eq!(chat_title(&notes, Some("me")), "You");
656        let members: Vec<Value> = ["A", "B", "C", "D", "E"]
657            .map(|n| json!({"userId": n, "displayName": n}))
658            .into();
659        let many = json!({ "members": members });
660        assert_eq!(chat_title(&many, None), "A, B, C +2");
661    }
662
663    #[test]
664    fn chat_rows_show_unread_title_time_preview_and_id() {
665        let chat = |from: &str, read: &str| {
666            json!({
667                "id": "19:a@unq.gbl.spaces", "topic": null, "chatType": "oneOnOne",
668                "members": [{"userId": "me", "displayName": "Sam"}, {"userId": "a", "displayName": "Ann"}],
669                "viewpoint": {"lastMessageReadDateTime": read},
670                "lastMessagePreview": {"createdDateTime": "2026-10-13T14:03:00Z", "messageType": "message",
671                    "from": {"user": {"id": from, "displayName": "Ann"}},
672                    "body": {"contentType": "html", "content": "<p>see you</p>"}}
673            })
674        };
675        let text = chats(
676            &page(vec![chat("a", "2026-10-13T10:00:00Z")], Some("me")),
677            &view(),
678            "c",
679        );
680        let lines: Vec<&str> = text.lines().collect();
681        assert!(lines[0].starts_with("● Ann ") && lines[0].ends_with(" 14:03"));
682        assert_eq!(lines[0].width(), 60);
683        assert_eq!(lines[1..], ["  see you", "  19:a@unq.gbl.spaces"]);
684        let text = chats(
685            &page(vec![chat("me", "2026-10-13T10:00:00Z")], Some("me")),
686            &view(),
687            "c",
688        );
689        assert!(text.starts_with("  Ann ") && text.contains("\n  You: see you\n"));
690        let mut more = page(vec![], None);
691        assert_eq!(chats(&more, &view(), "c"), "No chats.");
692        more.items
693            .push(json!({"id": "19:g@thread.v2", "chatType": "group", "topic": "Ops"}));
694        more.truncated = true;
695        let text = chats(&more, &view(), "abc");
696        assert!(text.starts_with("  Ops · group"));
697        assert!(text.ends_with("Continue with --cursor 'abc'"));
698    }
699
700    #[test]
701    fn messages_read_oldest_first_grouped_by_day_and_sender() {
702        let msg = |id: &str, at: &str, from: &str, body: &str| {
703            json!({"id": id, "createdDateTime": at,
704                "from": {"user": {"id": from, "displayName": from.to_uppercase()}},
705                "body": {"contentType": "text", "content": body}})
706        };
707        let mut deleted = msg("5", "2026-10-13T14:30:00Z", "ann", "x");
708        deleted["deleted"] = json!(true);
709        let mut event = msg("6", "2026-10-13T14:40:00Z", "ann", "");
710        event["messageType"] = json!("systemEventMessage");
711        let mut page = page(
712            vec![
713                event,
714                deleted,
715                msg("4", "2026-10-13T14:02:00Z", "ann", "and more"),
716                msg("3", "2026-10-13T14:00:00Z", "ann", "a &amp; b"),
717                msg("2", "2026-10-12T09:00:00Z", "me", "first"),
718            ],
719            Some("me"),
720        );
721        page.truncated = true;
722        assert_eq!(
723            messages(&page, &view(), "older"),
724            "Older messages available. Continue with --cursor 'older'\n\n\
725             ── Yesterday ──\nYou  09:00\n  first\n\n\
726             ── Today ──\nANN  14:00\n  a &amp; b\n  and more\nANN  14:30\n  (message deleted)"
727        );
728    }
729
730    #[test]
731    fn wrapping_respects_width_and_quote_prefix() {
732        assert_eq!(wrap("aaa bbb ccc ddd", 11), ["aaa bbb ccc", "ddd"]);
733        assert_eq!(wrap("> aaaa bbbb cccc", 12), ["> aaaa bbbb", "> cccc"]);
734        assert_eq!(wrap("abcdefghijklmn", 10), ["abcdefghij", "klmn"]);
735        assert_eq!(truncate("abcdef", 4), "abc…");
736    }
737}