mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-02-10 08:53:08 +00:00
feat: chat cli
This commit is contained in:
parent
77a670c668
commit
1aefd7eb0f
796
Cargo.lock
generated
796
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
resolver = "3"
|
||||
|
||||
members = [
|
||||
"chat-cli",
|
||||
"conversations",
|
||||
"crypto",
|
||||
"double-ratchets",
|
||||
|
||||
19
chat-cli/Cargo.toml
Normal file
19
chat-cli/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "chat-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "chat-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
logos-chat = { path = "../conversations" }
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.29"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
notify = "8.0"
|
||||
tempfile = "3"
|
||||
dirs = "6.0"
|
||||
106
chat-cli/README.md
Normal file
106
chat-cli/README.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Chat CLI
|
||||
|
||||
A terminal chat application built with [ratatui](https://ratatui.rs/) using the logos-chat library.
|
||||
|
||||
## Features
|
||||
|
||||
- 💬 End-to-end encrypted messaging using the Double Ratchet algorithm
|
||||
- 📁 File-based transport for local simulation (no network required)
|
||||
- 💾 Persistent storage (SQLite)
|
||||
- 🖥️ Beautiful terminal UI with ratatui
|
||||
|
||||
## Usage
|
||||
|
||||
Run two instances with different usernames in separate terminals:
|
||||
|
||||
### Terminal 1 (Alice)
|
||||
|
||||
```bash
|
||||
cargo run -p chat-cli -- alice
|
||||
```
|
||||
|
||||
### Terminal 2 (Bob)
|
||||
|
||||
```bash
|
||||
cargo run -p chat-cli -- bob
|
||||
```
|
||||
|
||||
### Establishing a Connection
|
||||
|
||||
1. In Alice's terminal, type `/intro` to generate an introduction bundle
|
||||
2. Copy the bundle string (starts with `Bundle:`)
|
||||
3. In Bob's terminal, type `/connect alice <bundle>` (paste Alice's bundle)
|
||||
4. Bob can now send messages to Alice
|
||||
5. Alice will see Bob's initial "Hello!" message and can reply
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | Show available commands |
|
||||
| `/intro` | Generate and display your introduction bundle |
|
||||
| `/connect <user> <bundle>` | Connect to a user using their introduction bundle |
|
||||
| `/status` | Show connection status and your address |
|
||||
| `/clear` | Clear message history |
|
||||
| `/quit` or `Esc` | Exit the application |
|
||||
|
||||
### Sending Messages
|
||||
|
||||
Simply type your message and press Enter. Messages are automatically encrypted and delivered via the file-based transport.
|
||||
|
||||
## How It Works
|
||||
|
||||
### File-Based Transport
|
||||
|
||||
Since this is a local demo without a real network, messages are passed between users via files:
|
||||
|
||||
1. Each user has an "inbox" directory at `~/.local/share/chat-cli/transport/<username>/`
|
||||
2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox
|
||||
3. Bob's client watches for new files and processes incoming messages
|
||||
4. Files are deleted after processing
|
||||
|
||||
### Storage
|
||||
|
||||
User data (identity keys, chat state) is stored in SQLite databases at:
|
||||
- `~/.local/share/chat-cli/data/<username>.db`
|
||||
|
||||
### Encryption
|
||||
|
||||
All messages are encrypted using:
|
||||
- X3DH key agreement for initial key exchange
|
||||
- Double Ratchet algorithm for ongoing message encryption
|
||||
- ChaCha20-Poly1305 for authenticated encryption
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
# Terminal 1 (Alice)
|
||||
$ cargo run -p chat-cli -- alice
|
||||
|
||||
/intro
|
||||
# Output: Bundle:abc123...def456
|
||||
|
||||
# Terminal 2 (Bob)
|
||||
$ cargo run -p chat-cli -- bob
|
||||
|
||||
/connect alice Bundle:abc123...def456
|
||||
# Connected! Bob sends "Hello!" automatically
|
||||
|
||||
# Now type messages in either terminal to chat!
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
chat-cli/
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point
|
||||
│ ├── app.rs # Application state and logic
|
||||
│ ├── transport.rs # File-based message transport
|
||||
│ └── ui.rs # Ratatui terminal UI
|
||||
```
|
||||
|
||||
The CLI uses logos-chat as a library without modifying it:
|
||||
- `ChatManager` handles all encryption/decryption
|
||||
- `Introduction` bundles enable key exchange
|
||||
- `AddressedEnvelope` carries encrypted messages
|
||||
236
chat-cli/src/app.rs
Normal file
236
chat-cli/src/app.rs
Normal file
@ -0,0 +1,236 @@
|
||||
//! Chat application logic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use logos_chat::{ChatManager, Introduction, StorageConfig};
|
||||
|
||||
use crate::transport::FileTransport;
|
||||
|
||||
/// A chat message for display.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DisplayMessage {
|
||||
pub from_self: bool,
|
||||
pub content: String,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// The chat application state.
|
||||
pub struct ChatApp {
|
||||
/// The logos-chat manager.
|
||||
pub manager: ChatManager,
|
||||
/// File-based transport for message passing.
|
||||
pub transport: FileTransport,
|
||||
/// Our introduction bundle (to share with others).
|
||||
pub intro_bundle: Option<Introduction>,
|
||||
/// Current chat ID (if in a conversation).
|
||||
pub current_chat_id: Option<String>,
|
||||
/// Remote user name (for transport).
|
||||
pub remote_user: Option<String>,
|
||||
/// Messages to display.
|
||||
pub messages: Vec<DisplayMessage>,
|
||||
/// Input buffer.
|
||||
pub input: String,
|
||||
/// Status message.
|
||||
pub status: String,
|
||||
/// Our user name.
|
||||
pub user_name: String,
|
||||
}
|
||||
|
||||
impl ChatApp {
|
||||
/// Create a new chat application.
|
||||
pub fn new(user_name: &str, data_dir: &PathBuf, transport_dir: &PathBuf) -> Result<Self> {
|
||||
// Create database path
|
||||
let db_path = data_dir.join(format!("{}.db", user_name));
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
|
||||
// Open or create the chat manager with file-based storage
|
||||
let manager = ChatManager::open(StorageConfig::File(
|
||||
db_path.to_string_lossy().to_string(),
|
||||
))
|
||||
.context("Failed to open ChatManager")?;
|
||||
|
||||
// Create file transport
|
||||
let transport = FileTransport::new(user_name, transport_dir)
|
||||
.context("Failed to create transport")?;
|
||||
|
||||
Ok(Self {
|
||||
manager,
|
||||
transport,
|
||||
intro_bundle: None,
|
||||
current_chat_id: None,
|
||||
remote_user: None,
|
||||
messages: Vec::new(),
|
||||
input: String::new(),
|
||||
status: format!("Welcome, {}! Type /help for commands.", user_name),
|
||||
user_name: user_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create and display our introduction bundle.
|
||||
pub fn create_intro(&mut self) -> Result<String> {
|
||||
let intro = self.manager.create_intro_bundle()?;
|
||||
let bundle_str: Vec<u8> = intro.clone().into();
|
||||
let bundle_string = String::from_utf8_lossy(&bundle_str).to_string();
|
||||
self.intro_bundle = Some(intro);
|
||||
self.status = "Introduction bundle created. Share it with others!".to_string();
|
||||
Ok(bundle_string)
|
||||
}
|
||||
|
||||
/// Connect to another user using their introduction bundle.
|
||||
pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> {
|
||||
let intro = Introduction::try_from(bundle_str.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?;
|
||||
|
||||
let (chat_id, envelopes) = self.manager.start_private_chat(&intro, "👋 Hello!")?;
|
||||
|
||||
self.current_chat_id = Some(chat_id.clone());
|
||||
self.remote_user = Some(remote_user.to_string());
|
||||
|
||||
// Send the envelopes via file transport
|
||||
for envelope in envelopes {
|
||||
self.transport.send(remote_user, envelope.data)?;
|
||||
}
|
||||
|
||||
self.messages.push(DisplayMessage {
|
||||
from_self: true,
|
||||
content: "👋 Hello!".to_string(),
|
||||
timestamp: now(),
|
||||
});
|
||||
|
||||
self.status = format!("Connected to {}! Chat ID: {}", remote_user, &chat_id[..8]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a message in the current chat.
|
||||
pub fn send_message(&mut self, content: &str) -> Result<()> {
|
||||
let chat_id = self.current_chat_id.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No active chat"))?;
|
||||
let remote_user = self.remote_user.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No remote user"))?;
|
||||
|
||||
let envelopes = self.manager.send_message(chat_id, content.as_bytes())?;
|
||||
|
||||
for envelope in envelopes {
|
||||
self.transport.send(remote_user, envelope.data)?;
|
||||
}
|
||||
|
||||
self.messages.push(DisplayMessage {
|
||||
from_self: true,
|
||||
content: content.to_string(),
|
||||
timestamp: now(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process incoming messages from transport.
|
||||
pub fn process_incoming(&mut self) -> Result<()> {
|
||||
// Check for new messages
|
||||
while let Some(envelope) = self.transport.try_recv() {
|
||||
self.handle_incoming_envelope(&envelope)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process existing messages on startup.
|
||||
pub fn process_existing(&mut self) -> Result<()> {
|
||||
let messages = self.transport.process_existing_messages();
|
||||
for envelope in messages {
|
||||
self.handle_incoming_envelope(&envelope)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an incoming envelope.
|
||||
fn handle_incoming_envelope(&mut self, envelope: &crate::transport::FileEnvelope) -> Result<()> {
|
||||
match self.manager.handle_incoming(&envelope.data) {
|
||||
Ok(content) => {
|
||||
// Update chat state if this is a new chat
|
||||
if self.current_chat_id.is_none() {
|
||||
self.current_chat_id = Some(content.conversation_id.clone());
|
||||
self.remote_user = Some(envelope.from.clone());
|
||||
self.status = format!("New chat from {}!", envelope.from);
|
||||
}
|
||||
|
||||
let message = String::from_utf8_lossy(&content.data).to_string();
|
||||
if !message.is_empty() {
|
||||
self.messages.push(DisplayMessage {
|
||||
from_self: false,
|
||||
content: message,
|
||||
timestamp: envelope.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.status = format!("Error handling message: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a command (starts with /).
|
||||
pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
let command = parts[0];
|
||||
let args = parts.get(1).copied().unwrap_or("");
|
||||
|
||||
match command {
|
||||
"/help" => {
|
||||
Ok(Some(
|
||||
"Commands:\n\
|
||||
/intro - Show your introduction bundle\n\
|
||||
/connect <user> <bundle> - Connect to a user\n\
|
||||
/status - Show connection status\n\
|
||||
/clear - Clear messages\n\
|
||||
/quit - Exit".to_string()
|
||||
))
|
||||
}
|
||||
"/intro" => {
|
||||
let bundle = self.create_intro()?;
|
||||
Ok(Some(format!("Your bundle:\n{}", bundle)))
|
||||
}
|
||||
"/connect" => {
|
||||
let connect_parts: Vec<&str> = args.splitn(2, ' ').collect();
|
||||
if connect_parts.len() < 2 {
|
||||
return Ok(Some("Usage: /connect <username> <bundle>".to_string()));
|
||||
}
|
||||
let remote_user = connect_parts[0];
|
||||
let bundle = connect_parts[1];
|
||||
self.connect(remote_user, bundle)?;
|
||||
Ok(Some(format!("Connected to {}", remote_user)))
|
||||
}
|
||||
"/status" => {
|
||||
let status = match &self.current_chat_id {
|
||||
Some(id) => format!(
|
||||
"Chat ID: {}\nRemote: {}\nAddress: {}",
|
||||
&id[..8.min(id.len())],
|
||||
self.remote_user.as_deref().unwrap_or("none"),
|
||||
self.manager.local_address()
|
||||
),
|
||||
None => format!(
|
||||
"No active chat\nAddress: {}",
|
||||
self.manager.local_address()
|
||||
),
|
||||
};
|
||||
Ok(Some(status))
|
||||
}
|
||||
"/clear" => {
|
||||
self.messages.clear();
|
||||
Ok(Some("Messages cleared".to_string()))
|
||||
}
|
||||
"/quit" => {
|
||||
Ok(None) // Signal to quit
|
||||
}
|
||||
_ => Ok(Some(format!("Unknown command: {}", command))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
}
|
||||
96
chat-cli/src/main.rs
Normal file
96
chat-cli/src/main.rs
Normal file
@ -0,0 +1,96 @@
|
||||
//! Chat CLI - A terminal chat application using logos-chat.
|
||||
//!
|
||||
//! This application demonstrates how to use the logos-chat library
|
||||
//! with a file-based transport for local simulation.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Run two instances with different usernames:
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Terminal 1
|
||||
//! cargo run -p chat-cli -- alice
|
||||
//!
|
||||
//! # Terminal 2
|
||||
//! cargo run -p chat-cli -- bob
|
||||
//! ```
|
||||
//!
|
||||
//! Then in alice's terminal:
|
||||
//! 1. Type `/intro` to get your introduction bundle
|
||||
//! 2. Copy the bundle string
|
||||
//!
|
||||
//! In bob's terminal:
|
||||
//! 1. Type `/connect alice <bundle>` (paste alice's bundle)
|
||||
//!
|
||||
//! Now bob can send messages to alice, and alice can reply.
|
||||
|
||||
mod app;
|
||||
mod transport;
|
||||
mod ui;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Parse arguments
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <username>", args[0]);
|
||||
eprintln!("\nExample:");
|
||||
eprintln!(" Terminal 1: {} alice", args[0]);
|
||||
eprintln!(" Terminal 2: {} bob", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let user_name = &args[1];
|
||||
|
||||
// Setup directories
|
||||
let base_dir = dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("chat-cli");
|
||||
let data_dir = base_dir.join("data");
|
||||
let transport_dir = base_dir.join("transport");
|
||||
|
||||
std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?;
|
||||
std::fs::create_dir_all(&transport_dir).context("Failed to create transport directory")?;
|
||||
|
||||
println!("Starting chat as '{}'...", user_name);
|
||||
println!("Data dir: {:?}", data_dir);
|
||||
println!("Transport dir: {:?}", transport_dir);
|
||||
|
||||
// Create app
|
||||
let mut app = app::ChatApp::new(user_name, &data_dir, &transport_dir)
|
||||
.context("Failed to create chat app")?;
|
||||
|
||||
// Process any existing messages
|
||||
app.process_existing()?;
|
||||
|
||||
// Initialize terminal UI
|
||||
let mut terminal = ui::init().context("Failed to initialize terminal")?;
|
||||
|
||||
// Main loop
|
||||
let result = run_app(&mut terminal, &mut app);
|
||||
|
||||
// Restore terminal
|
||||
ui::restore().context("Failed to restore terminal")?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn run_app(terminal: &mut ui::Tui, app: &mut app::ChatApp) -> Result<()> {
|
||||
loop {
|
||||
// Process incoming messages
|
||||
app.process_incoming()?;
|
||||
|
||||
// Draw UI
|
||||
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||
|
||||
// Handle input
|
||||
if !ui::handle_events(app)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
153
chat-cli/src/transport.rs
Normal file
153
chat-cli/src/transport.rs
Normal file
@ -0,0 +1,153 @@
|
||||
//! File-based transport for local chat simulation.
|
||||
//!
|
||||
//! Each user has an inbox directory where other users drop messages.
|
||||
//! Messages are JSON files with envelope data.
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A message envelope for file-based transport.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileEnvelope {
|
||||
pub from: String,
|
||||
pub data: Vec<u8>,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// File-based transport for simulating message passing.
|
||||
pub struct FileTransport {
|
||||
/// Our user name (used for inbox directory).
|
||||
user_name: String,
|
||||
/// Base directory for all inboxes.
|
||||
base_dir: PathBuf,
|
||||
/// Channel for receiving incoming messages.
|
||||
incoming_rx: Receiver<FileEnvelope>,
|
||||
/// Watcher handle (kept alive).
|
||||
_watcher: RecommendedWatcher,
|
||||
}
|
||||
|
||||
impl FileTransport {
|
||||
/// Create a new file transport.
|
||||
///
|
||||
/// `user_name` is used to create an inbox directory.
|
||||
/// `base_dir` is the shared directory where all user inboxes live.
|
||||
pub fn new(user_name: &str, base_dir: &Path) -> Result<Self> {
|
||||
let inbox_dir = base_dir.join(user_name);
|
||||
fs::create_dir_all(&inbox_dir)
|
||||
.with_context(|| format!("Failed to create inbox dir: {:?}", inbox_dir))?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let watcher = Self::start_watcher(&inbox_dir, tx)?;
|
||||
|
||||
Ok(Self {
|
||||
user_name: user_name.to_string(),
|
||||
base_dir: base_dir.to_path_buf(),
|
||||
incoming_rx: rx,
|
||||
_watcher: watcher,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start watching the inbox directory for new messages.
|
||||
fn start_watcher(inbox_dir: &Path, tx: Sender<FileEnvelope>) -> Result<RecommendedWatcher> {
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
if matches!(event.kind, EventKind::Create(_)) {
|
||||
for path in event.paths {
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
// Small delay to ensure file is fully written
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
if let Ok(envelope) = Self::read_message(&path) {
|
||||
let _ = tx.send(envelope);
|
||||
// Delete the message after reading
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_millis(100)),
|
||||
)?;
|
||||
|
||||
watcher.watch(inbox_dir, RecursiveMode::NonRecursive)?;
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
/// Read a message from a file.
|
||||
fn read_message(path: &Path) -> Result<FileEnvelope> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let envelope: FileEnvelope = serde_json::from_str(&contents)?;
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
/// Send a message to another user's inbox.
|
||||
pub fn send(&self, to_user: &str, data: Vec<u8>) -> Result<()> {
|
||||
let to_inbox = self.base_dir.join(to_user);
|
||||
fs::create_dir_all(&to_inbox)?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
let envelope = FileEnvelope {
|
||||
from: self.user_name.clone(),
|
||||
data,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
let filename = format!("{}_{}.json", self.user_name, timestamp);
|
||||
let path = to_inbox.join(filename);
|
||||
|
||||
let json = serde_json::to_string_pretty(&envelope)?;
|
||||
let mut file = File::create(&path)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to receive an incoming message (non-blocking).
|
||||
pub fn try_recv(&self) -> Option<FileEnvelope> {
|
||||
self.incoming_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
/// Get our user name.
|
||||
#[allow(dead_code)]
|
||||
pub fn user_name(&self) -> &str {
|
||||
&self.user_name
|
||||
}
|
||||
|
||||
/// Process any existing messages in inbox on startup.
|
||||
pub fn process_existing_messages(&self) -> Vec<FileEnvelope> {
|
||||
let inbox_dir = self.base_dir.join(&self.user_name);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&inbox_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Ok(envelope) = Self::read_message(&path) {
|
||||
messages.push(envelope);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
messages.sort_by_key(|m| m.timestamp);
|
||||
messages
|
||||
}
|
||||
}
|
||||
175
chat-cli/src/ui.rs
Normal file
175
chat-cli/src/ui.rs
Normal file
@ -0,0 +1,175 @@
|
||||
//! Terminal UI using ratatui.
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
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, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use crate::app::ChatApp;
|
||||
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal.
|
||||
pub fn init() -> io::Result<Tui> {
|
||||
execute!(io::stdout(), EnterAlternateScreen)?;
|
||||
enable_raw_mode()?;
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::new(backend)
|
||||
}
|
||||
|
||||
/// Restore the terminal to its original state.
|
||||
pub fn restore() -> io::Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(io::stdout(), LeaveAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw the UI.
|
||||
pub fn draw(frame: &mut Frame, app: &ChatApp) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(10), // Messages
|
||||
Constraint::Length(3), // Input
|
||||
Constraint::Length(3), // Status
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
draw_header(frame, app, chunks[0]);
|
||||
draw_messages(frame, app, chunks[1]);
|
||||
draw_input(frame, app, chunks[2]);
|
||||
draw_status(frame, app, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||
let title = match &app.remote_user {
|
||||
Some(remote) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, remote),
|
||||
None => format!(" 💬 {} (no active chat) ", app.user_name),
|
||||
};
|
||||
|
||||
let header = Paragraph::new(title)
|
||||
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
|
||||
frame.render_widget(header, area);
|
||||
}
|
||||
|
||||
fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||
let messages: Vec<ListItem> = app
|
||||
.messages
|
||||
.iter()
|
||||
.map(|msg| {
|
||||
let (prefix, style) = if msg.from_self {
|
||||
("You: ", Style::default().fg(Color::Green))
|
||||
} else {
|
||||
let name = app.remote_user.as_deref().unwrap_or("Them");
|
||||
(name, Style::default().fg(Color::Yellow))
|
||||
};
|
||||
|
||||
let content = if msg.from_self {
|
||||
Line::from(vec![
|
||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&msg.content),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&msg.content),
|
||||
])
|
||||
};
|
||||
|
||||
ListItem::new(content)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let messages_widget = List::new(messages)
|
||||
.block(Block::default().title(" Messages ").borders(Borders::ALL));
|
||||
|
||||
frame.render_widget(messages_widget, area);
|
||||
}
|
||||
|
||||
fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||
let input = Paragraph::new(app.input.as_str())
|
||||
.style(Style::default().fg(Color::White))
|
||||
.block(Block::default().title(" Input (Enter to send) ").borders(Borders::ALL));
|
||||
|
||||
frame.render_widget(input, area);
|
||||
|
||||
// Show cursor
|
||||
frame.set_cursor_position((
|
||||
area.x + app.input.len() as u16 + 1,
|
||||
area.y + 1,
|
||||
));
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||
let status = Paragraph::new(app.status.as_str())
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.block(Block::default().title(" Status ").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(status, area);
|
||||
}
|
||||
|
||||
/// Handle keyboard events.
|
||||
pub fn handle_events(app: &mut ChatApp) -> io::Result<bool> {
|
||||
// Poll for events with a short timeout to allow checking incoming messages
|
||||
if event::poll(std::time::Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => return Ok(false),
|
||||
KeyCode::Enter => {
|
||||
if !app.input.is_empty() {
|
||||
let input = std::mem::take(&mut app.input);
|
||||
|
||||
if input.starts_with('/') {
|
||||
match app.handle_command(&input) {
|
||||
Ok(Some(response)) => {
|
||||
app.status = response;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Quit signal
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e) => {
|
||||
app.status = format!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
} else if app.current_chat_id.is_some() {
|
||||
if let Err(e) = app.send_message(&input) {
|
||||
app.status = format!("Send error: {}", e);
|
||||
}
|
||||
} else {
|
||||
app.status = "No active chat. Use /connect first.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@ -4,6 +4,7 @@ use x25519_dalek::PublicKey;
|
||||
use crate::errors::ChatError;
|
||||
|
||||
/// Supplies remote participants with the required keys to use Inbox protocol
|
||||
#[derive(Clone)]
|
||||
pub struct Introduction {
|
||||
pub installation_key: PublicKey,
|
||||
pub ephemeral_key: PublicKey,
|
||||
|
||||
@ -15,6 +15,7 @@ mod utils;
|
||||
// Public API - this is what library users should use
|
||||
pub use chat::{ChatManager, ChatManagerError, StorageConfig};
|
||||
pub use inbox::Introduction;
|
||||
pub use types::{AddressedEnvelope, ContentData};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user