Messaging · Messages: messages in runs, threads, the unread line and receipts

ChatMessage sets a message under its author's picture, name and clock
time. The next by the same author within five minutes, on the same day,
joins the run: only its words, the time in the gutter on hover.
UnreadDivider marks where unread messages start. MessageThread tells who
replied, how many and when the last came; with a handler it opens on a
press, Enter or Space. ThreadPanel holds the first message, a rule
counting the replies, the replies and a composer, its body scrolling.
ReadReceipt says sending, sent, delivered or read, and who saw it in a
group. MessageReactions and TypingIndicator point to collab and motion.

gpui recolors nothing on hover: text takes its color at layout. The
gutter time rests invisible and turns visible through a group hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 21:48:17 -04:00
1 parent 3c8a5a026c
commit 5bbfdcd766
11 files changed
+1012 -8

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` with `.root()` and focus its handle. A root scope takes focus when the focused element leaves the tree, as a toast's or an alert's button does.
- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue.
- `hover()` needs an element id.
- gpui applies hover and group-hover styles once an element has a hitbox, in prepaint and paint, but text takes its color at layout, so a hover cannot recolor text. What shows on hover rests `invisible()` and turns `visible()` through `group_hover` (`chat::ConversationItem`, `messaging::ChatMessage`).
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn.
+2 -1
View File
@@ -98,7 +98,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T27b Files · Explorer
- [x] T27c Files · Find and look
- [x] T27d Files · Transfers
- [ ] T28a Messaging · Channels
- [x] T28a Messaging · Channels
- [ ] T28b Messaging · Messages
- [ ] T28c Messaging · People
- [ ] T28d Messaging · Calls
@@ -333,3 +333,4 @@ Per-component lines live in `tasks/`. Tags there:
| T27c | 2 | PASS | |
| T27d | 1 | FAIL | a press on a finished download's Show button also opened the file through its row |
| T27d | 2 | PASS | |
| T28a | 1 | PASS | |
@@ -0,0 +1,211 @@
use ely_gpui_component::{
buttons::Button,
chat::DateSeparator,
collab::{Reaction, Reactions, toggled},
data_display::Avatar,
forms::Input,
messaging::{ChatMessage, Delivery, MessageThread, ReadReceipt, ThreadPanel, UnreadDivider},
motion::TypingIndicator,
theme::{ActiveTheme, Radius},
};
use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, px};
use jiff::{SignedDuration, Timestamp, civil::date, tz::TimeZone};
use crate::{
probe::probe,
ui::{change, keep, section},
};
/// A story morning, so captures read the same times.
fn at(hour: i8, minute: i8) -> Timestamp {
date(2026, 9, 26)
.at(hour, minute, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp()
}
fn message(id: &'static str, author: &'static str, hour: i8, minute: i8) -> ChatMessage {
ChatMessage::new(id, author, at(hour, minute)).zone(TimeZone::UTC)
}
/// Whether the thread panel shows.
fn thread_open(window: &mut Window, cx: &mut App) -> Entity<bool> {
keep("messaging-thread-open", || true, window, cx)
}
fn thread_mark(open: Entity<bool>) -> MessageThread {
MessageThread::new(
"messaging-thread-mark",
3,
Timestamp::now() - SignedDuration::from_hours(2),
)
.repliers([
Avatar::new("messaging-replier-ana", "Ana Lima"),
Avatar::new("messaging-replier-ben", "Ben Ito"),
])
.on_open(move |_, cx| change(&open, cx, |open| *open = true))
}
pub fn conversation(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let reactions = keep(
"messaging-reactions",
|| {
vec![
Reaction {
emoji: "👍".into(),
count: 3,
mine: false,
},
Reaction {
emoji: "🌿".into(),
count: 1,
mine: true,
},
]
},
window,
cx,
);
let open = thread_open(window, cx);
let shown = reactions.read(cx).clone();
let footer = div()
.flex()
.flex_col()
.gap_1()
.child(
Reactions::new("messaging-reactions", shown).on_toggle(move |emoji, _, cx| {
change(&reactions, cx, |all| *all = toggled(all, emoji))
}),
)
.child(thread_mark(open));
section(
"ChatMessage / UnreadDivider / MessageReactions / TypingIndicator",
"Messages under their day: one author's messages within five minutes form a run, the head only on the first and the time in the gutter on hover. A rule marks where unread ones start. Reactions and a thread sit under a message, and a line says who is typing.",
cx,
)
.child(probe(
"messaging-conversation",
div()
.w(px(640.))
.flex()
.flex_col()
.child(DateSeparator::new(date(2026, 9, 26), date(2026, 9, 26)))
.child(
message("messaging-m1", "Ana Lima", 9, 2)
.child("Morning. The plaster samples came in overnight."),
)
.child(
message("messaging-m2", "Ana Lima", 9, 3)
.after("Ana Lima", at(9, 2))
.child("Three finishes: lime, tadelakt and a sanded gypsum."),
)
.child(
message("messaging-m3", "Ana Lima", 9, 5)
.after("Ana Lima", at(9, 3))
.child("They're in the stair hall, along the left wall."),
)
.child(
message("messaging-m4", "Ben Ito", 9, 7)
.after("Ana Lima", at(9, 5))
.child("I'll look before the review. Is the tadelakt sealed yet?"),
)
.child(
message("messaging-m5", "Ana Lima", 9, 21)
.after("Ben Ito", at(9, 7))
.child("Not yet. It needs another day."),
)
.child(UnreadDivider::new())
.child(
message("messaging-m6", "Chloé Martin", 9, 34)
.after("Ana Lima", at(9, 21))
.child("Pinned the brief: light from above, one stair, olive trees by the windows. Thoughts on where the trees go?")
.footer(footer),
)
.child(
div()
.px_3()
.py_2()
.child(TypingIndicator::new("messaging-typing").who("Dev Rao")),
),
))
}
pub fn thread(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = thread_open(window, cx);
let reply = window.use_keyed_state("messaging-thread-reply", cx, |window, cx| {
ely_gpui_component::forms::TextInput::new(window, cx).placeholder("Reply…")
});
let shown = *open.read(cx);
let theme = cx.theme();
let frame = div()
.w(px(360.))
.h(px(480.))
.border_1()
.border_color(theme.colors.border)
.rounded(theme.radius(Radius::Lg));
let body = if shown {
let closing = open.clone();
frame.child(
ThreadPanel::new(
"messaging-thread",
message("messaging-t0", "Chloé Martin", 9, 34)
.child("Pinned the brief: light from above, one stair, olive trees by the windows. Thoughts on where the trees go?"),
)
.chat("# design")
.child(
message("messaging-t1", "Ana Lima", 9, 40)
.child("By the south windows, where the stair takes its light."),
)
.child(
message("messaging-t2", "Ana Lima", 9, 41)
.after("Ana Lima", at(9, 40))
.child("Two there, and one on the landing."),
)
.child(
message("messaging-t3", "Ben Ito", 10, 12)
.after("Ana Lima", at(9, 41))
.child("Agreed. I'll move them on the plan."),
)
.composer(Input::new(&reply))
.on_close(move |_, cx| change(&closing, cx, |open| *open = false)),
)
} else {
let opening = open.clone();
frame.flex().items_center().justify_center().child(
Button::new("messaging-thread-reopen", "Open the thread")
.on_click(move |_, _, cx| change(&opening, cx, |open| *open = true)),
)
};
section(
"MessageThread / ThreadPanel",
"A thread's mark under its message: who replied, how many, and when the last came; a press opens the thread. The panel holds the first message, a rule counting the replies, the replies in their runs, and a composer at the bottom.",
cx,
)
.child(probe("messaging-thread", body))
}
pub fn receipts(cx: &mut App) -> impl IntoElement + use<> {
section(
"ReadReceipt",
"Where a sent message stands: a clock while it goes, one tick once sent, two once delivered, two in the info tone once read. Read in a group, it shows who saw it.",
cx,
)
.child(probe(
"messaging-receipts",
div()
.flex()
.flex_col()
.gap_3()
.child(ReadReceipt::new(Delivery::Sending))
.child(ReadReceipt::new(Delivery::Sent))
.child(ReadReceipt::new(Delivery::Delivered))
.child(ReadReceipt::new(Delivery::Read))
.child(ReadReceipt::new(Delivery::Read).readers([
Avatar::new("messaging-seen-ana", "Ana Lima"),
Avatar::new("messaging-seen-ben", "Ben Ito"),
Avatar::new("messaging-seen-chloe", "Chloé Martin"),
Avatar::new("messaging-seen-dev", "Dev Rao"),
])),
))
}
+8
View File
@@ -7,6 +7,8 @@ use ely_gpui_component::{
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::{SignedDuration, Timestamp};
mod messages;
use super::Page;
use crate::{
probe::probe,
@@ -29,6 +31,9 @@ const SCRIPT: &[Step] = &[
Step::Key("down"),
Step::Wait(200),
Step::Shot("channel-opened"),
Step::HoverAt("messaging-conversation", 200.0, 89.0),
Step::Wait(200),
Step::Shot("run-time"),
];
/// The demo's chats: which is open and what waits unread in each.
@@ -210,5 +215,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(items(cx))
.child(header(cx))
.child(pins(window, cx))
.child(messages::conversation(window, cx))
.child(messages::thread(window, cx))
.child(messages::receipts(cx))
.into_any_element()
}
+243
View File
@@ -0,0 +1,243 @@
use gpui::{
AnyElement, App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*, relative, rems,
};
use jiff::{SignedDuration, Timestamp, tz::TimeZone};
use smallvec::SmallVec;
use crate::{
data_display::Avatar,
theme::{ActiveTheme, AvatarSize, Radius, TextSize},
typography::{Ellipsis, LEADING, format},
};
/// How long after one message the next by the same author joins its run.
const RUN: SignedDuration = SignedDuration::from_mins(5);
/// Whether `next` joins the run of `before`: the same author, the same day in `zone`, within five minutes.
fn continues(before: (&str, Timestamp), next: (&str, Timestamp), zone: &TimeZone) -> bool {
let gap = next.1.duration_since(before.1);
let day = |at: Timestamp| at.to_zoned(zone.clone()).date();
before.0 == next.0 && gap >= SignedDuration::ZERO && gap <= RUN && day(before.1) == day(next.1)
}
/// A message in a chat: the author's picture, name and time over the words. After a message by the same author within five minutes, only the words, with the time in the gutter on hover. Reactions and a thread go in the footer.
#[derive(IntoElement)]
pub struct ChatMessage {
id: ElementId,
author: SharedString,
at: Timestamp,
picture: Option<ImageSource>,
after: Option<(SharedString, Timestamp)>,
zone: Option<TimeZone>,
body: SmallVec<[AnyElement; 2]>,
footer: Option<AnyElement>,
}
impl ChatMessage {
pub fn new(id: impl Into<ElementId>, author: impl Into<SharedString>, at: Timestamp) -> Self {
Self {
id: id.into(),
author: author.into(),
at,
picture: None,
after: None,
zone: None,
body: SmallVec::new(),
footer: None,
}
}
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
/// The message before this one, by its author and time.
pub fn after(mut self, author: impl Into<SharedString>, at: Timestamp) -> Self {
self.after = Some((author.into(), at));
self
}
/// The zone its time and day read in; the system's otherwise.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
}
impl ParentElement for ChatMessage {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for ChatMessage {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let zone = self
.zone
.unwrap_or_else(|| format::system_zone("chat message"));
let joined = self.after.as_ref().is_some_and(|(author, then)| {
continues((author, *then), (&self.author, self.at), &zone)
});
let time = format::datetime(self.at, &zone, "%H:%M").expect("a fixed pattern formats");
let group = SharedString::from(format!("chat-message-{}", self.id));
let line = rems(theme.text_size(TextSize::Base).0 * LEADING);
let gutter = div().flex_none().w(theme.avatar_size(AvatarSize::Md));
let gutter = if joined {
let named = self.id.clone();
gutter
.h(line)
.flex()
.items_center()
.justify_center()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.invisible()
.group_hover(group.clone(), |style| style.visible())
.child(
div()
.debug_selector(move || format!("run-time {named}"))
.child(time.clone()),
)
} else {
let avatar =
Avatar::new((self.id.clone(), "avatar"), self.author.clone()).size(AvatarSize::Md);
gutter.child(match self.picture {
Some(picture) => avatar.image(picture),
None => avatar,
})
};
let named = self.id.clone();
let head = (!joined).then(|| {
div()
.debug_selector(move || format!("message-head {named}"))
.flex()
.items_baseline()
.gap_2()
.child(
div()
.min_w_0()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(Ellipsis::new(self.author.clone())),
)
.child(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(time),
)
});
div()
.id(self.id.clone())
.debug_selector(move || format!("message {}", self.id))
.group(group)
.w_full()
.flex()
.gap_3()
.px_3()
.map(|row| {
if joined {
row.py_0p5()
} else {
row.pt_2().pb_0p5()
}
})
.rounded(theme.radius(Radius::Md))
.hover(|style| style.bg(colors.hover))
.child(gutter)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Base))
.line_height(relative(LEADING))
.children(head)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_color(colors.fg)
.children(self.body),
)
.children(self.footer.map(|footer| div().pt_1().child(footer))),
)
}
}
/// Where unread messages start: a rule and "New" in the danger tone.
#[derive(IntoElement, Default)]
pub struct UnreadDivider;
impl UnreadDivider {
pub fn new() -> Self {
Self
}
}
impl RenderOnce for UnreadDivider {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let danger = theme.colors.danger;
div()
.w_full()
.flex()
.items_center()
.gap_2()
.py_1()
.child(div().flex_1().border_t_1().border_color(danger))
.child(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::SEMIBOLD)
.text_color(danger)
.child("New"),
)
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::*;
fn at(hour: i8, minute: i8, second: i8) -> Timestamp {
date(2026, 9, 26)
.at(hour, minute, second, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp()
}
#[test]
fn a_run_holds_one_author_for_five_minutes_of_a_day() {
let joins = |before, next| continues(before, next, &TimeZone::UTC);
assert!(joins(("Ana", at(9, 0, 0)), ("Ana", at(9, 4, 59))));
assert!(joins(("Ana", at(9, 0, 0)), ("Ana", at(9, 5, 0))));
assert!(!joins(("Ana", at(9, 0, 0)), ("Ana", at(9, 5, 1))));
assert!(!joins(("Ana", at(9, 0, 0)), ("Ben", at(9, 1, 0))));
assert!(
!joins(("Ana", at(9, 1, 0)), ("Ana", at(9, 0, 0))),
"an earlier message starts its own run"
);
let midnight = at(0, 1, 0) + SignedDuration::from_hours(24);
assert!(
!joins(("Ana", at(23, 58, 0)), ("Ana", midnight)),
"a new day starts a new run"
);
}
}
+6
View File
@@ -1,10 +1,16 @@
mod channels;
mod header;
mod messages;
mod pins;
mod receipt;
mod thread;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use channels::{ChannelItem, ChannelList, ChatRow, DirectMessageItem};
pub use header::ChannelHeader;
pub use messages::{ChatMessage, UnreadDivider};
pub use pins::{PinnedMessage, PinnedMessages};
pub use receipt::{Delivery, ReadReceipt};
pub use thread::{MessageThread, ThreadPanel};
+93
View File
@@ -0,0 +1,93 @@
use gpui::{App, IntoElement, ParentElement, RenderOnce, Styled, Window, div, prelude::*};
use crate::{
data_display::{Avatar, AvatarGroup},
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
};
/// Where a sent message stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Delivery {
Sending,
Sent,
Delivered,
Read,
}
impl Delivery {
pub fn label(self) -> &'static str {
match self {
Self::Sending => "Sending",
Self::Sent => "Sent",
Self::Delivered => "Delivered",
Self::Read => "Read",
}
}
fn icon(self) -> IconName {
match self {
Self::Sending => IconName::Clock,
Self::Sent => IconName::Check,
Self::Delivered | Self::Read => IconName::CheckCheck,
}
}
}
/// A sent message's receipt: a clock while it goes, one tick once sent, two once delivered, two in the info tone once read, each with its word. Read in a group, it shows who saw it.
#[derive(IntoElement)]
pub struct ReadReceipt {
delivery: Delivery,
readers: Vec<Avatar>,
}
impl ReadReceipt {
pub fn new(delivery: Delivery) -> Self {
Self {
delivery,
readers: Vec::new(),
}
}
/// Who read it; only a read message has readers, and the first three show.
pub fn readers(mut self, readers: impl IntoIterator<Item = Avatar>) -> Self {
self.readers = readers.into_iter().collect();
assert!(
self.readers.is_empty() || self.delivery == Delivery::Read,
"only a read message has readers"
);
self
}
}
impl RenderOnce for ReadReceipt {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let tint = if self.delivery == Delivery::Read {
colors.info
} else {
colors.fg_subtle
};
let seen = !self.readers.is_empty();
div()
.flex()
.items_center()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(
Icon::new(self.delivery.icon())
.size(IconSize::Xs)
.color(tint),
)
.child(if seen {
"Seen by"
} else {
self.delivery.label()
})
.when(seen, |receipt| {
receipt.child(AvatarGroup::new(self.readers).max(3).size(AvatarSize::Xs))
})
}
}
+184
View File
@@ -0,0 +1,184 @@
use gpui::{
Context, IntoElement, Modifiers, ParentElement, Render, Styled, TestAppContext, Window, div, px,
};
use jiff::{Timestamp, civil::date, tz::TimeZone};
use super::{press, settle, setup, tab_to};
use crate::{
data_display::Avatar,
messaging::{ChatMessage, Delivery, MessageThread, ReadReceipt, ThreadPanel},
};
fn at(minute: i8) -> Timestamp {
date(2026, 9, 26)
.at(9, minute, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("a UTC time")
.timestamp()
}
/// Ana twice within a minute, then Ben.
struct Run;
impl Render for Run {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let message = |id: &'static str, author: &'static str, minute| {
ChatMessage::new(id, author, at(minute))
.zone(TimeZone::UTC)
.child("Words")
};
div()
.w(px(360.0))
.child(message("m1", "Ana", 0))
.child(message("m2", "Ana", 1).after("Ana", at(0)))
.child(message("m3", "Ben", 2).after("Ana", at(1)))
}
}
#[gpui::test]
fn a_message_in_a_run_drops_its_head(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Run);
settle(cx);
assert!(cx.debug_bounds("message-head m1").is_some());
assert!(
cx.debug_bounds("message-head m2").is_none(),
"Ana's second message joins her run"
);
assert!(
cx.debug_bounds("message-head m3").is_some(),
"Ben starts his own"
);
}
#[gpui::test]
fn a_run_shows_its_time_on_hover(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Run);
settle(cx);
assert!(
cx.debug_bounds("run-time m2").is_none(),
"the time rests hidden"
);
let row = cx
.debug_bounds("message m2")
.expect("the second message draws");
cx.simulate_mouse_move(row.center(), None, Modifiers::none());
settle(cx);
assert!(cx.debug_bounds("run-time m2").is_some());
}
/// A thread of three replies, and how often it opened.
struct Threaded {
opened: usize,
}
impl Render for Threaded {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(360.0)).child(
MessageThread::new("thread", 3, Timestamp::now())
.repliers([Avatar::new("r1", "Ana Lima"), Avatar::new("r2", "Ben Ito")])
.on_open(move |_, cx| owner.update(cx, |host, _| host.opened += 1)),
)
}
}
#[gpui::test]
fn a_thread_opens_on_a_press_or_space(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Threaded { opened: 0 });
cx.update(|window, _| window.activate_window());
settle(cx);
let thread = cx.debug_bounds("message-thread").expect("the thread draws");
cx.simulate_click(thread.center(), Modifiers::none());
settle(cx);
tab_to(1, cx);
press("space", cx);
assert_eq!(host.read_with(cx, |host, _| host.opened), 2);
}
/// A thread with nothing to open.
struct Unthreaded;
impl Render for Unthreaded {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(360.0))
.child(MessageThread::new("thread", 1, Timestamp::now()))
}
}
#[gpui::test]
fn a_thread_without_a_handler_takes_no_tab_stop(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Unthreaded);
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
}
#[test]
#[should_panic(expected = "a thread holds a reply")]
fn a_thread_holds_a_reply() {
let _ = MessageThread::new("thread", 0, Timestamp::UNIX_EPOCH);
}
#[test]
#[should_panic(expected = "more repliers than replies")]
fn repliers_fit_their_replies() {
let _ = MessageThread::new("thread", 1, Timestamp::UNIX_EPOCH)
.repliers([Avatar::new("a", "Ana"), Avatar::new("b", "Ben")]);
}
/// A thread panel with its replies, and whether it asked to close.
struct Paneled {
replies: usize,
closed: bool,
}
impl Render for Paneled {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(320.0)).h(px(400.0)).child(
ThreadPanel::new("panel", div().child("The first message"))
.chat("design")
.children((0..self.replies).map(|ix| div().child(format!("Reply {ix}"))))
.on_close(move |_, cx| owner.update(cx, |host, _| host.closed = true)),
)
}
}
#[gpui::test]
fn a_panel_counts_its_replies_and_closes_on_ask(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Paneled {
replies: 2,
closed: false,
});
cx.update(|window, _| window.activate_window());
settle(cx);
assert!(cx.debug_bounds("thread-replies").is_some());
tab_to(1, cx);
press("space", cx);
assert!(host.read_with(cx, |host, _| host.closed));
}
#[gpui::test]
fn a_panel_without_replies_draws_no_rule(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Paneled {
replies: 0,
closed: false,
});
settle(cx);
assert!(cx.debug_bounds("thread-panel").is_some());
assert!(cx.debug_bounds("thread-replies").is_none());
}
#[test]
#[should_panic(expected = "only a read message has readers")]
fn only_a_read_message_has_readers() {
let _ = ReadReceipt::new(Delivery::Delivered).readers([Avatar::new("a", "Ana")]);
}
+2
View File
@@ -9,6 +9,8 @@ use super::{
};
use crate::{data_display::Presence, primitives::FocusNext, theme::Theme};
mod messages;
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
+255
View File
@@ -0,0 +1,255 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use jiff::Timestamp;
use smallvec::SmallVec;
use crate::{
buttons::{ButtonVariant, IconButton},
data_display::{Avatar, AvatarGroup},
forms::Run,
layout::on_axis,
primitives::{Divider, FocusRing, IconName},
theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize},
typography::{Ellipsis, format},
};
/// A thread's mark under its first message: who replied, how many replies, and when the last came. With a handler, a press opens the thread.
#[derive(IntoElement)]
pub struct MessageThread {
id: ElementId,
replies: usize,
last: Timestamp,
repliers: Vec<Avatar>,
on_open: Option<Run>,
}
impl MessageThread {
pub fn new(id: impl Into<ElementId>, replies: usize, last: Timestamp) -> Self {
assert!(replies > 0, "a thread holds a reply");
Self {
id: id.into(),
replies,
last,
repliers: Vec::new(),
on_open: None,
}
}
/// Who replied; the first three show.
pub fn repliers(mut self, repliers: impl IntoIterator<Item = Avatar>) -> Self {
self.repliers = repliers.into_iter().collect();
assert!(
self.repliers.len() <= self.replies,
"more repliers than replies"
);
self
}
pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for MessageThread {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let count = format::plural(self.replies as u64, "reply", "replies");
let when = format!(
"Last reply {}",
format::relative(self.last, Timestamp::now())
);
let id = self.id.clone();
div()
.id(self.id)
.debug_selector(|| "message-thread".into())
.w_full()
.flex()
.items_center()
.gap_2()
.px_1p5()
.py_1()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.text_size(theme.text_size(TextSize::Sm))
.when_some(self.on_open, |row, open| {
row.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.hover(|style| style.bg(colors.surface))
.on_click(move |_, window, cx| {
log::info!("message thread {id}: open");
open(window, cx)
})
})
.when(!self.repliers.is_empty(), |row| {
row.child(
div()
.flex_none()
.child(AvatarGroup::new(self.repliers).max(3).size(AvatarSize::Xs)),
)
})
.child(
div()
.flex_none()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.link)
.child(count),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg_subtle)
.child(Ellipsis::new(when)),
)
}
}
/// A thread beside its chat: a head with the chat's name and a close, the first message, a rule counting the replies, the replies, and a composer held at the bottom. The body scrolls; the host gives the panel its size.
#[derive(IntoElement)]
pub struct ThreadPanel {
id: ElementId,
root: AnyElement,
chat: Option<SharedString>,
replies: SmallVec<[AnyElement; 4]>,
composer: Option<AnyElement>,
on_close: Option<Run>,
}
impl ThreadPanel {
pub fn new(id: impl Into<ElementId>, root: impl IntoElement) -> Self {
Self {
id: id.into(),
root: root.into_any_element(),
chat: None,
replies: SmallVec::new(),
composer: None,
on_close: None,
}
}
/// The chat the thread is in, after the title.
pub fn chat(mut self, name: impl Into<SharedString>) -> Self {
self.chat = Some(name.into());
self
}
pub fn composer(mut self, composer: impl IntoElement) -> Self {
self.composer = Some(composer.into_any_element());
self
}
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
}
impl ParentElement for ThreadPanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.replies.extend(elements);
}
}
impl RenderOnce for ThreadPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let count = self.replies.len();
let close = self.on_close.map(|close| {
let id = self.id.clone();
IconButton::new((self.id.clone(), "close"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Close thread")
.on_click(move |_, window, cx| {
log::info!("thread panel {id}: close");
close(window, cx)
})
});
let head = div()
.flex_none()
.flex()
.items_center()
.justify_between()
.gap_2()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.items_baseline()
.gap_2()
.child(
div()
.flex_none()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child("Thread"),
)
.children(self.chat.map(|chat| {
div()
.flex_1()
.min_w_0()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(Ellipsis::new(chat))
})),
)
.children(close.map(|close| div().flex_none().child(close)));
let rule = (count > 0).then(|| {
div()
.debug_selector(|| "thread-replies".into())
.px_3()
.py_1()
.child(Divider::horizontal().label(format::plural(
count as u64,
"reply",
"replies",
)))
});
let body = on_axis(
div()
.id((self.id.clone(), "body"))
.flex_1()
.min_h_0()
.overflow_y_scroll(),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.py_2()
.child(self.root)
.children(rule)
.children(self.replies),
);
div()
.debug_selector(|| "thread-panel".into())
.size_full()
.flex()
.flex_col()
.child(head)
.child(body)
.children(self.composer.map(|composer| {
div()
.flex_none()
.p_3()
.border_t_1()
.border_color(colors.border)
.child(composer)
}))
}
}
+7 -7
View File
@@ -330,13 +330,13 @@
## T28b Messaging · Messages
- [ ] ChatMessage
- [ ] MessageThread
- [ ] ThreadPanel
- [ ] UnreadDivider
- [ ] MessageReactions → `collab::Reactions`
- [ ] ReadReceipt
- [ ] TypingIndicator → `motion::TypingIndicator`
- [x] ChatMessage
- [x] MessageThread
- [x] ThreadPanel
- [x] UnreadDivider
- [x] MessageReactions → `collab::Reactions`
- [x] ReadReceipt
- [x] TypingIndicator → `motion::TypingIndicator`
## T28c Messaging · People