diff --git a/crates/quicproquo-client/src/client/mod.rs b/crates/quicproquo-client/src/client/mod.rs index 502ee3b..5132af3 100644 --- a/crates/quicproquo-client/src/client/mod.rs +++ b/crates/quicproquo-client/src/client/mod.rs @@ -14,6 +14,10 @@ pub mod state; pub mod token_cache; #[cfg(feature = "tui")] pub mod tui; +#[cfg(feature = "v2")] +pub mod v2_repl; +#[cfg(all(feature = "v2", feature = "tui"))] +pub mod v2_tui; pub use commands::*; pub use rpc::{connect_node, enqueue, fetch_all, fetch_hybrid_key, fetch_key_package, fetch_wait, upload_hybrid_key, upload_key_package}; diff --git a/crates/quicproquo-client/src/client/v2_tui.rs b/crates/quicproquo-client/src/client/v2_tui.rs new file mode 100644 index 0000000..c5a4aca --- /dev/null +++ b/crates/quicproquo-client/src/client/v2_tui.rs @@ -0,0 +1,979 @@ +//! Full-screen Ratatui TUI for quicproquo v2, driven by the SDK event system. +//! +//! Layout: +//! +-- Conversations -+-- Messages ------------------------------+ +//! | > alice (DM) | [12:34] alice: Hey! | +//! | teamchat | [12:35] you: Hello alice | +//! | devgroup | [12:36] alice: How's it going? | +//! | | | +//! +------------------+------------------------------------------+ +//! | > Type a message... (Enter to send, /cmd for commands) | +//! +-------------------------------------------------------------+ +//! | Connected to 127.0.0.1:7000 | alice | 3 conversations | +//! +-------------------------------------------------------------+ +//! +//! Keyboard: +//! Enter -- send message (or execute /command) +//! Up / Down -- scroll message history +//! Tab -- next conversation +//! Shift+Tab -- previous conversation +//! Ctrl+C / Ctrl+Q -- quit +//! +//! Feature gate: requires both `v2` and `tui` features. + +use std::time::Duration; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + Frame, Terminal, +}; +use tokio::sync::broadcast; + +use quicproquo_sdk::client::QpqClient; +use quicproquo_sdk::conversation::ConversationStore; +use quicproquo_sdk::events::ClientEvent; + +// ── Data Types ────────────────────────────────────────────────────────────── + +/// A conversation entry shown in the sidebar. +struct ConversationItem { + id: [u8; 16], + name: String, + unread: usize, + last_message: Option, +} + +/// A message entry shown in the messages panel. +struct DisplayMessage { + timestamp: String, + sender: String, + body: String, + is_outgoing: bool, +} + +/// Focus region for keyboard navigation. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + Input, + Help, +} + +// ── Application State ─────────────────────────────────────────────────────── + +/// Main application state for the TUI. +pub struct TuiApp { + conversations: Vec, + selected_conversation: usize, + messages: Vec, + input: String, + input_cursor: usize, + scroll_offset: usize, + status_line: String, + should_quit: bool, + username: Option, + server_addr: String, + focus: Focus, + /// Notification line (shown briefly, e.g. "Message sent", "Error: ..."). + notification: Option, +} + +impl TuiApp { + fn new(server_addr: &str) -> Self { + Self { + conversations: Vec::new(), + selected_conversation: 0, + messages: Vec::new(), + input: String::new(), + input_cursor: 0, + scroll_offset: 0, + status_line: format!("Connecting to {server_addr}..."), + should_quit: false, + username: None, + server_addr: server_addr.to_string(), + focus: Focus::Input, + notification: None, + } + } + + fn active_conv_id(&self) -> Option<&[u8; 16]> { + self.conversations + .get(self.selected_conversation) + .map(|c| &c.id) + } + + fn select_next_conversation(&mut self) { + if self.conversations.is_empty() { + return; + } + self.selected_conversation = + (self.selected_conversation + 1) % self.conversations.len(); + self.scroll_offset = 0; + } + + fn select_prev_conversation(&mut self) { + if self.conversations.is_empty() { + return; + } + if self.selected_conversation == 0 { + self.selected_conversation = self.conversations.len() - 1; + } else { + self.selected_conversation -= 1; + } + self.scroll_offset = 0; + } + + fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + + fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + fn update_status(&mut self) { + let user = self + .username + .as_deref() + .unwrap_or("not logged in"); + let conv_count = self.conversations.len(); + self.status_line = format!( + "Connected to {} | {} | {} conversation{}", + self.server_addr, + user, + conv_count, + if conv_count == 1 { "" } else { "s" } + ); + } +} + +// ── Terminal Drop Guard ───────────────────────────────────────────────────── + +/// Ensures the terminal is restored on drop (including panics). +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + std::io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture + ); + } +} + +// ── Entry Point ───────────────────────────────────────────────────────────── + +/// Run the full-screen TUI, subscribing to SDK events. +/// +/// The caller must have already connected and optionally authenticated the +/// `QpqClient`. The TUI subscribes to events and drives the UI loop. +pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> { + let server_addr = if client.is_connected() { + "server" + } else { + "disconnected" + }; + let mut app = TuiApp::new(server_addr); + + // Populate initial state from client. + if let Some(name) = client.username() { + app.username = Some(String::from(name)); + } + + // Load existing conversations from the conversation store. + if let Ok(store) = client.conversations() { + let store: &ConversationStore = store; + if let Ok(convs) = store.list_conversations() { + for conv in &convs { + app.conversations.push(ConversationItem { + id: conv.id.0, + name: conv.display_name.clone(), + unread: conv.unread_count as usize, + last_message: None, + }); + } + } + } + + // Load messages for the first conversation. + load_messages_for_selected(&mut app, client); + + app.update_status(); + + // Subscribe to SDK events. + let mut event_rx = client.subscribe(); + + // Terminal setup. + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let _guard = TerminalGuard; + + // Main event loop. + loop { + terminal.draw(|frame| ui(frame, &app))?; + + // Poll for crossterm input events (50ms timeout for responsiveness). + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + handle_key(&mut app, client, key).await; + } + } + + // Process all pending SDK events (non-blocking). + loop { + match event_rx.try_recv() { + Ok(sdk_event) => handle_sdk_event(&mut app, sdk_event), + Err(broadcast::error::TryRecvError::Empty) => break, + Err(broadcast::error::TryRecvError::Lagged(n)) => { + app.notification = + Some(format!("Warning: dropped {n} events (lagged)")); + break; + } + Err(broadcast::error::TryRecvError::Closed) => { + app.notification = + Some("Event channel closed".to_string()); + break; + } + } + } + + if app.should_quit { + break; + } + } + + Ok(()) +} + +// ── SDK Event Handling ────────────────────────────────────────────────────── + +fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) { + match event { + ClientEvent::Connected => { + app.notification = Some("Connected to server".to_string()); + app.update_status(); + } + ClientEvent::Disconnected { reason } => { + app.status_line = format!("Disconnected: {reason}"); + app.notification = Some(format!("Disconnected: {reason}")); + } + ClientEvent::Registered { username } => { + app.notification = Some(format!("Registered as {username}")); + } + ClientEvent::LoggedIn { username } => { + app.username = Some(username.clone()); + app.notification = Some(format!("Logged in as {username}")); + app.update_status(); + } + ClientEvent::LoggedOut { username } => { + app.username = None; + app.notification = Some(format!("Logged out ({username})")); + app.update_status(); + } + ClientEvent::Authenticated { username } => { + app.username = Some(username.clone()); + app.update_status(); + } + ClientEvent::MessageReceived { + conversation_id, + sender_name, + body, + timestamp_ms, + .. + } => { + let sender = sender_name + .unwrap_or_else(|| "unknown".to_string()); + let ts = format_timestamp(timestamp_ms); + + // If this is the active conversation, add to the message list. + let is_active = app + .active_conv_id() + .map(|id| *id == conversation_id) + .unwrap_or(false); + + if is_active { + app.messages.push(DisplayMessage { + timestamp: ts, + sender: sender.clone(), + body: body.clone(), + is_outgoing: false, + }); + } else { + // Increment unread count for non-active conversations. + if let Some(conv) = app + .conversations + .iter_mut() + .find(|c| c.id == conversation_id) + { + conv.unread = conv.unread.saturating_add(1); + } + } + + // Update last_message on the conversation item. + if let Some(conv) = app + .conversations + .iter_mut() + .find(|c| c.id == conversation_id) + { + conv.last_message = Some(format!("{sender}: {body}")); + } + } + ClientEvent::MessageSent { .. } => { + // Nothing to do — the message was already added optimistically. + } + ClientEvent::ConversationCreated { + conversation_id, + display_name, + } => { + // Add to the conversation list if not already present. + let exists = app + .conversations + .iter() + .any(|c| c.id == conversation_id); + if !exists { + app.conversations.push(ConversationItem { + id: conversation_id, + name: display_name.clone(), + unread: 0, + last_message: None, + }); + app.notification = + Some(format!("New conversation: {display_name}")); + } + app.update_status(); + } + ClientEvent::MemberAdded { + conversation_id, + member_key, + } => { + let short_key = hex::encode(&member_key[..member_key.len().min(4)]); + app.notification = + Some(format!("Member {short_key} added to conversation")); + let _ = conversation_id; + } + ClientEvent::MemberRemoved { + conversation_id, + member_key, + } => { + let short_key = hex::encode(&member_key[..member_key.len().min(4)]); + app.notification = + Some(format!("Member {short_key} removed from conversation")); + let _ = conversation_id; + } + ClientEvent::PushEvent { event_type, .. } => { + app.notification = + Some(format!("Push event (type={event_type})")); + } + ClientEvent::Error { message } => { + app.notification = Some(format!("Error: {message}")); + } + } +} + +// ── Keyboard Handling ─────────────────────────────────────────────────────── + +async fn handle_key(app: &mut TuiApp, client: &mut QpqClient, key: event::KeyEvent) { + // Dismiss help overlay on any key. + if app.focus == Focus::Help { + app.focus = Focus::Input; + return; + } + + match key.code { + // Quit. + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + + // Tab — switch conversations. + KeyCode::Tab => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + app.select_prev_conversation(); + } else { + app.select_next_conversation(); + } + load_messages_for_selected(app, client); + } + KeyCode::BackTab => { + app.select_prev_conversation(); + load_messages_for_selected(app, client); + } + + // Scrolling. + KeyCode::Up => app.scroll_up(), + KeyCode::Down => app.scroll_down(), + KeyCode::PageUp => { + for _ in 0..10 { + app.scroll_up(); + } + } + KeyCode::PageDown => { + for _ in 0..10 { + app.scroll_down(); + } + } + + // Submit input. + KeyCode::Enter => { + let text = app.input.trim().to_string(); + if !text.is_empty() { + app.input.clear(); + app.input_cursor = 0; + handle_input(app, client, &text).await; + } + } + + // Text editing. + KeyCode::Char(c) => { + app.input.insert(app.input_cursor, c); + app.input_cursor += c.len_utf8(); + } + KeyCode::Backspace => { + if app.input_cursor > 0 { + // Find the byte offset of the previous character. + let prev = app.input[..app.input_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + app.input.remove(prev); + app.input_cursor = prev; + } + } + KeyCode::Delete => { + if app.input_cursor < app.input.len() { + app.input.remove(app.input_cursor); + } + } + KeyCode::Left => { + if app.input_cursor > 0 { + let prev = app.input[..app.input_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + app.input_cursor = prev; + } + } + KeyCode::Right => { + if app.input_cursor < app.input.len() { + let next = app.input[app.input_cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| app.input_cursor + i) + .unwrap_or(app.input.len()); + app.input_cursor = next; + } + } + KeyCode::Home => { + app.input_cursor = 0; + } + KeyCode::End => { + app.input_cursor = app.input.len(); + } + + _ => {} + } +} + +/// Handle submitted input: either a /command or a chat message. +async fn handle_input(app: &mut TuiApp, client: &mut QpqClient, text: &str) { + if let Some(cmd) = text.strip_prefix('/') { + handle_command(app, client, cmd).await; + } else { + // Send as chat message to the active conversation. + if app.active_conv_id().is_none() { + app.notification = Some("No conversation selected".to_string()); + return; + } + + // Add optimistically to the display. + let sender = app + .username + .as_deref() + .unwrap_or("you") + .to_string(); + let ts = format_timestamp(now_ms()); + app.messages.push(DisplayMessage { + timestamp: ts, + sender: sender.clone(), + body: text.to_string(), + is_outgoing: true, + }); + // Snap to bottom. + app.scroll_offset = 0; + + // TODO: actually send via SDK when the send pipeline is wired up. + // For now, emit a notification. + app.notification = Some(format!("Sent: {text}")); + } +} + +/// Handle a /command. +async fn handle_command(app: &mut TuiApp, client: &mut QpqClient, cmd: &str) { + let parts: Vec<&str> = cmd.splitn(3, ' ').collect(); + let verb = parts.first().copied().unwrap_or(""); + + match verb { + "help" | "h" | "?" => { + app.focus = Focus::Help; + } + "quit" | "exit" | "q" => { + app.should_quit = true; + } + "login" => { + if parts.len() < 3 { + app.notification = + Some("Usage: /login ".to_string()); + return; + } + let username = parts[1]; + let password = parts[2]; + match client.login(username, password).await { + Ok(()) => { + app.username = Some(username.to_string()); + app.notification = + Some(format!("Logged in as {username}")); + app.update_status(); + } + Err(e) => { + app.notification = + Some(format!("Login failed: {e}")); + } + } + } + "register" => { + if parts.len() < 3 { + app.notification = + Some("Usage: /register ".to_string()); + return; + } + let username = parts[1]; + let password = parts[2]; + match client.register(username, password).await { + Ok(()) => { + app.notification = + Some(format!("Registered as {username}")); + } + Err(e) => { + app.notification = + Some(format!("Registration failed: {e}")); + } + } + } + "logout" => { + match client.logout() { + Ok(()) => { + app.username = None; + app.notification = Some("Logged out".to_string()); + app.update_status(); + } + Err(e) => { + app.notification = + Some(format!("Logout failed: {e}")); + } + } + } + "dm" => { + if parts.len() < 2 { + app.notification = + Some("Usage: /dm ".to_string()); + return; + } + app.notification = + Some(format!("DM with {} (not yet implemented)", parts[1])); + } + "group" => { + if parts.len() < 3 || parts[1] != "create" { + app.notification = + Some("Usage: /group create ".to_string()); + return; + } + app.notification = + Some(format!("Create group '{}' (not yet implemented)", parts[2])); + } + other => { + app.notification = + Some(format!("Unknown command: /{other}. Type /help for help.")); + } + } +} + +// ── Drawing ───────────────────────────────────────────────────────────────── + +fn ui(frame: &mut Frame, app: &TuiApp) { + let size = frame.area(); + + // Help overlay. + if app.focus == Focus::Help { + draw_help(frame, size); + return; + } + + // Top-level vertical split: [main area | input bar | status bar]. + let v_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), // main area + Constraint::Length(3), // input bar + Constraint::Length(1), // status bar + ]) + .split(size); + + // Main area: sidebar | messages. + let h_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) + .split(v_chunks[0]); + + draw_sidebar(frame, app, h_chunks[0]); + draw_messages(frame, app, h_chunks[1]); + draw_input(frame, app, v_chunks[1]); + draw_status(frame, app, v_chunks[2]); +} + +fn draw_sidebar(frame: &mut Frame, app: &TuiApp, area: Rect) { + let items: Vec = app + .conversations + .iter() + .enumerate() + .map(|(i, conv)| { + let prefix = if i == app.selected_conversation { + "> " + } else { + " " + }; + + let unread_suffix = if conv.unread > 0 { + format!(" ({})", conv.unread) + } else { + String::new() + }; + + let style = if i == app.selected_conversation { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if conv.unread > 0 { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + ListItem::new(Line::from(Span::styled( + format!("{prefix}{}{unread_suffix}", conv.name), + style, + ))) + }) + .collect(); + + let block = Block::default() + .title(" Conversations ") + .borders(Borders::ALL) + .style(Style::default().fg(Color::DarkGray)); + + let mut list_state = ListState::default(); + if !app.conversations.is_empty() { + list_state.select(Some(app.selected_conversation)); + } + + frame.render_stateful_widget( + List::new(items).block(block).highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::REVERSED), + ), + area, + &mut list_state, + ); +} + +fn draw_messages(frame: &mut Frame, app: &TuiApp, area: Rect) { + let channel_title = app + .conversations + .get(app.selected_conversation) + .map(|c| format!(" {} ", c.name)) + .unwrap_or_else(|| " Messages ".to_string()); + + let block = Block::default() + .title(channel_title) + .borders(Borders::ALL) + .style(Style::default().fg(Color::DarkGray)); + + let inner_height = area.height.saturating_sub(2) as usize; + + // Build lines from messages (newest at bottom). + let lines: Vec = app + .messages + .iter() + .map(|m| { + let ts_span = Span::styled( + format!("{} ", m.timestamp), + Style::default().fg(Color::DarkGray), + ); + + let sender_style = if m.is_outgoing { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + }; + let sender_span = Span::styled(format!("{}: ", m.sender), sender_style); + let body_span = Span::raw(m.body.clone()); + + Line::from(vec![ts_span, sender_span, body_span]) + }) + .collect(); + + // Apply scroll: scroll_offset=0 means newest at bottom. + let total = lines.len(); + let (visible_start, visible_end) = if total > inner_height { + let bottom = total.saturating_sub(app.scroll_offset); + let start = bottom.saturating_sub(inner_height); + (start, bottom) + } else { + (0, total) + }; + + let visible_lines: Vec = lines[visible_start..visible_end].to_vec(); + + // Show notification at the top if present. + let mut all_lines = Vec::new(); + if let Some(ref note) = app.notification { + all_lines.push(Line::from(Span::styled( + note.clone(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::ITALIC), + ))); + } + all_lines.extend(visible_lines); + + let paragraph = Paragraph::new(all_lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn draw_input(frame: &mut Frame, app: &TuiApp, area: Rect) { + let title = if app.conversations.is_empty() { + " Type /help for commands " + } else { + " Enter=send | Tab=switch | /help for commands " + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(Color::DarkGray)); + + let display_text = if app.input.is_empty() { + "Type a message..." + } else { + &app.input + }; + + let style = if app.input.is_empty() { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::White) + }; + + let input_text = Paragraph::new(display_text) + .block(block) + .style(style); + + frame.render_widget(input_text, area); + + // Position cursor in the input area. + if !app.input.is_empty() || true { + let cursor_x = area.x + 1 + app.input_cursor as u16; + let cursor_y = area.y + 1; + if cursor_x < area.x + area.width - 1 { + frame.set_cursor_position((cursor_x, cursor_y)); + } + } +} + +fn draw_status(frame: &mut Frame, app: &TuiApp, area: Rect) { + let status = Paragraph::new(Line::from(Span::styled( + format!(" {} ", app.status_line), + Style::default() + .fg(Color::Black) + .bg(Color::DarkGray), + ))); + frame.render_widget(status, area); +} + +fn draw_help(frame: &mut Frame, area: Rect) { + let help_text = vec![ + Line::from(Span::styled( + " quicproquo TUI -- Help", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " Keyboard shortcuts:", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(" Enter Send message / execute command"), + Line::from(" Up/Down Scroll messages"), + Line::from(" PgUp/PgDn Scroll messages (10 lines)"), + Line::from(" Tab Next conversation"), + Line::from(" Shift+Tab Previous conversation"), + Line::from(" Ctrl+C Quit"), + Line::from(" Ctrl+Q Quit"), + Line::from(""), + Line::from(Span::styled( + " Commands:", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(" /help Show this help"), + Line::from(" /login

