First commit in rust

This commit is contained in:
2026-02-19 13:51:07 +08:00
commit c37ca9ba52
73 changed files with 9737 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
/// Runtime configuration for the BackChannel client.
pub struct ClientConfig {
/// WebSocket server URL, e.g. `ws://127.0.0.1:7777` or `wss://server:7777`.
pub server_url: String,
/// Path to the keystore JSON file (holds the Ed25519 identity keypair).
pub keystore_path: String,
}
impl ClientConfig {
pub fn from_env() -> Self {
let server_url = std::env::var("BC_SERVER_URL")
.unwrap_or_else(|_| "ws://127.0.0.1:7777".into());
let keystore_path = std::env::var("BC_KEYSTORE").unwrap_or_else(|_| {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".into());
format!("{}/.backchannel/keystore.json", home)
});
Self { server_url, keystore_path }
}
}

View File

@@ -0,0 +1,36 @@
use backchannel_common::crypto::{aead, ecdh};
use x25519_dalek::EphemeralSecret;
use crate::error::Result;
/// Generate an ephemeral X25519 keypair to initiate a DM key exchange.
///
/// Returns the `EphemeralSecret` (store in `Keystore::pending_ecdh`) and the
/// base64-encoded public key to send to the server.
pub fn generate_exchange() -> (EphemeralSecret, String) {
let (secret, pubkey) = ecdh::generate_ephemeral();
let pubkey_b64 = ecdh::pubkey_to_b64(&pubkey);
(secret, pubkey_b64)
}
/// Complete a key exchange given our ephemeral secret and the peer's base64
/// public key. Returns the 32-byte derived DM session key.
///
/// The `EphemeralSecret` is consumed here — it cannot be reused.
pub fn complete_exchange(our_secret: EphemeralSecret, their_pubkey_b64: &str) -> Result<[u8; 32]> {
let their_pubkey = ecdh::b64_to_pubkey(their_pubkey_b64)?;
let raw_secret = ecdh::diffie_hellman(our_secret, &their_pubkey);
Ok(aead::derive_dm_key(&raw_secret))
}
/// Encrypt a plaintext DM using a derived session key.
/// Returns `(ciphertext_b64, nonce_b64)`.
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<(String, String)> {
aead::encrypt(key, plaintext.as_bytes()).map_err(Into::into)
}
/// Decrypt a received DM ciphertext using a derived session key.
pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<String> {
let bytes = aead::decrypt(key, nonce_b64, ciphertext_b64)?;
Ok(String::from_utf8(bytes)?)
}

View File

@@ -0,0 +1 @@
pub mod dm;

View File

