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.
212 lines
7.7 KiB
Rust
212 lines
7.7 KiB
Rust
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"),
|
|
])),
|
|
))
|
|
}
|