Skip to main content

gpui_kit/content/
message_list.rs

1//! A conversation, over the one virtualized list this library has.
2//!
3//! # The list does not own the conversation
4//!
5//! Messages, their order, their authors, and their delivery are all the
6//! caller's. This component renders what it is handed and reports what was
7//! asked for: a failed message stays exactly where it is, with its text
8//! intact, and a retry is a request rather than a resend.
9//!
10//! # Times are strings the host already wrote
11//!
12//! The time on a message is an [`EntryTime`], the same type
13//! [`Timeline`](crate::display::timeline::Timeline) takes, for the same reason: turning
14//! an instant into words is calendar, time-zone and locale work, and whoever
15//! owns the clock owns the wording. A message whose time nobody knows says so
16//! rather than being floated to an end that would claim when it happened.
17//!
18//! # One message, one slot
19//!
20//! Virtualization here is [`List`], which is `uniform_list`, so every row is
21//! the same height. That is the whole reason a conversation of ten thousand
22//! messages costs the same as one of ten, and the price is that a message
23//! occupies a slot rather than growing to fit: [`MessageList::body_lines`]
24//! decides how tall the slot is, and a body longer than that says how many
25//! lines it left out instead of being quietly cut off.
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::rc::Rc;
30
31use gpui::{
32    AnyElement, App, Global, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
33    div, prelude::FluentBuilder, px,
34};
35use gpui_kit_semantics::{NodeSpec, Role, Semantic};
36use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Surface, Theme, TypeScale};
37use web_time::Instant;
38
39use crate::content::markdown::{Markdown, MarkdownEvent};
40use crate::controls::button::Button;
41use crate::data::list::{List, ListItem, scroll_to_row};
42use crate::display::avatar::Avatar;
43use crate::display::badge::Tone;
44use crate::display::status::StatusDot;
45use crate::display::timeline::EntryTime;
46use crate::foundation::{Ident, Sizable, StyledExt};
47use crate::motion::keyed;
48use crate::strings::{ActiveStrings, StringKey, Strings};
49
50/// What a host said about a message's journey.
51///
52/// Five states, five renderings. Collapsing sent, delivered, and read into one
53/// tick tells the reader less than the host knows, and folding a failure into
54/// any of them tells them something untrue.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub enum DeliveryState {
57    /// Handed over, not yet acknowledged.
58    Sending,
59    /// The host accepted it.
60    #[default]
61    Sent,
62    /// It reached the other side.
63    Delivered,
64    /// The other side opened it.
65    Read,
66    /// It did not arrive, and here is why, in the host's own words.
67    Failed { reason: SharedString },
68}
69
70impl DeliveryState {
71    /// The name a node publishes, so a test asserts the state rather than the
72    /// colour it was drawn in.
73    pub fn name(&self) -> &'static str {
74        match self {
75            Self::Sending => "sending",
76            Self::Sent => "sent",
77            Self::Delivered => "delivered",
78            Self::Read => "read",
79            Self::Failed { .. } => "failed",
80        }
81    }
82
83    fn label(&self, strings: &Strings) -> SharedString {
84        match self {
85            Self::Sending => strings.text(StringKey::MessageSending),
86            Self::Sent => strings.text(StringKey::MessageSent),
87            Self::Delivered => strings.text(StringKey::MessageDelivered),
88            Self::Read => strings.text(StringKey::MessageRead),
89            Self::Failed { reason } => reason.clone(),
90        }
91    }
92
93    fn tone(&self) -> Tone {
94        match self {
95            Self::Sending => Tone::Neutral,
96            Self::Sent => Tone::Info,
97            Self::Delivered => Tone::Accent,
98            Self::Read => Tone::Success,
99            Self::Failed { .. } => Tone::Danger,
100        }
101    }
102
103    pub fn failed(&self) -> bool {
104        matches!(self, Self::Failed { .. })
105    }
106}
107
108/// What a message says.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum MessageBody {
111    /// Shown exactly as it is, with no markup read into it.
112    Text(SharedString),
113    /// Rendered through [`Markdown`], with its rules about HTML, links, and
114    /// images.
115    Markdown(SharedString),
116}
117
118impl MessageBody {
119    /// The text as the caller wrote it, markup and all.
120    pub fn source(&self) -> &SharedString {
121        match self {
122            Self::Text(text) | Self::Markdown(text) => text,
123        }
124    }
125}
126
127impl From<&'static str> for MessageBody {
128    fn from(value: &'static str) -> Self {
129        Self::Text(SharedString::new_static(value))
130    }
131}
132
133impl From<String> for MessageBody {
134    fn from(value: String) -> Self {
135        Self::Text(SharedString::from(value))
136    }
137}
138
139/// Something carried alongside a message. The list names it and fetches
140/// nothing.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Attachment {
143    id: SharedString,
144    name: SharedString,
145    detail: Option<SharedString>,
146}
147
148impl Attachment {
149    pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
150        Self {
151            id: id.into(),
152            name: name.into(),
153            detail: None,
154        }
155    }
156
157    /// A size, a kind, or whatever else the host already knows about it.
158    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
159        self.detail = Some(detail.into());
160        self
161    }
162}
163
164/// A reaction and how many people left it.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Reaction {
167    key: SharedString,
168    label: SharedString,
169    count: usize,
170}
171
172impl Reaction {
173    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>, count: usize) -> Self {
174        Self {
175            key: key.into(),
176            label: label.into(),
177            count,
178        }
179    }
180}
181
182/// One turn in a conversation.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Message {
185    id: SharedString,
186    author: Option<SharedString>,
187    time: EntryTime,
188    body: MessageBody,
189    delivery: DeliveryState,
190    streaming: bool,
191    attachments: Vec<Attachment>,
192    reactions: Vec<Reaction>,
193}
194
195impl Message {
196    /// `id` is the message's own identity, which every node it publishes is
197    /// derived from, so an assertion survives the conversation growing above
198    /// it.
199    pub fn new(id: impl Into<SharedString>, body: impl Into<MessageBody>) -> Self {
200        Self {
201            id: id.into(),
202            author: None,
203            time: EntryTime::Unknown,
204            body: body.into(),
205            delivery: DeliveryState::default(),
206            streaming: false,
207            attachments: Vec::new(),
208            reactions: Vec::new(),
209        }
210    }
211
212    pub fn markdown(id: impl Into<SharedString>, source: impl Into<SharedString>) -> Self {
213        Self::new(id, MessageBody::Markdown(source.into()))
214    }
215
216    pub fn author(mut self, author: impl Into<SharedString>) -> Self {
217        self.author = Some(author.into());
218        self
219    }
220
221    /// The time, in the words the host chose.
222    pub fn time(mut self, time: impl Into<EntryTime>) -> Self {
223        self.time = time.into();
224        self
225    }
226
227    pub fn delivery(mut self, delivery: DeliveryState) -> Self {
228        self.delivery = delivery;
229        self
230    }
231
232    pub fn failed(self, reason: impl Into<SharedString>) -> Self {
233        self.delivery(DeliveryState::Failed {
234            reason: reason.into(),
235        })
236    }
237
238    /// Marks the message as still arriving. The body keeps whatever has
239    /// arrived so far.
240    pub fn streaming(mut self, streaming: bool) -> Self {
241        self.streaming = streaming;
242        self
243    }
244
245    pub fn attachment(mut self, attachment: Attachment) -> Self {
246        self.attachments.push(attachment);
247        self
248    }
249
250    pub fn reaction(mut self, reaction: Reaction) -> Self {
251        self.reactions.push(reaction);
252        self
253    }
254
255    pub fn id(&self) -> &SharedString {
256        &self.id
257    }
258}
259
260type RetryHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
261type MarkdownHandler = Rc<dyn Fn(SharedString, &MarkdownEvent, &mut Window, &mut App)>;
262
263/// A conversation.
264#[derive(IntoElement)]
265pub struct MessageList {
266    ident: Ident,
267    messages: Vec<Message>,
268    visible_rows: Option<usize>,
269    body_lines: usize,
270    group_consecutive: bool,
271    on_retry: Option<RetryHandler>,
272    on_markdown: Option<MarkdownHandler>,
273}
274
275impl std::fmt::Debug for MessageList {
276    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        formatter
278            .debug_struct("MessageList")
279            .field("ident", &self.ident)
280            .field("messages", &self.messages.len())
281            .field("visible_rows", &self.visible_rows)
282            .field("group_consecutive", &self.group_consecutive)
283            .finish()
284    }
285}
286
287impl MessageList {
288    pub fn new(ident: impl Into<Ident>, messages: impl IntoIterator<Item = Message>) -> Self {
289        Self {
290            ident: ident.into(),
291            messages: messages.into_iter().collect(),
292            visible_rows: None,
293            body_lines: 3,
294            group_consecutive: false,
295            on_retry: None,
296            on_markdown: None,
297        }
298    }
299
300    /// Bounds the viewport to `rows` messages, which is what lets the list
301    /// skip the ones it does not show.
302    pub fn visible_rows(mut self, rows: usize) -> Self {
303        self.visible_rows = Some(rows);
304        self
305    }
306
307    /// How many lines of body each message's slot holds.
308    pub fn body_lines(mut self, lines: usize) -> Self {
309        self.body_lines = lines.max(1);
310        self
311    }
312
313    /// Whether consecutive messages from one author are drawn as one turn.
314    ///
315    /// Declared, not inferred, for the same reason
316    /// [`Toolbar::overflow_after`](crate::layout::Toolbar::overflow_after) is:
317    /// whether two messages a minute apart are one turn or two is a product
318    /// judgement about the conversation, and this component knows nothing
319    /// about the conversation.
320    pub fn group_consecutive(mut self, group: bool) -> Self {
321        self.group_consecutive = group;
322        self
323    }
324
325    /// Reports that a failed message should be tried again. Nothing is resent
326    /// and nothing is removed.
327    pub fn on_retry(
328        mut self,
329        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
330    ) -> Self {
331        self.on_retry = Some(Rc::new(handler));
332        self
333    }
334
335    /// Forwards what a Markdown body reported, stamped with the message it
336    /// came from.
337    pub fn on_markdown(
338        mut self,
339        handler: impl Fn(SharedString, &MarkdownEvent, &mut Window, &mut App) + 'static,
340    ) -> Self {
341        self.on_markdown = Some(Rc::new(handler));
342        self
343    }
344
345    /// The height of one slot: a header line, `body_lines` of body, and a
346    /// footer line, inside the row's own padding.
347    fn row_height(&self, theme: &Theme) -> f32 {
348        theme.space(Space::Sm) * 2.0
349            + theme.typography.caption.line_height
350            + theme.space(Space::Xs) * 2.0
351            + theme.typography.body.line_height * self.body_lines as f32
352            + theme.control.get(ControlSize::Sm).height
353    }
354}
355
356impl RenderOnce for MessageList {
357    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
358        let theme = cx.theme().clone();
359        let ident = self.ident.clone();
360        let count = self.messages.len();
361        let row_height = self.row_height(&theme);
362        let body_lines = self.body_lines;
363        let group = self.group_consecutive;
364        let on_retry = self.on_retry.clone();
365        let on_markdown = self.on_markdown.clone();
366
367        let follow = self.follow(count, cx);
368        if follow.stick {
369            scroll_to_row(&ident, count.saturating_sub(1), cx);
370        }
371        let pending = self.pending(&follow, &theme, cx);
372
373        let messages = Rc::new(self.messages);
374        let seen = follow.cell.clone();
375        let list_ident = ident.clone();
376        let rows = List::new(ident.clone(), count, move |index, window, cx| {
377            if let Some(highest) = seen.borrow_mut().current.as_mut() {
378                *highest = (*highest).max(index);
379            } else {
380                seen.borrow_mut().current = Some(index);
381            }
382            let Some(message) = messages.get(index) else {
383                return ListItem::new("unknown", div());
384            };
385            let continues = group
386                && index > 0
387                && messages
388                    .get(index - 1)
389                    .is_some_and(|previous| previous.author == message.author);
390            ListItem::new(
391                message.id.clone(),
392                row(
393                    &list_ident,
394                    message,
395                    continues,
396                    body_lines,
397                    on_retry.as_ref(),
398                    on_markdown.as_ref(),
399                    window,
400                    cx,
401                ),
402            )
403            .text(shown_author(message.author.as_ref()))
404        })
405        .row_height(row_height)
406        .when_some(self.visible_rows, List::visible_rows);
407
408        div()
409            .column()
410            .w_full()
411            .relative()
412            .child(rows)
413            .children(pending)
414    }
415}
416
417/// What the list knows about where the reader is and what has arrived.
418#[derive(Debug, Default)]
419struct Following {
420    /// Whether this identity has rendered before. Nothing is "new" on the
421    /// frame a conversation first appears.
422    started: bool,
423    /// How many messages there were last time.
424    count: usize,
425    /// The highest index rendered in the frame being built.
426    current: Option<usize>,
427    /// The highest index rendered in the previous frame, which is where the
428    /// reader was.
429    previous: Option<usize>,
430    /// The highest index the reader has ever had on screen.
431    ever: Option<usize>,
432    /// How many messages arrived while the reader was somewhere else.
433    arrived: usize,
434}
435
436/// What one frame decided about following.
437struct Follow {
438    cell: Rc<RefCell<Following>>,
439    /// Whether this frame should bring the newest message into view.
440    stick: bool,
441    /// How many messages sit below the viewport that the reader has not seen.
442    below: usize,
443    /// How many of those arrived after the conversation was first drawn.
444    arrived: usize,
445}
446
447impl std::fmt::Debug for Follow {
448    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        formatter
450            .debug_struct("Follow")
451            .field("stick", &self.stick)
452            .field("below", &self.below)
453            .field("arrived", &self.arrived)
454            .finish()
455    }
456}
457
458impl MessageList {
459    /// Decides, once per frame, whether the newest message is followed.
460    ///
461    /// Following is conditional on purpose: dragging the reader back down
462    /// while they are reading something further up takes the conversation away
463    /// from them. When it does not follow it says how many messages it did not
464    /// follow, which is the same rule
465    /// [`ScrollArea`](crate::layout::ScrollArea) keeps — content continuing
466    /// past the view is published rather than left to be noticed.
467    fn follow(&self, count: usize, cx: &mut App) -> Follow {
468        let cell = keyed::slot::<Following>(&self.ident.child("following").semantic_id(), cx);
469        let mut following = cell.borrow_mut();
470        following.previous = following.current.take();
471        if let Some(previous) = following.previous {
472            following.ever = Some(following.ever.map_or(previous, |ever| ever.max(previous)));
473        }
474
475        // Before the first frame has drawn a row, where the reader is has to
476        // come from the viewport the caller asked for: a list opens at its
477        // top.
478        let reach = |highest: Option<usize>| match highest {
479            Some(highest) => highest + 1,
480            None => self.visible_rows.unwrap_or(count),
481        };
482        let at_bottom = reach(following.previous) >= following.count.max(1);
483
484        let mut stick = false;
485        if !following.started {
486            following.started = true;
487        } else if count > following.count {
488            if at_bottom {
489                stick = true;
490            } else {
491                following.arrived += count - following.count;
492            }
493        }
494        following.count = count;
495
496        let mut below = count.saturating_sub(reach(following.ever));
497        if below == 0 {
498            following.arrived = 0;
499        }
500        let arrived = following.arrived.min(below);
501        // A frame that is following the newest message is not behind it, even
502        // though the row it is scrolling to has not been drawn yet.
503        if stick {
504            below = 0;
505        }
506        drop(following);
507
508        Follow {
509            cell,
510            stick,
511            below,
512            arrived,
513        }
514    }
515
516    /// The affordance naming what is below the view, when anything is.
517    fn pending(&self, follow: &Follow, theme: &Theme, cx: &mut App) -> Option<AnyElement> {
518        if follow.below == 0 {
519            return None;
520        }
521        // Messages that arrived while the reader was away are a different fact
522        // from messages that have simply always been further down, so they are
523        // worded differently rather than counted together.
524        let counted = if follow.arrived > 0 {
525            follow.arrived
526        } else {
527            follow.below
528        };
529        let strings = cx.strings();
530        let label = match (follow.arrived, counted) {
531            (0, 1) => strings.text(StringKey::MessageMoreOne),
532            (0, more) => strings.format(StringKey::MessageMoreMany, &[&more.to_string()]),
533            (_, 1) => strings.text(StringKey::MessageNewOne),
534            (_, new) => strings.format(StringKey::MessageNewMany, &[&new.to_string()]),
535        };
536        let ident = self.ident.child("pending");
537        let list = self.ident.clone();
538        let last = self.messages.len().saturating_sub(1);
539        let cell = follow.cell.clone();
540
541        Some(
542            div()
543                .absolute()
544                .bottom(px(theme.space(Space::Sm)))
545                .right(px(theme.space(Space::Sm)))
546                .child(
547                    Button::new(ident.child("follow"))
548                        .label(label.clone())
549                        .secondary()
550                        .control_size(ControlSize::Sm)
551                        .on_click(move |window, cx| {
552                            cell.borrow_mut().arrived = 0;
553                            scroll_to_row(&list, last, cx);
554                            window.refresh();
555                        }),
556                )
557                .semantic_in(
558                    cx,
559                    NodeSpec::new(ident.semantic_id(), Role::Status)
560                        .parent(self.ident.semantic_id())
561                        .text(label)
562                        .value(counted.to_string()),
563                )
564                .into_any_element(),
565        )
566    }
567}
568
569/// When the indicator for a streaming message started, keyed by message.
570#[derive(Debug, Default)]
571struct Streams(RefCell<HashMap<SharedString, Instant>>);
572
573impl Global for Streams {}
574
575/// When the streaming indicator on `message` in `list` began.
576///
577/// Keyed by the message's identity and nothing else, so text arriving into a
578/// message that is already streaming does not restart the indicator: a
579/// conversation whose "still writing" mark flickered on every token would be
580/// reporting the token, not the stream. Answers `None` once the message stops
581/// streaming.
582pub fn streaming_since(list: &Ident, message: &str, cx: &App) -> Option<Instant> {
583    cx.try_global::<Streams>()
584        .and_then(|streams| streams.0.borrow().get(&stream_key(list, message)).copied())
585}
586
587fn stream_key(list: &Ident, message: &str) -> SharedString {
588    list.child(message).child("streaming").semantic_id()
589}
590
591/// The instant this message's stream began, started on first sight.
592fn stream_began(list: &Ident, message: &str, streaming: bool, cx: &mut App) -> Option<Instant> {
593    if !cx.has_global::<Streams>() {
594        cx.set_global(Streams::default());
595    }
596    let key = stream_key(list, message);
597    let streams = cx.global::<Streams>();
598    let mut clocks = streams.0.borrow_mut();
599    if !streaming {
600        clocks.remove(&key);
601        return None;
602    }
603    let now = cx.background_executor().now();
604    Some(*clocks.entry(key).or_insert(now))
605}
606
607fn shown_author(author: Option<&SharedString>) -> SharedString {
608    // An author nobody named is named `unknown`, because a blank byline reads
609    // as a message with no author rather than one whose author was not
610    // recorded.
611    match author {
612        Some(author) if !author.trim().is_empty() => author.clone(),
613        _ => SharedString::new_static("unknown"),
614    }
615}
616
617#[allow(clippy::too_many_arguments)]
618fn row(
619    list: &Ident,
620    message: &Message,
621    continues: bool,
622    body_lines: usize,
623    on_retry: Option<&RetryHandler>,
624    on_markdown: Option<&MarkdownHandler>,
625    window: &mut Window,
626    cx: &mut App,
627) -> AnyElement {
628    let theme = cx.theme().clone();
629    let ident = list.child(message.id.as_ref());
630    let author = shown_author(message.author.as_ref());
631    let began = stream_began(list, message.id.as_ref(), message.streaming, cx);
632
633    let header = div()
634        .row()
635        .w_full()
636        .gap_token(&theme, Space::Sm)
637        .type_scale(&theme, TypeScale::Caption)
638        .h(px(theme.typography.caption.line_height))
639        // A continued turn keeps the space its byline would occupy, because
640        // the slot is one height and a hole where a name was is not a saving.
641        .when(!continues, |element| {
642            element
643                .child(Avatar::new(author.clone()).size(theme.typography.caption.line_height))
644                .child(
645                    div()
646                        .text_color(theme.colors.text)
647                        .child(author.clone())
648                        .semantic_in(
649                            cx,
650                            NodeSpec::new(ident.child("author").semantic_id(), Role::Text)
651                                .parent(ident.semantic_id())
652                                .text(author.clone()),
653                        ),
654                )
655        })
656        .child(
657            div()
658                .text_color(if message.time == EntryTime::Unknown {
659                    theme.colors.warning
660                } else {
661                    theme.colors.text_faint
662                })
663                .child(message.time.shown(cx))
664                .semantic_in(
665                    cx,
666                    NodeSpec::new(ident.child("time").semantic_id(), Role::Text)
667                        .parent(ident.semantic_id())
668                        .text(message.time.shown(cx))
669                        .value(match &message.time {
670                            EntryTime::At(time) => time.clone(),
671                            EntryTime::Unknown => SharedString::new_static("time unknown"),
672                        }),
673                ),
674        )
675        .children(began.map(|_| streaming_mark(&ident, &theme, cx)));
676
677    let body = body_element(&ident, message, body_lines, on_markdown, &theme, cx);
678
679    let mut footer = div()
680        .row()
681        .w_full()
682        .gap_token(&theme, Space::Sm)
683        .h(px(theme.control.get(ControlSize::Sm).height))
684        .child(delivery_mark(&ident, &message.delivery, &theme, cx));
685
686    for attachment in &message.attachments {
687        footer = footer.child(attachment_chip(&ident, attachment, &theme, cx));
688    }
689    for reaction in &message.reactions {
690        footer = footer.child(reaction_chip(&ident, reaction, &theme, cx));
691    }
692
693    // A failed message keeps everything it had and gains one thing: a way to
694    // ask for it to be tried again. It is never removed, and nothing here
695    // retries it.
696    if let (DeliveryState::Failed { .. }, Some(handler)) = (&message.delivery, on_retry) {
697        let id = message.id.clone();
698        let handler = Rc::clone(handler);
699        footer = footer.child(
700            Button::new(ident.child("retry"))
701                .label(cx.strings().text(StringKey::TryAgain))
702                .secondary()
703                .control_size(ControlSize::Xs)
704                .on_click(move |window, cx| handler(id.clone(), window, cx)),
705        );
706    }
707
708    let _ = window;
709    div()
710        .column()
711        .w_full()
712        .gap_token(&theme, Space::Xs)
713        .py_token(&theme, Space::Sm)
714        .child(header)
715        .child(body)
716        .child(footer)
717        .into_any_element()
718}
719
720fn body_element(
721    ident: &Ident,
722    message: &Message,
723    body_lines: usize,
724    on_markdown: Option<&MarkdownHandler>,
725    theme: &Theme,
726    cx: &mut App,
727) -> AnyElement {
728    let height = theme.typography.body.line_height * body_lines as f32;
729    match &message.body {
730        MessageBody::Markdown(source) => {
731            let mut markdown =
732                Markdown::new(ident.child("body"), source.clone()).max_lines(body_lines);
733            if let Some(handler) = on_markdown {
734                let handler = Rc::clone(handler);
735                let id = message.id.clone();
736                markdown = markdown
737                    .on_event(move |event, window, cx| handler(id.clone(), event, window, cx));
738            }
739            div()
740                .w_full()
741                .h(px(height))
742                .overflow_hidden()
743                .child(markdown)
744                .into_any_element()
745        }
746        MessageBody::Text(text) => {
747            let lines: Vec<&str> = text.lines().collect();
748            let hidden = lines.len().saturating_sub(body_lines);
749            let shown = lines
750                .iter()
751                .take(body_lines)
752                .copied()
753                .collect::<Vec<_>>()
754                .join("\n");
755            div()
756                .column()
757                .w_full()
758                .h(px(height))
759                .overflow_hidden()
760                .type_scale(theme, TypeScale::Body)
761                .text_color(theme.colors.text)
762                .child(SharedString::from(shown))
763                .children((hidden > 0).then(|| {
764                    let label = if hidden == 1 {
765                        cx.strings().text(StringKey::MessageShowMoreOne)
766                    } else {
767                        cx.strings()
768                            .format(StringKey::MessageShowMoreMany, &[&hidden.to_string()])
769                    };
770                    div()
771                        .type_scale(theme, TypeScale::Caption)
772                        .text_color(theme.colors.text_faint)
773                        .child(label.clone())
774                        .semantic_in(
775                            cx,
776                            NodeSpec::new(ident.child("truncated").semantic_id(), Role::Status)
777                                .parent(ident.semantic_id())
778                                .text(label)
779                                .value(hidden.to_string()),
780                        )
781                }))
782                .into_any_element()
783        }
784    }
785}
786
787fn streaming_mark(ident: &Ident, theme: &Theme, cx: &mut App) -> AnyElement {
788    let label = cx.strings().text(StringKey::MessageStreaming);
789    div()
790        .row()
791        .gap(px(4.0))
792        .text_color(theme.colors.accent)
793        // The mark already published `busy`; a still dot meant a reader
794        // watching the screen had to take the word for it.
795        .child(StatusDot::new(Tone::Accent).busy(ident.child("streaming.mark")))
796        .child(label.clone())
797        .semantic_in(
798            cx,
799            NodeSpec::new(ident.child("streaming").semantic_id(), Role::Status)
800                .parent(ident.semantic_id())
801                .text(label)
802                .value("streaming")
803                .busy(true),
804        )
805        .into_any_element()
806}
807
808fn delivery_mark(ident: &Ident, state: &DeliveryState, theme: &Theme, cx: &mut App) -> AnyElement {
809    let label = state.label(cx.strings());
810    let tone = state.tone();
811    div()
812        .row()
813        .gap(px(4.0))
814        .min_w_0()
815        .type_scale(theme, TypeScale::Caption)
816        .text_color(tone.color(theme))
817        .child(StatusDot::new(tone))
818        .child(div().min_w_0().child(label.clone()))
819        .semantic_in(
820            cx,
821            NodeSpec::new(ident.child("delivery").semantic_id(), Role::Status)
822                .parent(ident.semantic_id())
823                .text(label)
824                .value(state.name())
825                .invalid(state.failed())
826                .busy(matches!(state, DeliveryState::Sending)),
827        )
828        .into_any_element()
829}
830
831fn attachment_chip(
832    ident: &Ident,
833    attachment: &Attachment,
834    theme: &Theme,
835    cx: &mut App,
836) -> AnyElement {
837    let text = match &attachment.detail {
838        Some(detail) => SharedString::from(format!("{} — {detail}", attachment.name)),
839        None => attachment.name.clone(),
840    };
841    div()
842        .px_token(theme, Space::Xs)
843        .radius(theme, Radius::Small)
844        .surface(theme, Surface::Raised)
845        .type_scale(theme, TypeScale::Caption)
846        .text_color(theme.colors.text_muted)
847        .child(text.clone())
848        .semantic_in(
849            cx,
850            NodeSpec::new(
851                ident
852                    .child("attachment")
853                    .child(attachment.id.as_ref())
854                    .semantic_id(),
855                Role::Text,
856            )
857            .parent(ident.semantic_id())
858            .text(text),
859        )
860        .into_any_element()
861}
862
863fn reaction_chip(ident: &Ident, reaction: &Reaction, theme: &Theme, cx: &mut App) -> AnyElement {
864    let text = SharedString::from(format!("{} {}", reaction.label, reaction.count));
865    div()
866        .px_token(theme, Space::Xs)
867        .radius(theme, Radius::Pill)
868        .bg(theme.colors.raised)
869        .type_scale(theme, TypeScale::Caption)
870        .text_color(theme.colors.text_muted)
871        .child(text.clone())
872        .semantic_in(
873            cx,
874            NodeSpec::new(
875                ident
876                    .child("reaction")
877                    .child(reaction.key.as_ref())
878                    .semantic_id(),
879                Role::Status,
880            )
881            .parent(ident.semantic_id())
882            .text(text)
883            .value(reaction.count.to_string()),
884        )
885        .into_any_element()
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    #[test]
893    fn every_delivery_state_has_its_own_name() {
894        let states = [
895            DeliveryState::Sending,
896            DeliveryState::Sent,
897            DeliveryState::Delivered,
898            DeliveryState::Read,
899            DeliveryState::Failed {
900                reason: "The host refused it.".into(),
901            },
902        ];
903        let mut names: Vec<&str> = states.iter().map(DeliveryState::name).collect();
904        names.sort_unstable();
905        names.dedup();
906        assert_eq!(names.len(), states.len());
907    }
908
909    #[test]
910    fn a_failure_states_the_hosts_reason_rather_than_a_word_of_its_own() {
911        let state = DeliveryState::Failed {
912            reason: "The workspace is read only.".into(),
913        };
914        // A host's own reason is passed through untouched, so it needs no
915        // catalogue to read it back.
916        // A host's own reason is passed through untouched, so an empty
917        // catalogue is enough to read it back.
918        assert_eq!(
919            state.label(&Strings::new()).as_ref(),
920            "The workspace is read only."
921        );
922        assert!(state.failed());
923    }
924
925    #[test]
926    fn an_unnamed_author_is_named_unknown() {
927        assert_eq!(shown_author(None).as_ref(), "unknown");
928        assert_eq!(shown_author(Some(&"  ".into())).as_ref(), "unknown");
929        assert_eq!(shown_author(Some(&"Ada".into())).as_ref(), "Ada");
930    }
931}