@@ -0,0 +1,30 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ClientError>;
#[derive(Debug, Error)]
pub enum ClientError {
#[error("not authenticated")]
NotAuthenticated,
#[error("server error {code}: {message}")]
Server { code: u16, message: String },
#[error("no DM key for peer — initiate key exchange first")]
NoDmKey,
#[error("crypto error: {0}")]
Crypto(#[from] backchannel_common::BackchannelError),
#[error("serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("UTF-8 error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("{0}")]
Other(String),
}

View File

@@ -0,0 +1,91 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use x25519_dalek::EphemeralSecret;
use backchannel_common::crypto::identity;
use crate::error::{ClientError, Result};
/// On-disk representation of the keystore.
#[derive(Serialize, Deserialize)]
struct KeystoreFile {
/// Base64-encoded Ed25519 signing key (32 bytes).
signing_key_b64: String,
}
/// Holds cryptographic material for the local client.
pub struct Keystore {
pub signing_key: SigningKey,
pub verifying_key: VerifyingKey,
/// Derived DM session keys cached in memory: peer_user_id → 32-byte key.
/// Populated after completing an X25519 key exchange.
pub dm_keys: HashMap<Uuid, [u8; 32]>,
/// Ephemeral secrets awaiting the remote party's public key.
/// Keyed by the peer's user UUID. Consumed on exchange completion.
pub pending_ecdh: HashMap<Uuid, EphemeralSecret>,
}
impl Keystore {
/// Load an existing keystore from `path`, or generate a fresh one and save it.
pub fn load_or_create(path: &str) -> Result<Self> {
if Path::new(path).exists() {
let contents = fs::read_to_string(path)?;
let file: KeystoreFile =
serde_json::from_str(&contents).map_err(ClientError::Json)?;
let signing_key = identity::b64_to_signing_key(&file.signing_key_b64)?;
let verifying_key = signing_key.verifying_key();
tracing::debug!("Loaded keystore from {}", path);
Ok(Self::from_signing_key(signing_key, verifying_key))
} else {
let (signing_key, verifying_key) = identity::generate_keypair();
let file = KeystoreFile {
signing_key_b64: identity::signing_key_to_b64(&signing_key),
};
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(&file)?)?;
tracing::info!("Generated new identity keypair, saved to {}", path);
Ok(Self::from_signing_key(signing_key, verifying_key))
}
}
fn from_signing_key(signing_key: SigningKey, verifying_key: VerifyingKey) -> Self {
Self {
signing_key,
verifying_key,
dm_keys: HashMap::new(),
pending_ecdh: HashMap::new(),
}
}
/// Base64-encoded Ed25519 public key, suitable for sending to the server.
pub fn identity_pubkey_b64(&self) -> String {
identity::pubkey_to_b64(&self.verifying_key)
}
/// Store an `EphemeralSecret` while waiting for the remote peer's response.
pub fn store_pending_ecdh(&mut self, peer_id: Uuid, secret: EphemeralSecret) {
self.pending_ecdh.insert(peer_id, secret);
}
/// Retrieve and remove a pending ephemeral secret (consumed by ECDH).
pub fn take_pending_ecdh(&mut self, peer_id: Uuid) -> Option<EphemeralSecret> {
self.pending_ecdh.remove(&peer_id)
}
pub fn cache_dm_key(&mut self, peer_id: Uuid, key: [u8; 32]) {
self.dm_keys.insert(peer_id, key);
}
pub fn get_dm_key(&self, peer_id: &Uuid) -> Option<&[u8; 32]> {
self.dm_keys.get(peer_id)
}
}

View File

@@ -0,0 +1,53 @@
mod config;
mod crypto;
mod error;
mod keystore;
mod net;
mod tui;
use std::io;
use anyhow::Result;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "backchannel=info".into()),
)
.with_writer(std::io::stderr) // keep TUI output clean
.init();
let cfg = config::ClientConfig::from_env();
let keystore = keystore::Keystore::load_or_create(&cfg.keystore_path)?;
tracing::info!("Connecting to {}", cfg.server_url);
let (net_tx, mut server_rx) = net::connection::connect(&cfg.server_url).await?;
let app = &mut tui::app::App::new(net_tx, keystore);
// ── Terminal setup ────────────────────────────────────────────────────
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
// ── Main event loop ───────────────────────────────────────────────────
let result = tui::events::run(app, &mut terminal, &mut server_rx).await;
// ── Cleanup (always run, even on error) ───────────────────────────────
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}

View File

