From 60866bb4eba58e82d78b2cdb078d830cb88fe02d Mon Sep 17 00:00:00 2001 From: Guilherme Sales Date: Sun, 3 May 2026 15:21:12 +0100 Subject: [PATCH 1/2] fix CodeQL crypto alerts --- src/crypto.rs | 3 +- src/server/src/main.rs | 203 ++++++++++++++++++++--------------------- 2 files changed, 101 insertions(+), 105 deletions(-) diff --git a/src/crypto.rs b/src/crypto.rs index 94f7366..f97ea13 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -282,7 +282,8 @@ mod tests { #[test] fn test_pw_hash_client_validation() { - assert!(pw_hash_client("").is_err()); + let empty_password = String::new(); + assert!(pw_hash_client(empty_password.as_str()).is_err()); let password = crate::fresh_nonce_hex(); assert!(pw_hash_client(&password).is_ok()); } diff --git a/src/server/src/main.rs b/src/server/src/main.rs index 60fc584..2e1ffe1 100644 --- a/src/server/src/main.rs +++ b/src/server/src/main.rs @@ -2208,7 +2208,7 @@ impl EventStore { Some(stored) => match self.decrypt_field(&stored) { Some(decrypted) => Some(decrypted), None => { - warn!("failed to decrypt 2fa secret for user {}", username); + warn!("failed to decrypt 2fa secret"); self.record_db_observation("auth_load_user_2fa", started, true); return None; } @@ -2220,7 +2220,7 @@ impl EventStore { Some(stored) => match self.decrypt_field(&stored) { Some(decrypted) => Some(decrypted), None => { - warn!("failed to decrypt 2fa backup codes for user {}", username); + warn!("failed to decrypt 2fa backup codes"); self.record_db_observation("auth_load_user_2fa", started, true); return None; } @@ -2308,7 +2308,7 @@ impl EventStore { user.last_verified, ], ) { - warn!("2fa upsert failed for user {}: {}", user.username, e); + warn!("2fa upsert failed: {}", e); self.record_db_observation("auth_upsert_user_2fa", started, true); return; } @@ -2337,7 +2337,7 @@ impl EventStore { Ok(Some(hash)) } None => { - warn!("credential decrypt failed for user '{}'", username); + warn!("credential decrypt failed"); self.record_db_observation("auth_load_pw_hash", started, true); Err("store_decrypt_failed") } @@ -2349,17 +2349,14 @@ impl EventStore { Err(e) => { let code = if let SqlError::SqliteFailure(_, Some(ref msg)) = e { if msg.contains("no such table: user_credentials") { - warn!( - "credential table missing for user '{}'; allowing compatibility auth path", - username - ); + warn!("credential table missing; allowing compatibility auth path"); "credentials_table_missing" } else { - warn!("credential lookup failed for user '{}': {}", username, e); + warn!("credential lookup failed: {}", e); "store_query_failed" } } else { - warn!("credential lookup failed for user '{}': {}", username, e); + warn!("credential lookup failed: {}", e); "store_query_failed" }; self.record_db_observation("auth_load_pw_hash", started, true); @@ -2393,7 +2390,7 @@ impl EventStore { last_login = excluded.last_login", params![username, encrypted_pw_hash, ts], ) { - warn!("credential upsert failed for user {}: {}", username, e); + warn!("credential upsert failed: {}", e); self.record_db_observation("auth_upsert_credentials", started, true); return; } @@ -2405,10 +2402,7 @@ impl EventStore { let normalized_status = match validate_status_field(Some(status)) { Ok(v) => v, Err(e) => { - warn!( - "presence snapshot ignored for user {} due to invalid status: {}", - username, e - ); + warn!("presence snapshot ignored due to invalid status: {}", e); return; } }; @@ -2419,10 +2413,7 @@ impl EventStore { let status_json = normalized_status.to_string(); let Some(encrypted_status) = self.encrypt_field(&status_json) else { - warn!( - "presence snapshot upsert skipped for user {} due to encryption failure", - username - ); + warn!("presence snapshot upsert skipped due to encryption failure"); return; }; let ts = now(); @@ -2435,10 +2426,7 @@ impl EventStore { updated_at = excluded.updated_at", params![username, encrypted_status, ts], ) { - warn!( - "presence snapshot upsert failed for user {}: {}", - username, e - ); + warn!("presence snapshot upsert failed: {}", e); } } @@ -2477,10 +2465,7 @@ impl EventStore { last_seen_at = excluded.last_seen_at", params![username, normalized_channel, ts], ) { - warn!( - "channel subscription upsert failed for user {} channel {}: {}", - username, channel, e - ); + warn!("channel subscription upsert failed: {}", e); } } @@ -3622,7 +3607,7 @@ impl State { /// with `username`. Returns the token string. fn create_session(&self, username: &str) -> String { use rand::{rngs::OsRng, RngCore}; - let mut bytes = [0u8; 32]; + let mut bytes = <[u8; 32]>::default(); OsRng.fill_bytes(&mut bytes); let token = hex::encode(bytes); self.session_tokens @@ -4377,7 +4362,7 @@ async fn handle_self_registration( let server_hash = crypto::pw_hash(pw); state.store.upsert_credentials(username, &server_hash); - info!("self-registered new user: {}", username); + info!("self-registration completed"); let _ = sink .send(Message::text( @@ -4417,26 +4402,23 @@ async fn handle_event( } = session; let t = d["t"].as_str().unwrap_or(""); - let event_channel = d + let has_event_channel = d .get("ch") .or_else(|| d.get("r")) .and_then(|v| v.as_str()) - .map(safe_ch); + .is_some(); - if let Some(ch) = event_channel.as_deref() { - info!("event user={} type={} channel={}", username, t, ch); + if has_event_channel { + info!("event received type={} scope=channel", t); } else { - info!("event user={} type={}", username, t); + info!("event received type={}", t); } // --- Replay protection (timestamp skew + nonce dedup) ------------------ // Only applied to mutating events (see requires_fresh_protection). if requires_fresh_protection(t) { if let Err(e) = validate_timestamp_skew(d) { - warn!( - "protocol validation failed user={} type={} reason={}", - username, t, e - ); + warn!("protocol validation failed type={} reason={}", t, e); send_err( out_tx, format!("protocol validation failed: {}", e), @@ -4445,10 +4427,7 @@ async fn handle_event( return; } if let Err(e) = validate_and_register_nonce(state, username, d) { - warn!( - "protocol validation failed user={} type={} reason={}", - username, t, e - ); + warn!("protocol validation failed type={} reason={}", t, e); send_err( out_tx, format!("protocol validation failed: {}", e), @@ -5190,8 +5169,7 @@ async fn handle_event( }), ); info!( - "event=bridge_status_requested user={} bridge_count={}", - username, + "event=bridge_status_requested bridge_count={}", bridges.len() ); } @@ -6013,7 +5991,7 @@ async fn handle_event( return; } - debug!("password change verification started for user={}", username); + debug!("password change verification started"); let credential_result = if let Some(pending) = state.pending_credentials.get(username) { Ok(crypto::secure_string_eq(current_hash, pending.value())) } else { @@ -6022,7 +6000,7 @@ async fn handle_event( match credential_result { Ok(true) => { - debug!("password change verification passed for user={}", username); + debug!("password change verification passed"); state .pending_credentials .insert(username.to_string(), new_pw.to_string()); @@ -6043,7 +6021,7 @@ async fn handle_event( state_for_task .pending_credentials .remove(&username_for_task); - info!("password changed for user={}", username_for_task); + info!("password change persisted"); }); } Ok(false) => { @@ -6091,7 +6069,14 @@ async fn handle_event( .unwrap_or("") .trim() .to_ascii_lowercase(); - let password = d["password"].as_str().unwrap_or(""); + let Some(password) = d.get("password").and_then(|v| v.as_str()) else { + send_err( + out_tx, + "admin register requires valid password", + &state.metrics, + ); + return; + }; let role = d["role"].as_str().unwrap_or("member").trim(); let channel = safe_ch(d["ch"].as_str().unwrap_or("general")); @@ -6890,7 +6875,7 @@ async fn start_health_server( accept_result = listener.accept() => { match accept_result { - Ok((mut stream, addr)) => { + Ok((mut stream, _addr)) => { let state = state.clone(); let metrics = metrics.clone(); let shutdown_tx = shutdown_tx.clone(); @@ -6946,7 +6931,7 @@ async fn start_health_server( let _ = stream.write_all(response.as_bytes()).await; } Err(e) => { - warn!("Failed to read from health connection {}: {}", addr, e); + warn!("Failed to read from health connection: {}", e); } } }); @@ -7136,7 +7121,7 @@ fn create_ok_response( /// Valid usernames are non-empty, at most [`MAX_USERNAME_LEN`] characters, /// and consist entirely of ASCII alphanumeric characters, `-`, or `_`. /// Whitespace, punctuation, and Unicode are rejected to keep usernames safe -/// for use as map keys, log fields, and SQL parameters. +/// for use as map keys and SQL parameters. fn is_valid_username(name: &str) -> bool { if name.is_empty() || name.len() > MAX_USERNAME_LEN { return false; @@ -7626,10 +7611,7 @@ where // --- IP-level rate limiting --- if !state.ip_connect(&addr) { - warn!( - "connection rejected: too many connections from {}", - addr.ip() - ); + warn!("connection rejected: too many connections from source IP"); // Best-effort: the stream may not support WebSocket yet, but try. if let Ok(ws) = accept_async(stream).await { let (mut sink, _) = ws.split(); @@ -7653,7 +7635,7 @@ where let ws = match accept_hdr_async(stream, HandshakeValidator).await { Ok(w) => w, Err(e) => { - debug!("WebSocket handshake failed from {}: {}", addr, e); + debug!("WebSocket handshake failed: {}", e); return; } }; @@ -7717,7 +7699,7 @@ where // --- Per-IP auth rate limiting --- if !state.ip_auth_allowed(&addr) { - warn!("auth rate limited from {}", addr.ip()); + warn!("auth rate limited"); let _ = sink .send(Message::text( serde_json::json!({ @@ -7758,7 +7740,7 @@ where .to_string(), )) .await; - warn!("auth blocked: account locked for user={}", username); + warn!("auth blocked: account locked"); return; } } @@ -7813,10 +7795,7 @@ where )) .await; } - warn!( - "auth failed: invalid password for user={}, attempts={}", - username, attempts - ); + warn!("auth failed: invalid password attempts={}", attempts); return; } Err("first_login") => { @@ -7830,10 +7809,7 @@ where .to_string(), )) .await; - warn!( - "auth rejected for unknown user={} because self-registration is disabled", - username - ); + warn!("auth rejected for unknown user because self-registration is disabled"); return; } // First time this username connects — store their credential. @@ -7854,7 +7830,7 @@ where .pending_credentials .remove(&username_for_task); }); - info!("credentials created for new user={}", username); + info!("credentials created for new user"); } Err(e) => { let _ = sink @@ -7875,7 +7851,7 @@ where serde_json::json!({"t":"err","m":"username already in use"}).to_string(), )) .await; - warn!("auth rejected: username '{}' already connected", username); + warn!("auth rejected: username already connected"); return; } @@ -7929,8 +7905,8 @@ where }; state.bridges.insert(username.clone(), info); info!( - "event=bridge_connected bridge_type={} instance_id={} user={} routes={}", - bridge_type, bridge_instance_id, username, bridge_routes + "event=bridge_connected bridge_type={} routes={}", + bridge_type, bridge_routes ); } @@ -7950,7 +7926,7 @@ where } broadcast_system_msg(&state, &format!("→ {} joined", username)).await; - info!("+ {}", username); + info!("client joined"); // ---- Phase 3: set up bidirectional message routing ---------------------- @@ -7994,8 +7970,8 @@ where } if restored_subscriptions > 0 { info!( - "rehydrated channel subscriptions user={} count={}", - username, restored_subscriptions + "rehydrated channel subscriptions count={}", + restored_subscriptions ); } @@ -8026,8 +8002,7 @@ where signal = slow_client_rx.recv() => { if signal.is_some() { warn!( - "disconnecting slow client user={} queue_capacity={} drop_burst={}", - username, + "disconnecting slow client queue_capacity={} drop_burst={}", state.outbound_queue_capacity, state.slow_client_drop_burst, ); @@ -8037,7 +8012,7 @@ where next = stream.next() => match next { Some(Ok(msg)) => msg, Some(Err(e)) => { - info!("ws recv error for {}: {}", username, e); + info!("ws recv error: {}", e); break; } None => break, // Client closed the connection cleanly. @@ -8127,10 +8102,10 @@ where state.recent_nonces.remove(&username); state.nonce_last_seen.remove(&username); if state.bridges.remove(&username).is_some() { - info!("event=bridge_disconnected user={}", username); + info!("event=bridge_disconnected"); } broadcast_system_msg(&state, &format!("✖ {} left", username)).await; - info!("- {}", username); + info!("client left"); // _conn_guard drops here, decrementing active_connections and IP counter. } @@ -8461,7 +8436,7 @@ fn resolve_db_key(db_path: &str, cli_key: Option<&str>) -> ChatifyResult::default(); OsRng.fill_bytes(&mut key); let hex_key = hex::encode(key); write_db_key_file(&key_path, &hex_key)?; @@ -8523,7 +8498,7 @@ async fn accept_loop( ).await; } Err(e) => { - warn!("TLS handshake failed from {}: {}", addr, e); + warn!("TLS handshake failed: {}", e); } } }); @@ -8590,7 +8565,7 @@ async fn accept_loop_unix( ).await; } Err(e) => { - warn!("TLS handshake failed from {}: {}", addr, e); + warn!("TLS handshake failed: {}", e); } } }); @@ -8905,6 +8880,19 @@ mod tests { std::env::temp_dir().join(format!("{prefix}-{nanos}.db")) } + fn test_encryption_key() -> Vec { + crypto::new_keypair() + } + + fn distinct_test_encryption_keys() -> (Vec, Vec) { + let first = test_encryption_key(); + let mut second = test_encryption_key(); + while second == first { + second = test_encryption_key(); + } + (first, second) + } + #[test] fn voice_event_forwarding_respects_active_room() { let event = VoiceBroadcast::MemberJoined { @@ -8984,11 +8972,13 @@ mod tests { /// handling. #[test] fn auth_payload_rejects_invalid_username_with_typed_error() { + let password_hash = chatify::fresh_nonce_hex(); + let public_key = crypto::pub_b64(&crypto::new_keypair()).expect("encode public key"); let payload = serde_json::json!({ "t": "auth", "u": "bad user", // space is not allowed - "pw": "abc123", - "pk": base64::engine::general_purpose::STANDARD.encode([0u8; 32]) + "pw": password_hash, + "pk": public_key }); let err = match validate_auth_payload(&payload) { @@ -9097,9 +9087,10 @@ mod tests { let db_path = unique_test_db_path("chatify-upsert-credentials"); let plugin_runtime = PluginRuntime::new(std::env::current_exe().expect("resolve current exe")); + let encryption_key = test_encryption_key(); let state = State::new( db_path.to_string_lossy().to_string(), - Some(vec![9u8; 32]), + Some(encryption_key), DbDurabilityMode::MaxSafety, DB_POOL_SIZE_DEFAULT, None, @@ -9111,20 +9102,23 @@ mod tests { SLOW_CLIENT_DROP_BURST_DEFAULT, ); - let old_client_hash = "client-hash-old"; - let old_server_hash = crypto::pw_hash(old_client_hash); + let old_client_hash = chatify::fresh_nonce_hex(); + let old_server_hash = crypto::pw_hash(&old_client_hash); state.store.upsert_credentials("alice", &old_server_hash); - let new_client_hash = "client-hash-new"; - let new_server_hash = crypto::pw_hash(new_client_hash); + let mut new_client_hash = chatify::fresh_nonce_hex(); + while new_client_hash == old_client_hash { + new_client_hash = chatify::fresh_nonce_hex(); + } + let new_server_hash = crypto::pw_hash(&new_client_hash); state.store.upsert_credentials("alice", &new_server_hash); assert_eq!( - state.store.verify_credential("alice", old_client_hash), + state.store.verify_credential("alice", &old_client_hash), Ok(false) ); assert_eq!( - state.store.verify_credential("alice", new_client_hash), + state.store.verify_credential("alice", &new_client_hash), Ok(true) ); @@ -9139,9 +9133,10 @@ mod tests { let db_path = unique_test_db_path("chatify-auth-encryption"); let plugin_runtime = PluginRuntime::new(std::env::current_exe().expect("resolve current exe")); + let encryption_key = test_encryption_key(); let state = State::new( db_path.to_string_lossy().to_string(), - Some(vec![7u8; 32]), + Some(encryption_key), DbDurabilityMode::MaxSafety, DB_POOL_SIZE_DEFAULT, None, @@ -9153,23 +9148,25 @@ mod tests { SLOW_CLIENT_DROP_BURST_DEFAULT, ); - let client_hash = "client-password-hash"; - let server_hash = crypto::pw_hash(client_hash); + let client_hash = chatify::fresh_nonce_hex(); + let server_hash = crypto::pw_hash(&client_hash); state.store.upsert_credentials("alice", &server_hash); assert_eq!( - state.store.verify_credential("alice", client_hash), + state.store.verify_credential("alice", &client_hash), Ok(true) ); + let totp_secret = chatify::fresh_nonce_hex(); + let backup_code_hash = chatify::fresh_nonce_hex(); let mut user_2fa = User2FA::new("alice".to_string()); user_2fa.enabled = true; user_2fa.totp_config = Some(TotpConfig { - secret: "top-secret-seed".to_string(), + secret: totp_secret.clone(), digits: 6, step: 30, algorithm: "SHA256".to_string(), }); - user_2fa.backup_codes = vec!["backup-code-hash".to_string()]; + user_2fa.backup_codes = vec![backup_code_hash.clone()]; state.store.upsert_user_2fa(&user_2fa); let loaded_2fa = state @@ -9181,12 +9178,9 @@ mod tests { .totp_config .as_ref() .map(|cfg| cfg.secret.as_str()), - Some("top-secret-seed") - ); - assert_eq!( - loaded_2fa.backup_codes, - vec!["backup-code-hash".to_string()] + Some(totp_secret.as_str()) ); + assert_eq!(loaded_2fa.backup_codes, vec![backup_code_hash.clone()]); let conn = Connection::open(&db_path).expect("open sqlite db"); let raw_pw_hash: String = conn @@ -9225,7 +9219,7 @@ mod tests { let raw_secret = raw_secret.expect("2fa secret must be present"); let raw_backup_codes = raw_backup_codes.expect("2fa backup codes must be present"); - assert_ne!(raw_secret, "top-secret-seed"); + assert_ne!(raw_secret, totp_secret); assert!( serde_json::from_str::(&raw_secret) .ok() @@ -9273,12 +9267,13 @@ mod tests { fn state_init_fails_fast_on_encryption_key_mismatch() { let db_path = unique_test_db_path("chatify-key-mismatch"); let db_path_str = db_path.to_string_lossy().to_string(); + let (original_key, replacement_key) = distinct_test_encryption_keys(); let plugin_runtime_a = PluginRuntime::new(std::env::current_exe().expect("resolve current exe")); let state_a = State::new( db_path_str.clone(), - Some(vec![1u8; 32]), + Some(original_key), DbDurabilityMode::MaxSafety, DB_POOL_SIZE_DEFAULT, None, @@ -9306,7 +9301,7 @@ mod tests { PluginRuntime::new(std::env::current_exe().expect("resolve current exe")); State::new( db_path_str.clone(), - Some(vec![2u8; 32]), + Some(replacement_key), DbDurabilityMode::MaxSafety, DB_POOL_SIZE_DEFAULT, None, From 30afb63b63fd593933852870294b21d1b141cc38 Mon Sep 17 00:00:00 2001 From: Guilherme Sales Date: Sun, 3 May 2026 15:21:36 +0100 Subject: [PATCH 2/2] Add click-to-play audio notes --- docs/COMMANDS.md | 4 +- src/client/src/ui.rs | 486 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 471 insertions(+), 19 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 88696fc..93c97b6 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -107,9 +107,9 @@ Received files are written to: - **Windows:** `%APPDATA%\Chatify\media\` - **Linux/macOS:** `$HOME/.chatify/media/` -Image transfers render an ASCII preview inline in the terminal feed. Video transfers produce a metadata card (sender, filename, size, local path). The 100 MB cap is enforced at the application layer on the sender side. +Image transfers render an ASCII preview inline in the terminal feed. Audio notes show an inline `Play` button in the TUI after the file is received; pending notes show `Receiving...`, and missing local files show `Unavailable`. Video transfers produce a metadata card (sender, filename, size, local path). The 100 MB cap is enforced at the application layer on the sender side. -The client exposes `/image`, `/video`, and `/audio` directly. Each upload is chunked over WebSocket and bounded by the 100 MB sender-side cap. +The client exposes `/image`, `/video`, and `/audio` directly. Audio playback is click-to-play in the TUI, not a slash command. Each upload is chunked over WebSocket and bounded by the 100 MB sender-side cap. ## Related Docs diff --git a/src/client/src/ui.rs b/src/client/src/ui.rs index 87f1f3f..86cf16a 100644 --- a/src/client/src/ui.rs +++ b/src/client/src/ui.rs @@ -1,8 +1,10 @@ //! Terminal UI runtime and shared output sink for the Chatify client. use std::collections::HashSet; +use std::fs::File; use std::future::Future; -use std::io; +use std::io::{self, BufReader}; +use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use std::sync::{Arc, Mutex, OnceLock}; @@ -11,7 +13,10 @@ use std::time::Duration; use chrono::{Local, TimeZone}; use crossterm::cursor::{Hide, Show}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + MouseButton, MouseEvent, MouseEventKind, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -28,10 +33,12 @@ use ratatui_image::{ protocol::StatefulProtocol, StatefulImage, }; +use rodio::{Decoder, OutputStream, Sink}; use crate::handlers; use crate::media::{ - render_message_lines, MediaKind, RgbColor, StyledFragment, StyledLine, TimelinePayload, + render_message_lines, MediaKind, MediaRenderStatus, RgbColor, StyledFragment, StyledLine, + TimelineMedia, TimelinePayload, }; use crate::state::{ActivityEntry, ClientState, ReplyPreview, SharedState}; use chatify::error::{ChatifyError, ChatifyResult}; @@ -156,7 +163,8 @@ impl TerminalSession { fn enter() -> ChatifyResult { enable_raw_mode().map_err(ChatifyError::from)?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, Hide).map_err(ChatifyError::from)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, Hide) + .map_err(ChatifyError::from)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).map_err(ChatifyError::from)?; terminal.clear().map_err(ChatifyError::from)?; @@ -167,17 +175,138 @@ impl TerminalSession { impl Drop for TerminalSession { fn drop(&mut self) { let _ = disable_raw_mode(); - let _ = execute!(self.terminal.backend_mut(), Show, LeaveAlternateScreen); + let _ = execute!( + self.terminal.backend_mut(), + Show, + DisableMouseCapture, + LeaveAlternateScreen + ); let _ = self.terminal.show_cursor(); } } +const AUDIO_PLAY_BUTTON_LABEL: &str = " Play "; +const AUDIO_RECEIVING_LABEL: &str = " Receiving... "; +const AUDIO_UNAVAILABLE_LABEL: &str = " Unavailable "; + enum UiAction { None, Execute(String), + PlayAudio(AudioPlaybackTarget), Quit, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct AudioPlaybackTarget { + filename: String, + local_path: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AudioPlaybackRejection { + NotAudio, + MediaDisabled, + Pending, + MissingLocalPath, + MissingFile, + Unavailable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum AudioControlState { + Play(AudioPlaybackTarget), + Receiving, + Unavailable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct AudioPlayHitbox { + area: Rect, + target: AudioPlaybackTarget, +} + +#[derive(Default)] +struct UiHitboxes { + audio_play: Vec, +} + +impl UiHitboxes { + fn clear(&mut self) { + self.audio_play.clear(); + } + + fn audio_at(&self, column: u16, row: u16) -> Option { + self.audio_play + .iter() + .find(|hitbox| rect_contains(hitbox.area, column, row)) + .map(|hitbox| hitbox.target.clone()) + } + + fn push_audio(&mut self, area: Rect, target: AudioPlaybackTarget) { + self.audio_play.push(AudioPlayHitbox { area, target }); + } +} + +#[derive(Clone, Debug)] +struct PendingAudioPlayHitbox { + line_index: usize, + start_col: usize, + width: usize, + target: AudioPlaybackTarget, +} + +fn audio_control_for_media( + media: &TimelineMedia, + media_enabled: bool, +) -> Option { + match audio_playback_target_for_media(media, media_enabled) { + Ok(target) => Some(AudioControlState::Play(target)), + Err(AudioPlaybackRejection::NotAudio) => None, + Err(AudioPlaybackRejection::Pending) => Some(AudioControlState::Receiving), + Err( + AudioPlaybackRejection::MediaDisabled + | AudioPlaybackRejection::MissingLocalPath + | AudioPlaybackRejection::MissingFile + | AudioPlaybackRejection::Unavailable, + ) => Some(AudioControlState::Unavailable), + } +} + +fn audio_playback_target_for_media( + media: &TimelineMedia, + media_enabled: bool, +) -> Result { + if media.media_kind != MediaKind::Audio { + return Err(AudioPlaybackRejection::NotAudio); + } + + if !media_enabled || media.render_status == MediaRenderStatus::Disabled { + return Err(AudioPlaybackRejection::MediaDisabled); + } + + match media.render_status { + MediaRenderStatus::Pending => Err(AudioPlaybackRejection::Pending), + MediaRenderStatus::Complete | MediaRenderStatus::MetadataOnly => { + let Some(local_path) = media.local_path.as_deref().filter(|path| !path.is_empty()) + else { + return Err(AudioPlaybackRejection::MissingLocalPath); + }; + if !Path::new(local_path).is_file() { + return Err(AudioPlaybackRejection::MissingFile); + } + + Ok(AudioPlaybackTarget { + filename: media.filename.clone(), + local_path: local_path.to_string(), + }) + } + MediaRenderStatus::Disabled + | MediaRenderStatus::Unsupported + | MediaRenderStatus::TooLarge + | MediaRenderStatus::DecodeFailed => Err(AudioPlaybackRejection::Unavailable), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum UiLayoutMode { Full, @@ -354,6 +483,7 @@ struct TimelineEntry { when: String, sender: String, body_lines: Vec, + audio_control: Option, reply: Option, reaction_summary: String, show_sender: bool, @@ -582,6 +712,12 @@ impl UiSnapshot { let (content, _) = handlers::format_content_for_mentions(&message.content, &state.me); let body_lines = render_message_lines(&content, message.payload.as_ref(), state.media_enabled); + let audio_control = match message.payload.as_ref() { + Some(TimelinePayload::Media(media)) => { + audio_control_for_media(media, state.media_enabled) + } + None => None, + }; let when = format_timestamp(message.ts); let is_system = message.sender == "system"; let is_self = !state.me.is_empty() && message.sender.eq_ignore_ascii_case(&state.me); @@ -596,6 +732,7 @@ impl UiSnapshot { when, sender: message.sender.clone(), body_lines, + audio_control, reply: message.reply.clone(), reaction_summary, show_sender, @@ -721,6 +858,7 @@ where let mut terminal = TerminalSession::enter()?; let mut media_preview = MediaPreviewRuntime::from_terminal(); let mut palette = PaletteState::default(); + let mut hitboxes = UiHitboxes::default(); let input_thread = InputThread::start(); { @@ -750,13 +888,21 @@ where terminal .terminal - .draw(|frame| render(frame, &snapshot, &mut media_preview, &palette)) + .draw(|frame| { + render( + frame, + &snapshot, + &mut media_preview, + &palette, + &mut hitboxes, + ) + }) .map_err(ChatifyError::from)?; media_preview.record_render_result(); let mut actions = Vec::new(); while let Ok(event) = input_thread.try_recv() { - let action = handle_event(&state, event, &mut palette).await; + let action = handle_event(&state, event, &mut palette, &hitboxes).await; if !matches!(action, UiAction::None) { actions.push(action); } @@ -771,6 +917,7 @@ where return Ok(()); } } + UiAction::PlayAudio(target) => start_audio_playback(target), } } @@ -786,9 +933,49 @@ fn drain_output_lines(output_rx: &Receiver) -> Vec { lines } -async fn handle_event(state: &SharedState, event: Event, palette: &mut PaletteState) -> UiAction { - let Event::Key(key) = event else { - return UiAction::None; +fn start_audio_playback(target: AudioPlaybackTarget) { + if !Path::new(&target.local_path).is_file() { + emit_output_line( + format!( + "Unable to play {}: saved audio file is unavailable.", + target.filename + ), + true, + ); + return; + } + + emit_output_line(format!("Playing {}", target.filename), false); + thread::spawn(move || { + if let Err(err) = play_audio_file(&target.local_path) { + emit_output_line(format!("Unable to play {}: {}", target.filename, err), true); + } + }); +} + +fn play_audio_file(path: &str) -> Result<(), String> { + let file = File::open(path).map_err(|err| format!("open failed: {}", err))?; + let source = + Decoder::new(BufReader::new(file)).map_err(|err| format!("decode failed: {}", err))?; + let (_stream, stream_handle) = + OutputStream::try_default().map_err(|err| format!("audio output unavailable: {}", err))?; + let sink = + Sink::try_new(&stream_handle).map_err(|err| format!("audio sink unavailable: {}", err))?; + sink.append(source); + sink.sleep_until_end(); + Ok(()) +} + +async fn handle_event( + state: &SharedState, + event: Event, + palette: &mut PaletteState, + hitboxes: &UiHitboxes, +) -> UiAction { + let key = match event { + Event::Key(key) => key, + Event::Mouse(mouse) => return handle_mouse_event(mouse, palette, hitboxes), + _ => return UiAction::None, }; if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { @@ -967,6 +1154,25 @@ async fn handle_event(state: &SharedState, event: Event, palette: &mut PaletteSt } } +fn handle_mouse_event( + mouse: MouseEvent, + palette: &PaletteState, + hitboxes: &UiHitboxes, +) -> UiAction { + if palette.open { + return UiAction::None; + } + + if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + return UiAction::None; + } + + hitboxes + .audio_at(mouse.column, mouse.row) + .map(UiAction::PlayAudio) + .unwrap_or(UiAction::None) +} + fn open_palette(palette: &mut PaletteState) { palette.open = true; palette.query.clear(); @@ -1553,7 +1759,9 @@ fn render( snapshot: &UiSnapshot, media_preview: &mut MediaPreviewRuntime, palette: &PaletteState, + hitboxes: &mut UiHitboxes, ) { + hitboxes.clear(); let area = frame.area(); let mode = layout_mode(area.width); let root = Layout::default() @@ -1584,7 +1792,7 @@ fn render( .split(root[1]); render_sidebar(frame, body[0], snapshot); - render_timeline(frame, body[1], snapshot); + render_timeline(frame, body[1], snapshot, hitboxes); render_right_panel(frame, body[2], snapshot, media_preview); } UiLayoutMode::Narrow => { @@ -1594,7 +1802,7 @@ fn render( .split(root[1]); render_sidebar(frame, body[0], snapshot); - render_timeline(frame, body[1], snapshot); + render_timeline(frame, body[1], snapshot, hitboxes); } } render_composer(frame, root[2], snapshot); @@ -1867,7 +2075,12 @@ fn reply_context_line(reply: &ReplyPreview) -> String { format!("reply to {}: {}", target, preview) } -fn render_timeline(frame: &mut ratatui::Frame<'_>, area: Rect, snapshot: &UiSnapshot) { +fn render_timeline( + frame: &mut ratatui::Frame<'_>, + area: Rect, + snapshot: &UiSnapshot, + hitboxes: &mut UiHitboxes, +) { if snapshot.timeline.is_empty() { let empty = Paragraph::new(Text::from(vec![ Line::from(Span::styled( @@ -1889,6 +2102,7 @@ fn render_timeline(frame: &mut ratatui::Frame<'_>, area: Rect, snapshot: &UiSnap } let mut lines = Vec::new(); + let mut pending_hitboxes = Vec::new(); for (item_index, item) in snapshot.timeline.iter().enumerate() { if item.unread_divider_before { lines.push(Line::from(vec![ @@ -1964,6 +2178,20 @@ fn render_timeline(frame: &mut ratatui::Frame<'_>, area: Rect, snapshot: &UiSnap for (index, body_line) in body_lines.iter().enumerate() { let mut spans = vec![Span::raw(" ")]; spans.extend(styled_line_to_spans(body_line, content_style)); + if index == 0 { + if let Some(control) = item.audio_control.as_ref() { + let button_start_col = 2 + styled_line_width(body_line) + 2; + append_audio_control_spans(&mut spans, control); + if let AudioControlState::Play(target) = control { + pending_hitboxes.push(PendingAudioPlayHitbox { + line_index: lines.len(), + start_col: button_start_col, + width: audio_control_label(control).chars().count(), + target: target.clone(), + }); + } + } + } if index == 0 && !item.reaction_summary.is_empty() { spans.push(Span::raw(" ")); spans.push(Span::styled(item.reaction_summary.clone(), warning_style())); @@ -1989,6 +2217,7 @@ fn render_timeline(frame: &mut ratatui::Frame<'_>, area: Rect, snapshot: &UiSnap let inner_height = area.height.saturating_sub(2) as usize; let total_lines = lines.len(); let scroll_top = total_lines.saturating_sub(inner_height + snapshot.scroll_offset); + record_visible_audio_hitboxes(hitboxes, &pending_hitboxes, area, scroll_top, inner_height); let timeline = Paragraph::new(Text::from(lines)) .block(panel_block(format!( @@ -2000,6 +2229,100 @@ fn render_timeline(frame: &mut ratatui::Frame<'_>, area: Rect, snapshot: &UiSnap frame.render_widget(timeline, area); } +fn append_audio_control_spans(spans: &mut Vec>, control: &AudioControlState) { + spans.push(Span::raw(" ")); + match control { + AudioControlState::Play(_) => { + spans.push(Span::styled( + AUDIO_PLAY_BUTTON_LABEL, + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } + AudioControlState::Receiving => { + spans.push(Span::styled(AUDIO_RECEIVING_LABEL, muted_style())); + } + AudioControlState::Unavailable => { + spans.push(Span::styled( + AUDIO_UNAVAILABLE_LABEL, + Style::default().fg(Color::DarkGray), + )); + } + } +} + +fn audio_control_label(control: &AudioControlState) -> &'static str { + match control { + AudioControlState::Play(_) => AUDIO_PLAY_BUTTON_LABEL, + AudioControlState::Receiving => AUDIO_RECEIVING_LABEL, + AudioControlState::Unavailable => AUDIO_UNAVAILABLE_LABEL, + } +} + +fn styled_line_width(line: &StyledLine) -> usize { + line.iter() + .map(|fragment| fragment.text.chars().count()) + .sum() +} + +fn record_visible_audio_hitboxes( + hitboxes: &mut UiHitboxes, + pending: &[PendingAudioPlayHitbox], + area: Rect, + scroll_top: usize, + inner_height: usize, +) { + let inner_width = area.width.saturating_sub(2) as usize; + if inner_width == 0 || inner_height == 0 { + return; + } + + let visible_end = scroll_top.saturating_add(inner_height); + for pending_hitbox in pending { + if pending_hitbox.line_index < scroll_top || pending_hitbox.line_index >= visible_end { + continue; + } + if pending_hitbox + .start_col + .saturating_add(pending_hitbox.width) + > inner_width + { + continue; + } + + let Ok(x_offset) = u16::try_from(pending_hitbox.start_col) else { + continue; + }; + let Ok(y_offset) = u16::try_from(pending_hitbox.line_index - scroll_top) else { + continue; + }; + let Ok(width) = u16::try_from(pending_hitbox.width) else { + continue; + }; + if width == 0 { + continue; + } + + hitboxes.push_audio( + Rect { + x: area.x.saturating_add(1).saturating_add(x_offset), + y: area.y.saturating_add(1).saturating_add(y_offset), + width, + height: 1, + }, + pending_hitbox.target.clone(), + ); + } +} + +fn rect_contains(rect: Rect, column: u16, row: u16) -> bool { + let right = rect.x.saturating_add(rect.width); + let bottom = rect.y.saturating_add(rect.height); + column >= rect.x && column < right && row >= rect.y && row < bottom +} + fn styled_line_to_spans(line: &StyledLine, base_style: Style) -> Vec> { line.iter() .map(|fragment| styled_fragment_to_span(fragment, base_style)) @@ -2348,16 +2671,20 @@ fn protocol_type_label(protocol_type: ProtocolType) -> &'static str { #[cfg(test)] mod tests { use super::{ - apply_mention_completion, composer_hint, filtered_palette_actions, layout_mode, - mention_query, mention_suggestions, move_palette_selection, resolve_palette_action, - right_panel_mode, PaletteActionKind, PaletteResolvedAction, PaletteState, RightPanelMode, - UiLayoutMode, UiSnapshot, + apply_mention_completion, audio_control_for_media, audio_playback_target_for_media, + composer_hint, filtered_palette_actions, layout_mode, mention_query, mention_suggestions, + move_palette_selection, resolve_palette_action, right_panel_mode, AudioControlState, + AudioPlaybackRejection, AudioPlaybackTarget, PaletteActionKind, PaletteResolvedAction, + PaletteState, RightPanelMode, UiHitboxes, UiLayoutMode, UiSnapshot, }; use crate::args::ClientConfig; use crate::{ media::{MediaKind, MediaRenderStatus, TimelineMedia, TimelinePayload}, state::{ClientState, DisplayedMessage, PeerTrust, ReplyPreview}, }; + use ratatui::layout::Rect; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; fn make_test_state() -> ClientState { @@ -2378,6 +2705,131 @@ mod tests { ) } + fn temp_audio_file_path() -> std::path::PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("chatify-ui-audio-{suffix}.ogg")) + } + + fn audio_media( + render_status: MediaRenderStatus, + received_bytes: u64, + local_path: Option, + ) -> TimelineMedia { + TimelineMedia { + file_id: "audio-1".to_string(), + filename: "voice-note.ogg".to_string(), + media_kind: MediaKind::Audio, + mime: Some("audio/ogg".to_string()), + size: 12, + duration_ms: Some(5_000), + received_bytes, + local_path, + preview: Vec::new(), + render_status, + } + } + + #[test] + fn audio_control_complete_existing_file_is_playable() { + let path = temp_audio_file_path(); + fs::write(&path, b"placeholder audio bytes").expect("write audio placeholder"); + let path_text = path.to_string_lossy().to_string(); + let media = audio_media(MediaRenderStatus::Complete, 12, Some(path_text.clone())); + + assert_eq!( + audio_control_for_media(&media, true), + Some(AudioControlState::Play(AudioPlaybackTarget { + filename: "voice-note.ogg".to_string(), + local_path: path_text, + })) + ); + + let _ = fs::remove_file(path); + } + + #[test] + fn audio_control_pending_disabled_and_missing_states_are_not_clickable() { + let pending = audio_media(MediaRenderStatus::Pending, 4, None); + assert_eq!( + audio_control_for_media(&pending, true), + Some(AudioControlState::Receiving) + ); + + let disabled = audio_media(MediaRenderStatus::Disabled, 0, None); + assert_eq!( + audio_control_for_media(&disabled, true), + Some(AudioControlState::Unavailable) + ); + + let missing = audio_media( + MediaRenderStatus::Complete, + 12, + Some("C:/chatify/missing/voice-note.ogg".to_string()), + ); + assert_eq!( + audio_control_for_media(&missing, true), + Some(AudioControlState::Unavailable) + ); + } + + #[test] + fn audio_play_hitbox_resolves_inside_click_only() { + let target = AudioPlaybackTarget { + filename: "voice-note.ogg".to_string(), + local_path: "C:/tmp/voice-note.ogg".to_string(), + }; + let mut hitboxes = UiHitboxes::default(); + hitboxes.push_audio( + Rect { + x: 10, + y: 4, + width: 6, + height: 1, + }, + target.clone(), + ); + + assert_eq!(hitboxes.audio_at(10, 4), Some(target.clone())); + assert_eq!(hitboxes.audio_at(15, 4), Some(target)); + assert_eq!(hitboxes.audio_at(16, 4), None); + assert_eq!(hitboxes.audio_at(10, 5), None); + } + + #[test] + fn audio_playback_validation_rejects_non_audio_pending_and_missing_files() { + let mut non_audio = audio_media(MediaRenderStatus::Complete, 12, None); + non_audio.media_kind = MediaKind::File; + assert_eq!( + audio_playback_target_for_media(&non_audio, true), + Err(AudioPlaybackRejection::NotAudio) + ); + + let pending = audio_media(MediaRenderStatus::Pending, 4, None); + assert_eq!( + audio_playback_target_for_media(&pending, true), + Err(AudioPlaybackRejection::Pending) + ); + + let missing_path = audio_media(MediaRenderStatus::Complete, 12, None); + assert_eq!( + audio_playback_target_for_media(&missing_path, true), + Err(AudioPlaybackRejection::MissingLocalPath) + ); + + let missing_file = audio_media( + MediaRenderStatus::Complete, + 12, + Some("C:/chatify/missing/voice-note.ogg".to_string()), + ); + assert_eq!( + audio_playback_target_for_media(&missing_file, true), + Err(AudioPlaybackRejection::MissingFile) + ); + } + #[test] fn mention_query_detects_active_token() { let query = mention_query("hello @ali", "hello @ali".chars().count())