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", _) => 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
408fn 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
517pub(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>, 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>"#;
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; A &"), "a &bogus; A &");
595 }
596
597 #[test]
598 fn plain_text_entities_are_literal_in_conversations_and_previews() {
599 let content = "Use & and A 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(¬es, 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 & 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 & 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}