use jmap_base_client::SseEvent;
use jmap_chat_types::Presence;
use jmap_types::Id;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum ChatSseEvent {
StateChange(jmap_base_client::StateChange),
Typing {
chat_id: Id,
sender_id: Id,
typing: bool,
},
Presence {
contact_id: Id,
presence: Presence,
last_active_at: Option<String>,
status_text: Option<String>,
status_emoji: Option<String>,
},
Unknown {
event_type: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ChatSseFrame {
pub event: ChatSseEvent,
pub id: Option<String>,
}
pub fn parse_chat_sse_block(block: &str) -> ChatSseFrame {
let frame = jmap_base_client::parse_sse_block(block);
let event = match frame.event {
SseEvent::StateChange(sc) => ChatSseEvent::StateChange(sc),
SseEvent::Unknown { event_type, data } => match event_type.as_str() {
"typing" => parse_typing_data(&data).unwrap_or(ChatSseEvent::Unknown { event_type }),
"presence" => {
parse_presence_data(&data).unwrap_or(ChatSseEvent::Unknown { event_type })
}
_ => ChatSseEvent::Unknown { event_type },
},
_ => ChatSseEvent::Unknown {
event_type: String::new(),
},
};
ChatSseFrame {
event,
id: frame.id,
}
}
#[derive(serde::Deserialize)]
struct TypingPayload {
#[serde(rename = "chatId")]
chat_id: Id,
#[serde(rename = "senderId")]
sender_id: Id,
typing: bool,
}
#[derive(serde::Deserialize)]
struct PresencePayload {
#[serde(rename = "contactId")]
contact_id: Id,
presence: Presence,
#[serde(rename = "lastActiveAt")]
last_active_at: Option<String>,
#[serde(rename = "statusText")]
status_text: Option<String>,
#[serde(rename = "statusEmoji")]
status_emoji: Option<String>,
}
fn parse_typing_data(data: &str) -> Option<ChatSseEvent> {
let p: TypingPayload = serde_json::from_str(data).ok()?;
Some(ChatSseEvent::Typing {
chat_id: p.chat_id,
sender_id: p.sender_id,
typing: p.typing,
})
}
fn parse_presence_data(data: &str) -> Option<ChatSseEvent> {
let p: PresencePayload = serde_json::from_str(data).ok()?;
Some(ChatSseEvent::Presence {
contact_id: p.contact_id,
presence: p.presence,
last_active_at: p.last_active_at,
status_text: p.status_text,
status_emoji: p.status_emoji,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_state_event_promotes_to_state_change() {
let block = "event: state\ndata: {\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
match event {
ChatSseEvent::StateChange(sc) => {
assert_eq!(
sc.changed
.get("acc1")
.and_then(|m| m.get("Message"))
.map(|s| s.as_ref()),
Some("s42"),
"changed[acc1][Message] must equal s42"
);
}
other => panic!("expected StateChange, got {other:?}"),
}
}
#[test]
fn parse_typing_event_valid_json() {
let block = "event: typing\ndata: {\"chatId\":\"c1\",\"senderId\":\"u1\",\"typing\":true}";
let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
match event {
ChatSseEvent::Typing {
chat_id,
sender_id,
typing,
} => {
assert_eq!(chat_id.as_ref(), "c1");
assert_eq!(sender_id.as_ref(), "u1");
assert!(typing, "typing must be true");
}
other => panic!("expected Typing, got {other:?}"),
}
}
#[test]
fn parse_presence_event_all_fields() {
let block = concat!(
"event: presence\n",
"data: {\"contactId\":\"ct1\",\"presence\":\"online\",",
"\"lastActiveAt\":\"2024-01-01T00:00:00Z\",",
"\"statusText\":\"in a meeting\",\"statusEmoji\":\"📅\"}"
);
let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
match event {
ChatSseEvent::Presence {
contact_id,
presence,
last_active_at,
status_text,
status_emoji,
} => {
assert_eq!(contact_id.as_ref(), "ct1");
assert_eq!(presence, Presence::Online);
assert_eq!(last_active_at.as_deref(), Some("2024-01-01T00:00:00Z"));
assert_eq!(status_text.as_deref(), Some("in a meeting"));
assert_eq!(status_emoji.as_deref(), Some("📅"));
}
other => panic!("expected Presence, got {other:?}"),
}
}
#[test]
fn parse_typing_malformed_json_degrades_to_unknown() {
let block = "event: typing\ndata: not-json";
let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
match event {
ChatSseEvent::Unknown { event_type } => {
assert_eq!(
event_type, "typing",
"Unknown must carry original event_type"
);
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn parse_presence_malformed_json_degrades_to_unknown() {
let block = "event: presence\ndata: {\"not\":\"valid-presence\"}";
let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
assert!(
matches!(event, ChatSseEvent::Unknown { .. }),
"invalid presence JSON must yield Unknown"
);
}
#[test]
fn parse_unknown_event_type_preserved() {
let block = "event: ping\ndata: {}";
let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
match event {
ChatSseEvent::Unknown { event_type } => {
assert_eq!(event_type, "ping");
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn id_line_propagated_through_frame() {
let block = "id: evt-99\nevent: state\ndata: {\"changed\":{}}";
let ChatSseFrame { id, .. } = parse_chat_sse_block(block);
assert_eq!(id.as_deref(), Some("evt-99"));
}
}