notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit c7ea993931a1157706d41534bb36b23b7448eb20
parent 0ab6dba541c26c535f94918cc5b23372e76e84d0
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 18 Dec 2025 17:58:23 -0500

feat(msgs-ui): add `ConversationUi`

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Acrates/notedeck_messages/src/ui/convo.rs | 612+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_messages/src/ui/mod.rs | 2++
2 files changed, 614 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_messages/src/ui/convo.rs b/crates/notedeck_messages/src/ui/convo.rs @@ -0,0 +1,612 @@ +use chrono::{DateTime, Duration, Local, NaiveDate}; +use egui::{ + vec2, Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, ScrollArea, TextEdit, +}; +use egui_extras::{Size, StripBuilder}; +use enostr::Pubkey; +use nostrdb::{Ndb, NoteKey, Transaction}; +use notedeck::{ + name::get_display_name, tr, ui::is_narrow, Images, Localization, MediaJobSender, NostrName, +}; +use notedeck_ui::{include_input, ProfilePic}; + +use crate::{ + cache::{ + Conversation, ConversationCache, ConversationId, ConversationState, ConversationStates, + }, + convo_renderable::{ConversationItem, MessageType}, + nav::MessagesAction, + nip17::{parse_chat_message, Nip17ChatMessage}, + ui::{local_datetime_from_nostr, title_label}, +}; + +pub struct ConversationUi<'a> { + conversation: &'a Conversation, + state: &'a mut ConversationState, + ndb: &'a Ndb, + jobs: &'a MediaJobSender, + img_cache: &'a mut Images, + i18n: &'a mut Localization, +} + +impl<'a> ConversationUi<'a> { + pub fn new( + conversation: &'a Conversation, + state: &'a mut ConversationState, + ndb: &'a Ndb, + jobs: &'a MediaJobSender, + img_cache: &'a mut Images, + i18n: &'a mut Localization, + ) -> Self { + Self { + conversation, + state, + ndb, + jobs, + img_cache, + i18n, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui, selected_pubkey: &Pubkey) -> Option<MessagesAction> { + let txn = Transaction::new(self.ndb).expect("txn"); + + let mut action = None; + Frame::new().fill(ui.visuals().panel_fill).show(ui, |ui| { + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let focusing_composer = ui + .allocate_ui(vec2(ui.available_width(), 64.0), |ui| { + let comp_resp = + conversation_composer(ui, self.state, self.conversation.id, self.i18n); + if action.is_none() { + action = comp_resp.action; + } + comp_resp.composer_has_focus + }) + .inner; + ui.with_layout(Layout::top_down(Align::Min), |ui| { + ScrollArea::vertical() + .stick_to_bottom(focusing_composer) + .id_salt(ui.id().with(self.conversation.id)) + .show(ui, |ui| { + conversation_history( + ui, + self.conversation, + self.state, + self.jobs, + self.ndb, + &txn, + self.img_cache, + selected_pubkey, + self.i18n, + ); + }); + }); + }) + }); + + action + } +} + +#[allow(clippy::too_many_arguments)] +fn conversation_history( + ui: &mut egui::Ui, + conversation: &Conversation, + state: &mut ConversationState, + jobs: &MediaJobSender, + ndb: &Ndb, + txn: &Transaction, + img_cache: &mut Images, + selected_pk: &Pubkey, + i18n: &mut Localization, +) { + let renderable = &conversation.renderable; + + state.last_read = conversation + .messages + .messages_ordered + .first() + .map(|n| &n.note_ref) + .copied(); + Frame::new() + .inner_margin(Margin::symmetric(16, 0)) + .show(ui, |ui| { + let today = Local::now().date_naive(); + let total = renderable.len(); + state.list.ui_custom_layout(ui, total, |ui, index| { + let Some(renderable) = renderable.get(index) else { + return 1; + }; + + match renderable { + ConversationItem::Date(date) => render_date_line(ui, *date, &today, i18n), + ConversationItem::Message { msg_type, key } => { + render_chat_msg( + ui, + img_cache, + jobs, + ndb, + txn, + *key, + *msg_type, + selected_pk, + ); + } + }; + + 1 + }); + }); +} + +fn render_date_line( + ui: &mut egui::Ui, + date: NaiveDate, + today: &NaiveDate, + i18n: &mut Localization, +) { + let label = format_day_heading(date, today, i18n); + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.add( + egui::Label::new( + RichText::new(label) + .strong() + .color(ui.visuals().weak_text_color()), + ) + .wrap(), + ); + }); + ui.add_space(4.0); +} + +#[allow(clippy::too_many_arguments)] +fn render_chat_msg( + ui: &mut egui::Ui, + img_cache: &mut Images, + jobs: &MediaJobSender, + ndb: &Ndb, + txn: &Transaction, + key: NoteKey, + msg_type: MessageType, + selected_pk: &Pubkey, +) { + let Ok(note) = ndb.get_note_by_key(txn, key) else { + tracing::error!("Could not get key {:?}", key); + return; + }; + + let Some(chat_msg) = parse_chat_message(&note) else { + tracing::error!("Could not parse chat message for note {key:?}"); + return; + }; + + match msg_type { + MessageType::Standalone => { + ui.add_space(2.0); + render_msg_with_pfp( + ui, + img_cache, + jobs, + ndb, + txn, + selected_pk, + msg_type, + chat_msg, + ); + ui.add_space(2.0); + } + MessageType::FirstInSeries => { + ui.add_space(2.0); + render_msg_no_pfp(ui, ndb, txn, selected_pk, msg_type, chat_msg); + } + MessageType::MiddleInSeries => { + render_msg_no_pfp(ui, ndb, txn, selected_pk, msg_type, chat_msg); + } + MessageType::LastInSeries => { + render_msg_with_pfp( + ui, + img_cache, + jobs, + ndb, + txn, + selected_pk, + msg_type, + chat_msg, + ); + ui.add_space(2.0); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn render_msg_with_pfp( + ui: &mut egui::Ui, + img_cache: &mut Images, + jobs: &MediaJobSender, + ndb: &Ndb, + txn: &Transaction, + selected_pk: &Pubkey, + msg_type: MessageType, + chat_msg: Nip17ChatMessage, +) { + if selected_pk.bytes() == chat_msg.sender { + self_chat_bubble(ui, chat_msg.message, msg_type, chat_msg.created_at); + return; + } + + let avatar_size = ProfilePic::medium_size() as f32; + let profile = ndb.get_profile_by_pubkey(txn, chat_msg.sender).ok(); + let mut pic = + ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref()).size(avatar_size); + ui.horizontal(|ui| { + ui.add(&mut pic); + ui.add_space(8.0); + + other_chat_bubble(ui, chat_msg, get_display_name(profile.as_ref()), msg_type); + }); +} + +fn render_msg_no_pfp( + ui: &mut egui::Ui, + ndb: &Ndb, + txn: &Transaction, + selected_pk: &Pubkey, + msg_type: MessageType, + chat_msg: Nip17ChatMessage, +) { + if selected_pk.bytes() == chat_msg.sender { + self_chat_bubble(ui, chat_msg.message, msg_type, chat_msg.created_at); + return; + } + + ui.horizontal(|ui| { + ui.add_space(ProfilePic::medium_size() as f32 + ui.spacing().item_spacing.x + 8.0); + let profile = ndb.get_profile_by_pubkey(txn, chat_msg.sender).ok(); + other_chat_bubble(ui, chat_msg, get_display_name(profile.as_ref()), msg_type); + }); +} + +fn conversation_composer( + ui: &mut egui::Ui, + state: &mut ConversationState, + conversation_id: ConversationId, + i18n: &mut Localization, +) -> ComposerResponse { + { + let rect = ui.available_rect_before_wrap(); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, ui.visuals().panel_fill); + } + let margin = Margin::symmetric(16, 4); + let mut action = None; + let mut composer_has_focus = false; + Frame::new().inner_margin(margin).show(ui, |ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + // TODO(kernelkind): ideally this will be multiline, but the default multiline impl doesn't work the way + // signal's multiline works... TBC + + let old = mut_visuals_corner_radius(ui, CornerRadius::same(16)); + + let hint_text = RichText::new(tr!( + i18n, + "Type a message", + "Placeholder text for the message composer in chats" + )) + .color(ui.visuals().noninteractive().fg_stroke.color); + let mut send = false; + let is_narrow = is_narrow(ui.ctx()); + let send_button_section = if is_narrow { 32.0 } else { 0.0 }; + + StripBuilder::new(ui) + .size(Size::remainder()) + .size(Size::exact(send_button_section)) + .horizontal(|mut strip| { + strip.cell(|ui| { + let spacing = ui.spacing().item_spacing.x; + let text_height = ui.spacing().item_spacing.y * 1.4; + let text_width = (ui.available_width() - spacing).max(0.0); + let size = vec2(text_width, text_height); + + let text_edit = TextEdit::singleline(&mut state.composer) + .margin(Margin::symmetric(16, 8)) + .vertical_align(Align::Center) + .desired_width(text_width) + .hint_text(hint_text) + .min_size(size); + let text_resp = ui.add(text_edit); + restore_widgets_corner_rad(ui, old); + send = text_resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)); + include_input(ui, &text_resp); + composer_has_focus = text_resp.has_focus(); + }); + + if is_narrow { + strip.cell(|ui| { + ui.add_space(6.0); + if ui + .add_enabled( + !state.composer.is_empty(), + egui::Button::new("Send").frame(false), + ) + .clicked() + { + send = true; + } + }); + } else { + strip.empty(); + } + }); + if send { + action = prepare_send_action(conversation_id, state); + } + }); + }); + + ComposerResponse { + action, + composer_has_focus, + } +} + +struct ComposerResponse { + action: Option<MessagesAction>, + composer_has_focus: bool, +} + +fn prepare_send_action( + conversation_id: ConversationId, + state: &mut ConversationState, +) -> Option<MessagesAction> { + if state.composer.trim().is_empty() { + return None; + } + + let message = std::mem::take(&mut state.composer); + Some(MessagesAction::SendMessage { + conversation_id, + content: message, + }) +} + +fn chat_bubble<R>( + ui: &mut egui::Ui, + msg_type: MessageType, + is_self: bool, + bubble_fill: Color32, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let d = 18; + let i = 4; + + let (inner_top, inner_bottom) = match msg_type { + MessageType::Standalone => (d, d), + MessageType::FirstInSeries => (d, i), + MessageType::MiddleInSeries => (i, i), + MessageType::LastInSeries => (i, d), + }; + + let corner_radius = if is_self { + CornerRadius { + nw: d, + ne: inner_top, + sw: d, + se: inner_bottom, + } + } else { + CornerRadius { + nw: inner_top, + ne: d, + sw: inner_bottom, + se: d, + } + }; + + Frame::new() + .fill(bubble_fill) + .corner_radius(corner_radius) + .inner_margin(Margin::symmetric(14, 10)) + .show(ui, |ui| { + ui.set_max_width(ui.available_width() * 0.9); + contents(ui) + }) + .inner +} + +fn self_chat_bubble( + ui: &mut egui::Ui, + message: &str, + msg_type: MessageType, + timestamp: u64, +) -> egui::Response { + let bubble_fill = ui.visuals().selection.bg_fill; + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + chat_bubble(ui, msg_type, true, bubble_fill, |ui| { + ui.with_layout(Layout::top_down(Align::Max), |ui| { + ui.label(RichText::new(message).color(ui.visuals().text_color())); + + if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries { + let timestamp_label = + format_timestamp_label(&local_datetime_from_nostr(timestamp)); + ui.label( + RichText::new(timestamp_label) + .small() + .color(ui.visuals().window_fill), + ); + } + }) + }) + .inner + }) + .response +} + +fn other_chat_bubble( + ui: &mut egui::Ui, + chat_msg: Nip17ChatMessage, + sender_name: NostrName, + msg_type: MessageType, +) -> egui::Response { + let message = chat_msg.message; + let bubble_fill = ui.visuals().extreme_bg_color; + let text_color = ui.visuals().text_color(); + let secondary_color = ui.visuals().weak_text_color(); + + chat_bubble(ui, msg_type, false, bubble_fill, |ui| { + ui.vertical(|ui| { + if msg_type == MessageType::FirstInSeries || msg_type == MessageType::Standalone { + ui.label( + RichText::new(sender_name.name()) + .strong() + .color(secondary_color), + ); + ui.add_space(2.0); + } + + ui.with_layout( + Layout::left_to_right(Align::Max).with_main_wrap(true), + |ui| { + ui.label(RichText::new(message).color(text_color)); + if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries + { + ui.add_space(6.0); + let timestamp_label = + format_timestamp_label(&local_datetime_from_nostr(chat_msg.created_at)); + ui.add( + egui::Label::new( + RichText::new(timestamp_label) + .small() + .color(secondary_color), + ) + .wrap_mode(egui::TextWrapMode::Extend), + ); + } + }, + ); + }) + .response + }) +} + +/// An unfortunate hack to change the corner radius of a TextEdit... +/// returns old `CornerRadius` +fn mut_visuals_corner_radius(ui: &mut egui::Ui, rad: CornerRadius) -> WidgetsCornerRadius { + let widgets = &ui.visuals().widgets; + let old = WidgetsCornerRadius { + active: widgets.active.corner_radius, + hovered: widgets.hovered.corner_radius, + inactive: widgets.inactive.corner_radius, + noninteractive: widgets.noninteractive.corner_radius, + open: widgets.open.corner_radius, + }; + + let widgets = &mut ui.visuals_mut().widgets; + widgets.active.corner_radius = rad; + widgets.hovered.corner_radius = rad; + widgets.inactive.corner_radius = rad; + widgets.noninteractive.corner_radius = rad; + widgets.open.corner_radius = rad; + + old +} + +fn restore_widgets_corner_rad(ui: &mut egui::Ui, old: WidgetsCornerRadius) { + let widgets = &mut ui.visuals_mut().widgets; + + widgets.active.corner_radius = old.active; + widgets.hovered.corner_radius = old.hovered; + widgets.inactive.corner_radius = old.inactive; + widgets.noninteractive.corner_radius = old.noninteractive; + widgets.open.corner_radius = old.open; +} + +struct WidgetsCornerRadius { + active: CornerRadius, + hovered: CornerRadius, + inactive: CornerRadius, + noninteractive: CornerRadius, + open: CornerRadius, +} + +fn format_day_heading(date: NaiveDate, today: &NaiveDate, i18n: &mut Localization) -> String { + if date == *today { + tr!( + i18n, + "Today", + "Label shown between chat messages for the current day" + ) + } else if date == *today - Duration::days(1) { + tr!( + i18n, + "Yesterday", + "Label shown between chat messages for the previous day" + ) + } else { + date.format("%A, %B %-d, %Y").to_string() + } +} + +pub fn format_time_short( + today: NaiveDate, + time: &DateTime<Local>, + i18n: &mut Localization, +) -> String { + let d = time.date_naive(); + + if d == today { + return format_timestamp_label(time); + } else if d == today - Duration::days(1) { + return tr!( + i18n, + "Yest", + "Abbreviated version of yesterday used in conversation summaries" + ); + } + + let days_ago = today.signed_duration_since(d).num_days(); + + if days_ago < 7 { + return d.format("%a").to_string(); + } + + d.format("%b %-d").to_string() +} + +fn format_timestamp_label(dt: &DateTime<Local>) -> String { + dt.format("%-I:%M %p").to_string() +} + +#[allow(clippy::too_many_arguments)] +pub fn conversation_ui( + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + ui: &mut egui::Ui, + img_cache: &mut Images, + i18n: &mut Localization, + selected_pubkey: &Pubkey, +) -> Option<MessagesAction> { + let Some(id) = cache.active else { + title_label( + ui, + &tr!( + i18n, + "No conversations yet", + "label describing that there are no conversations yet", + ), + ); + return None; + }; + + let Some(conversation) = cache.get(id) else { + tracing::error!("could not find active convo id {id}"); + return None; + }; + + let state = states.get_or_insert(id); + + ConversationUi::new(conversation, state, ndb, jobs, img_cache, i18n).ui(ui, selected_pubkey) +} diff --git a/crates/notedeck_messages/src/ui/mod.rs b/crates/notedeck_messages/src/ui/mod.rs @@ -12,6 +12,8 @@ use notedeck_ui::ProfilePic; use crate::cache::{Conversation, ConversationCache, ConversationMetadata}; +pub mod convo; + #[derive(Clone, Debug)] pub struct ConversationSummary<'a> { pub metadata: &'a ConversationMetadata,