Skip to main content

ably_chat/
reactions.rs

1//! The reactions handle and reaction operations (ADR-0010).
2
3use std::future::{Future, IntoFuture};
4use std::pin::Pin;
5
6use reqwest::Method;
7use serde_json::{Map, Value};
8
9use crate::client::Client;
10use crate::dispatch::{decode_json, message_path};
11use crate::error::{Error, Result};
12use crate::types::{ReactionSummary, ReactionType, RoomName, Serial};
13
14/// Reaction operations on messages in a room.
15///
16/// Cheap to `Clone` (`Arc`-backed via [`Client`]) and `Send + Sync`.
17#[derive(Clone, Debug)]
18pub struct Reactions {
19    pub(crate) client: Client,
20    pub(crate) room: RoomName,
21}
22
23impl Reactions {
24    pub(crate) fn new(client: Client, room: RoomName) -> Self {
25        Self { client, room }
26    }
27
28    /// Adds a reaction to a message.
29    ///
30    /// `POST /chat/v4/rooms/{roomName}/messages/{serial}/reactions`. The reaction
31    /// [`kind`](SendReaction::kind) defaults to [`ReactionType::Distinct`] (the JS
32    /// SDK default). **Never retried** (ADR-0006): a send carries no idempotency
33    /// key, and a `multiple` reaction increments a counter, so a blind retry could
34    /// double-count.
35    pub fn send(&self, serial: impl Into<Serial>, name: impl Into<String>) -> SendReaction {
36        SendReaction {
37            client: self.client.clone(),
38            room: self.room.clone(),
39            serial: serial.into(),
40            name: name.into(),
41            kind: ReactionType::Distinct,
42            count: None,
43        }
44    }
45
46    /// Removes a reaction from a message.
47    ///
48    /// `DELETE /chat/v4/rooms/{roomName}/messages/{serial}/reactions`. The
49    /// reaction [`kind`](DeleteReaction::kind) defaults to
50    /// [`ReactionType::Distinct`]. A [`name`](DeleteReaction::name) is required
51    /// for `distinct` and `multiple` reactions and optional for `unique`; the
52    /// missing-name case is rejected client-side with [`Error::InvalidRequest`]
53    /// before any request is sent. Retry-safe (`DELETE` is idempotent, ADR-0006).
54    ///
55    /// [`Error::InvalidRequest`]: crate::Error::InvalidRequest
56    pub fn delete(&self, serial: impl Into<Serial>) -> DeleteReaction {
57        DeleteReaction {
58            client: self.client.clone(),
59            room: self.room.clone(),
60            serial: serial.into(),
61            kind: ReactionType::Distinct,
62            name: None,
63        }
64    }
65
66    /// Fetches the reaction summary for a single message, optionally filtered to
67    /// one client via [`ClientReactions::client_id`].
68    ///
69    /// `GET /chat/v4/rooms/{roomName}/messages/{serial}/client-reactions`.
70    /// Retry-safe. Useful when a message's summary is clipped and you need to
71    /// determine whether a specific client has reacted.
72    pub fn for_client(&self, serial: impl Into<Serial>) -> ClientReactions {
73        ClientReactions {
74            client: self.client.clone(),
75            room: self.room.clone(),
76            serial: serial.into(),
77            client_id: None,
78        }
79    }
80}
81
82/// Builder for [`Reactions::send`]; `.await` it to add the reaction. Resolves to
83/// `()` on success (the endpoint returns `201` with no body).
84#[derive(Clone, Debug)]
85pub struct SendReaction {
86    client: Client,
87    room: RoomName,
88    serial: Serial,
89    name: String,
90    kind: ReactionType,
91    count: Option<u64>,
92}
93
94impl SendReaction {
95    /// Sets the reaction aggregation model. Defaults to [`ReactionType::Distinct`].
96    pub fn kind(mut self, kind: ReactionType) -> Self {
97        self.kind = kind;
98        self
99    }
100
101    /// Sets the count for a `multiple`-type reaction (defaults to `1` server-side;
102    /// ignored by the server for other types).
103    pub fn count(mut self, count: u64) -> Self {
104        self.count = Some(count);
105        self
106    }
107}
108
109impl IntoFuture for SendReaction {
110    type Output = Result<()>;
111    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
112
113    fn into_future(self) -> Self::IntoFuture {
114        Box::pin(async move {
115            let mut obj = Map::new();
116            obj.insert("type".to_owned(), Value::String(self.kind.into()));
117            obj.insert("name".to_owned(), Value::String(self.name));
118            if let Some(count) = self.count {
119                obj.insert("count".to_owned(), Value::from(count));
120            }
121            self.client
122                .inner
123                .send(
124                    Method::POST,
125                    &message_path(self.room.as_str(), self.serial.as_str(), "/reactions"),
126                    &[],
127                    Some(Value::Object(obj)),
128                    // Never retried (ADR-0006): no idempotency key and `multiple`
129                    // reactions count each call.
130                    false,
131                )
132                .await?;
133            Ok(())
134        })
135    }
136}
137
138/// Builder for [`Reactions::delete`]; `.await` it to remove the reaction.
139/// Resolves to `()` on success (the endpoint returns `204` with no body).
140#[derive(Clone, Debug)]
141pub struct DeleteReaction {
142    client: Client,
143    room: RoomName,
144    serial: Serial,
145    kind: ReactionType,
146    name: Option<String>,
147}
148
149impl DeleteReaction {
150    /// Sets the reaction aggregation model. Defaults to [`ReactionType::Distinct`].
151    pub fn kind(mut self, kind: ReactionType) -> Self {
152        self.kind = kind;
153        self
154    }
155
156    /// Sets the reaction name (e.g. the emoji) to remove. Required for `distinct`
157    /// and `multiple` reactions.
158    pub fn name(mut self, name: impl Into<String>) -> Self {
159        self.name = Some(name.into());
160        self
161    }
162}
163
164impl IntoFuture for DeleteReaction {
165    type Output = Result<()>;
166    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
167
168    fn into_future(self) -> Self::IntoFuture {
169        Box::pin(async move {
170            // `name` is required for `distinct`/`multiple`; enforce before any
171            // request is sent (permissive for unknown `Other` kinds; ADR-0007).
172            let name_required =
173                matches!(self.kind, ReactionType::Distinct | ReactionType::Multiple);
174            if name_required && self.name.is_none() {
175                return Err(Error::InvalidRequest(format!(
176                    "reaction name is required to delete a `{}` reaction",
177                    String::from(self.kind)
178                )));
179            }
180            let mut query: Vec<(&str, String)> = vec![("type", self.kind.into())];
181            if let Some(name) = self.name {
182                query.push(("name", name));
183            }
184            self.client
185                .inner
186                .send(
187                    Method::DELETE,
188                    &message_path(self.room.as_str(), self.serial.as_str(), "/reactions"),
189                    &query,
190                    None,
191                    false,
192                )
193                .await?;
194            Ok(())
195        })
196    }
197}
198
199/// Builder for [`Reactions::for_client`]; `.await` it to fetch a
200/// [`ReactionSummary`]. Without [`client_id`](Self::client_id), the server
201/// defaults to the authenticated caller's client ID.
202#[derive(Clone, Debug)]
203pub struct ClientReactions {
204    client: Client,
205    room: RoomName,
206    serial: Serial,
207    client_id: Option<String>,
208}
209
210impl ClientReactions {
211    /// Filters the summary to a specific client ID (`forClientId`).
212    pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
213        self.client_id = Some(client_id.into());
214        self
215    }
216}
217
218impl IntoFuture for ClientReactions {
219    type Output = Result<ReactionSummary>;
220    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
221
222    fn into_future(self) -> Self::IntoFuture {
223        Box::pin(async move {
224            let mut query: Vec<(&str, String)> = Vec::new();
225            if let Some(cid) = self.client_id {
226                query.push(("forClientId", cid));
227            }
228            let resp = self
229                .client
230                .inner
231                .send(
232                    Method::GET,
233                    &message_path(
234                        self.room.as_str(),
235                        self.serial.as_str(),
236                        "/client-reactions",
237                    ),
238                    &query,
239                    None,
240                    false,
241                )
242                .await?;
243            decode_json(&resp.body)
244        })
245    }
246}