ruma_events/
recent_emoji.rs

1//! Types for the [`m.recent_emoji`] account data event.
2//!
3//! [`m.recent_emoji`]: https://github.com/matrix-org/matrix-spec-proposals/pull/4356
4
5use js_int::{UInt, uint};
6use ruma_macros::EventContent;
7use serde::{Deserialize, Serialize};
8
9/// The content of an [`m.recent_emoji`] event.
10///
11/// [`m.recent_emoji`]: https://github.com/matrix-org/matrix-spec-proposals/pull/4356
12#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
13#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
14#[ruma_event(type = "m.recent_emoji", kind = GlobalAccountData)]
15pub struct RecentEmojiEventContent {
16    /// The list of recently used emojis, ordered by last usage time.
17    #[serde(default, deserialize_with = "ruma_common::serde::ignore_invalid_vec_items")]
18    pub recent_emoji: Vec<RecentEmoji>,
19}
20
21impl RecentEmojiEventContent {
22    /// The maximum length of the list recommended in the Matrix specification.
23    pub const RECOMMENDED_MAX_LEN: usize = 100;
24
25    /// Creates a new `RecentEmojiEventContent` from the given list.
26    pub fn new(recent_emoji: Vec<RecentEmoji>) -> Self {
27        Self { recent_emoji }
28    }
29
30    /// Increment the total for the given emoji.
31    ///
32    /// If the emoji is in the list, its total is incremented and it is moved to the start of the
33    /// list.
34    ///
35    /// If the emoji is not in the list, it is added at the start of the list with a total set to
36    /// `1`.
37    ///
38    /// If the length of the list is bigger than [`RECOMMENDED_MAX_LEN`](Self::RECOMMENDED_MAX_LEN),
39    /// the list is truncated.
40    pub fn increment_emoji_total(&mut self, emoji: &str) {
41        // Start by truncating the list if necessary to make sure that shifting items doesn't take
42        // too much time.
43        self.recent_emoji.truncate(Self::RECOMMENDED_MAX_LEN);
44
45        if let Some(position) = self.recent_emoji.iter().position(|e| e.emoji == emoji) {
46            let total = &mut self.recent_emoji[position].total;
47            *total = (*total).saturating_add(uint!(1));
48
49            if position > 0 {
50                let emoji = self.recent_emoji.remove(position);
51                self.recent_emoji.insert(0, emoji);
52            }
53        } else {
54            let emoji = RecentEmoji::new(emoji.to_owned());
55            self.recent_emoji.insert(0, emoji);
56
57            // Truncate it again if necessary.
58            self.recent_emoji.truncate(Self::RECOMMENDED_MAX_LEN);
59        }
60    }
61
62    /// Get the list of recent emoji sorted by the number of uses.
63    ///
64    /// When several emoji have the same number of uses they are sorted by last usage time.
65    ///
66    /// The returned list is truncated to [`RECOMMENDED_MAX_LEN`](Self::RECOMMENDED_MAX_LEN).
67    pub fn recent_emoji_sorted_by_total(&self) -> Vec<RecentEmoji> {
68        let mut recent_emoji =
69            self.recent_emoji.iter().take(Self::RECOMMENDED_MAX_LEN).cloned().collect::<Vec<_>>();
70        // We reverse the sorting to get the highest count first.
71        recent_emoji.sort_by(|lhs, rhs| rhs.total.cmp(&lhs.total));
72        recent_emoji
73    }
74}
75
76/// A recently used emoji.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
79pub struct RecentEmoji {
80    /// The emoji as a string.
81    pub emoji: String,
82
83    /// The number of times the emoji has been used.
84    pub total: UInt,
85}
86
87impl RecentEmoji {
88    /// Creates a new `RecentEmoji` for the given emoji.
89    ///
90    /// The total is set to `1`.
91    pub fn new(emoji: String) -> Self {
92        Self { emoji, total: uint!(1) }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use assert_matches2::assert_matches;
99    use js_int::uint;
100    use ruma_common::canonical_json::assert_to_canonical_json_eq;
101    use serde_json::{from_value as from_json_value, json};
102
103    use super::{RecentEmoji, RecentEmojiEventContent};
104    use crate::AnyGlobalAccountDataEvent;
105
106    #[test]
107    fn recent_emoji_serialization() {
108        let content = RecentEmojiEventContent::new([RecentEmoji::new("😎".to_owned())].into());
109
110        assert_to_canonical_json_eq!(
111            content,
112            json!({
113                "recent_emoji": [{
114                    "emoji": "😎",
115                    "total": 1,
116                }],
117            }),
118        );
119    }
120
121    #[test]
122    fn recent_emoji_deserialization() {
123        let json = json!({
124            "content": {
125                "recent_emoji": [
126                    {
127                        "emoji": "😎",
128                        "total": 1,
129                    },
130                    // Invalid item that will be ignored.
131                    {
132                        "emoji": "🏠",
133                        "total": -1,
134                    },
135                ],
136            },
137            "type": "m.recent_emoji",
138        });
139
140        assert_matches!(
141            from_json_value::<AnyGlobalAccountDataEvent>(json),
142            Ok(AnyGlobalAccountDataEvent::RecentEmoji(ev))
143        );
144        assert_eq!(ev.content.recent_emoji, [RecentEmoji::new("😎".to_owned())]);
145    }
146
147    #[test]
148    fn recent_emoji_increment() {
149        let json = json!({
150            "recent_emoji": [
151                {
152                    "emoji": "😎",
153                    "total": 1,
154                },
155                {
156                    "emoji": "🏠",
157                    "total": 5,
158                },
159                {
160                    "emoji": "🧑‍💻",
161                    "total": 2,
162                },
163            ],
164        });
165        let mut content = from_json_value::<RecentEmojiEventContent>(json).unwrap();
166
167        // Check first the initial order.
168        let mut iter = content.recent_emoji.iter();
169        assert_eq!(iter.next().unwrap().emoji, "😎");
170        assert_eq!(iter.next().unwrap().emoji, "🏠");
171        assert_eq!(iter.next().unwrap().emoji, "🧑‍💻");
172        assert_eq!(iter.next(), None);
173
174        // Increment a known emoji.
175        content.increment_emoji_total("🏠");
176        assert_eq!(content.recent_emoji.first().unwrap().total, uint!(6));
177
178        let mut iter = content.recent_emoji.iter();
179        assert_eq!(iter.next().unwrap().emoji, "🏠");
180        assert_eq!(iter.next().unwrap().emoji, "😎");
181        assert_eq!(iter.next().unwrap().emoji, "🧑‍💻");
182        assert_eq!(iter.next(), None);
183
184        // Increment an unknown emoji.
185        content.increment_emoji_total("💩");
186        assert_eq!(content.recent_emoji.first().unwrap().total, uint!(1));
187
188        let mut iter = content.recent_emoji.iter();
189        assert_eq!(iter.next().unwrap().emoji, "💩");
190        assert_eq!(iter.next().unwrap().emoji, "🏠");
191        assert_eq!(iter.next().unwrap().emoji, "😎");
192        assert_eq!(iter.next().unwrap().emoji, "🧑‍💻");
193        assert_eq!(iter.next(), None);
194
195        // Construct a list of more than 100 emojis.
196        let first_emoji = "\u{2700}";
197        let first_emoji_u32 = 0x2700_u32;
198        let mut content = RecentEmojiEventContent::new(
199            std::iter::repeat_n(first_emoji_u32, 110)
200                .enumerate()
201                .map(|(n, start)| {
202                    let char = char::from_u32(start + (n as u32)).unwrap();
203                    RecentEmoji::new(char.into())
204                })
205                .collect(),
206        );
207        assert_eq!(content.recent_emoji.len(), 110);
208
209        // Increment the first emoji, the list should be truncated.
210        content.increment_emoji_total(first_emoji);
211        assert_eq!(content.recent_emoji.first().unwrap().total, uint!(2));
212        assert_eq!(content.recent_emoji.len(), 100);
213    }
214
215    #[test]
216    fn recent_emoji_sorted_by_total() {
217        let json = json!({
218            "recent_emoji": [
219                {
220                    "emoji": "😎",
221                    "total": 1,
222                },
223                {
224                    "emoji": "🏠",
225                    "total": 5,
226                },
227                {
228                    "emoji": "🧑‍💻",
229                    "total": 2,
230                },
231                {
232                    "emoji": "🚀",
233                    "total": 1,
234                },
235            ],
236        });
237        let content = from_json_value::<RecentEmojiEventContent>(json).unwrap();
238
239        // Check first the initial order.
240        let mut iter = content.recent_emoji.iter();
241        assert_eq!(iter.next().unwrap().emoji, "😎");
242        assert_eq!(iter.next().unwrap().emoji, "🏠");
243        assert_eq!(iter.next().unwrap().emoji, "🧑‍💻");
244        assert_eq!(iter.next().unwrap().emoji, "🚀");
245        assert_eq!(iter.next(), None);
246
247        // Check the sorted order.
248        let sorted = content.recent_emoji_sorted_by_total();
249        let mut iter = sorted.iter();
250        assert_eq!(iter.next().unwrap().emoji, "🏠");
251        assert_eq!(iter.next().unwrap().emoji, "🧑‍💻");
252        assert_eq!(iter.next().unwrap().emoji, "😎");
253        assert_eq!(iter.next().unwrap().emoji, "🚀");
254        assert_eq!(iter.next(), None);
255    }
256}