feat: Sprint 4 — rich messaging: receipts, typing, reactions, edit/delete
- Auto-send read receipts on Chat/Reply receive, display "✓ read" notification (loop-safe: only Chat/Reply trigger receipts) - Typing indicators with /typing command, 10s timeout expiry, /typing-notify toggle, ephemeral (not stored in DB) - /react <emoji> [index] command for message reactions, display inline with sender name - Add Edit (0x06) and Delete (0x07) AppMessage variants with serialize/parse, /edit and /delete REPL commands (own messages only), incoming edit/delete handling with DB updates - 3 new roundtrip tests for Edit/Delete serialization (70 core tests)
This commit is contained in:
@@ -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<u8>,
|
||||
},
|
||||
/// 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<u8> {
|
||||
@@ -138,6 +153,23 @@ pub fn serialize_typing(active: u8) -> Vec<u8> {
|
||||
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<Vec<u8>, 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<u8> {
|
||||
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<AppMessage, CoreError> {
|
||||
Ok(AppMessage::Typing { active: payload[0] })
|
||||
}
|
||||
|
||||
fn parse_edit(payload: &[u8]) -> Result<AppMessage, CoreError> {
|
||||
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<AppMessage, CoreError> {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user