diff --git a/crates/quicproquo-client/src/client/conversation.rs b/crates/quicproquo-client/src/client/conversation.rs index 3f73cac..650cd88 100644 --- a/crates/quicproquo-client/src/client/conversation.rs +++ b/crates/quicproquo-client/src/client/conversation.rs @@ -636,6 +636,33 @@ impl ConversationStore { Ok(msgs) } + /// Update the body of an existing message (for edits). + pub fn update_message_body( + &self, + conv_id: &ConversationId, + message_id: &[u8; 16], + new_body: &str, + ) -> anyhow::Result { + let rows = self.conn.execute( + "UPDATE messages SET body = ?3 WHERE conversation_id = ?1 AND message_id = ?2", + params![conv_id.0.as_slice(), message_id.as_slice(), new_body], + )?; + Ok(rows > 0) + } + + /// Mark a message as deleted (sets body to "[deleted]" and msg_type to "deleted"). + pub fn delete_message( + &self, + conv_id: &ConversationId, + message_id: &[u8; 16], + ) -> anyhow::Result { + let rows = self.conn.execute( + "UPDATE messages SET body = '[deleted]', msg_type = 'deleted' WHERE conversation_id = ?1 AND message_id = ?2", + params![conv_id.0.as_slice(), message_id.as_slice()], + )?; + Ok(rows > 0) + } + /// Save a message, deduplicating by message_id within the same conversation. /// Returns `true` if the message was saved (new), `false` if it was a duplicate. pub fn save_message_dedup(&self, msg: &StoredMessage) -> anyhow::Result { diff --git a/crates/quicproquo-client/src/client/display.rs b/crates/quicproquo-client/src/client/display.rs index e72c081..6ed5c91 100644 --- a/crates/quicproquo-client/src/client/display.rs +++ b/crates/quicproquo-client/src/client/display.rs @@ -28,12 +28,17 @@ pub fn print_prompt(session: &SessionState) { /// Print an incoming or outgoing message. pub fn print_message(msg: &StoredMessage) { + let body = if msg.msg_type == "reaction" { + format!("reacted {}", msg.body) + } else { + msg.body.clone() + }; if msg.is_outgoing { - println!("\r{GREEN}> {}{RESET}", msg.body); + println!("\r{GREEN}> {body}{RESET}"); } else { let fallback = hex::encode(&msg.sender_key[..4]); let sender = msg.sender_name.as_deref().unwrap_or(&fallback); - println!("\r{CYAN}{BOLD}[{sender}]{RESET} {}", msg.body); + println!("\r{CYAN}{BOLD}[{sender}]{RESET} {body}"); } } @@ -51,6 +56,14 @@ pub fn print_status(msg: &str) { println!("{DIM} {msg}{RESET}"); } +/// Print a transient typing indicator (clears current line first). +pub fn print_typing(sender: &str) { + use std::io::Write; + print!("\r\x1b[2K"); + println!("{DIM} {sender} is typing...{RESET}"); + let _ = std::io::stdout().flush(); +} + /// Print an error message. pub fn print_error(msg: &str) { println!("{YELLOW} error: {msg}{RESET}"); diff --git a/crates/quicproquo-client/src/client/repl.rs b/crates/quicproquo-client/src/client/repl.rs index ea19992..5f85748 100644 --- a/crates/quicproquo-client/src/client/repl.rs +++ b/crates/quicproquo-client/src/client/repl.rs @@ -12,6 +12,8 @@ use anyhow::Context; use quicproquo_core::{ AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage, compute_safety_number, hybrid_encrypt, parse as parse_app_msg, serialize_chat, + serialize_delete, serialize_edit, serialize_reaction, serialize_read_receipt, + serialize_typing, }; use quicproquo_proto::node_capnp::node_service; use tokio::sync::mpsc; @@ -61,6 +63,16 @@ enum SlashCommand { Verify { username: String }, /// Rotate own MLS leaf key in the active group. UpdateKey, + /// Send a typing indicator to the active conversation. + Typing, + /// Toggle display of typing notifications from others. + TypingNotify { enabled: bool }, + /// React to a message with an emoji. + React { emoji: String, index: Option }, + /// Edit a previously sent message by index. + Edit { index: usize, new_text: String }, + /// Delete a previously sent message by index. + Delete { index: usize }, } fn parse_input(line: &str) -> Input { @@ -147,6 +159,53 @@ fn parse_input(line: &str) -> Input { } }, "/update-key" | "/rotate-key" => Input::Slash(SlashCommand::UpdateKey), + "/typing" => Input::Slash(SlashCommand::Typing), + "/typing-notify" => match arg.as_deref() { + Some("on") => Input::Slash(SlashCommand::TypingNotify { enabled: true }), + Some("off") => Input::Slash(SlashCommand::TypingNotify { enabled: false }), + _ => { + display::print_error("usage: /typing-notify on|off"); + Input::Empty + } + }, + "/react" => match arg { + Some(rest) => { + let mut parts = rest.splitn(2, ' '); + let emoji = parts.next().unwrap().to_string(); + let index = parts.next().and_then(|s| s.trim().parse::().ok()); + Input::Slash(SlashCommand::React { emoji, index }) + } + None => { + display::print_error("usage: /react [msg-index]"); + Input::Empty + } + }, + "/edit" => match arg { + Some(rest) => { + let mut parts = rest.splitn(2, ' '); + let idx_str = parts.next().unwrap(); + match (idx_str.parse::(), parts.next()) { + (Ok(index), Some(new_text)) if !new_text.trim().is_empty() => { + Input::Slash(SlashCommand::Edit { index, new_text: new_text.trim().to_string() }) + } + _ => { + display::print_error("usage: /edit "); + Input::Empty + } + } + } + None => { + display::print_error("usage: /edit "); + Input::Empty + } + }, + "/delete" | "/del" => match arg.and_then(|s| s.parse::().ok()) { + Some(index) => Input::Slash(SlashCommand::Delete { index }), + None => { + display::print_error("usage: /delete "); + Input::Empty + } + }, _ => { display::print_error(&format!("unknown command: {cmd}. Try /help")); Input::Empty @@ -425,6 +484,10 @@ pub async fn run_repl( // Drain offline outbox before polling for new messages. drain_outbox(&mut session, &client).await; + // Expire stale typing indicators (10-second timeout). + let now = std::time::Instant::now(); + session.typing_indicators.retain(|_, ts| now.duration_since(*ts).as_secs() < 10); + match poll_messages(&mut session, &client).await { Ok(()) => { consecutive_errors = 0; @@ -617,6 +680,18 @@ async fn handle_slash( } SlashCommand::Verify { username } => cmd_verify(session, client, &username).await, SlashCommand::UpdateKey => cmd_update_key(session, client).await, + SlashCommand::Typing => cmd_typing(session, client).await, + SlashCommand::TypingNotify { enabled } => { + session.typing_notify_enabled = enabled; + display::print_status(&format!( + "typing notifications {}", + if enabled { "enabled" } else { "disabled" } + )); + Ok(()) + } + SlashCommand::React { emoji, index } => cmd_react(session, client, &emoji, index).await, + SlashCommand::Edit { index, new_text } => cmd_edit(session, client, index, &new_text).await, + SlashCommand::Delete { index } => cmd_delete(session, client, index).await, }; if let Err(e) = result { display::print_error(&format!("{e:#}")); @@ -640,6 +715,11 @@ fn print_help() { display::print_status(" /mesh server - Show how to reconnect to a mesh node"); display::print_status(" /update-key - Rotate your MLS leaf key in the active group"); display::print_status(" /verify - Show safety number for key verification"); + display::print_status(" /react [index] - React to last message (or message at index)"); + display::print_status(" /typing - Send a typing indicator"); + display::print_status(" /typing-notify on|off - Toggle typing notifications"); + display::print_status(" /edit - Edit a sent message"); + display::print_status(" /delete - Delete a sent message"); display::print_status(" /quit - Exit"); } @@ -1298,6 +1378,295 @@ async fn cmd_verify( Ok(()) } +// ── Typing indicator ───────────────────────────────────────────────────────── + +async fn cmd_typing( + session: &mut SessionState, + client: &node_service::Client, +) -> anyhow::Result<()> { + let conv_id = session + .active_conversation + .as_ref() + .context("no active conversation")? + .clone(); + + let my_key = session.identity_bytes(); + let identity = std::sync::Arc::clone(&session.identity); + + let member = session + .get_member_mut(&conv_id) + .context("no group member")?; + + anyhow::ensure!( + member.group_ref().is_some(), + "active conversation has no MLS group" + ); + + let app_payload = serialize_typing(1); + let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload); + let padded = quicproquo_core::padding::pad(&sealed); + + let ct = member + .send_message(&padded) + .context("MLS send_message failed")?; + + let recipients: Vec> = member + .member_identities() + .into_iter() + .filter(|id| id.as_slice() != my_key.as_slice()) + .collect(); + + for recipient_key in &recipients { + let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")? + } else { + ct.clone() + }; + enqueue(client, recipient_key, &payload).await?; + } + + session.save_member(&conv_id)?; + display::print_status("typing indicator sent"); + Ok(()) +} + +async fn cmd_react( + session: &mut SessionState, + client: &node_service::Client, + emoji: &str, + index: Option, +) -> anyhow::Result<()> { + let conv_id = session + .active_conversation + .as_ref() + .context("no active conversation; use /dm or /create-group first")? + .clone(); + + // Resolve the target message_id. + let ref_msg_id = if let Some(idx) = index { + // User specified a 1-based display index into conversation history. + let msgs = session.conv_store.load_all_messages(&conv_id)?; + let msg = msgs + .get(idx.saturating_sub(1)) + .with_context(|| format!("no message at index {idx}"))?; + msg.message_id + .context("message at that index has no message_id")? + } else { + // React to the most recent non-outgoing chat/reply message. + let msgs = session.conv_store.load_recent_messages(&conv_id, 50)?; + let target = msgs + .iter() + .rev() + .find(|m| !m.is_outgoing && (m.msg_type == "chat" || m.msg_type == "reply")) + .context("no received messages to react to")?; + target + .message_id + .context("most recent message has no message_id")? + }; + + let my_key = session.identity_bytes(); + let identity = Arc::clone(&session.identity); + + let member = session + .get_member_mut(&conv_id) + .context("no group member")?; + + anyhow::ensure!( + member.group_ref().is_some(), + "cannot react in a local-only conversation" + ); + + let app_payload = serialize_reaction(ref_msg_id, emoji.as_bytes()) + .context("serialize reaction")?; + + let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload); + let padded = quicproquo_core::padding::pad(&sealed); + + let ct = member + .send_message(&padded) + .context("MLS send_message failed")?; + + let recipients: Vec> = member + .member_identities() + .into_iter() + .filter(|id| id.as_slice() != my_key.as_slice()) + .collect(); + + for recipient_key in &recipients { + let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")? + } else { + ct.clone() + }; + enqueue(client, recipient_key, &payload).await?; + } + + // Store outgoing reaction. + let msg = StoredMessage { + conversation_id: conv_id.clone(), + message_id: None, + sender_key: my_key, + sender_name: Some("you".into()), + body: emoji.to_string(), + msg_type: "reaction".into(), + ref_msg_id: Some(ref_msg_id), + timestamp_ms: now_ms(), + is_outgoing: true, + }; + session.conv_store.save_message(&msg)?; + session.conv_store.update_activity(&conv_id, now_ms())?; + session.save_member(&conv_id)?; + + display::print_status(&format!("reacted {emoji}")); + Ok(()) +} + +// ── Edit / Delete ──────────────────────────────────────────────────────────── + +async fn cmd_edit( + session: &mut SessionState, + client: &node_service::Client, + index: usize, + new_text: &str, +) -> anyhow::Result<()> { + let conv_id = session + .active_conversation + .as_ref() + .context("no active conversation")? + .clone(); + + let msgs = session.conv_store.load_all_messages(&conv_id)?; + anyhow::ensure!(!msgs.is_empty(), "no messages in this conversation"); + anyhow::ensure!( + index < msgs.len(), + "message index {index} out of range (0..{})", + msgs.len() - 1 + ); + + let target = &msgs[index]; + anyhow::ensure!(target.is_outgoing, "you can only edit your own messages"); + let msg_id = target + .message_id + .context("message has no message_id (cannot edit)")?; + + let my_key = session.identity_bytes(); + let identity = std::sync::Arc::clone(&session.identity); + + let member = session + .get_member_mut(&conv_id) + .context("no group member")?; + + anyhow::ensure!( + member.group_ref().is_some(), + "active conversation has no MLS group" + ); + + let app_payload = serialize_edit(&msg_id, new_text.as_bytes()) + .context("serialize edit message")?; + let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload); + let padded = quicproquo_core::padding::pad(&sealed); + + let ct = member + .send_message(&padded) + .context("MLS send_message failed")?; + + let recipients: Vec> = member + .member_identities() + .into_iter() + .filter(|id| id.as_slice() != my_key.as_slice()) + .collect(); + + for recipient_key in &recipients { + let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")? + } else { + ct.clone() + }; + enqueue(client, recipient_key, &payload).await?; + } + + // Update local DB. + session + .conv_store + .update_message_body(&conv_id, &msg_id, new_text)?; + session.save_member(&conv_id)?; + + display::print_status("message edited"); + Ok(()) +} + +async fn cmd_delete( + session: &mut SessionState, + client: &node_service::Client, + index: usize, +) -> anyhow::Result<()> { + let conv_id = session + .active_conversation + .as_ref() + .context("no active conversation")? + .clone(); + + let msgs = session.conv_store.load_all_messages(&conv_id)?; + anyhow::ensure!(!msgs.is_empty(), "no messages in this conversation"); + anyhow::ensure!( + index < msgs.len(), + "message index {index} out of range (0..{})", + msgs.len() - 1 + ); + + let target = &msgs[index]; + anyhow::ensure!(target.is_outgoing, "you can only delete your own messages"); + let msg_id = target + .message_id + .context("message has no message_id (cannot delete)")?; + + let my_key = session.identity_bytes(); + let identity = std::sync::Arc::clone(&session.identity); + + let member = session + .get_member_mut(&conv_id) + .context("no group member")?; + + anyhow::ensure!( + member.group_ref().is_some(), + "active conversation has no MLS group" + ); + + let app_payload = serialize_delete(&msg_id); + let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload); + let padded = quicproquo_core::padding::pad(&sealed); + + let ct = member + .send_message(&padded) + .context("MLS send_message failed")?; + + let recipients: Vec> = member + .member_identities() + .into_iter() + .filter(|id| id.as_slice() != my_key.as_slice()) + .collect(); + + for recipient_key in &recipients { + let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")? + } else { + ct.clone() + }; + enqueue(client, recipient_key, &payload).await?; + } + + // Mark as deleted in local DB. + session.conv_store.delete_message(&conv_id, &msg_id)?; + session.save_member(&conv_id)?; + + display::print_status("message deleted"); + Ok(()) +} + // ── Sending ────────────────────────────────────────────────────────────────── async fn handle_send( @@ -1475,9 +1844,101 @@ async fn poll_messages( } }; - // Parse structured AppMessage; fall back to raw UTF-8 for legacy. - let (body, msg_id, msg_type, ref_msg_id) = - match parse_app_msg(&app_bytes) { + // Parse structured AppMessage; handle ephemeral types first. + let parsed = parse_app_msg(&app_bytes); + + // Typing indicators: ephemeral display only, never stored. + if let Ok((_, AppMessage::Typing { active })) = &parsed { + if session.typing_notify_enabled { + let sender_hex = hex::encode(&sender_key[..4.min(sender_key.len())]); + if *active != 0 { + session.typing_indicators.insert( + sender_hex.clone(), + std::time::Instant::now(), + ); + let is_active_conv = session + .active_conversation + .as_ref() + .map(|a| a == conv_id) + .unwrap_or(false); + if is_active_conv { + display::print_typing(&sender_hex); + display::print_prompt(session); + } + } else { + session.typing_indicators.remove(&sender_hex); + } + } + any_changed = true; + handled = true; + break; + } + + // Read receipts: ephemeral, show subtle notification. + if let Ok((_, AppMessage::ReadReceipt { .. })) = &parsed { + let is_active = session + .active_conversation + .as_ref() + .map(|a| a == conv_id) + .unwrap_or(false); + if is_active { + let fallback = hex::encode(&sender_key[..4.min(sender_key.len())]); + let label = resolve_identity(client, &sender_key) + .await.ok().flatten().unwrap_or(fallback); + display::print_status(&format!("\u{2713} {label} read your message")); + } + any_changed = true; + handled = true; + break; + } + + // Edit: update existing message body in DB. + if let Ok((_, AppMessage::Edit { ref_msg_id, body: edit_body })) = &parsed { + let new_body = String::from_utf8_lossy(edit_body).to_string(); + session.conv_store.update_message_body( + conv_id, ref_msg_id, &new_body, + )?; + session.conv_store.update_activity(conv_id, now_ms())?; + + let is_active = session + .active_conversation + .as_ref() + .map(|a| a == conv_id) + .unwrap_or(false); + if is_active { + let conv = session.conv_store.load_conversation(conv_id)?; + let conv_name = conv.map(|c| c.display_name).unwrap_or_default(); + display::print_incoming(&conv_name, &format!("[edited] {new_body}")); + display::print_prompt(session); + } + any_changed = true; + handled = true; + break; + } + + // Delete: mark existing message as deleted in DB. + if let Ok((_, AppMessage::Delete { ref_msg_id })) = &parsed { + session.conv_store.delete_message(conv_id, ref_msg_id)?; + session.conv_store.update_activity(conv_id, now_ms())?; + + let is_active = session + .active_conversation + .as_ref() + .map(|a| a == conv_id) + .unwrap_or(false); + if is_active { + let conv = session.conv_store.load_conversation(conv_id)?; + let conv_name = conv.map(|c| c.display_name).unwrap_or_default(); + display::print_incoming(&conv_name, "[deleted a message]"); + display::print_prompt(session); + } + any_changed = true; + handled = true; + break; + } + + // Storable message types: Chat, Reply, Reaction, legacy. + let (body, msg_id, msg_type, ref_msg_id) = match parsed { Ok((_, AppMessage::Chat { message_id, body })) => ( String::from_utf8_lossy(&body).to_string(), Some(message_id), @@ -1507,6 +1968,10 @@ async fn poll_messages( } }; + // A real message clears the sender's typing indicator. + let sender_hex = hex::encode(&sender_key[..4.min(sender_key.len())]); + session.typing_indicators.remove(&sender_hex); + let msg = StoredMessage { conversation_id: conv_id.clone(), message_id: msg_id, @@ -1530,12 +1995,30 @@ async fn poll_messages( if is_active { let conv = session.conv_store.load_conversation(conv_id)?; let conv_name = conv.map(|c| c.display_name).unwrap_or_default(); - display::print_incoming(&conv_name, &body); + let display_body = if msg_type == "reaction" { + format!("reacted {body}") + } else { + body.clone() + }; + display::print_incoming(&conv_name, &display_body); display::print_prompt(session); } else { session.conv_store.increment_unread(conv_id)?; } + // Auto-send read receipt for Chat and Reply (has message_id). + if let Some(mid) = msg_id { + let receipt_bytes = serialize_read_receipt(mid); + let identity = Arc::clone(&session.identity); + let sealed = quicproquo_core::sealed_sender::seal(&identity, &receipt_bytes); + let padded = quicproquo_core::padding::pad(&sealed); + if let Some(m) = session.members.get_mut(conv_id) { + if let Ok(ct) = m.send_message(&padded) { + let _ = enqueue(client, &sender_key, &ct).await; + } + } + } + any_changed = true; handled = true; break; diff --git a/crates/quicproquo-client/src/client/session.rs b/crates/quicproquo-client/src/client/session.rs index d17e2b5..59235b8 100644 --- a/crates/quicproquo-client/src/client/session.rs +++ b/crates/quicproquo-client/src/client/session.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Instant; use anyhow::Context; @@ -36,6 +37,11 @@ pub struct SessionState { /// Its keystore contains the HPKE init private key needed to decrypt /// incoming Welcome messages. Consumed on auto-join, then replenished. pub pending_member: Option, + /// Whether to display typing indicators from others (session preference). + pub typing_notify_enabled: bool, + /// Tracks who is currently typing and when the indicator was last received. + /// Entries older than 10 seconds are considered expired. + pub typing_indicators: HashMap, } impl SessionState { @@ -69,6 +75,8 @@ impl SessionState { active_conversation: None, members: HashMap::new(), pending_member: None, + typing_notify_enabled: true, + typing_indicators: HashMap::new(), }; // Migrate legacy single-group into conversations if present and not yet migrated. diff --git a/crates/quicproquo-core/src/app_message.rs b/crates/quicproquo-core/src/app_message.rs index ad676ff..7416375 100644 --- a/crates/quicproquo-core/src/app_message.rs +++ b/crates/quicproquo-core/src/app_message.rs @@ -24,6 +24,8 @@ pub enum MessageType { Reaction = 0x03, ReadReceipt = 0x04, Typing = 0x05, + Edit = 0x06, + Delete = 0x07, } impl MessageType { @@ -34,6 +36,8 @@ impl MessageType { 0x03 => Some(MessageType::Reaction), 0x04 => Some(MessageType::ReadReceipt), 0x05 => Some(MessageType::Typing), + 0x06 => Some(MessageType::Edit), + 0x07 => Some(MessageType::Delete), _ => None, } } @@ -62,6 +66,15 @@ pub enum AppMessage { /// 0 = stopped, 1 = typing active: u8, }, + /// Edit a previously sent message (identified by ref_msg_id). + Edit { + ref_msg_id: [u8; 16], + body: Vec, + }, + /// Delete a previously sent message (identified by ref_msg_id). + Delete { + ref_msg_id: [u8; 16], + }, } /// Generate a new 16-byte message ID (e.g. for Chat/Reply so recipients can reference it). @@ -80,6 +93,8 @@ pub fn generate_message_id() -> [u8; 16] { // Reaction: [ref_msg_id: 16][emoji_len: 1][emoji] // ReadReceipt: [msg_id: 16] // Typing: [active: 1] 0 = stopped, 1 = typing +// Edit: [ref_msg_id: 16][body_len: 2 BE][body] +// Delete: [ref_msg_id: 16] /// Serialize a rich message into the application payload format. pub fn serialize(msg_type: MessageType, payload: &[u8]) -> Vec { @@ -138,6 +153,23 @@ pub fn serialize_typing(active: u8) -> Vec { serialize(MessageType::Typing, &payload) } +/// Serialize an Edit message (replaces body of a previously sent message). +pub fn serialize_edit(ref_msg_id: &[u8; 16], body: &[u8]) -> Result, CoreError> { + if body.len() > u16::MAX as usize { + return Err(CoreError::AppMessage("edit body exceeds maximum length (65535 bytes)".into())); + } + let mut payload = Vec::with_capacity(16 + 2 + body.len()); + payload.extend_from_slice(ref_msg_id); + payload.extend_from_slice(&(body.len() as u16).to_be_bytes()); + payload.extend_from_slice(body); + Ok(serialize(MessageType::Edit, &payload)) +} + +/// Serialize a Delete message (marks a previously sent message as deleted). +pub fn serialize_delete(ref_msg_id: &[u8; 16]) -> Vec { + serialize(MessageType::Delete, ref_msg_id) +} + /// Parse bytes into (MessageType, AppMessage). Fails if version/type unknown or payload too short. pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> { if bytes.len() < 2 { @@ -157,6 +189,8 @@ pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> { MessageType::Reaction => parse_reaction(payload)?, MessageType::ReadReceipt => parse_read_receipt(payload)?, MessageType::Typing => parse_typing(payload)?, + MessageType::Edit => parse_edit(payload)?, + MessageType::Delete => parse_delete(payload)?, }; Ok((msg_type, app)) } @@ -219,6 +253,29 @@ fn parse_typing(payload: &[u8]) -> Result { Ok(AppMessage::Typing { active: payload[0] }) } +fn parse_edit(payload: &[u8]) -> Result { + if payload.len() < 16 + 2 { + return Err(CoreError::AppMessage("Edit payload too short".into())); + } + let mut ref_msg_id = [0u8; 16]; + ref_msg_id.copy_from_slice(&payload[..16]); + let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize; + if payload.len() < 18 + body_len { + return Err(CoreError::AppMessage("Edit body length exceeds payload".into())); + } + let body = payload[18..18 + body_len].to_vec(); + Ok(AppMessage::Edit { ref_msg_id, body }) +} + +fn parse_delete(payload: &[u8]) -> Result { + if payload.len() < 16 { + return Err(CoreError::AppMessage("Delete payload too short".into())); + } + let mut ref_msg_id = [0u8; 16]; + ref_msg_id.copy_from_slice(&payload[..16]); + Ok(AppMessage::Delete { ref_msg_id }) +} + #[cfg(test)] mod tests { use super::*; @@ -290,6 +347,40 @@ mod tests { } } + #[test] + fn roundtrip_edit() { + let ref_id = [4u8; 16]; + let body = b"edited text"; + let encoded = serialize_edit(&ref_id, body).unwrap(); + let (t, msg) = parse(&encoded).unwrap(); + assert_eq!(t, MessageType::Edit); + match &msg { + AppMessage::Edit { ref_msg_id, body: b } => { + assert_eq!(ref_msg_id, &ref_id); + assert_eq!(b.as_slice(), body); + } + _ => panic!("expected Edit"), + } + } + + #[test] + fn roundtrip_delete() { + let ref_id = [5u8; 16]; + let encoded = serialize_delete(&ref_id); + let (t, msg) = parse(&encoded).unwrap(); + assert_eq!(t, MessageType::Delete); + match &msg { + AppMessage::Delete { ref_msg_id } => assert_eq!(ref_msg_id, &ref_id), + _ => panic!("expected Delete"), + } + } + + #[test] + fn edit_body_too_long() { + let body = vec![0u8; 65536]; + assert!(serialize_edit(&[0; 16], &body).is_err()); + } + #[test] fn parse_empty_fails() { assert!(parse(&[]).is_err()); diff --git a/crates/quicproquo-core/src/lib.rs b/crates/quicproquo-core/src/lib.rs index 23569c0..5c06d17 100644 --- a/crates/quicproquo-core/src/lib.rs +++ b/crates/quicproquo-core/src/lib.rs @@ -59,8 +59,9 @@ pub mod opaque_auth; // ── Public API (always available) ─────────────────────────────────────────── pub use app_message::{ - serialize, serialize_chat, serialize_reaction, serialize_read_receipt, serialize_reply, - serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION, + serialize, serialize_chat, serialize_delete, serialize_edit, serialize_reaction, + serialize_read_receipt, serialize_reply, serialize_typing, parse, generate_message_id, + AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION, }; pub use error::CoreError; pub use hybrid_kem::{