feat(client): ratatui TUI subscribing to SDK events

Single-file v2 TUI (v2_tui.rs, ~580 lines) gated behind
cfg(all(feature = "v2", feature = "tui")).

Layout: conversations sidebar, scrollable messages panel,
input bar with cursor editing, status line. Help overlay
on /help. Subscribes to SDK broadcast::Receiver<ClientEvent>
for real-time updates. TerminalGuard ensures terminal restore
on panic. Commands: /login, /register, /logout, /dm, /group,
/quit, /help.

Build: cargo build -p quicproquo-client --features v2,tui
This commit is contained in:
2026-03-04 12:47:15 +01:00
parent 029c701780
commit 99f9abe9ed
2 changed files with 983 additions and 0 deletions

View File

@@ -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};

View File

@@ -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<String>,
}
/// 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<ConversationItem>,
selected_conversation: usize,
messages: Vec<DisplayMessage>,
input: String,
input_cursor: usize,
scroll_offset: usize,
status_line: String,
should_quit: bool,
username: Option<String>,
server_addr: String,
focus: Focus,
/// Notification line (shown briefly, e.g. "Message sent", "Error: ...").
notification: Option<String>,
}
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 <username> <password>".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 <username> <password>".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 <username>".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 <name>".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<ListItem> = 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<Line> = 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<Line> = 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 <u> <p> Log in"),
Line::from(" /register <u> <p> Register a new account"),
Line::from(" /logout Log out"),
Line::from(" /dm <username> Start a DM"),
Line::from(" /group create <n> 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
}