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", _) => newline(&mut out, quote),
362            ("p" | "div" | "tr" | "h1" | "h2" | "h3" | "pre" | "ul" | "ol", _) => {
363                block(&mut out, quote)
364            }
365            ("li", false) => {
366                block(&mut out, quote);
367                out.push_str("• ");
368            }
369            ("td" | "th", false) => out.push(' '),
370            ("blockquote", false) => {
371                quote += 1;
372                block(&mut out, quote);
373            }
374            ("blockquote", true) => {
375                quote = quote.saturating_sub(1);
376                newline(&mut out, quote);
377            }
378            ("at", false) => out.push('@'),
379            ("img" | "emoji", false) if skip == 0 => match attribute(tag, "alt") {
380                Some(alt) if name == "emoji" || tag.contains("emoji") => {
381                    out.push_str(&decode_entities(&alt))
382                }
383                _ if name == "img" => out.push_str("[image]"),
384                _ => {}
385            },
386            ("attachment", false) => out.push_str("[attachment]"),
387            _ => {}
388        }
389    }
390    if skip == 0 {
391        push_text(&mut out, &decode_entities(rest), quote);
392    }
393    let mut lines: Vec<String> = Vec::new();
394    for line in out.lines() {
395        let line = line.split_whitespace().collect::<Vec<_>>().join(" ");
396        let blank = line.chars().all(|c| c == '>' || c == ' ');
397        if blank && lines.last().is_none_or(|l: &String| l.is_empty()) {
398            continue;
399        }
400        lines.push(if blank { String::new() } else { line });
401    }
402    while lines.last().is_some_and(String::is_empty) {
403        lines.pop();
404    }
405    lines.join("\n")
406}
407
408/// Starts a new line unless the current one is still empty.
409fn block(out: &mut String, quote: usize) {
410    let current = out.rsplit('\n').next().unwrap_or_default();
411    if current.chars().any(|c| c != '>' && !c.is_whitespace()) {
412        newline(out, quote);
413    } else {
414        let start = out.len() - current.len();
415        out.truncate(start);
416        out.push_str(&"> ".repeat(quote));
417    }
418}
419
420fn newline(out: &mut String, quote: usize) {
421    out.push('\n');
422    out.push_str(&"> ".repeat(quote));
423}
424
425fn push_text(out: &mut String, text: &str, quote: usize) {
426    let mut parts = text.split('\n');
427    if let Some(first) = parts.next() {
428        out.push_str(first);
429    }
430    for part in parts {
431        newline(out, quote);
432        out.push_str(part);
433    }
434}
435
436fn attribute(tag: &str, name: &str) -> Option<String> {
437    let lower = tag.to_ascii_lowercase();
438    let mut from = 0;
439    while let Some(found) = lower[from..].find(name) {
440        let at = from + found;
441        from = at + name.len();
442        let before = lower[..at].chars().last();
443        let after = lower[from..].trim_start();
444        if before.is_some_and(char::is_whitespace) && after.starts_with('=') {
445            let value = tag[tag.len() - after.len() + 1..].trim_start();
446            let quote = value.chars().next()?;
447            return if quote == '"' || quote == '\'' {
448                value[1..].split(quote).next().map(String::from)
449            } else {
450                value.split_whitespace().next().map(String::from)
451            };
452        }
453    }
454    None
455}
456
457fn decode_entities(text: &str) -> String {
458    let mut out = String::with_capacity(text.len());
459    let mut rest = text;
460    while let Some(start) = rest.find('&') {
461        out.push_str(&rest[..start]);
462        rest = &rest[start..];
463        let decoded = rest[1..]
464            .find(';')
465            .filter(|end| *end <= 10)
466            .and_then(|end| {
467                let entity = &rest[1..=end];
468                let c = match entity {
469                    "amp" => Some('&'),
470                    "lt" => Some('<'),
471                    "gt" => Some('>'),
472                    "quot" => Some('"'),
473                    "apos" => Some('\''),
474                    "nbsp" => Some(' '),
475                    _ => entity
476                        .strip_prefix("#x")
477                        .or_else(|| entity.strip_prefix("#X"))
478                        .and_then(|hex| u32::from_str_radix(hex, 16).ok())
479                        .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok()))
480                        .and_then(char::from_u32),
481                }?;
482                Some((c, end + 2))
483            });
484        match decoded {
485            Some((c, len)) => {
486                out.push(c);
487                rest = &rest[len..];
488            }
489            None => {
490                out.push('&');
491                rest = &rest[1..];
492            }
493        }
494    }
495    out.push_str(rest);
496    out
497}
498
499pub(crate) fn truncate(text: &str, width: usize) -> String {
500    if text.width() <= width {
501        return text.into();
502    }
503    let mut out = String::new();
504    let mut used = 0;
505    for c in text.chars() {
506        let w = c.width().unwrap_or(0);
507        if used + w + 1 > width {
508            break;
509        }
510        used += w;
511        out.push(c);
512    }
513    out.push('…');
514    out
515}
516
517/// Greedy word wrap by display width; words longer than a line are split.
518pub(crate) fn wrap(text: &str, width: usize) -> Vec<String> {
519    let width = width.max(10);
520    let mut lines = Vec::new();
521    let prefix_len = text.len() - text.trim_start_matches("> ").len();
522    let (prefix, body) = text.split_at(prefix_len);
523    let room = width.saturating_sub(prefix.width()).max(10);
524    let mut line = String::new();
525    for word in body.split_whitespace() {
526        let mut word = word.to_string();
527        loop {
528            let need = if line.is_empty() {
529                word.width()
530            } else {
531                line.width() + 1 + word.width()
532            };
533            if need <= room {
534                if !line.is_empty() {
535                    line.push(' ');
536                }
537                line.push_str(&word);
538                break;
539            }
540            if !line.is_empty() {
541                lines.push(std::mem::take(&mut line));
542                continue;
543            }
544            let mut head = String::new();
545            let mut tail = word.chars();
546            while let Some(c) = tail.clone().next() {
547                if head.width() + c.width().unwrap_or(0) > room {
548                    break;
549                }
550                head.push(c);
551                tail.next();
552            }
553            lines.push(head);
554            word = tail.collect();
555        }
556    }
557    if !line.is_empty() || lines.is_empty() {
558        lines.push(line);
559    }
560    lines.into_iter().map(|l| format!("{prefix}{l}")).collect()
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use serde_json::json;
567
568    fn view() -> View {
569        View {
570            now: "2026-10-13T15:00:00+00:00[UTC]".parse().unwrap(),
571            width: 60,
572        }
573    }
574
575    fn page(items: Vec<Value>, viewer: Option<&str>) -> Page {
576        Page {
577            items,
578            next_cursor: None,
579            next_offset: None,
580            truncated: false,
581            source: None,
582            viewer: viewer.map(String::from),
583        }
584    }
585
586    #[test]
587    fn html_becomes_readable_text() {
588        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>"#;
589        assert_eq!(
590            html_to_text(html),
591            "> Ann\n> old point\n\nHi @Bob, 5 < 6 & 😀\n• one\n• two\n🙂[image]\na\n\nb\nc"
592        );
593        assert_eq!(html_to_text("<p></p><p>  </p>"), "");
594        assert_eq!(decode_entities("a &bogus; &#65; &"), "a &bogus; A &");
595    }
596
597    #[test]
598    fn plain_text_entities_are_literal_in_conversations_and_previews() {
599        let content = "Use &amp; and &#65; literally";
600        let message = json!({"from": {"user": {"displayName": "Ann"}},
601            "body": {"contentType": "text", "content": content}});
602        let entries = conversation(std::slice::from_ref(&message), &view(), None);
603        assert!(entries.contains(&Entry::Text(content.into())));
604        let chat = json!({"id": "chat", "chatType": "oneOnOne", "lastMessagePreview": message});
605        assert_eq!(
606            ChatRow::new(&chat, &view(), None).preview.as_deref(),
607            Some(content)
608        );
609        assert_eq!(
610            message_text(&json!({"body": {"contentType": "html", "content": content}})),
611            "Use & and A literally"
612        );
613    }
614
615    #[test]
616    fn times_are_relative_to_the_local_day() {
617        let view = view();
618        let at = |s: &str| view.local(&json!(s)).unwrap();
619        assert_eq!(view.short(&at("2026-10-13T09:05:00Z")), "09:05");
620        assert_eq!(view.short(&at("2026-10-12T09:05:00.123Z")), "Mon 09:05");
621        assert_eq!(view.short(&at("2026-09-02T09:05:00Z")), "2 Sep");
622        assert_eq!(view.short(&at("2025-09-02T09:05:00Z")), "2 Sep 2025");
623        assert_eq!(view.day_header(&at("2026-10-12T23:00:00Z")), "Yesterday");
624        assert_eq!(
625            view.day_header(&at("2026-10-01T23:00:00Z")),
626            "Thursday 1 October"
627        );
628    }
629
630    #[test]
631    fn chat_titles_prefer_topic_then_other_members() {
632        let members = json!([
633            {"userId": "me", "displayName": "Sam"},
634            {"userId": "a", "displayName": "Ann Smith"},
635            {"userId": "b", "displayName": null}
636        ]);
637        let chat = json!({"topic": " Release ", "members": members});
638        assert_eq!(chat_title(&chat, Some("me")), "Release");
639        let chat = json!({"topic": null, "chatType": "oneOnOne", "members": members});
640        assert_eq!(chat_title(&chat, Some("me")), "Ann Smith");
641        assert_eq!(chat_title(&chat, None), "Sam, Ann Smith");
642        let chat = json!({"chatType": "oneOnOne", "members": []});
643        assert_eq!(chat_title(&chat, Some("me")), "(unknown person)");
644        let notes =
645            json!({"chatType": "oneOnOne", "members": [{"userId": "me", "displayName": "Sam"}]});
646        assert_eq!(chat_title(&notes, Some("me")), "You");
647        let members: Vec<Value> = ["A", "B", "C", "D", "E"]
648            .map(|n| json!({"userId": n, "displayName": n}))
649            .into();
650        let many = json!({ "members": members });
651        assert_eq!(chat_title(&many, None), "A, B, C +2");
652    }
653
654    #[test]
655    fn chat_rows_show_unread_title_time_preview_and_id() {
656        let chat = |from: &str, read: &str| {
657            json!({
658                "id": "19:a@unq.gbl.spaces", "topic": null, "chatType": "oneOnOne",
659                "members": [{"userId": "me", "displayName": "Sam"}, {"userId": "a", "displayName": "Ann"}],
660                "viewpoint": {"lastMessageReadDateTime": read},
661                "lastMessagePreview": {"createdDateTime": "2026-10-13T14:03:00Z", "messageType": "message",
662                    "from": {"user": {"id": from, "displayName": "Ann"}},
663                    "body": {"contentType": "html", "content": "<p>see you</p>"}}
664            })
665        };
666        let text = chats(
667            &page(vec![chat("a", "2026-10-13T10:00:00Z")], Some("me")),
668            &view(),
669            "c",
670        );
671        let lines: Vec<&str> = text.lines().collect();
672        assert!(lines[0].starts_with("● Ann ") && lines[0].ends_with(" 14:03"));
673        assert_eq!(lines[0].width(), 60);
674        assert_eq!(lines[1..], ["  see you", "  19:a@unq.gbl.spaces"]);
675        let text = chats(
676            &page(vec![chat("me", "2026-10-13T10:00:00Z")], Some("me")),
677            &view(),
678            "c",
679        );
680        assert!(text.starts_with("  Ann ") && text.contains("\n  You: see you\n"));
681        let mut more = page(vec![], None);
682        assert_eq!(chats(&more, &view(), "c"), "No chats.");
683        more.items
684            .push(json!({"id": "19:g@thread.v2", "chatType": "group", "topic": "Ops"}));
685        more.truncated = true;
686        let text = chats(&more, &view(), "abc");
687        assert!(text.starts_with("  Ops · group"));
688        assert!(text.ends_with("Continue with --cursor 'abc'"));
689    }
690
691    #[test]
692    fn messages_read_oldest_first_grouped_by_day_and_sender() {
693        let msg = |id: &str, at: &str, from: &str, body: &str| {
694            json!({"id": id, "createdDateTime": at,
695                "from": {"user": {"id": from, "displayName": from.to_uppercase()}},
696                "body": {"contentType": "text", "content": body}})
697        };
698        let mut deleted = msg("5", "2026-10-13T14:30:00Z", "ann", "x");
699        deleted["deleted"] = json!(true);
700        let mut event = msg("6", "2026-10-13T14:40:00Z", "ann", "");
701        event["messageType"] = json!("systemEventMessage");
702        let mut page = page(
703            vec![
704                event,
705                deleted,
706                msg("4", "2026-10-13T14:02:00Z", "ann", "and more"),
707                msg("3", "2026-10-13T14:00:00Z", "ann", "a &amp; b"),
708                msg("2", "2026-10-12T09:00:00Z", "me", "first"),
709            ],
710            Some("me"),
711        );
712        page.truncated = true;
713        assert_eq!(
714            messages(&page, &view(), "older"),
715            "Older messages available. Continue with --cursor 'older'\n\n\
716             ── Yesterday ──\nYou  09:00\n  first\n\n\
717             ── Today ──\nANN  14:00\n  a &amp; b\n  and more\nANN  14:30\n  (message deleted)"
718        );
719    }
720
721    #[test]
722    fn wrapping_respects_width_and_quote_prefix() {
723        assert_eq!(wrap("aaa bbb ccc ddd", 11), ["aaa bbb ccc", "ddd"]);
724        assert_eq!(wrap("> aaaa bbbb cccc", 12), ["> aaaa bbbb", "> cccc"]);
725        assert_eq!(wrap("abcdefghijklmn", 10), ["abcdefghij", "klmn"]);
726        assert_eq!(truncate("abcdef", 4), "abc…");
727    }
728}