notedeck

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

commit ae498ff9e3633728b4cc7547cfba63cb193e1579
parent c7ea993931a1157706d41534bb36b23b7448eb20
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 18 Dec 2025 17:59:36 -0500

feat(msgs-ui): add `ConversationListUi`

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

Diffstat:
Acrates/notedeck_messages/src/ui/convo_list.rs | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_messages/src/ui/mod.rs | 1+
2 files changed, 312 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_messages/src/ui/convo_list.rs b/crates/notedeck_messages/src/ui/convo_list.rs @@ -0,0 +1,311 @@ +use chrono::Local; +use egui::{ + Align, Color32, CornerRadius, Frame, Label, Layout, Margin, RichText, ScrollArea, Sense, +}; +use egui_extras::{Size, Strip, StripBuilder}; +use enostr::Pubkey; +use nostrdb::{Ndb, Note, ProfileRecord, Transaction}; +use notedeck::{ + fonts::get_font_size, tr, ui::is_narrow, Images, Localization, MediaJobSender, + NotedeckTextStyle, +}; +use notedeck_ui::ProfilePic; + +use crate::{ + cache::{ + Conversation, ConversationCache, ConversationId, ConversationState, ConversationStates, + }, + nav::MessagesAction, + ui::{ + conversation_title, convo::format_time_short, direct_chat_partner, local_datetime, + ConversationSummary, + }, +}; + +pub struct ConversationListUi<'a> { + cache: &'a ConversationCache, + states: &'a mut ConversationStates, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + img_cache: &'a mut Images, + i18n: &'a mut Localization, +} + +impl<'a> ConversationListUi<'a> { + pub fn new( + cache: &'a ConversationCache, + states: &'a mut ConversationStates, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + img_cache: &'a mut Images, + i18n: &'a mut Localization, + ) -> Self { + Self { + cache, + states, + ndb, + jobs, + img_cache, + i18n, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui, selected_pubkey: &Pubkey) -> Option<MessagesAction> { + let mut action = None; + if self.cache.is_empty() { + ui.centered_and_justified(|ui| { + ui.label(tr!( + self.i18n, + "No conversations yet", + "Empty state text when the user has no conversations" + )); + }); + return None; + } + + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let num_convos = self.cache.len(); + + self.states + .convos_list + .ui_custom_layout(ui, num_convos, |ui, index| { + let Some(id) = self.cache.get_id_by_index(index).copied() else { + return 1; + }; + + let Some(convo) = self.cache.get(id) else { + return 1; + }; + + let state = self.states.cache.get(&id); + + if let Some(a) = render_list_item( + ui, + self.ndb, + self.cache.active, + id, + convo, + state, + self.jobs, + self.img_cache, + selected_pubkey, + self.i18n, + ) { + action = Some(a); + } + + 1 + }); + }); + action + } +} + +#[allow(clippy::too_many_arguments)] +fn render_list_item( + ui: &mut egui::Ui, + ndb: &Ndb, + active: Option<ConversationId>, + id: ConversationId, + convo: &Conversation, + state: Option<&ConversationState>, + jobs: &MediaJobSender, + img_cache: &mut Images, + selected_pubkey: &Pubkey, + i18n: &mut Localization, +) -> Option<MessagesAction> { + let txn = Transaction::new(ndb).expect("txn"); + let summary = ConversationSummary::new(convo, state.and_then(|s| s.last_read)); + + let title = conversation_title(summary.metadata, &txn, ndb, selected_pubkey, i18n); + + let partner = direct_chat_partner(summary.metadata.participants.as_slice(), selected_pubkey); + let partner_profile = partner.and_then(|pk| ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok()); + + let last_msg = summary + .last_message + .and_then(|r| ndb.get_note_by_key(&txn, r.key).ok()); + + let response = render_summary( + ui, + summary, + active == Some(id), + title.as_ref(), + partner.is_some(), + last_msg.as_ref(), + partner_profile.as_ref(), + jobs, + img_cache, + i18n, + ); + + response.clicked().then_some(MessagesAction::Open(id)) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_summary( + ui: &mut egui::Ui, + summary: ConversationSummary, + selected: bool, + title: &str, + show_partner_avatar: bool, + last_message: Option<&Note>, + partner_profile: Option<&ProfileRecord<'_>>, + jobs: &MediaJobSender, + img_cache: &mut Images, + i18n: &mut Localization, +) -> egui::Response { + let visuals = ui.visuals(); + let fill = if is_narrow(ui.ctx()) { + Color32::TRANSPARENT + } else if selected { + visuals.selection.bg_fill + } else if summary.unread { + visuals.faint_bg_color + } else { + Color32::TRANSPARENT + }; + + Frame::new() + .fill(fill) + .corner_radius(CornerRadius::same(12)) + .inner_margin(Margin::symmetric(12, 8)) + .show(ui, |ui| { + render_summary_inner( + ui, + title, + show_partner_avatar, + last_message, + partner_profile, + jobs, + img_cache, + i18n, + ); + }) + .response + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) +} + +#[allow(clippy::too_many_arguments)] +fn render_summary_inner( + ui: &mut egui::Ui, + title: &str, + show_partner_avatar: bool, + last_message: Option<&Note>, + partner_profile: Option<&ProfileRecord<'_>>, + jobs: &MediaJobSender, + img_cache: &mut Images, + i18n: &mut Localization, +) { + let summary_height = 40.0; + StripBuilder::new(ui) + .size(Size::exact(summary_height)) + .vertical(|mut strip| { + strip.strip(|builder| { + builder + .size(Size::exact(summary_height + 8.0)) + .size(Size::remainder()) + .horizontal(|strip| { + render_summary_horizontal( + title, + show_partner_avatar, + last_message, + partner_profile, + jobs, + img_cache, + summary_height, + i18n, + strip, + ); + }); + }); + }); +} + +#[allow(clippy::too_many_arguments)] +fn render_summary_horizontal( + title: &str, + show_partner_avatar: bool, + last_message: Option<&Note>, + partner_profile: Option<&ProfileRecord<'_>>, + jobs: &MediaJobSender, + img_cache: &mut Images, + summary_height: f32, + i18n: &mut Localization, + mut strip: Strip, +) { + if show_partner_avatar { + strip.cell(|ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let size = ProfilePic::default_size() as f32; + let mut pic = ProfilePic::from_profile_or_default(img_cache, jobs, partner_profile) + .size(size); + ui.add(&mut pic); + }); + }); + } else { + strip.empty(); + } + + let title_height = 8.0; + strip.cell(|ui| { + StripBuilder::new(ui) + .size(Size::exact(title_height)) + .size(Size::exact(summary_height - title_height)) + .vertical(|strip| { + render_summary_body(title, last_message, i18n, strip); + }); + }); +} + +fn render_summary_body( + title: &str, + last_message: Option<&Note>, + i18n: &mut Localization, + mut strip: Strip, +) { + strip.cell(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if let Some(last_msg) = last_message { + let today = Local::now().date_naive(); + let last_msg_ts = i64::try_from(last_msg.created_at()).unwrap_or(i64::MAX); + let time_str = format_time_short(today, &local_datetime(last_msg_ts), i18n); + + ui.add_enabled( + false, + Label::new( + RichText::new(time_str) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4)), + ), + ); + } + + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add( + egui::Label::new(RichText::new(title).strong()) + .truncate() + .selectable(false), + ); + }); + }); + }); + + let Some(last_msg) = last_message else { + strip.empty(); + return; + }; + + strip.cell(|ui| { + ui.add_enabled( + false, // disables hover & makes text grayed out + Label::new( + RichText::new(last_msg.content()) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)), + ) + .truncate(), + ); + }); +} diff --git a/crates/notedeck_messages/src/ui/mod.rs b/crates/notedeck_messages/src/ui/mod.rs @@ -13,6 +13,7 @@ use notedeck_ui::ProfilePic; use crate::cache::{Conversation, ConversationCache, ConversationMetadata}; pub mod convo; +pub mod convo_list; #[derive(Clone, Debug)] pub struct ConversationSummary<'a> {