use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use gpui::{
AnyElement, App, Global, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div, prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Surface, Theme, TypeScale};
use web_time::Instant;
use crate::content::markdown::{Markdown, MarkdownEvent};
use crate::controls::button::Button;
use crate::data::list::{List, ListItem, scroll_to_row};
use crate::display::avatar::Avatar;
use crate::display::badge::Tone;
use crate::display::status::StatusDot;
use crate::display::timeline::EntryTime;
use crate::foundation::{Ident, Sizable, StyledExt};
use crate::motion::keyed;
use crate::strings::{ActiveStrings, StringKey, Strings};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum DeliveryState {
Sending,
#[default]
Sent,
Delivered,
Read,
Failed { reason: SharedString },
}
impl DeliveryState {
pub fn name(&self) -> &'static str {
match self {
Self::Sending => "sending",
Self::Sent => "sent",
Self::Delivered => "delivered",
Self::Read => "read",
Self::Failed { .. } => "failed",
}
}
fn label(&self, strings: &Strings) -> SharedString {
match self {
Self::Sending => strings.text(StringKey::MessageSending),
Self::Sent => strings.text(StringKey::MessageSent),
Self::Delivered => strings.text(StringKey::MessageDelivered),
Self::Read => strings.text(StringKey::MessageRead),
Self::Failed { reason } => reason.clone(),
}
}
fn tone(&self) -> Tone {
match self {
Self::Sending => Tone::Neutral,
Self::Sent => Tone::Info,
Self::Delivered => Tone::Accent,
Self::Read => Tone::Success,
Self::Failed { .. } => Tone::Danger,
}
}
pub fn failed(&self) -> bool {
matches!(self, Self::Failed { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageBody {
Text(SharedString),
Markdown(SharedString),
}
impl MessageBody {
pub fn source(&self) -> &SharedString {
match self {
Self::Text(text) | Self::Markdown(text) => text,
}
}
}
impl From<&'static str> for MessageBody {
fn from(value: &'static str) -> Self {
Self::Text(SharedString::new_static(value))
}
}
impl From<String> for MessageBody {
fn from(value: String) -> Self {
Self::Text(SharedString::from(value))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attachment {
id: SharedString,
name: SharedString,
detail: Option<SharedString>,
}
impl Attachment {
pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
detail: None,
}
}
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
self.detail = Some(detail.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reaction {
key: SharedString,
label: SharedString,
count: usize,
}
impl Reaction {
pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>, count: usize) -> Self {
Self {
key: key.into(),
label: label.into(),
count,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
id: SharedString,
author: Option<SharedString>,
time: EntryTime,
body: MessageBody,
delivery: DeliveryState,
streaming: bool,
attachments: Vec<Attachment>,
reactions: Vec<Reaction>,
}
impl Message {
pub fn new(id: impl Into<SharedString>, body: impl Into<MessageBody>) -> Self {
Self {
id: id.into(),
author: None,
time: EntryTime::Unknown,
body: body.into(),
delivery: DeliveryState::default(),
streaming: false,
attachments: Vec::new(),
reactions: Vec::new(),
}
}
pub fn markdown(id: impl Into<SharedString>, source: impl Into<SharedString>) -> Self {
Self::new(id, MessageBody::Markdown(source.into()))
}
pub fn author(mut self, author: impl Into<SharedString>) -> Self {
self.author = Some(author.into());
self
}
pub fn time(mut self, time: impl Into<EntryTime>) -> Self {
self.time = time.into();
self
}
pub fn delivery(mut self, delivery: DeliveryState) -> Self {
self.delivery = delivery;
self
}
pub fn failed(self, reason: impl Into<SharedString>) -> Self {
self.delivery(DeliveryState::Failed {
reason: reason.into(),
})
}
pub fn streaming(mut self, streaming: bool) -> Self {
self.streaming = streaming;
self
}
pub fn attachment(mut self, attachment: Attachment) -> Self {
self.attachments.push(attachment);
self
}
pub fn reaction(mut self, reaction: Reaction) -> Self {
self.reactions.push(reaction);
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
}
type RetryHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
type MarkdownHandler = Rc<dyn Fn(SharedString, &MarkdownEvent, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct MessageList {
ident: Ident,
messages: Vec<Message>,
visible_rows: Option<usize>,
body_lines: usize,
group_consecutive: bool,
on_retry: Option<RetryHandler>,
on_markdown: Option<MarkdownHandler>,
}
impl std::fmt::Debug for MessageList {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MessageList")
.field("ident", &self.ident)
.field("messages", &self.messages.len())
.field("visible_rows", &self.visible_rows)
.field("group_consecutive", &self.group_consecutive)
.finish()
}
}
impl MessageList {
pub fn new(ident: impl Into<Ident>, messages: impl IntoIterator<Item = Message>) -> Self {
Self {
ident: ident.into(),
messages: messages.into_iter().collect(),
visible_rows: None,
body_lines: 3,
group_consecutive: false,
on_retry: None,
on_markdown: None,
}
}
pub fn visible_rows(mut self, rows: usize) -> Self {
self.visible_rows = Some(rows);
self
}
pub fn body_lines(mut self, lines: usize) -> Self {
self.body_lines = lines.max(1);
self
}
pub fn group_consecutive(mut self, group: bool) -> Self {
self.group_consecutive = group;
self
}
pub fn on_retry(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
pub fn on_markdown(
mut self,
handler: impl Fn(SharedString, &MarkdownEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_markdown = Some(Rc::new(handler));
self
}
fn row_height(&self, theme: &Theme) -> f32 {
theme.space(Space::Sm) * 2.0
+ theme.typography.caption.line_height
+ theme.space(Space::Xs) * 2.0
+ theme.typography.body.line_height * self.body_lines as f32
+ theme.control.get(ControlSize::Sm).height
}
}
impl RenderOnce for MessageList {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let ident = self.ident.clone();
let count = self.messages.len();
let row_height = self.row_height(&theme);
let body_lines = self.body_lines;
let group = self.group_consecutive;
let on_retry = self.on_retry.clone();
let on_markdown = self.on_markdown.clone();
let follow = self.follow(count, cx);
if follow.stick {
scroll_to_row(&ident, count.saturating_sub(1), cx);
}
let pending = self.pending(&follow, &theme, cx);
let messages = Rc::new(self.messages);
let seen = follow.cell.clone();
let list_ident = ident.clone();
let rows = List::new(ident.clone(), count, move |index, window, cx| {
if let Some(highest) = seen.borrow_mut().current.as_mut() {
*highest = (*highest).max(index);
} else {
seen.borrow_mut().current = Some(index);
}
let Some(message) = messages.get(index) else {
return ListItem::new("unknown", div());
};
let continues = group
&& index > 0
&& messages
.get(index - 1)
.is_some_and(|previous| previous.author == message.author);
ListItem::new(
message.id.clone(),
row(
&list_ident,
message,
continues,
body_lines,
on_retry.as_ref(),
on_markdown.as_ref(),
window,
cx,
),
)
.text(shown_author(message.author.as_ref()))
})
.row_height(row_height)
.when_some(self.visible_rows, List::visible_rows);
div()
.column()
.w_full()
.relative()
.child(rows)
.children(pending)
}
}
#[derive(Debug, Default)]
struct Following {
started: bool,
count: usize,
current: Option<usize>,
previous: Option<usize>,
ever: Option<usize>,
arrived: usize,
}
struct Follow {
cell: Rc<RefCell<Following>>,
stick: bool,
below: usize,
arrived: usize,
}
impl std::fmt::Debug for Follow {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Follow")
.field("stick", &self.stick)
.field("below", &self.below)
.field("arrived", &self.arrived)
.finish()
}
}
impl MessageList {
fn follow(&self, count: usize, cx: &mut App) -> Follow {
let cell = keyed::slot::<Following>(&self.ident.child("following").semantic_id(), cx);
let mut following = cell.borrow_mut();
following.previous = following.current.take();
if let Some(previous) = following.previous {
following.ever = Some(following.ever.map_or(previous, |ever| ever.max(previous)));
}
let reach = |highest: Option<usize>| match highest {
Some(highest) => highest + 1,
None => self.visible_rows.unwrap_or(count),
};
let at_bottom = reach(following.previous) >= following.count.max(1);
let mut stick = false;
if !following.started {
following.started = true;
} else if count > following.count {
if at_bottom {
stick = true;
} else {
following.arrived += count - following.count;
}
}
following.count = count;
let mut below = count.saturating_sub(reach(following.ever));
if below == 0 {
following.arrived = 0;
}
let arrived = following.arrived.min(below);
if stick {
below = 0;
}
drop(following);
Follow {
cell,
stick,
below,
arrived,
}
}
fn pending(&self, follow: &Follow, theme: &Theme, cx: &mut App) -> Option<AnyElement> {
if follow.below == 0 {
return None;
}
let counted = if follow.arrived > 0 {
follow.arrived
} else {
follow.below
};
let strings = cx.strings();
let label = match (follow.arrived, counted) {
(0, 1) => strings.text(StringKey::MessageMoreOne),
(0, more) => strings.format(StringKey::MessageMoreMany, &[&more.to_string()]),
(_, 1) => strings.text(StringKey::MessageNewOne),
(_, new) => strings.format(StringKey::MessageNewMany, &[&new.to_string()]),
};
let ident = self.ident.child("pending");
let list = self.ident.clone();
let last = self.messages.len().saturating_sub(1);
let cell = follow.cell.clone();
Some(
div()
.absolute()
.bottom(px(theme.space(Space::Sm)))
.right(px(theme.space(Space::Sm)))
.child(
Button::new(ident.child("follow"))
.label(label.clone())
.secondary()
.control_size(ControlSize::Sm)
.on_click(move |window, cx| {
cell.borrow_mut().arrived = 0;
scroll_to_row(&list, last, cx);
window.refresh();
}),
)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Status)
.parent(self.ident.semantic_id())
.text(label)
.value(counted.to_string()),
)
.into_any_element(),
)
}
}
#[derive(Debug, Default)]
struct Streams(RefCell<HashMap<SharedString, Instant>>);
impl Global for Streams {}
pub fn streaming_since(list: &Ident, message: &str, cx: &App) -> Option<Instant> {
cx.try_global::<Streams>()
.and_then(|streams| streams.0.borrow().get(&stream_key(list, message)).copied())
}
fn stream_key(list: &Ident, message: &str) -> SharedString {
list.child(message).child("streaming").semantic_id()
}
fn stream_began(list: &Ident, message: &str, streaming: bool, cx: &mut App) -> Option<Instant> {
if !cx.has_global::<Streams>() {
cx.set_global(Streams::default());
}
let key = stream_key(list, message);
let streams = cx.global::<Streams>();
let mut clocks = streams.0.borrow_mut();
if !streaming {
clocks.remove(&key);
return None;
}
let now = cx.background_executor().now();
Some(*clocks.entry(key).or_insert(now))
}
fn shown_author(author: Option<&SharedString>) -> SharedString {
match author {
Some(author) if !author.trim().is_empty() => author.clone(),
_ => SharedString::new_static("unknown"),
}
}
#[allow(clippy::too_many_arguments)]
fn row(
list: &Ident,
message: &Message,
continues: bool,
body_lines: usize,
on_retry: Option<&RetryHandler>,
on_markdown: Option<&MarkdownHandler>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme().clone();
let ident = list.child(message.id.as_ref());
let author = shown_author(message.author.as_ref());
let began = stream_began(list, message.id.as_ref(), message.streaming, cx);
let header = div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.type_scale(&theme, TypeScale::Caption)
.h(px(theme.typography.caption.line_height))
.when(!continues, |element| {
element
.child(Avatar::new(author.clone()).size(theme.typography.caption.line_height))
.child(
div()
.text_color(theme.colors.text)
.child(author.clone())
.semantic_in(
cx,
NodeSpec::new(ident.child("author").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(author.clone()),
),
)
})
.child(
div()
.text_color(if message.time == EntryTime::Unknown {
theme.colors.warning
} else {
theme.colors.text_faint
})
.child(message.time.shown(cx))
.semantic_in(
cx,
NodeSpec::new(ident.child("time").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(message.time.shown(cx))
.value(match &message.time {
EntryTime::At(time) => time.clone(),
EntryTime::Unknown => SharedString::new_static("time unknown"),
}),
),
)
.children(began.map(|_| streaming_mark(&ident, &theme, cx)));
let body = body_element(&ident, message, body_lines, on_markdown, &theme, cx);
let mut footer = div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.h(px(theme.control.get(ControlSize::Sm).height))
.child(delivery_mark(&ident, &message.delivery, &theme, cx));
for attachment in &message.attachments {
footer = footer.child(attachment_chip(&ident, attachment, &theme, cx));
}
for reaction in &message.reactions {
footer = footer.child(reaction_chip(&ident, reaction, &theme, cx));
}
if let (DeliveryState::Failed { .. }, Some(handler)) = (&message.delivery, on_retry) {
let id = message.id.clone();
let handler = Rc::clone(handler);
footer = footer.child(
Button::new(ident.child("retry"))
.label(cx.strings().text(StringKey::TryAgain))
.secondary()
.control_size(ControlSize::Xs)
.on_click(move |window, cx| handler(id.clone(), window, cx)),
);
}
let _ = window;
div()
.column()
.w_full()
.gap_token(&theme, Space::Xs)
.py_token(&theme, Space::Sm)
.child(header)
.child(body)
.child(footer)
.into_any_element()
}
fn body_element(
ident: &Ident,
message: &Message,
body_lines: usize,
on_markdown: Option<&MarkdownHandler>,
theme: &Theme,
cx: &mut App,
) -> AnyElement {
let height = theme.typography.body.line_height * body_lines as f32;
match &message.body {
MessageBody::Markdown(source) => {
let mut markdown =
Markdown::new(ident.child("body"), source.clone()).max_lines(body_lines);
if let Some(handler) = on_markdown {
let handler = Rc::clone(handler);
let id = message.id.clone();
markdown = markdown
.on_event(move |event, window, cx| handler(id.clone(), event, window, cx));
}
div()
.w_full()
.h(px(height))
.overflow_hidden()
.child(markdown)
.into_any_element()
}
MessageBody::Text(text) => {
let lines: Vec<&str> = text.lines().collect();
let hidden = lines.len().saturating_sub(body_lines);
let shown = lines
.iter()
.take(body_lines)
.copied()
.collect::<Vec<_>>()
.join("\n");
div()
.column()
.w_full()
.h(px(height))
.overflow_hidden()
.type_scale(theme, TypeScale::Body)
.text_color(theme.colors.text)
.child(SharedString::from(shown))
.children((hidden > 0).then(|| {
let label = if hidden == 1 {
cx.strings().text(StringKey::MessageShowMoreOne)
} else {
cx.strings()
.format(StringKey::MessageShowMoreMany, &[&hidden.to_string()])
};
div()
.type_scale(theme, TypeScale::Caption)
.text_color(theme.colors.text_faint)
.child(label.clone())
.semantic_in(
cx,
NodeSpec::new(ident.child("truncated").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(label)
.value(hidden.to_string()),
)
}))
.into_any_element()
}
}
}
fn streaming_mark(ident: &Ident, theme: &Theme, cx: &mut App) -> AnyElement {
let label = cx.strings().text(StringKey::MessageStreaming);
div()
.row()
.gap(px(4.0))
.text_color(theme.colors.accent)
.child(StatusDot::new(Tone::Accent).busy(ident.child("streaming.mark")))
.child(label.clone())
.semantic_in(
cx,
NodeSpec::new(ident.child("streaming").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(label)
.value("streaming")
.busy(true),
)
.into_any_element()
}
fn delivery_mark(ident: &Ident, state: &DeliveryState, theme: &Theme, cx: &mut App) -> AnyElement {
let label = state.label(cx.strings());
let tone = state.tone();
div()
.row()
.gap(px(4.0))
.min_w_0()
.type_scale(theme, TypeScale::Caption)
.text_color(tone.color(theme))
.child(StatusDot::new(tone))
.child(div().min_w_0().child(label.clone()))
.semantic_in(
cx,
NodeSpec::new(ident.child("delivery").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(label)
.value(state.name())
.invalid(state.failed())
.busy(matches!(state, DeliveryState::Sending)),
)
.into_any_element()
}
fn attachment_chip(
ident: &Ident,
attachment: &Attachment,
theme: &Theme,
cx: &mut App,
) -> AnyElement {
let text = match &attachment.detail {
Some(detail) => SharedString::from(format!("{} — {detail}", attachment.name)),
None => attachment.name.clone(),
};
div()
.px_token(theme, Space::Xs)
.radius(theme, Radius::Small)
.surface(theme, Surface::Raised)
.type_scale(theme, TypeScale::Caption)
.text_color(theme.colors.text_muted)
.child(text.clone())
.semantic_in(
cx,
NodeSpec::new(
ident
.child("attachment")
.child(attachment.id.as_ref())
.semantic_id(),
Role::Text,
)
.parent(ident.semantic_id())
.text(text),
)
.into_any_element()
}
fn reaction_chip(ident: &Ident, reaction: &Reaction, theme: &Theme, cx: &mut App) -> AnyElement {
let text = SharedString::from(format!("{} {}", reaction.label, reaction.count));
div()
.px_token(theme, Space::Xs)
.radius(theme, Radius::Pill)
.bg(theme.colors.raised)
.type_scale(theme, TypeScale::Caption)
.text_color(theme.colors.text_muted)
.child(text.clone())
.semantic_in(
cx,
NodeSpec::new(
ident
.child("reaction")
.child(reaction.key.as_ref())
.semantic_id(),
Role::Status,
)
.parent(ident.semantic_id())
.text(text)
.value(reaction.count.to_string()),
)
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_delivery_state_has_its_own_name() {
let states = [
DeliveryState::Sending,
DeliveryState::Sent,
DeliveryState::Delivered,
DeliveryState::Read,
DeliveryState::Failed {
reason: "The host refused it.".into(),
},
];
let mut names: Vec<&str> = states.iter().map(DeliveryState::name).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), states.len());
}
#[test]
fn a_failure_states_the_hosts_reason_rather_than_a_word_of_its_own() {
let state = DeliveryState::Failed {
reason: "The workspace is read only.".into(),
};
assert_eq!(
state.label(&Strings::new()).as_ref(),
"The workspace is read only."
);
assert!(state.failed());
}
#[test]
fn an_unnamed_author_is_named_unknown() {
assert_eq!(shown_author(None).as_ref(), "unknown");
assert_eq!(shown_author(Some(&" ".into())).as_ref(), "unknown");
assert_eq!(shown_author(Some(&"Ada".into())).as_ref(), "Ada");
}
}