diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 81b0ffc..6d7ffc3 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.36" +version = "0.0.37" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.36" +version = "0.0.37" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.36" +version = "0.0.37" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.36" +version = "0.0.37" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.36" +version = "0.0.37" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index a88a123..3030685 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.36" +version = "0.0.37" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index eeedb6c..f67458f 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -549,6 +549,12 @@ impl App { .map(|s| if s.len() > 16 { format!("{}...", &s[..16]) } else { s.to_string() }) .unwrap_or_default(); self.add_message(ChatLine { sender: "system".into(), text: format!("📞 Calling {}...", display), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.call_state = Some(super::types::CallInfo { + peer_fp: peer_fp_clean.clone(), + peer_display: display.clone(), + state: super::types::CallPhase::Calling, + started_at: Local::now(), + }); } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); @@ -578,6 +584,12 @@ impl App { if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; self.add_message(ChatLine { sender: "system".into(), text: "✓ Call accepted".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.call_state = Some(super::types::CallInfo { + peer_fp: normfp(&peer), + peer_display: peer[..peer.len().min(16)].to_string(), + state: super::types::CallPhase::Active, + started_at: Local::now(), + }); } return; } @@ -603,6 +615,7 @@ impl App { if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; self.add_message(ChatLine { sender: "system".into(), text: "✗ Call rejected".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.call_state = None; } return; } @@ -629,6 +642,7 @@ impl App { if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; self.add_message(ChatLine { sender: "system".into(), text: "Call ended".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.call_state = None; } return; } diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index 78a7445..ea2c9ce 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -6,6 +6,8 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; +use chrono::Local; + use super::types::{App, ReceiptStatus}; impl App { @@ -65,6 +67,28 @@ impl App { } else { format!("{}", &self.our_eth[..self.our_eth.len().min(12)]) }; + // Call indicator + let call_span = match &self.call_state { + Some(info) => { + let label = match info.state { + super::types::CallPhase::Calling => format!(" \u{1f4de} Calling {}...", &info.peer_display[..info.peer_display.len().min(12)]), + super::types::CallPhase::Ringing => format!(" \u{1f4de} Incoming from {}", &info.peer_display[..info.peer_display.len().min(12)]), + super::types::CallPhase::Active => { + let elapsed = Local::now().signed_duration_since(info.started_at); + let mins = elapsed.num_minutes(); + let secs = elapsed.num_seconds() % 60; + format!(" \u{1f50a} {}:{:02}", mins, secs) + } + }; + let color = match info.state { + super::types::CallPhase::Calling => Color::Yellow, + super::types::CallPhase::Ringing => Color::Magenta, + super::types::CallPhase::Active => Color::Green, + }; + Span::styled(label, Style::default().fg(color)) + } + None => Span::raw(""), + }; let header = Paragraph::new(Line::from(vec![ Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::styled(identity_display, Style::default().fg(Color::Green)), @@ -75,6 +99,7 @@ impl App { Style::default().fg(Color::DarkGray), ), Span::styled(conn_indicator, Style::default().fg(conn_color)), + call_span, ])); frame.render_widget(header, chunks[0]); diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs index 47eacce..d64c177 100644 --- a/warzone/crates/warzone-client/src/tui/network.rs +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -496,14 +496,75 @@ fn process_wire_message( payload: _, target: _, } => { - let type_str = format!("{:?}", signal_type); - messages.lock().unwrap().push(ChatLine { - sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }, - text: format!("\u{1f4de} Call signal: {}", type_str), - is_system: false, - is_self: false, - message_id: None, timestamp: Local::now(), - }); + use warzone_protocol::message::CallSignalType; + let sender_short = { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }; + match signal_type { + CallSignalType::Offer => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + // Terminal bell for incoming call + print!("\x07"); + } + CallSignalType::Answer => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("\u{2713} {} accepted the call", sender_short), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + } + CallSignalType::Hangup => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Call ended".into(), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + } + CallSignalType::Reject => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("{} rejected the call", sender_short), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + } + CallSignalType::Ringing => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Ringing...".into(), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + } + CallSignalType::Busy => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("{} is busy", sender_short), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + } + _ => { + messages.lock().unwrap().push(ChatLine { + sender: sender_short, + text: format!("\u{1f4de} Call signal: {:?}", signal_type), + is_system: false, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + } + } } } } @@ -545,8 +606,44 @@ pub async fn poll_loop( let (_, mut read) = ws_stream.split(); while let Some(Ok(msg)) = read.next().await { - if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg { - process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer); + match msg { + tokio_tungstenite::tungstenite::Message::Binary(data) => { + process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer); + } + tokio_tungstenite::tungstenite::Message::Text(text) => { + if let Ok(json) = serde_json::from_str::(&text) { + if json.get("type").and_then(|v| v.as_str()) == Some("missed_call") { + let data = json.get("data").cloned().unwrap_or_default(); + let caller = data.get("caller_fp").and_then(|v| v.as_str()).unwrap_or("unknown"); + let ts = data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + let when = chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string()) + .unwrap_or_else(|| "?".to_string()); + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("\u{1f4de} Missed call from {} at {}", &caller[..caller.len().min(12)], when), + is_system: true, + is_self: false, + message_id: None, + timestamp: Local::now(), + }); + print!("\x07"); + } else if json.get("type").and_then(|v| v.as_str()) == Some("bot_message") { + let from = json.get("from_name").or(json.get("from")).and_then(|v| v.as_str()).unwrap_or("bot"); + let text_content = json.get("text").and_then(|v| v.as_str()).unwrap_or(""); + messages.lock().unwrap().push(ChatLine { + sender: format!("@{}", from), + text: text_content.to_string(), + is_system: false, + is_self: false, + message_id: None, + timestamp: Local::now(), + }); + print!("\x07"); + } + } + } + _ => {} } } diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index f6265fa..e88f7cd 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -28,6 +28,22 @@ pub enum ReceiptStatus { Read, } +/// Active call information. +#[derive(Clone)] +pub struct CallInfo { + pub peer_fp: String, + pub peer_display: String, + pub state: CallPhase, + pub started_at: DateTime, +} + +#[derive(Clone, PartialEq)] +pub enum CallPhase { + Calling, // we initiated, waiting for answer + Ringing, // incoming call, waiting for user to accept/reject + Active, // call connected +} + pub struct App { pub input: String, pub messages: Arc>>, @@ -49,6 +65,8 @@ pub struct App { pub scroll_offset: usize, /// Whether the WebSocket connection is active. pub connected: Arc, + /// Current call state: None=idle, Some(state)=active + pub call_state: Option, } #[derive(Clone)] @@ -128,6 +146,7 @@ impl App { peer_eth: None, scroll_offset: 0, connected: Arc::new(AtomicBool::new(false)), + call_state: None, } } diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 67a3aea..161f497 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.36" +version = "0.0.37" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index c1afbc2..88b5378 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v18'; +const CACHE = 'wz-v19'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -204,6 +204,12 @@ const WEB_HTML: &str = r##" .incoming-call { animation: pulse 1.5s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } + .inline-kbd { margin: 4px 0; display: flex; flex-wrap: wrap; gap: 4px; } + .inline-kbd-row { display: flex; gap: 4px; width: 100%; } + .kbd-btn { padding: 4px 12px; background: #1a1a3e; border: 1px solid #333; border-radius: 4px; + color: #4fc3f7; cursor: pointer; font-family: inherit; font-size: 0.8em; flex: 1; text-align: center; } + .kbd-btn:hover { background: #252550; border-color: #4fc3f7; } + @media (max-width: 500px) { .msg { font-size: 0.8em; } #chat-header input { width: 180px; } @@ -281,7 +287,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.36'; +const VERSION = '0.0.37'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -584,22 +590,67 @@ function connectWebSocket() { if (json.type === 'bot_message') { const botName = json.from_name || json.from || 'bot'; let msgText = json.text || ''; - if (json.reply_markup && json.reply_markup.inline_keyboard) { - msgText += '\\n'; - for (const row of json.reply_markup.inline_keyboard) { - for (const btn of row) { - msgText += ' [' + btn.text + '] '; - } - msgText += '\\n'; - } - } const useHtml = json.parse_mode === 'HTML'; addMsg('@' + botName, msgText, false, null, useHtml); lastDmPeer = json.from ? normFP(json.from) : ''; + // Render inline keyboard if present + if (json.reply_markup && json.reply_markup.inline_keyboard) { + const kbdDiv = document.createElement('div'); + kbdDiv.className = 'inline-kbd'; + for (const row of json.reply_markup.inline_keyboard) { + const rowDiv = document.createElement('div'); + rowDiv.className = 'inline-kbd-row'; + for (const btn of row) { + const btnEl = document.createElement('button'); + btnEl.className = 'kbd-btn'; + btnEl.textContent = btn.text; + btnEl.onclick = async function() { + const cbData = btn.callback_data || btn.text; + const botFp = json.from ? normFP(json.from) : ''; + if (botFp) { + const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true}; + await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})}); + addSys('Sent: ' + btn.text); + } + }; + rowDiv.appendChild(btnEl); + } + kbdDiv.appendChild(rowDiv); + } + $messages.appendChild(kbdDiv); + $messages.scrollTop = $messages.scrollHeight; + } return; } if (json.type === 'bot_edit') { addSys('[bot updated: ' + (json.text || '') + ']'); + // Render inline keyboard if present + if (json.reply_markup && json.reply_markup.inline_keyboard) { + const kbdDiv = document.createElement('div'); + kbdDiv.className = 'inline-kbd'; + for (const row of json.reply_markup.inline_keyboard) { + const rowDiv = document.createElement('div'); + rowDiv.className = 'inline-kbd-row'; + for (const btn of row) { + const btnEl = document.createElement('button'); + btnEl.className = 'kbd-btn'; + btnEl.textContent = btn.text; + btnEl.onclick = async function() { + const cbData = btn.callback_data || btn.text; + const botFp = json.from ? normFP(json.from) : ''; + if (botFp) { + const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true}; + await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})}); + addSys('Sent: ' + btn.text); + } + }; + rowDiv.appendChild(btnEl); + } + kbdDiv.appendChild(rowDiv); + } + $messages.appendChild(kbdDiv); + $messages.scrollTop = $messages.scrollHeight; + } return; } if (json.type === 'bot_document') { @@ -735,23 +786,67 @@ async function handleIncomingMessage(bytes) { if (json.type === 'bot_message') { const botName = json.from_name || json.from || 'bot'; let msgText = json.text || ''; - // Handle inline keyboard if present - if (json.reply_markup && json.reply_markup.inline_keyboard) { - msgText += '\\n'; - for (const row of json.reply_markup.inline_keyboard) { - for (const btn of row) { - msgText += ' [' + btn.text + '] '; - } - msgText += '\\n'; - } - } const useHtml = json.parse_mode === 'HTML'; addMsg('@' + botName, msgText, false, null, useHtml); lastDmPeer = json.from ? normFP(json.from) : ''; + // Render inline keyboard if present + if (json.reply_markup && json.reply_markup.inline_keyboard) { + const kbdDiv = document.createElement('div'); + kbdDiv.className = 'inline-kbd'; + for (const row of json.reply_markup.inline_keyboard) { + const rowDiv = document.createElement('div'); + rowDiv.className = 'inline-kbd-row'; + for (const btn of row) { + const btnEl = document.createElement('button'); + btnEl.className = 'kbd-btn'; + btnEl.textContent = btn.text; + btnEl.onclick = async function() { + const cbData = btn.callback_data || btn.text; + const botFp = json.from ? normFP(json.from) : ''; + if (botFp) { + const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true}; + await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})}); + addSys('Sent: ' + btn.text); + } + }; + rowDiv.appendChild(btnEl); + } + kbdDiv.appendChild(rowDiv); + } + $messages.appendChild(kbdDiv); + $messages.scrollTop = $messages.scrollHeight; + } return; } if (json.type === 'bot_edit') { addSys('[bot updated message: ' + (json.text || '') + ']'); + // Render inline keyboard if present + if (json.reply_markup && json.reply_markup.inline_keyboard) { + const kbdDiv = document.createElement('div'); + kbdDiv.className = 'inline-kbd'; + for (const row of json.reply_markup.inline_keyboard) { + const rowDiv = document.createElement('div'); + rowDiv.className = 'inline-kbd-row'; + for (const btn of row) { + const btnEl = document.createElement('button'); + btnEl.className = 'kbd-btn'; + btnEl.textContent = btn.text; + btnEl.onclick = async function() { + const cbData = btn.callback_data || btn.text; + const botFp = json.from ? normFP(json.from) : ''; + if (botFp) { + const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true}; + await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})}); + addSys('Sent: ' + btn.text); + } + }; + rowDiv.appendChild(btnEl); + } + kbdDiv.appendChild(rowDiv); + } + $messages.appendChild(kbdDiv); + $messages.scrollTop = $messages.scrollHeight; + } return; } if (json.type === 'bot_document') {