@@ -0,0 +1,66 @@
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
use backchannel_common::protocol::{ClientMessage, ServerMessage};
/// Connect to the BackChannel server and return a pair of mpsc channels:
///
/// - `UnboundedSender<ClientMessage>` — send messages **to** the server
/// - `UnboundedReceiver<ServerMessage>` — receive messages **from** the server
///
/// Two background tasks handle the actual WebSocket I/O so the caller never
/// touches the raw stream.
pub async fn connect(
server_url: &str,
) -> Result<(
mpsc::UnboundedSender<ClientMessage>,
mpsc::UnboundedReceiver<ServerMessage>,
)> {
let (ws_stream, _) = connect_async(server_url)
.await
.with_context(|| format!("Failed to connect to {}", server_url))?;
let (mut ws_sink, mut ws_source) = ws_stream.split();
let (client_tx, mut client_rx) = mpsc::unbounded_channel::<ClientMessage>();
let (server_tx, server_rx) = mpsc::unbounded_channel::<ServerMessage>();
// Outbound: client_rx → WS sink
tokio::spawn(async move {
while let Some(msg) = client_rx.recv().await {
match serde_json::to_string(&msg) {
Ok(json) => {
if ws_sink.send(WsMessage::Text(json)).await.is_err() {
break;
}
}
Err(e) => tracing::error!("Failed to serialize outbound message: {}", e),
}
}
});
// Inbound: WS source → server_tx
tokio::spawn(async move {
while let Some(item) = ws_source.next().await {
match item {
Ok(WsMessage::Text(text)) => {
match serde_json::from_str::<ServerMessage>(&text) {
Ok(msg) => {
if server_tx.send(msg).is_err() {
break; // receiver dropped; shut down
}
}
Err(e) => tracing::warn!("Failed to parse server message: {}", e),
}
}
Ok(WsMessage::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
tracing::info!("Connection to server closed");
});
Ok((client_tx, server_rx))
}

View File

@@ -0,0 +1 @@
pub mod connection;

View File

@@ -0,0 +1,351 @@
use std::collections::{HashMap, VecDeque};
use backchannel_common::protocol::{ClientMessage, ServerMessage};
use chrono::Local;
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::keystore::Keystore;
const MAX_MESSAGES: usize = 500;
/// What the TUI is currently displaying.
#[derive(Debug, Clone, PartialEq)]
pub enum View {
Login,
Channel,
Dm,
}
/// Which input field is focused on the login screen.
#[derive(Debug, Clone, PartialEq)]
pub enum LoginField {
Username,
Password,
}
/// A single chat message for display.
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub author: String,
pub content: String,
pub timestamp: String,
}
/// All mutable state for the TUI application.
pub struct App {
// ── Navigation ────────────────────────────────────────────────────────
pub view: View,
pub should_quit: bool,
// ── Login screen ─────────────────────────────────────────────────────
pub login_username: String,
pub login_password: String,
pub login_field: LoginField,
pub login_register: bool, // toggle between Login and Register mode
pub status_msg: Option<String>,
// ── Auth state ────────────────────────────────────────────────────────
pub my_user_id: Option<Uuid>,
pub my_username: Option<String>,
// ── Channel state ─────────────────────────────────────────────────────
pub channels: Vec<(Uuid, String)>, // (id, name)
pub selected_channel: usize,
pub channel_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
// ── DM state ──────────────────────────────────────────────────────────
pub dm_peer_id: Option<Uuid>,
pub dm_peer_name: Option<String>,
pub dm_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
// ── Text input (shared between channel + DM views) ────────────────────
pub input: String,
// ── Online users ──────────────────────────────────────────────────────
pub online_users: Vec<(Uuid, String)>,
// ── Network ───────────────────────────────────────────────────────────
/// Send channel messages to the server connection task.
pub net_tx: mpsc::UnboundedSender<ClientMessage>,
/// Keystore for crypto operations.
pub keystore: Keystore,
}
impl App {
pub fn new(net_tx: mpsc::UnboundedSender<ClientMessage>, keystore: Keystore) -> Self {
Self {
view: View::Login,
should_quit: false,
login_username: String::new(),
login_password: String::new(),
login_field: LoginField::Username,
login_register: false,
status_msg: None,
my_user_id: None,
my_username: None,
channels: Vec::new(),
selected_channel: 0,
channel_messages: HashMap::new(),
dm_peer_id: None,
dm_peer_name: None,
dm_messages: HashMap::new(),
input: String::new(),
online_users: Vec::new(),
net_tx,
keystore,
}
}
// ── Network message handling ──────────────────────────────────────────
pub fn handle_server_message(&mut self, msg: ServerMessage) {
match msg {
ServerMessage::AuthSuccess { user_id, username, .. } => {
self.my_user_id = Some(user_id);
self.my_username = Some(username.clone());
self.status_msg = None;
self.view = View::Channel;
self.status_msg = Some(format!("Logged in as {}", username));
}
ServerMessage::AuthError { reason } => {
self.status_msg = Some(format!("Auth failed: {}", reason));
}
ServerMessage::ChannelMessage {
channel_id,
author_username,
content,
timestamp,
..
} => {
let entry = self
.channel_messages
.entry(channel_id)
.or_default();
entry.push_back(ChatMessage {
author: author_username,
content,
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
});
if entry.len() > MAX_MESSAGES {
entry.pop_front();
}
}
ServerMessage::ChannelCreated { channel_id, name, .. } => {
if !self.channels.iter().any(|(id, _)| *id == channel_id) {
self.channels.push((channel_id, name));
}
}
ServerMessage::ChannelDeleted { channel_id } => {
self.channels.retain(|(id, _)| *id != channel_id);
self.channel_messages.remove(&channel_id);
}
ServerMessage::DirectMessage {
sender_id,
sender_username,
ciphertext,
nonce,
timestamp,
..
} => {
// Determine the peer UUID (the other party in the conversation).
let peer_id = if Some(sender_id) == self.my_user_id {
self.dm_peer_id.unwrap_or(sender_id)
} else {
sender_id
};
// Try to decrypt; fall back to a placeholder if the key is missing.
let plaintext = self.keystore
.get_dm_key(&peer_id)
.and_then(|key| {
crate::crypto::dm::decrypt(key, &nonce, &ciphertext).ok()
})
.unwrap_or_else(|| "[encrypted]".into());
let entry = self.dm_messages.entry(peer_id).or_default();
entry.push_back(ChatMessage {
author: sender_username,
content: plaintext,
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
});
if entry.len() > MAX_MESSAGES {
entry.pop_front();
}
}
ServerMessage::DmKeyExchangeRequest {
initiator_id,
initiator_username,
sender_ephemeral_pubkey,
} => {
// Bob receives this — generate our ephemeral pair and respond.
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
// Derive the shared key now (Bob has both keys).
if let Ok(key) = crate::crypto::dm::complete_exchange(secret, &sender_ephemeral_pubkey) {
self.keystore.cache_dm_key(initiator_id, key);
}
let _ = self.net_tx.send(ClientMessage::AcceptDmKeyExchange {
initiator_id,
recipient_ephemeral_pubkey: our_pubkey_b64,
});
self.status_msg = Some(format!("{} wants to DM you", initiator_username));
}
ServerMessage::DmKeyExchangeResponse {
recipient_id,
recipient_ephemeral_pubkey,
..
} => {
// Alice receives this — complete the exchange.
if let Some(secret) = self.keystore.take_pending_ecdh(recipient_id) {
if let Ok(key) =
crate::crypto::dm::complete_exchange(secret, &recipient_ephemeral_pubkey)
{
self.keystore.cache_dm_key(recipient_id, key);
self.status_msg = Some("DM key exchange complete — you can now send encrypted messages".into());
}
}
}
ServerMessage::UserOnline { user_id, username } => {
if !self.online_users.iter().any(|(id, _)| *id == user_id) {
self.online_users.push((user_id, username));
}
}
ServerMessage::UserOffline { user_id, .. } => {
self.online_users.retain(|(id, _)| *id != user_id);
}
ServerMessage::Error { code, message } => {
self.status_msg = Some(format!("Error {}: {}", code, message));
}
// Other messages (history, role events, etc.) are acknowledged but
// not yet wired into the TUI — they will be handled in future iterations.
_ => {}
}
}
// ── Input helpers ─────────────────────────────────────────────────────
/// Submit the current channel text input.
pub fn submit_channel_message(&mut self) {
let content = self.input.trim().to_string();
if content.is_empty() {
return;
}
if let Some((channel_id, _)) = self.channels.get(self.selected_channel) {
let _ = self.net_tx.send(ClientMessage::SendChannelMessage {
channel_id: *channel_id,
content,
});
}
self.input.clear();
}
/// Submit the current DM text input (encrypted).
pub fn submit_dm_message(&mut self) {
let content = self.input.trim().to_string();
if content.is_empty() {
return;
}
let Some(peer_id) = self.dm_peer_id else { return };
match self.keystore.get_dm_key(&peer_id) {
Some(key) => {
let key = *key;
match crate::crypto::dm::encrypt(&key, &content) {
Ok((ciphertext, nonce)) => {
let _ = self.net_tx.send(ClientMessage::SendDm {
recipient_id: peer_id,
ciphertext,
nonce,
});
}
Err(e) => {
self.status_msg = Some(format!("Encryption error: {}", e));
}
}
}
None => {
self.status_msg = Some(
"No DM key — initiate key exchange with /dm @user first".into(),
);
}
}
self.input.clear();
}
/// Initiate a DM key exchange with a peer by their UUID.
pub fn initiate_dm(&mut self, peer_id: Uuid, peer_name: String) {
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
self.keystore.store_pending_ecdh(peer_id, secret);
let _ = self.net_tx.send(ClientMessage::InitDmKeyExchange {
recipient_id: peer_id,
sender_ephemeral_pubkey: our_pubkey_b64,
});
self.dm_peer_id = Some(peer_id);
self.dm_peer_name = Some(peer_name);
self.view = View::Dm;
self.status_msg = Some("Key exchange initiated — waiting for peer...".into());
}
/// Submit the login/register form.
pub fn submit_login(&mut self) {
let username = self.login_username.trim().to_string();
let password = self.login_password.trim().to_string();
if username.is_empty() || password.is_empty() {
self.status_msg = Some("Username and password are required".into());
return;
}
let identity_pubkey = self.keystore.identity_pubkey_b64();
let msg = if self.login_register {
ClientMessage::Register { username, password, identity_pubkey }
} else {
ClientMessage::Login { username, password, identity_pubkey }
};
let _ = self.net_tx.send(msg);
self.status_msg = Some("Connecting...".into());
}
/// Current channel (Uuid, name), if any channel is selected.
pub fn current_channel(&self) -> Option<&(Uuid, String)> {
self.channels.get(self.selected_channel)
}
/// Messages for the currently viewed channel.
pub fn current_channel_messages(&self) -> Option<&VecDeque<ChatMessage>> {
self.current_channel()
.and_then(|(id, _)| self.channel_messages.get(id))
}
/// Messages for the current DM peer.
pub fn current_dm_messages(&self) -> Option<&VecDeque<ChatMessage>> {
self.dm_peer_id
.and_then(|id| self.dm_messages.get(&id))
}
}

View File

@@ -0,0 +1,116 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::Stdout;
use std::time::Duration;
use crate::tui::app::{App, LoginField, View};
/// The main event loop. Runs until `app.should_quit` is set.
///
/// Every ~50 ms we:
/// 1. Drain any pending server messages from `server_rx`.
/// 2. Poll for a crossterm keyboard event.
/// 3. Redraw the terminal.
pub async fn run(
app: &mut App,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
server_rx: &mut tokio::sync::mpsc::UnboundedReceiver<backchannel_common::protocol::ServerMessage>,
) -> Result<()> {
loop {
// Drain server messages (non-blocking).
while let Ok(msg) = server_rx.try_recv() {
app.handle_server_message(msg);
}
// Draw.
terminal.draw(|f| crate::tui::views::draw(f, app))?;
// Poll keyboard events with a short timeout so server messages stay responsive.
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
handle_key(app, key.code, key.modifiers);
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
// Ctrl-C or Ctrl-Q always quit.
if modifiers.contains(KeyModifiers::CONTROL)
&& (code == KeyCode::Char('c') || code == KeyCode::Char('q'))
{
app.should_quit = true;
return;
}
match app.view {
View::Login => handle_login_key(app, code),
View::Channel => handle_channel_key(app, code),
View::Dm => handle_dm_key(app, code),
}
}
fn handle_login_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Tab => {
app.login_field = match app.login_field {
LoginField::Username => LoginField::Password,
LoginField::Password => LoginField::Username,
};
}
KeyCode::Enter => app.submit_login(),
KeyCode::Char('t') if app.login_username.is_empty() && app.login_password.is_empty() => {
// Toggle register/login mode only when both fields are empty
app.login_register = !app.login_register;
}
KeyCode::Char(c) => match app.login_field {
LoginField::Username => app.login_username.push(c),
LoginField::Password => app.login_password.push(c),
},
KeyCode::Backspace => match app.login_field {
LoginField::Username => { app.login_username.pop(); }
LoginField::Password => { app.login_password.pop(); }
},
_ => {}
}
}
fn handle_channel_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Enter => app.submit_channel_message(),
KeyCode::Char(c) => app.input.push(c),
KeyCode::Backspace => { app.input.pop(); }
// Channel navigation
KeyCode::Up => {
if app.selected_channel > 0 {
app.selected_channel -= 1;
}
}
KeyCode::Down => {
if app.selected_channel + 1 < app.channels.len() {
app.selected_channel += 1;
}
}
KeyCode::Esc => app.status_msg = None,
_ => {}
}
}
fn handle_dm_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Enter => app.submit_dm_message(),
KeyCode::Char(c) => app.input.push(c),
KeyCode::Backspace => { app.input.pop(); }
KeyCode::Esc => {
app.view = View::Channel;
app.input.clear();
}
_ => {}
}
}