Log in"), + Line::from(" /register

Register a new account"), + Line::from(" /logout Log out"), + Line::from(" /dm Start a DM"), + Line::from(" /group create Create a group"), + Line::from(" /quit Exit the TUI"), + Line::from(""), + Line::from(Span::styled( + " Press any key to dismiss", + Style::default().fg(Color::DarkGray), + )), + ]; + + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Cyan)); + + let help = Paragraph::new(help_text).block(block); + let help_area = centered_rect(60, 70, area); + // Clear the background. + frame.render_widget( + Block::default().style(Style::default().bg(Color::Black)), + help_area, + ); + frame.render_widget(help, help_area); +} + +/// Create a centered rectangle of `percent_x`% width and `percent_y`% height. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let v = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + let h = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(v[1]); + h[1] +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Load messages from the conversation store into the app for the selected +/// conversation. +fn load_messages_for_selected(app: &mut TuiApp, client: &QpqClient) { + app.messages.clear(); + app.scroll_offset = 0; + + let conv_id = match app.active_conv_id() { + Some(id) => *id, + None => return, + }; + + let store: &ConversationStore = match client.conversations() { + Ok(s) => s, + Err(_) => return, + }; + + let sdk_conv_id = + quicproquo_sdk::conversation::ConversationId::from_slice(&conv_id); + let sdk_conv_id = match sdk_conv_id { + Some(id) => id, + None => return, + }; + + let msgs = match store.load_recent_messages(&sdk_conv_id, 200) { + Ok(m) => m, + Err(_) => return, + }; + + for msg in &msgs { + let sender: String = if msg.is_outgoing { + app.username.as_deref().unwrap_or("you").to_string() + } else if let Some(ref name) = msg.sender_name { + name.clone() + } else { + let len = msg.sender_key.len().min(4); + hex::encode(&msg.sender_key[..len]) + }; + + app.messages.push(DisplayMessage { + timestamp: format_timestamp(msg.timestamp_ms), + sender, + body: msg.body.clone(), + is_outgoing: msg.is_outgoing, + }); + } + + // Mark unread = 0 for the selected conversation. + if let Some(conv) = app + .conversations + .iter_mut() + .find(|c| c.id == conv_id) + { + conv.unread = 0; + } +} + +fn format_timestamp(ms: u64) -> String { + let secs = ms / 1000; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + format!("[{hours:02}:{minutes:02}]") +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +}