1use jmap_base_client::{ClientError, WsFrame, WsSession};
11use jmap_chat_types::{ChatPresenceEvent, ChatStreamEnable, ChatTypingEvent, EphemeralMessage};
12
13#[non_exhaustive]
18#[derive(Debug, Clone)]
19pub enum ChatWsFrame {
20 StateChange(jmap_base_client::StateChange),
22 Response(jmap_types::JmapResponse),
24 ChatTyping(ChatTypingEvent),
27 ChatPresence(ChatPresenceEvent),
30 Unknown {
36 type_name: String,
38 },
39}
40
41pub fn parse_chat_ws_frame(frame: WsFrame) -> ChatWsFrame {
49 match frame {
50 WsFrame::StateChange(sc) => ChatWsFrame::StateChange(sc),
51 WsFrame::Response(r) => ChatWsFrame::Response(r),
52 WsFrame::Unknown { type_name, raw } => match type_name.as_str() {
53 "ChatTypingEvent" => match serde_json::from_value::<ChatTypingEvent>(raw) {
54 Ok(evt) => ChatWsFrame::ChatTyping(evt),
55 Err(_) => ChatWsFrame::Unknown {
56 type_name: "ChatTypingEvent".to_owned(),
57 },
58 },
59 "ChatPresenceEvent" => match serde_json::from_value::<ChatPresenceEvent>(raw) {
60 Ok(evt) => ChatWsFrame::ChatPresence(evt),
61 Err(_) => ChatWsFrame::Unknown {
62 type_name: "ChatPresenceEvent".to_owned(),
63 },
64 },
65 _ => ChatWsFrame::Unknown { type_name },
66 },
67 _ => ChatWsFrame::Unknown {
69 type_name: "<unknown>".to_owned(),
70 },
71 }
72}
73
74pub trait ChatWsExt {
78 fn next_chat_frame(
84 &mut self,
85 ) -> impl std::future::Future<Output = Option<Result<ChatWsFrame, ClientError>>> + Send;
86
87 fn send_stream_enable(
95 &mut self,
96 enable: &ChatStreamEnable,
97 ) -> impl std::future::Future<Output = Result<(), ClientError>> + Send;
98
99 fn send_stream_disable(
106 &mut self,
107 ) -> impl std::future::Future<Output = Result<(), ClientError>> + Send;
108}
109
110impl ChatWsExt for WsSession {
111 async fn next_chat_frame(&mut self) -> Option<Result<ChatWsFrame, ClientError>> {
112 self.next_frame().await.map(|r| r.map(parse_chat_ws_frame))
113 }
114
115 async fn send_stream_enable(&mut self, enable: &ChatStreamEnable) -> Result<(), ClientError> {
116 let msg = EphemeralMessage::Enable(enable.clone());
119 let text =
120 serde_json::to_string(&msg).map_err(|e| ClientError::InvalidArgument(e.to_string()))?;
121 self.send_text(text).await
122 }
123
124 async fn send_stream_disable(&mut self) -> Result<(), ClientError> {
125 let msg: EphemeralMessage = serde_json::from_str(r#"{"@type":"ChatStreamDisable"}"#)
127 .map_err(|e| {
128 ClientError::InvalidArgument(format!("ChatStreamDisable serialization: {e}"))
129 })?;
130 let text =
131 serde_json::to_string(&msg).map_err(|e| ClientError::InvalidArgument(e.to_string()))?;
132 self.send_text(text).await
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use jmap_types::Id;
140
141 fn unknown_frame(type_name: &str, json: &str) -> WsFrame {
143 let raw = serde_json::from_str(json).expect("test fixture must be valid JSON");
144 WsFrame::Unknown {
145 type_name: type_name.to_owned(),
146 raw,
147 }
148 }
149
150 #[test]
153 fn parse_state_change_forwarded() {
154 let sc: jmap_base_client::StateChange =
156 serde_json::from_str(r#"{"changed":{"acc1":{"Chat":"s1"}}}"#)
157 .expect("test fixture must deserialize");
158 let frame = parse_chat_ws_frame(WsFrame::StateChange(sc));
159 match frame {
160 ChatWsFrame::StateChange(got) => {
161 assert_eq!(
162 got.changed
163 .get(&Id::from("acc1"))
164 .and_then(|m| m.get("Chat"))
165 .map(|s| s.as_ref()),
166 Some("s1")
167 );
168 }
169 other => panic!("expected StateChange, got {other:?}"),
170 }
171 }
172
173 #[test]
176 fn parse_chat_typing_event_valid() {
177 let frame = unknown_frame(
178 "ChatTypingEvent",
179 r#"{"@type":"ChatTypingEvent","chatId":"c1","senderId":"u1","typing":true}"#,
180 );
181 let got = parse_chat_ws_frame(frame);
182 match got {
183 ChatWsFrame::ChatTyping(evt) => {
184 assert_eq!(evt.chat_id.as_ref(), "c1");
185 assert_eq!(evt.sender_id, "u1");
186 assert!(evt.typing);
187 }
188 other => panic!("expected ChatTyping, got {other:?}"),
189 }
190 }
191
192 #[test]
195 fn parse_chat_presence_event_valid() {
196 let frame = unknown_frame(
197 "ChatPresenceEvent",
198 r#"{"@type":"ChatPresenceEvent","contactId":"u2","presence":"away"}"#,
199 );
200 let got = parse_chat_ws_frame(frame);
201 match got {
202 ChatWsFrame::ChatPresence(evt) => {
203 assert_eq!(evt.contact_id.as_ref(), "u2");
204 assert_eq!(evt.presence, "away");
205 }
206 other => panic!("expected ChatPresence, got {other:?}"),
207 }
208 }
209
210 #[test]
213 fn parse_chat_typing_malformed_degrades_to_unknown() {
214 let frame = WsFrame::Unknown {
215 type_name: "ChatTypingEvent".to_owned(),
216 raw: serde_json::json!({"missing": "required fields"}),
217 };
218 let got = parse_chat_ws_frame(frame);
219 match got {
220 ChatWsFrame::Unknown { type_name } => {
221 assert_eq!(type_name, "ChatTypingEvent");
222 }
223 other => panic!("expected Unknown, got {other:?}"),
224 }
225 }
226
227 #[test]
230 fn parse_unknown_type_forwarded() {
231 let frame = unknown_frame("FutureEvent", r#"{"@type":"FutureEvent","foo":"bar"}"#);
232 let got = parse_chat_ws_frame(frame);
233 match got {
234 ChatWsFrame::Unknown { type_name } => assert_eq!(type_name, "FutureEvent"),
235 other => panic!("expected Unknown, got {other:?}"),
236 }
237 }
238
239 #[test]
242 fn send_stream_enable_serializes_with_at_type() {
243 let enable: ChatStreamEnable = serde_json::from_str(r#"{"dataTypes":["typing"]}"#)
244 .expect("test fixture must deserialize");
245 let msg = EphemeralMessage::Enable(enable);
246 let text = serde_json::to_string(&msg).expect("must serialize");
247 assert!(
248 text.contains("\"@type\":\"ChatStreamEnable\""),
249 "ChatStreamEnable frame must include @type discriminant; got: {text}"
250 );
251 assert!(
252 text.contains("\"dataTypes\""),
253 "ChatStreamEnable frame must include dataTypes field; got: {text}"
254 );
255 }
256
257 #[test]
260 fn send_stream_disable_serializes_with_at_type() {
261 let msg: EphemeralMessage =
263 serde_json::from_str(r#"{"@type":"ChatStreamDisable"}"#).expect("must deserialize");
264 let text = serde_json::to_string(&msg).expect("must serialize");
265 assert!(
266 text.contains("\"@type\":\"ChatStreamDisable\""),
267 "ChatStreamDisable frame must include @type discriminant; got: {text}"
268 );
269 }
270}