View File

@@ -0,0 +1,3 @@
pub mod app;
pub mod events;
pub mod views;

View File

@@ -0,0 +1,147 @@
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::tui::app::App;
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.size();
// Split into sidebar | main column
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(22), Constraint::Min(0)])
.split(area);
draw_sidebar(frame, app, columns[0]);
draw_main(frame, app, columns[1]);
}
fn draw_sidebar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(6)])
.split(area);
// ── Channel list ────────────────────────────────────────────────────
let items: Vec<ListItem> = app
.channels
.iter()
.enumerate()
.map(|(i, (_, name))| {
let style = if i == app.selected_channel {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(format!("# {}", name)).style(style)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected_channel));
let channels = List::new(items)
.block(
Block::default()
.title(" Channels ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan)),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
frame.render_stateful_widget(channels, rows[0], &mut state);
// ── Online users ─────────────────────────────────────────────────────
let user_items: Vec<ListItem> = app
.online_users
.iter()
.map(|(_, name)| ListItem::new(format!("{}", name)))
.collect();
let online = List::new(user_items).block(
Block::default()
.title(" Online ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Green)),
);
frame.render_widget(online, rows[1]);
}
fn draw_main(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // channel title bar
Constraint::Min(0), // messages
Constraint::Length(3), // input box
Constraint::Length(1), // status bar
])
.split(area);
// ── Title bar ────────────────────────────────────────────────────────
let title = app
.current_channel()
.map(|(_, name)| format!(" # {} ", name))
.unwrap_or_else(|| " No channel selected ".into());
let title_bar = Paragraph::new(title)
.style(Style::default().fg(Color::White).bg(Color::DarkGray).add_modifier(Modifier::BOLD));
frame.render_widget(title_bar, rows[0]);
// ── Messages ─────────────────────────────────────────────────────────
let msgs: Vec<ListItem> = app
.current_channel_messages()
.map(|deque| {
deque
.iter()
.map(|m| {
ListItem::new(Line::from(vec![
Span::styled(
format!("{} ", m.timestamp),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{}: ", m.author),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(m.content.clone()),
]))
})
.collect()
})
.unwrap_or_default();
let message_count = msgs.len();
let mut list_state = ListState::default();
if message_count > 0 {
list_state.select(Some(message_count - 1));
}
let messages = List::new(msgs)
.block(Block::default().borders(Borders::LEFT))
.highlight_style(Style::default());
frame.render_stateful_widget(messages, rows[1], &mut list_state);
// ── Input box ─────────────────────────────────────────────────────────
let input = Paragraph::new(app.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Message (Enter to send) "),
);
frame.render_widget(input, rows[2]);
// ── Status bar ────────────────────────────────────────────────────────
let status_text = app
.status_msg
.as_deref()
.unwrap_or("Ctrl-Q: quit | ↑↓: channels | Esc: dismiss");
let status = Paragraph::new(status_text)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, rows[3]);
}

View File

@@ -0,0 +1,86 @@
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::tui::app::App;
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.size();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // messages
Constraint::Length(3), // input
Constraint::Length(1), // status
])
.split(area);
// ── Title bar ─────────────────────────────────────────────────────────
let peer_name = app
.dm_peer_name
.as_deref()
.unwrap_or("Unknown");
let title = Paragraph::new(format!(" DM: {} (E2EE) ", peer_name))
.style(Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD));
frame.render_widget(title, rows[0]);
// ── Messages ──────────────────────────────────────────────────────────
let msgs: Vec<ListItem> = app
.current_dm_messages()
.map(|deque| {
deque
.iter()
.map(|m| {
let is_self = app.my_username.as_deref() == Some(m.author.as_str());
let author_style = if is_self {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
};
ListItem::new(Line::from(vec![
Span::styled(
format!("{} ", m.timestamp),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!("{}: ", m.author), author_style),
Span::raw(m.content.clone()),
]))
})
.collect()
})
.unwrap_or_default();
let msg_count = msgs.len();
let mut state = ListState::default();
if msg_count > 0 {
state.select(Some(msg_count - 1));
}
let messages = List::new(msgs).block(Block::default().borders(Borders::LEFT));
frame.render_stateful_widget(messages, rows[1], &mut state);
// ── Input box ─────────────────────────────────────────────────────────
let input = Paragraph::new(app.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Encrypted message (Enter: send Esc: back) "),
);
frame.render_widget(input, rows[2]);
// ── Status bar ────────────────────────────────────────────────────────
let status_text = app
.status_msg
.as_deref()
.unwrap_or("🔒 End-to-end encrypted");
let status = Paragraph::new(status_text)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, rows[3]);
}

