1use jiff::{Timestamp, Zoned, tz::TimeZone};
3use serde_json::Value;
4use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
5
6use crate::graph::Page;
7
8pub 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 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
58pub 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#[derive(Debug, PartialEq)]
94pub enum Entry {
95 Day(String),
96 Sender {
97 name: String,
98 time: Option<String>,
99 own: bool,
100 },
101 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 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 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
335pub 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 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
413fn 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
522pub(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>, 5 < 6 & 😀</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; A &"), "a &bogus; A &");
604 }
605
606 #[test]
607 fn plain_text_entities_are_literal_in_conversations_and_previews() {
608 let content = "Use & and A 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(¬es, 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 & 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 & 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}