Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
808 lines
30 KiB
Rust
808 lines
30 KiB
Rust
//! Full-screen Ratatui TUI for quicprochat.
|
|
//!
|
|
//! Layout:
|
|
//! ┌──────────────┬──────────────────────────────────────────┐
|
|
//! │ Channels │ Messages │
|
|
//! │ (20%) │ (80%) │
|
|
//! │ │ │
|
|
//! │ ├──────────────────────────────────────────┤
|
|
//! │ │ Input bar │
|
|
//! └──────────────┴──────────────────────────────────────────┘
|
|
//!
|
|
//! Keyboard:
|
|
//! Enter — send message
|
|
//! Up / Down — scroll message history
|
|
//! Tab — next channel
|
|
//! Shift+Tab — prev channel
|
|
//! Ctrl+C / q — quit
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
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::mpsc;
|
|
use tokio::time::interval;
|
|
|
|
use crate::{ClientAuth, init_auth};
|
|
use super::commands::{opaque_login, opaque_register};
|
|
use super::conversation::{now_ms, ConversationId, StoredMessage};
|
|
use super::rpc::{
|
|
connect_node, enqueue, fetch_hybrid_key, fetch_wait, try_hybrid_decrypt, upload_hybrid_key,
|
|
upload_key_package,
|
|
};
|
|
use super::session::SessionState;
|
|
use super::state::load_or_init_state;
|
|
use super::token_cache::{load_cached_session, save_cached_session};
|
|
|
|
use quicprochat_core::{
|
|
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
|
|
hybrid_encrypt, parse as parse_app_msg, serialize_chat,
|
|
};
|
|
use quicprochat_proto::node_capnp::node_service;
|
|
|
|
// ── App events ───────────────────────────────────────────────────────────────
|
|
|
|
/// Events sent from background tasks into the main TUI loop.
|
|
enum TuiEvent {
|
|
/// A key event from the terminal.
|
|
Key(event::KeyEvent),
|
|
/// New messages received from the server (conv_id, sender_short, body).
|
|
NewMessages(Vec<(ConversationId, String, String)>),
|
|
/// Tick — redraw periodically even if nothing happened.
|
|
Tick,
|
|
}
|
|
|
|
// ── Display message ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone)]
|
|
struct DisplayMessage {
|
|
sender: String,
|
|
body: String,
|
|
timestamp_ms: u64,
|
|
is_outgoing: bool,
|
|
}
|
|
|
|
// ── App state ─────────────────────────────────────────────────────────────────
|
|
|
|
struct App {
|
|
/// Channel (conversation) names shown in the sidebar.
|
|
channel_names: Vec<String>,
|
|
/// Conversation IDs, parallel to `channel_names`.
|
|
channel_ids: Vec<ConversationId>,
|
|
/// Index of the selected channel in the sidebar.
|
|
selected_channel: usize,
|
|
/// Messages for the currently active channel.
|
|
messages: Vec<DisplayMessage>,
|
|
/// Current input buffer.
|
|
input: String,
|
|
/// Scroll offset (0 = bottom).
|
|
scroll_offset: usize,
|
|
/// Whether the user has requested quit.
|
|
should_quit: bool,
|
|
/// Short identity string for display.
|
|
identity_short: String,
|
|
}
|
|
|
|
impl App {
|
|
fn new(session: &SessionState) -> anyhow::Result<Self> {
|
|
let convs = session.conv_store.list_conversations()?;
|
|
let channel_names: Vec<String> = convs.iter().map(|c| c.display_name.clone()).collect();
|
|
let channel_ids: Vec<ConversationId> = convs.iter().map(|c| c.id.clone()).collect();
|
|
|
|
Ok(Self {
|
|
channel_names,
|
|
channel_ids,
|
|
selected_channel: 0,
|
|
messages: Vec::new(),
|
|
input: String::new(),
|
|
scroll_offset: 0,
|
|
should_quit: false,
|
|
identity_short: session.identity_short(),
|
|
})
|
|
}
|
|
|
|
fn active_conv_id(&self) -> Option<&ConversationId> {
|
|
self.channel_ids.get(self.selected_channel)
|
|
}
|
|
|
|
/// Reload messages for the currently selected channel from the session store.
|
|
fn reload_messages(&mut self, session: &SessionState) -> anyhow::Result<()> {
|
|
let conv_id = match self.active_conv_id() {
|
|
Some(id) => id.clone(),
|
|
None => {
|
|
self.messages.clear();
|
|
return Ok(());
|
|
}
|
|
};
|
|
let stored = session.conv_store.load_recent_messages(&conv_id, 200)?;
|
|
self.messages = stored
|
|
.into_iter()
|
|
.map(|m| {
|
|
let sender = if m.is_outgoing {
|
|
format!("me({})", &self.identity_short)
|
|
} else if let Some(name) = &m.sender_name {
|
|
name.clone()
|
|
} else {
|
|
// Shorten sender key to 8 hex chars.
|
|
let hex_short = hex::encode(&m.sender_key[..m.sender_key.len().min(4)]);
|
|
format!("{hex_short}")
|
|
};
|
|
DisplayMessage {
|
|
sender,
|
|
body: m.body,
|
|
timestamp_ms: m.timestamp_ms,
|
|
is_outgoing: m.is_outgoing,
|
|
}
|
|
})
|
|
.collect();
|
|
// Reset scroll to bottom on channel switch.
|
|
self.scroll_offset = 0;
|
|
Ok(())
|
|
}
|
|
|
|
fn select_next_channel(&mut self, session: &SessionState) {
|
|
if self.channel_names.is_empty() {
|
|
return;
|
|
}
|
|
self.selected_channel = (self.selected_channel + 1) % self.channel_names.len();
|
|
let _ = self.reload_messages(session);
|
|
}
|
|
|
|
fn select_prev_channel(&mut self, session: &SessionState) {
|
|
if self.channel_names.is_empty() {
|
|
return;
|
|
}
|
|
if self.selected_channel == 0 {
|
|
self.selected_channel = self.channel_names.len() - 1;
|
|
} else {
|
|
self.selected_channel -= 1;
|
|
}
|
|
let _ = self.reload_messages(session);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// Append newly received messages to the in-memory list (no DB reload needed
|
|
/// since we already have them from the poll task, but we also save them via
|
|
/// the session so they appear on reload).
|
|
fn append_messages(&mut self, msgs: Vec<(ConversationId, String, String)>) {
|
|
let active = self.active_conv_id().cloned();
|
|
for (conv_id, sender, body) in msgs {
|
|
if active.as_ref() == Some(&conv_id) {
|
|
self.messages.push(DisplayMessage {
|
|
sender,
|
|
body,
|
|
timestamp_ms: now_ms(),
|
|
is_outgoing: false,
|
|
});
|
|
// Snap to bottom if user wasn't scrolled.
|
|
if self.scroll_offset == 0 {
|
|
// Already at bottom — nothing to do.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Drawing ───────────────────────────────────────────────────────────────────
|
|
|
|
fn ui(frame: &mut Frame, app: &App) {
|
|
let size = frame.area();
|
|
|
|
// Top-level split: sidebar | main area.
|
|
let h_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
|
|
.split(size);
|
|
|
|
// Main area split: messages | input bar.
|
|
let v_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(3), Constraint::Length(3)])
|
|
.split(h_chunks[1]);
|
|
|
|
draw_sidebar(frame, app, h_chunks[0]);
|
|
draw_messages(frame, app, v_chunks[0]);
|
|
draw_input(frame, app, v_chunks[1]);
|
|
}
|
|
|
|
fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
|
let items: Vec<ListItem> = app
|
|
.channel_names
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, name)| {
|
|
let style = if i == app.selected_channel {
|
|
Style::default()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
|
} else {
|
|
Style::default().fg(Color::Cyan)
|
|
};
|
|
ListItem::new(Line::from(Span::styled(name.clone(), style)))
|
|
})
|
|
.collect();
|
|
|
|
let block = Block::default()
|
|
.title(" Channels ")
|
|
.borders(Borders::ALL)
|
|
.style(Style::default().fg(Color::DarkGray));
|
|
|
|
let mut list_state = ListState::default();
|
|
if !app.channel_names.is_empty() {
|
|
list_state.select(Some(app.selected_channel));
|
|
}
|
|
|
|
frame.render_stateful_widget(
|
|
List::new(items).block(block),
|
|
area,
|
|
&mut list_state,
|
|
);
|
|
}
|
|
|
|
fn draw_messages(frame: &mut Frame, app: &App, area: Rect) {
|
|
let channel_title = app
|
|
.channel_names
|
|
.get(app.selected_channel)
|
|
.map(|n| format!(" {n} "))
|
|
.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 mut lines: Vec<Line> = app
|
|
.messages
|
|
.iter()
|
|
.map(|m| {
|
|
let ts = format_timestamp(m.timestamp_ms);
|
|
let ts_span = Span::styled(ts, 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 = if total > inner_height {
|
|
let bottom = total - app.scroll_offset.min(total);
|
|
bottom.saturating_sub(inner_height)
|
|
} else {
|
|
0
|
|
};
|
|
let visible_end = if total > inner_height {
|
|
total - app.scroll_offset.min(total)
|
|
} else {
|
|
total
|
|
};
|
|
let visible_lines: Vec<Line> = lines
|
|
.drain(visible_start..visible_end.min(lines.len()))
|
|
.collect();
|
|
|
|
let paragraph = Paragraph::new(visible_lines)
|
|
.block(block)
|
|
.wrap(Wrap { trim: false });
|
|
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn draw_input(frame: &mut Frame, app: &App, area: Rect) {
|
|
let block = Block::default()
|
|
.title(" Input (Enter=send, Tab=switch channel, q/Ctrl+C=quit) ")
|
|
.borders(Borders::ALL)
|
|
.style(Style::default().fg(Color::DarkGray));
|
|
|
|
let input_text = Paragraph::new(app.input.as_str())
|
|
.block(block)
|
|
.style(Style::default().fg(Color::White));
|
|
|
|
frame.render_widget(input_text, area);
|
|
|
|
// Position cursor at end of input.
|
|
let cursor_x = area.x + 1 + app.input.len() 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 format_timestamp(ms: u64) -> String {
|
|
// Simple HH:MM format from epoch ms.
|
|
let secs = ms / 1000;
|
|
let hours = (secs / 3600) % 24;
|
|
let minutes = (secs / 60) % 60;
|
|
format!("[{:02}:{:02}] ", hours, minutes)
|
|
}
|
|
|
|
// ── Message polling task ──────────────────────────────────────────────────────
|
|
|
|
/// Background task that polls the server for new messages and sends them via `tx`.
|
|
async fn poll_task(
|
|
mut session: SessionState,
|
|
client: node_service::Client,
|
|
tx: mpsc::Sender<TuiEvent>,
|
|
) {
|
|
let mut poll_interval = interval(Duration::from_millis(1000));
|
|
poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
|
|
loop {
|
|
poll_interval.tick().await;
|
|
|
|
let identity_bytes = session.identity_bytes();
|
|
let payloads = match fetch_wait(&client, &identity_bytes, 0).await {
|
|
Ok(p) => p,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
if payloads.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let mut new_msgs: Vec<(ConversationId, String, String)> = Vec::new();
|
|
let my_key = session.identity_bytes();
|
|
|
|
let mut sorted = payloads;
|
|
sorted.sort_by_key(|(seq, _)| *seq);
|
|
|
|
for (_seq, payload) in &sorted {
|
|
let mls_payload = match try_hybrid_decrypt(session.hybrid_kp.as_ref(), payload) {
|
|
Ok(b) => b,
|
|
Err(_) => payload.clone(),
|
|
};
|
|
|
|
let conv_ids: Vec<ConversationId> = session.members.keys().cloned().collect();
|
|
|
|
for conv_id in &conv_ids {
|
|
let member = match session.members.get_mut(conv_id) {
|
|
Some(m) => m,
|
|
None => continue,
|
|
};
|
|
|
|
match member.receive_message(&mls_payload) {
|
|
Ok(ReceivedMessage::Application(plaintext)) => {
|
|
let (sender_key, app_bytes) = {
|
|
let after_unpad = quicprochat_core::padding::unpad(&plaintext)
|
|
.unwrap_or_else(|_| plaintext.clone());
|
|
|
|
if quicprochat_core::sealed_sender::is_sealed(&after_unpad) {
|
|
match quicprochat_core::sealed_sender::unseal(&after_unpad) {
|
|
Ok((sk, inner)) => (sk.to_vec(), inner),
|
|
Err(_) => (my_key.clone(), after_unpad),
|
|
}
|
|
} else {
|
|
(my_key.clone(), after_unpad)
|
|
}
|
|
};
|
|
|
|
let (body, msg_id, msg_type, ref_msg_id) =
|
|
match parse_app_msg(&app_bytes) {
|
|
Ok((_, AppMessage::Chat { message_id, body })) => (
|
|
String::from_utf8_lossy(&body).to_string(),
|
|
Some(message_id),
|
|
"chat",
|
|
None,
|
|
),
|
|
Ok((_, AppMessage::Reply { ref_msg_id, body })) => (
|
|
String::from_utf8_lossy(&body).to_string(),
|
|
None,
|
|
"reply",
|
|
Some(ref_msg_id),
|
|
),
|
|
Ok((_, AppMessage::Reaction { ref_msg_id, emoji })) => (
|
|
String::from_utf8_lossy(&emoji).to_string(),
|
|
None,
|
|
"reaction",
|
|
Some(ref_msg_id),
|
|
),
|
|
_ => (
|
|
String::from_utf8_lossy(&app_bytes).to_string(),
|
|
None,
|
|
"chat",
|
|
None,
|
|
),
|
|
};
|
|
|
|
let stored = StoredMessage {
|
|
conversation_id: conv_id.clone(),
|
|
message_id: msg_id,
|
|
sender_key: sender_key.clone(),
|
|
sender_name: None,
|
|
body: body.clone(),
|
|
msg_type: msg_type.into(),
|
|
ref_msg_id,
|
|
timestamp_ms: now_ms(),
|
|
is_outgoing: false,
|
|
};
|
|
|
|
if session.conv_store.save_message(&stored).is_ok() {
|
|
let sender_short = hex::encode(&sender_key[..sender_key.len().min(4)]);
|
|
new_msgs.push((conv_id.clone(), sender_short, body));
|
|
}
|
|
|
|
let _ = session.conv_store.update_activity(conv_id, now_ms());
|
|
let _ = session.save_member(conv_id);
|
|
break;
|
|
}
|
|
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {
|
|
let _ = session.save_member(conv_id);
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !new_msgs.is_empty() {
|
|
if tx.send(TuiEvent::NewMessages(new_msgs)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Send message ──────────────────────────────────────────────────────────────
|
|
|
|
async fn send_message(
|
|
session: &mut SessionState,
|
|
client: &node_service::Client,
|
|
conv_id: &ConversationId,
|
|
text: &str,
|
|
) -> anyhow::Result<()> {
|
|
let my_key = session.identity_bytes();
|
|
let identity = Arc::clone(&session.identity);
|
|
|
|
let member = session
|
|
.members
|
|
.get_mut(conv_id)
|
|
.context("no GroupMember for this conversation")?;
|
|
|
|
// Wrap in structured AppMessage format.
|
|
let app_payload = serialize_chat(text.as_bytes(), None)
|
|
.context("serialize app message")?;
|
|
|
|
// Metadata protection: seal + pad.
|
|
let sealed = quicprochat_core::sealed_sender::seal(&identity, &app_payload);
|
|
let padded = quicprochat_core::padding::pad(&sealed);
|
|
|
|
let ct = member.send_message(&padded).context("MLS encrypt")?;
|
|
|
|
let recipients: Vec<Vec<u8>> = member
|
|
.member_identities()
|
|
.into_iter()
|
|
.filter(|id| id.as_slice() != my_key.as_slice())
|
|
.collect();
|
|
|
|
for recipient_key in &recipients {
|
|
let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?;
|
|
let payload = if let Some(ref pk) = peer_hybrid_pk {
|
|
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")?
|
|
} else {
|
|
ct.clone()
|
|
};
|
|
enqueue(client, recipient_key, &payload).await?;
|
|
}
|
|
|
|
// Extract message_id from what we just serialized.
|
|
let msg_id = parse_app_msg(&app_payload)
|
|
.ok()
|
|
.and_then(|(_, m)| match m {
|
|
AppMessage::Chat { message_id, .. } => Some(message_id),
|
|
_ => None,
|
|
});
|
|
|
|
// Save outgoing message.
|
|
let stored = StoredMessage {
|
|
conversation_id: conv_id.clone(),
|
|
message_id: msg_id,
|
|
sender_key: my_key,
|
|
sender_name: Some("you".into()),
|
|
body: text.to_string(),
|
|
msg_type: "chat".into(),
|
|
ref_msg_id: None,
|
|
timestamp_ms: now_ms(),
|
|
is_outgoing: true,
|
|
};
|
|
session.conv_store.save_message(&stored)?;
|
|
session.conv_store.update_activity(conv_id, now_ms())?;
|
|
session.save_member(conv_id)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── TUI entry point ───────────────────────────────────────────────────────────
|
|
|
|
/// Entry point for `qpc tui`. Sets up the terminal, runs the event loop, and
|
|
/// restores the terminal on exit.
|
|
pub async fn run_tui(
|
|
state_path: &Path,
|
|
server: &str,
|
|
ca_cert: &Path,
|
|
server_name: &str,
|
|
password: Option<&str>,
|
|
username: Option<&str>,
|
|
opaque_password: Option<&str>,
|
|
access_token: &str,
|
|
device_id: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
let resolved_token = resolve_tui_access_token(
|
|
state_path,
|
|
server,
|
|
ca_cert,
|
|
server_name,
|
|
password,
|
|
username,
|
|
opaque_password,
|
|
access_token,
|
|
)
|
|
.await?;
|
|
|
|
let token_bytes = hex::decode(&resolved_token)
|
|
.unwrap_or_else(|_| resolved_token.into_bytes());
|
|
let auth_ctx = ClientAuth::from_raw(token_bytes, device_id.map(String::from));
|
|
init_auth(auth_ctx);
|
|
|
|
// ── Session + RPC ─────────────────────────────────────────────────────────
|
|
let mut session = SessionState::load(state_path, password)?;
|
|
let client = connect_node(server, ca_cert, server_name).await?;
|
|
|
|
// Auto-upload KeyPackage.
|
|
let _ = auto_upload_keys_tui(&session, &client).await;
|
|
|
|
// ── Terminal setup ────────────────────────────────────────────────────────
|
|
enable_raw_mode().context("enable raw mode")?;
|
|
let mut stdout = std::io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
|
|
.context("enter alternate screen")?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend).context("create terminal")?;
|
|
|
|
let result = tui_loop(&mut terminal, &mut session, client).await;
|
|
|
|
// ── Terminal cleanup (always restore, even on error) ───────────────────
|
|
disable_raw_mode().ok();
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
)
|
|
.ok();
|
|
terminal.show_cursor().ok();
|
|
|
|
session.save_all()?;
|
|
|
|
result
|
|
}
|
|
|
|
async fn tui_loop(
|
|
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
|
session: &mut SessionState,
|
|
client: node_service::Client,
|
|
) -> anyhow::Result<()> {
|
|
let mut app = App::new(session)?;
|
|
app.reload_messages(session)?;
|
|
|
|
let (event_tx, mut event_rx) = mpsc::channel::<TuiEvent>(256);
|
|
|
|
// ── Keyboard event task ───────────────────────────────────────────────────
|
|
let key_tx = event_tx.clone();
|
|
tokio::task::spawn_local(async move {
|
|
loop {
|
|
// crossterm event polling — 50ms timeout so we can tick.
|
|
match event::poll(Duration::from_millis(50)) {
|
|
Ok(true) => {
|
|
if let Ok(Event::Key(key)) = event::read() {
|
|
if key_tx.send(TuiEvent::Key(key)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(false) => {
|
|
// No event — send a tick so the UI redraws.
|
|
if key_tx.send(TuiEvent::Tick).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Message poll task ─────────────────────────────────────────────────────
|
|
// Clone session state for the poll task (it needs its own SessionState).
|
|
let poll_session = SessionState::load(
|
|
&session.state_path.clone(),
|
|
session.password.as_ref().map(|p| p.as_str()),
|
|
)?;
|
|
let poll_tx = event_tx.clone();
|
|
tokio::task::spawn_local(poll_task(poll_session, client.clone(), poll_tx));
|
|
|
|
// ── Main loop ─────────────────────────────────────────────────────────────
|
|
loop {
|
|
terminal.draw(|f| ui(f, &app)).context("draw")?;
|
|
|
|
match event_rx.recv().await {
|
|
None => break,
|
|
Some(TuiEvent::Tick) => {
|
|
// Just redraw.
|
|
}
|
|
Some(TuiEvent::NewMessages(msgs)) => {
|
|
app.append_messages(msgs);
|
|
}
|
|
Some(TuiEvent::Key(key)) => {
|
|
match key.code {
|
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.should_quit = true;
|
|
}
|
|
KeyCode::Char('q') if app.input.is_empty() => {
|
|
app.should_quit = true;
|
|
}
|
|
KeyCode::Enter => {
|
|
let text = app.input.trim().to_string();
|
|
if !text.is_empty() {
|
|
app.input.clear();
|
|
if let Some(conv_id) = app.active_conv_id().cloned() {
|
|
match send_message(session, &client, &conv_id, &text).await {
|
|
Ok(()) => {
|
|
// Add to in-memory list immediately.
|
|
app.messages.push(DisplayMessage {
|
|
sender: format!("me({})", app.identity_short),
|
|
body: text,
|
|
timestamp_ms: now_ms(),
|
|
is_outgoing: true,
|
|
});
|
|
}
|
|
Err(_e) => {
|
|
// Silently drop — user will see nothing happened.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.input.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.input.pop();
|
|
}
|
|
KeyCode::Up => {
|
|
app.scroll_up();
|
|
}
|
|
KeyCode::Down => {
|
|
app.scroll_down();
|
|
}
|
|
KeyCode::Tab => {
|
|
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
|
app.select_prev_channel(session);
|
|
} else {
|
|
app.select_next_channel(session);
|
|
}
|
|
app.reload_messages(session)?;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if app.should_quit {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Startup helpers ───────────────────────────────────────────────────────────
|
|
|
|
async fn auto_upload_keys_tui(
|
|
session: &SessionState,
|
|
client: &node_service::Client,
|
|
) -> anyhow::Result<()> {
|
|
let ks_path = session.state_path.with_extension("pending.ks");
|
|
let ks = DiskKeyStore::persistent(&ks_path).unwrap_or_else(|_| DiskKeyStore::ephemeral());
|
|
let mut member = GroupMember::new_with_state(
|
|
Arc::clone(&session.identity),
|
|
ks,
|
|
None,
|
|
false,
|
|
);
|
|
let kp_bytes = member.generate_key_package().context("generate KeyPackage")?;
|
|
let id_key = session.identity.public_key_bytes();
|
|
upload_key_package(client, &id_key, &kp_bytes).await?;
|
|
if let Some(ref hkp) = session.hybrid_kp {
|
|
upload_hybrid_key(client, &id_key, &hkp.public_key()).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn resolve_tui_access_token(
|
|
state_path: &Path,
|
|
server: &str,
|
|
ca_cert: &Path,
|
|
server_name: &str,
|
|
state_password: Option<&str>,
|
|
username: Option<&str>,
|
|
opaque_password: Option<&str>,
|
|
cli_access_token: &str,
|
|
) -> anyhow::Result<String> {
|
|
if !cli_access_token.is_empty() {
|
|
return Ok(cli_access_token.to_string());
|
|
}
|
|
|
|
if let Some(cached) = load_cached_session(state_path, state_password) {
|
|
return Ok(cached.token_hex);
|
|
}
|
|
|
|
let username = match username {
|
|
Some(u) => u.to_string(),
|
|
None => {
|
|
use std::io::Write;
|
|
eprint!("Username: ");
|
|
std::io::stderr().flush().ok();
|
|
let mut input = String::new();
|
|
std::io::stdin()
|
|
.read_line(&mut input)
|
|
.context("failed to read username")?;
|
|
let trimmed = input.trim().to_string();
|
|
anyhow::ensure!(!trimmed.is_empty(), "username is required");
|
|
trimmed
|
|
}
|
|
};
|
|
|
|
let opaque_password = match opaque_password {
|
|
Some(p) => p.to_string(),
|
|
None => rpassword::read_password().context("failed to read password")?,
|
|
};
|
|
|
|
let state = load_or_init_state(state_path, state_password)?;
|
|
let identity = IdentityKeypair::from_seed(state.identity_seed);
|
|
let identity_key = identity.public_key_bytes().to_vec();
|
|
|
|
let node_client = connect_node(server, ca_cert, server_name).await?;
|
|
|
|
match opaque_register(&node_client, &username, &opaque_password, Some(&identity_key)).await {
|
|
Ok(()) | Err(_) => {}
|
|
}
|
|
|
|
let token_bytes = opaque_login(&node_client, &username, &opaque_password, &identity_key)
|
|
.await
|
|
.context("OPAQUE login failed")?;
|
|
let token_hex = hex::encode(&token_bytes);
|
|
|
|
save_cached_session(state_path, &username, &token_hex, state_password)?;
|
|
|
|
Ok(token_hex)
|
|
}
|