View File

@@ -0,0 +1,98 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::tui::app::{App, LoginField};
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.size();
// Centre a 50×12 box on screen.
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30),
Constraint::Length(14),
Constraint::Min(0),
])
.split(area);
let h = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(v[1]);
let box_area = h[1];
let mode_label = if app.login_register { "Register" } else { "Login" };
let outer = Block::default()
.title(format!(" BackChannel — {} ", mode_label))
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan));
frame.render_widget(outer, box_area);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // padding
Constraint::Length(3), // username
Constraint::Length(3), // password
Constraint::Length(1), // padding
Constraint::Length(1), // hint
Constraint::Length(1), // status
])
.margin(1)
.split(box_area);
// Username field
let username_style = if app.login_field == LoginField::Username {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let username_widget = Paragraph::new(app.login_username.as_str())
.block(Block::default().borders(Borders::ALL).title(" Username "))
.style(username_style);
frame.render_widget(username_widget, inner[1]);
// Password field (mask with asterisks)
let password_style = if app.login_field == LoginField::Password {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let masked: String = "*".repeat(app.login_password.len());
let password_widget = Paragraph::new(masked.as_str())
.block(Block::default().borders(Borders::ALL).title(" Password "))
.style(password_style);
frame.render_widget(password_widget, inner[2]);
// Hints
let hint = Paragraph::new(Line::from(vec![
Span::raw("Tab: switch field | Enter: submit | "),
Span::styled(
if app.login_register { "T: switch to Login" } else { "T: switch to Register" },
Style::default().fg(Color::DarkGray),
),
]))
.alignment(Alignment::Center);
frame.render_widget(hint, inner[4]);
// Status / error message
if let Some(msg) = &app.status_msg {
let status = Paragraph::new(msg.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center);
frame.render_widget(status, inner[5]);
}
}

View File

@@ -0,0 +1,16 @@
pub mod channel;
pub mod dm;
pub mod login;
use ratatui::Frame;
use crate::tui::app::{App, View};
/// Top-level draw dispatcher — renders the correct view based on `app.view`.
pub fn draw(frame: &mut Frame, app: &App) {
match app.view {
View::Login => login::draw(frame, app),
View::Channel => channel::draw(frame, app),
View::Dm => dm::draw(frame, app),
}
}