Skip to main content

jmap_chat_client/methods/
chat.rs

1//! JMAP Chat — Chat/* method implementations on SessionClient.
2//!
3//! Each method follows the standard five-step pattern:
4//!   1. Validate arguments (defence-in-depth empty-state guards).
5//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
6//!   3. Build args JSON with `serde_json::json!({…})`.
7//!   4. Call `build_request(method_name, args, USING_CHAT)`.
8//!   5. Call `self.call_internal(api_url, &req).await?`.
9//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
10
11use jmap_types::{Id, PatchObject, State};
12
13use super::{
14    AddMemberInput, ChangesResponse, ChatCreateInput, ChatPatch, ChatQueryInput, GetResponse,
15    QueryChangesResponse, QueryResponse, SetResponse, TypingResponse, UpdateMemberRoleInput,
16};
17
18impl super::SessionClient {
19    /// Fetch Chat objects by IDs (RFC 8620 §5.1 / JMAP Chat §Chat/get).
20    ///
21    /// If `ids` is `None`, the server returns all Chats for the account.
22    /// Pass `properties: None` to return all fields.
23    pub async fn chat_get(
24        &self,
25        ids: Option<&[Id]>,
26        properties: Option<&[&str]>,
27    ) -> Result<GetResponse<jmap_chat_types::Chat>, jmap_base_client::ClientError> {
28        let (api_url, account_id) = self.session_parts()?;
29        // Omit `ids` / `properties` entirely when None rather than sending
30        // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
31        // crate's other builders (set/changes/query) consistently use the
32        // conditional-add idiom; matching it here keeps the wire request
33        // canonical and avoids "present-but-null vs absent" interop quirks
34        // in proxies / audit loggers.
35        let mut args = serde_json::json!({ "accountId": account_id });
36        if let Some(id_slice) = ids {
37            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
38        }
39        if let Some(props) = properties {
40            args["properties"] = serde_json::Value::Array(
41                props.iter().copied().map(serde_json::Value::from).collect(),
42            );
43        }
44        let req = super::build_request("Chat/get", args, super::USING_CHAT);
45        let resp = self.call_internal(api_url, &req).await?;
46        jmap_base_client::extract_response(&resp, super::CALL_ID)
47    }
48
49    /// Query Chat IDs with optional filter (RFC 8620 §5.5 / JMAP Chat §Chat/query).
50    ///
51    /// Only keys that are `Some` in `input` are included in the filter object;
52    /// an empty filter object is sent as JSON `null`.
53    pub async fn chat_query(
54        &self,
55        input: &ChatQueryInput,
56    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
57        let (api_url, account_id) = self.session_parts()?;
58        let mut filter = serde_json::Map::new();
59        if let Some(ref k) = input.filter_kind {
60            let kind_str = serde_json::to_value(k).map_err(jmap_base_client::ClientError::Parse)?;
61            filter.insert("kind".into(), kind_str);
62        }
63        if let Some(m) = input.filter_muted {
64            filter.insert("muted".into(), m.into());
65        }
66        let filter_val = if filter.is_empty() {
67            serde_json::Value::Null
68        } else {
69            serde_json::Value::Object(filter)
70        };
71        let mut args = serde_json::json!({
72            "accountId": account_id,
73            "filter": filter_val,
74        });
75        if let Some(p) = input.position {
76            args["position"] = p.into();
77        }
78        if let Some(l) = input.limit {
79            args["limit"] = l.into();
80        }
81        let req = super::build_request("Chat/query", args, super::USING_CHAT);
82        let resp = self.call_internal(api_url, &req).await?;
83        jmap_base_client::extract_response(&resp, super::CALL_ID)
84    }
85
86    /// Fetch changes to Chat objects since `since_state` (RFC 8620 §5.2 / Chat/changes).
87    ///
88    /// If `has_more_changes` is true in the response, call again with `new_state`
89    /// as `since_state` until the flag is false.
90    pub async fn chat_changes(
91        &self,
92        since_state: &State,
93        max_changes: Option<u64>,
94    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
95        // Defence-in-depth: even with the typed-`State` parameter (a transparent
96        // newtype around `String`), an empty state token is still a logically
97        // invalid value that should be caught client-side rather than producing
98        // a confusing server-side `cannotCalculateChanges` error.
99        if since_state.as_ref().is_empty() {
100            return Err(jmap_base_client::ClientError::InvalidArgument(
101                "chat_changes: since_state may not be empty".into(),
102            ));
103        }
104        let (api_url, account_id) = self.session_parts()?;
105        let mut args = serde_json::json!({
106            "accountId": account_id,
107            "sinceState": since_state,
108        });
109        if let Some(mc) = max_changes {
110            args["maxChanges"] = mc.into();
111        }
112        let req = super::build_request("Chat/changes", args, super::USING_CHAT);
113        let resp = self.call_internal(api_url, &req).await?;
114        jmap_base_client::extract_response(&resp, super::CALL_ID)
115    }
116
117    /// Send a typing indicator for a Chat (JMAP Chat §Chat/typing).
118    ///
119    /// Notifies other participants that the account is (or has stopped) typing.
120    /// The server silently drops the event if `Chat.receiveTypingIndicators` is
121    /// `false` for a recipient (direct/group chats); for channel chats the
122    /// preference has no effect. The server SHOULD rate-limit to one call per
123    /// account per chat per 3 seconds — excess calls MAY be silently discarded.
124    /// Debouncing (send once per keypress, stop event on idle) is the caller's
125    /// responsibility.
126    pub async fn chat_typing(
127        &self,
128        chat_id: &Id,
129        typing: bool,
130    ) -> Result<TypingResponse, jmap_base_client::ClientError> {
131        let (api_url, account_id) = self.session_parts()?;
132        let args = serde_json::json!({
133            "accountId": account_id,
134            "chatId": chat_id,
135            "typing": typing,
136        });
137        let req = super::build_request("Chat/typing", args, super::USING_CHAT);
138        let resp = self.call_internal(api_url, &req).await?;
139        jmap_base_client::extract_response(&resp, super::CALL_ID)
140    }
141
142    /// Fetch query-result changes for Chat since `since_query_state`
143    /// (RFC 8620 §5.6 / Chat/queryChanges).
144    ///
145    /// Returns which Chat IDs were removed from or added to the query result set
146    /// since the given state. `max_changes` may be `None`.
147    pub async fn chat_query_changes(
148        &self,
149        since_query_state: &State,
150        max_changes: Option<u64>,
151    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
152        // Defence-in-depth: see `chat_changes`.
153        if since_query_state.as_ref().is_empty() {
154            return Err(jmap_base_client::ClientError::InvalidArgument(
155                "chat_query_changes: since_query_state may not be empty".into(),
156            ));
157        }
158        let (api_url, account_id) = self.session_parts()?;
159        let mut args = serde_json::json!({
160            "accountId": account_id,
161            "sinceQueryState": since_query_state,
162        });
163        if let Some(mc) = max_changes {
164            args["maxChanges"] = mc.into();
165        }
166        let req = super::build_request("Chat/queryChanges", args, super::USING_CHAT);
167        let resp = self.call_internal(api_url, &req).await?;
168        jmap_base_client::extract_response(&resp, super::CALL_ID)
169    }
170
171    /// Create a Chat (JMAP Chat §Chat/set create).
172    ///
173    /// Dispatches to the correct spec `kind` based on the `input` variant:
174    /// `Direct`, `Group`, or `Channel`. When `client_id` inside the variant is
175    /// `None`, a ULID is generated automatically.
176    ///
177    /// For `Direct` chats: if one already exists with the given `contact_id`,
178    /// the server returns it in `SetResponse.updated` rather than `created`
179    /// (dedup rule per spec).
180    pub async fn chat_create(
181        &self,
182        input: &ChatCreateInput<'_>,
183    ) -> Result<SetResponse, jmap_base_client::ClientError> {
184        let (api_url, account_id) = self.session_parts()?;
185        let create_obj;
186        let client_id_opt = match input {
187            ChatCreateInput::Direct {
188                client_id,
189                contact_id,
190            } => {
191                create_obj = serde_json::json!({
192                    "kind": "direct",
193                    "contactId": contact_id,
194                });
195                *client_id
196            }
197            ChatCreateInput::Group {
198                client_id,
199                name,
200                member_ids,
201                description,
202                avatar_blob_id,
203                message_expiry_seconds,
204            } => {
205                if name.is_empty() {
206                    return Err(jmap_base_client::ClientError::InvalidArgument(
207                        "chat_create: name may not be empty".into(),
208                    ));
209                }
210                let mut obj = serde_json::json!({
211                    "kind": "group",
212                    "name": name,
213                    "memberIds": member_ids,
214                });
215                if let Some(d) = description {
216                    obj["description"] = (*d).into();
217                }
218                if let Some(b) = avatar_blob_id {
219                    obj["avatarBlobId"] = b.as_ref().into();
220                }
221                if let Some(s) = message_expiry_seconds {
222                    obj["messageExpirySeconds"] = (*s).into();
223                }
224                create_obj = obj;
225                *client_id
226            }
227            ChatCreateInput::Channel {
228                client_id,
229                space_id,
230                name,
231                description,
232            } => {
233                if name.is_empty() {
234                    return Err(jmap_base_client::ClientError::InvalidArgument(
235                        "chat_create: name may not be empty".into(),
236                    ));
237                }
238                let mut obj = serde_json::json!({
239                    "kind": "channel",
240                    "spaceId": space_id,
241                    "name": name,
242                });
243                if let Some(d) = description {
244                    obj["description"] = (*d).into();
245                }
246                create_obj = obj;
247                *client_id
248            }
249        };
250        let client_id = super::resolve_client_id(client_id_opt);
251        let args = serde_json::json!({
252            "accountId": account_id,
253            "create": { client_id: create_obj },
254        });
255        let req = super::build_request("Chat/set", args, super::USING_CHAT);
256        let resp = self.call_internal(api_url, &req).await?;
257        jmap_base_client::extract_response(&resp, super::CALL_ID)
258    }
259
260    /// Update Chat properties (JMAP Chat §Chat/set update).
261    ///
262    /// Issues an `update` operation patching only the fields present in `patch`.
263    /// Use `Patch::Set(v)` to set nullable fields, `Patch::Clear` to null-clear
264    /// them, and `Patch::Keep` (default) to leave them unchanged. Slice fields
265    /// default to `None` for no-change.
266    ///
267    /// If all fields are `Keep`/`None`, an empty patch is sent — RFC 8620 §5.3
268    /// permits this; the server treats it as a no-op but still returns the chat
269    /// in `updated`.
270    pub async fn chat_update(
271        &self,
272        id: &Id,
273        patch: &ChatPatch<'_>,
274    ) -> Result<SetResponse, jmap_base_client::ClientError> {
275        let (api_url, account_id) = self.session_parts()?;
276        let mut patch_map = serde_json::Map::new();
277
278        if let Some(m) = patch.muted {
279            patch_map.insert("muted".into(), m.into());
280        }
281        if let Some(entry) = patch
282            .mute_until
283            .map_entry()
284            .map_err(jmap_base_client::ClientError::Parse)?
285        {
286            patch_map.insert("muteUntil".into(), entry);
287        }
288        if let Some(rti) = patch.receive_typing_indicators {
289            patch_map.insert("receiveTypingIndicators".into(), rti.into());
290        }
291        if let Some(ids) = patch.pinned_message_ids {
292            patch_map.insert(
293                "pinnedMessageIds".into(),
294                serde_json::to_value(ids).expect("Id slice Serialize is infallible"),
295            );
296        }
297        if let Some(s) = patch.message_expiry_seconds {
298            patch_map.insert("messageExpirySeconds".into(), s.into());
299        }
300        if let Some(rs) = patch.receipt_sharing {
301            patch_map.insert("receiptSharing".into(), rs.into());
302        }
303        if let Some(n) = patch.name {
304            patch_map.insert("name".into(), n.into());
305        }
306        if let Some(entry) = patch
307            .description
308            .map_entry()
309            .map_err(jmap_base_client::ClientError::Parse)?
310        {
311            patch_map.insert("description".into(), entry);
312        }
313        if let Some(entry) = patch
314            .avatar_blob_id
315            .map_entry()
316            .map_err(jmap_base_client::ClientError::Parse)?
317        {
318            patch_map.insert("avatarBlobId".into(), entry);
319        }
320        if let Some(members) = patch.add_members {
321            if !members.is_empty() {
322                let arr = members
323                    .iter()
324                    .map(|m: &AddMemberInput<'_>| {
325                        let mut obj = serde_json::json!({ "id": m.id });
326                        if let Some(ref role) = m.role {
327                            obj["role"] = serde_json::to_value(role)
328                                .map_err(jmap_base_client::ClientError::Parse)?;
329                        }
330                        Ok(obj)
331                    })
332                    .collect::<Result<Vec<_>, jmap_base_client::ClientError>>()?;
333                patch_map.insert("addMembers".into(), serde_json::Value::Array(arr));
334            }
335        }
336        if let Some(rm) = patch.remove_members {
337            if !rm.is_empty() {
338                patch_map.insert(
339                    "removeMembers".into(),
340                    serde_json::to_value(rm).expect("Id slice Serialize is infallible"),
341                );
342            }
343        }
344        if let Some(umr) = patch.update_member_roles {
345            if !umr.is_empty() {
346                let arr = umr
347                    .iter()
348                    .map(|u: &UpdateMemberRoleInput<'_>| {
349                        Ok(serde_json::json!({
350                            "id": u.id,
351                            "role": serde_json::to_value(&u.role)
352                                .map_err(jmap_base_client::ClientError::Parse)?,
353                        }))
354                    })
355                    .collect::<Result<Vec<_>, jmap_base_client::ClientError>>()?;
356                patch_map.insert("updateMemberRoles".into(), serde_json::Value::Array(arr));
357            }
358        }
359
360        // Wrap the constructed map in a PatchObject (RFC 8620 §5.3) before
361        // serializing. Wire bytes are unchanged because PatchObject is
362        // #[serde(transparent)]; the typed boundary documents that this
363        // value is a JMAP patch, not arbitrary JSON.
364        let patch_value = serde_json::Value::Object(PatchObject::from_map(patch_map).into_inner());
365        let args = serde_json::json!({
366            "accountId": account_id,
367            "update": { id.as_ref(): patch_value },
368        });
369        let req = super::build_request("Chat/set", args, super::USING_CHAT);
370        let resp = self.call_internal(api_url, &req).await?;
371        jmap_base_client::extract_response(&resp, super::CALL_ID)
372    }
373
374    /// Destroy Chat objects (RFC 8620 §5.3 / Chat/set destroy).
375    ///
376    /// Permanently removes the listed Chat IDs from the account.
377    /// `ids` must be non-empty; the guard fires before any network call.
378    pub async fn chat_destroy(
379        &self,
380        ids: &[Id],
381    ) -> Result<SetResponse, jmap_base_client::ClientError> {
382        if ids.is_empty() {
383            return Err(jmap_base_client::ClientError::InvalidArgument(
384                "chat_destroy: ids may not be empty".into(),
385            ));
386        }
387        let (api_url, account_id) = self.session_parts()?;
388        let args = serde_json::json!({
389            "accountId": account_id,
390            "destroy": ids,
391        });
392        let req = super::build_request("Chat/set", args, super::USING_CHAT);
393        let resp = self.call_internal(api_url, &req).await?;
394        jmap_base_client::extract_response(&resp, super::CALL_ID)
395    }
396}