diff --git a/android/src/ui/chat.rs b/android/src/ui/chat.rs index b65f1c4..93ee012 100644 --- a/android/src/ui/chat.rs +++ b/android/src/ui/chat.rs @@ -10,7 +10,8 @@ use crate::clipboard; use crate::state::{AppState, ComposeAttach, NickTabComplete, ReplyTarget}; use crate::ui::search::{message_search_panel, SearchAction}; use crate::ui::widgets::{ - avatar_circle, empty_state, message_bubble, react_picker_overlay, MessageBubbleAction, + avatar_circle, date_separator, empty_state, format_day_separator, message_bubble, + react_picker_overlay, MessageBubbleAction, }; pub enum ChatAction { @@ -668,7 +669,16 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: let mut target_in_view = false; let msg_by_id: std::collections::HashMap<&str, &crate::state::ChatMessage> = messages.iter().map(|m| (m.id.as_str(), m)).collect(); + let mut prev_day: Option = None; for msg in &messages { + let day = msg.timestamp.date_naive(); + if prev_day != Some(day) { + let label = format_day_separator(msg.timestamp); + ui.push_id(("day_sep", day), |ui| { + date_separator(ui, th, &label); + }); + prev_day = Some(day); + } let highlighted = highlight_id.as_ref().is_some_and(|id| id == &msg.id); let picker_open = state .react_picker_msg diff --git a/android/src/ui/mod.rs b/android/src/ui/mod.rs index 2efd248..0031adc 100644 --- a/android/src/ui/mod.rs +++ b/android/src/ui/mod.rs @@ -19,6 +19,7 @@ pub use settings::{settings_tab, SettingsAction}; // Widget helpers are used from screens via `widgets::` or direct calls as needed. #[allow(unused_imports)] pub use widgets::{ - avatar_circle, card, conversation_row, image_lightbox_overlay, message_bubble, - react_picker_overlay, section_label, MessageBubbleAction, + avatar_circle, card, conversation_row, date_separator, format_day_separator, + image_lightbox_overlay, message_bubble, react_picker_overlay, section_label, + MessageBubbleAction, }; diff --git a/android/src/ui/widgets.rs b/android/src/ui/widgets.rs index ac2864b..ac96151 100644 --- a/android/src/ui/widgets.rs +++ b/android/src/ui/widgets.rs @@ -59,6 +59,57 @@ pub fn section_label(ui: &mut egui::Ui, th: &Theme, text: &str) { ); } +/// Local-calendar day label for chat stream separators (freeq-android `formatDate`). +pub fn format_day_separator(ts: chrono::DateTime) -> String { + let today = chrono::Local::now().date_naive(); + let day = ts.date_naive(); + if day == today { + "Today".into() + } else if day + chrono::Duration::days(1) == today { + "Yesterday".into() + } else { + ts.format("%B %-d, %Y").to_string() + } +} + +/// Centered day divider between messages when the calendar day changes. +pub fn date_separator(ui: &mut egui::Ui, th: &Theme, label: &str) { + let sp = &th.spacing; + let p = &th.palette; + ui.add_space(sp.md); + let full = ui.available_width(); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(full, th.type_scale.caption + sp.sm), + Sense::hover(), + ); + let galley = ui.painter().layout_no_wrap( + label.to_owned(), + FontId::proportional(th.type_scale.caption), + p.text_secondary.gamma_multiply(0.85), + ); + let text_w = galley.size().x; + let gap = sp.md; + let line_y = rect.center().y; + let mid = rect.center().x; + let left_end = (mid - text_w * 0.5 - gap).max(rect.left()); + let right_start = (mid + text_w * 0.5 + gap).min(rect.right()); + let stroke = Stroke::new(1.0_f32, p.border_soft.gamma_multiply(0.7)); + if left_end > rect.left() + 1.0 { + ui.painter() + .hline(rect.left()..=left_end, line_y, stroke); + } + if right_start + 1.0 < rect.right() { + ui.painter() + .hline(right_start..=rect.right(), line_y, stroke); + } + ui.painter().galley( + egui::pos2(mid - text_w * 0.5, rect.center().y - galley.size().y * 0.5), + galley, + p.text_secondary, + ); + ui.add_space(sp.sm); +} + /// Colored initial circle (freeq-style avatar stand-in). pub fn avatar_circle(ui: &mut egui::Ui, th: &Theme, name: &str, size: f32) { let letter = name @@ -2647,3 +2698,28 @@ mod embed_image_tests { assert!((a.x / a.y - b.x / b.y).abs() < 0.001); } } + +#[cfg(test)] +mod day_separator_tests { + use super::format_day_separator; + use chrono::{Duration, Local, TimeZone}; + + #[test] + fn today_and_yesterday_labels() { + let now = Local::now(); + assert_eq!(format_day_separator(now), "Today"); + assert_eq!(format_day_separator(now - Duration::days(1)), "Yesterday"); + } + + #[test] + fn older_day_uses_month_day_year() { + let ts = Local + .with_ymd_and_hms(2024, 3, 5, 14, 30, 0) + .single() + .expect("valid local datetime"); + let label = format_day_separator(ts); + assert_eq!(label, "March 5, 2024"); + assert!(!label.contains("Today")); + assert!(!label.contains("Yesterday")); + } +}