matrix_sdk_ui/timeline/
latest_event.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use matrix_sdk::{Client, Room, latest_events::LocalLatestEventValue};
16use matrix_sdk_base::latest_event::LatestEventValue as BaseLatestEventValue;
17use ruma::{
18    MilliSecondsSinceUnixEpoch, OwnedUserId,
19    events::{
20        AnyMessageLikeEventContent, relation::Replacement, room::message::RoomMessageEventContent,
21    },
22};
23use tracing::trace;
24
25use crate::timeline::{
26    Profile, TimelineDetails, TimelineItemContent,
27    event_handler::{HandleAggregationKind, TimelineAction},
28    traits::RoomDataProvider,
29};
30
31/// A simplified version of [`matrix_sdk_base::latest_event::LatestEventValue`]
32/// tailored for this `timeline` module.
33#[derive(Debug)]
34pub enum LatestEventValue {
35    /// No value has been computed yet, or no candidate value was found.
36    None,
37
38    /// The latest event represents a remote event.
39    Remote {
40        /// The timestamp of the remote event.
41        timestamp: MilliSecondsSinceUnixEpoch,
42
43        /// The sender of the remote event.
44        sender: OwnedUserId,
45
46        /// Has this event been sent by the current logged user?
47        is_own: bool,
48
49        /// The sender's profile.
50        profile: TimelineDetails<Profile>,
51
52        /// The content of the remote event.
53        content: TimelineItemContent,
54    },
55
56    /// The latest event represents an invite to a room.
57    RemoteInvite {
58        /// The timestamp of the invite.
59        timestamp: MilliSecondsSinceUnixEpoch,
60
61        /// The inviter (can be unknown).
62        inviter: Option<OwnedUserId>,
63
64        /// The inviter's profile (can be unknown).
65        inviter_profile: TimelineDetails<Profile>,
66    },
67
68    /// The latest event represents a local event that is sending, or that
69    /// cannot be sent, either because a previous local event, or this local
70    /// event cannot be sent.
71    Local {
72        /// The timestamp of the local event.
73        timestamp: MilliSecondsSinceUnixEpoch,
74
75        /// The sender of the remote event.
76        sender: OwnedUserId,
77
78        /// The sender's profile.
79        profile: TimelineDetails<Profile>,
80
81        /// The content of the local event.
82        content: TimelineItemContent,
83
84        /// Whether the local event is sending, has been sent or cannot be sent.
85        state: LatestEventValueLocalState,
86    },
87}
88
89#[derive(Debug)]
90#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
91pub enum LatestEventValueLocalState {
92    IsSending,
93    HasBeenSent,
94    CannotBeSent,
95}
96
97impl LatestEventValue {
98    pub(crate) async fn from_base_latest_event_value(
99        value: BaseLatestEventValue,
100        room: &Room,
101        client: &Client,
102    ) -> Self {
103        match value {
104            BaseLatestEventValue::None => Self::None,
105            BaseLatestEventValue::Remote(timeline_event) => {
106                let raw_any_sync_timeline_event = timeline_event.into_raw();
107                let Ok(any_sync_timeline_event) = raw_any_sync_timeline_event.deserialize() else {
108                    return Self::None;
109                };
110
111                let timestamp = any_sync_timeline_event.origin_server_ts();
112                let sender = any_sync_timeline_event.sender().to_owned();
113                let is_own = client.user_id().map(|user_id| user_id == sender).unwrap_or(false);
114                let profile = room
115                    .profile_from_user_id(&sender)
116                    .await
117                    .map(TimelineDetails::Ready)
118                    .unwrap_or(TimelineDetails::Unavailable);
119
120                match TimelineAction::from_event(
121                    any_sync_timeline_event,
122                    &raw_any_sync_timeline_event,
123                    room,
124                    None,
125                    None,
126                    None,
127                    None,
128                )
129                .await
130                {
131                    // Easy path: no aggregation, direct event.
132                    Some(TimelineAction::AddItem { content }) => {
133                        Self::Remote { timestamp, sender, is_own, profile, content }
134                    }
135
136                    // Aggregated event.
137                    //
138                    // Only edits are supported for the moment.
139                    Some(TimelineAction::HandleAggregation {
140                        kind:
141                            HandleAggregationKind::Edit { replacement: Replacement { new_content, .. } },
142                        ..
143                    }) => {
144                        // Let's map the edit into a regular message.
145                        match TimelineAction::from_content(
146                            AnyMessageLikeEventContent::RoomMessage(RoomMessageEventContent::new(
147                                new_content.msgtype,
148                            )),
149                            // We don't care about the `InReplyToDetails` in the context of a
150                            // `LatestEventValue`.
151                            None,
152                            // We don't care about the thread information in the context of a
153                            // `LatestEventValue`.
154                            None,
155                            None,
156                        ) {
157                            // The expected case.
158                            TimelineAction::AddItem { content } => {
159                                Self::Remote { timestamp, sender, is_own, profile, content }
160                            }
161
162                            // Supposedly unreachable, but let's pretend there is no
163                            // `LatestEventValue` if it happens.
164                            _ => {
165                                trace!("latest event was an edit that failed to be un-aggregated");
166
167                                Self::None
168                            }
169                        }
170                    }
171
172                    _ => Self::None,
173                }
174            }
175            BaseLatestEventValue::RemoteInvite { timestamp, inviter, .. } => {
176                let inviter_profile = if let Some(inviter_id) = &inviter {
177                    room.profile_from_user_id(inviter_id)
178                        .await
179                        .map(TimelineDetails::Ready)
180                        .unwrap_or(TimelineDetails::Unavailable)
181                } else {
182                    TimelineDetails::Unavailable
183                };
184
185                Self::RemoteInvite { timestamp, inviter, inviter_profile }
186            }
187            BaseLatestEventValue::LocalIsSending(ref local_value)
188            | BaseLatestEventValue::LocalHasBeenSent { value: ref local_value, .. }
189            | BaseLatestEventValue::LocalCannotBeSent(ref local_value) => {
190                let LocalLatestEventValue { timestamp, content: serialized_content } = local_value;
191
192                let Ok(message_like_event_content) = serialized_content.deserialize() else {
193                    return Self::None;
194                };
195
196                let sender =
197                    client.user_id().expect("The `Client` is supposed to be logged").to_owned();
198                let profile = room
199                    .profile_from_user_id(&sender)
200                    .await
201                    .map(TimelineDetails::Ready)
202                    .unwrap_or(TimelineDetails::Unavailable);
203
204                match TimelineAction::from_content(message_like_event_content, None, None, None) {
205                    TimelineAction::AddItem { content } => Self::Local {
206                        timestamp: *timestamp,
207                        sender,
208                        profile,
209                        content,
210                        state: match value {
211                            BaseLatestEventValue::LocalIsSending(_) => {
212                                LatestEventValueLocalState::IsSending
213                            }
214                            BaseLatestEventValue::LocalHasBeenSent { .. } => {
215                                LatestEventValueLocalState::HasBeenSent
216                            }
217                            BaseLatestEventValue::LocalCannotBeSent(_) => {
218                                LatestEventValueLocalState::CannotBeSent
219                            }
220                            BaseLatestEventValue::Remote(_)
221                            | BaseLatestEventValue::RemoteInvite { .. }
222                            | BaseLatestEventValue::None => {
223                                unreachable!("Only local latest events are supposed to be handled");
224                            }
225                        },
226                    },
227
228                    TimelineAction::HandleAggregation { kind, .. } => {
229                        // Add some debug logging here to help diagnose issues with the latest
230                        // event.
231                        trace!("latest event is an aggregation: {}", kind.debug_string());
232                        Self::None
233                    }
234                }
235            }
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use std::ops::Not;
243
244    use assert_matches::assert_matches;
245    use matrix_sdk::{
246        latest_events::{LocalLatestEventValue, RemoteLatestEventValue},
247        store::SerializableEventContent,
248        test_utils::mocks::MatrixMockServer,
249    };
250    use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory};
251    use ruma::{
252        MilliSecondsSinceUnixEpoch, event_id,
253        events::{AnyMessageLikeEventContent, room::message::RoomMessageEventContent},
254        room_id, uint, user_id,
255    };
256
257    use super::{
258        super::{MsgLikeContent, MsgLikeKind, TimelineItemContent},
259        BaseLatestEventValue, LatestEventValue, LatestEventValueLocalState, TimelineDetails,
260    };
261
262    #[async_test]
263    async fn test_none() {
264        let server = MatrixMockServer::new().await;
265        let client = server.client_builder().build().await;
266        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
267
268        let base_value = BaseLatestEventValue::None;
269        let value =
270            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
271
272        assert_matches!(value, LatestEventValue::None);
273    }
274
275    #[async_test]
276    async fn test_remote() {
277        let server = MatrixMockServer::new().await;
278        let client = server.client_builder().build().await;
279        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
280        let sender = user_id!("@mnt_io:matrix.org");
281        let event_factory = EventFactory::new();
282
283        let base_value = BaseLatestEventValue::Remote(RemoteLatestEventValue::from_plaintext(
284            event_factory
285                .server_ts(42)
286                .sender(sender)
287                .text_msg("raclette")
288                .event_id(event_id!("$ev0"))
289                .into_raw_sync(),
290        ));
291        let value =
292            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
293
294        assert_matches!(value, LatestEventValue::Remote { timestamp, sender: received_sender, is_own, profile, content } => {
295            assert_eq!(u64::from(timestamp.get()), 42u64);
296            assert_eq!(received_sender, sender);
297            assert!(is_own.not());
298            assert_matches!(profile, TimelineDetails::Unavailable);
299            assert_matches!(
300                content,
301                TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Message(message), .. }) => {
302                    assert_eq!(message.body(), "raclette");
303                }
304            );
305        })
306    }
307
308    #[async_test]
309    async fn test_remote_invite() {
310        let server = MatrixMockServer::new().await;
311        let client = server.client_builder().build().await;
312        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
313        let user_id = user_id!("@mnt_io:matrix.org");
314
315        let base_value = BaseLatestEventValue::RemoteInvite {
316            event_id: None,
317            timestamp: MilliSecondsSinceUnixEpoch(42u32.into()),
318            inviter: Some(user_id.to_owned()),
319        };
320        let value =
321            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
322
323        assert_matches!(value, LatestEventValue::RemoteInvite { timestamp, inviter, inviter_profile} => {
324            assert_eq!(u64::from(timestamp.get()), 42u64);
325            assert_eq!(inviter.as_deref(), Some(user_id));
326            assert_matches!(inviter_profile, TimelineDetails::Unavailable);
327        })
328    }
329
330    #[async_test]
331    async fn test_local_is_sending() {
332        let server = MatrixMockServer::new().await;
333        let client = server.client_builder().build().await;
334        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
335
336        let base_value = BaseLatestEventValue::LocalIsSending(LocalLatestEventValue {
337            timestamp: MilliSecondsSinceUnixEpoch(uint!(42)),
338            content: SerializableEventContent::new(&AnyMessageLikeEventContent::RoomMessage(
339                RoomMessageEventContent::text_plain("raclette"),
340            ))
341            .unwrap(),
342        });
343        let value =
344            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
345
346        assert_matches!(value, LatestEventValue::Local { timestamp, sender, profile, content, state } => {
347            assert_eq!(u64::from(timestamp.get()), 42u64);
348            assert_eq!(sender, "@example:localhost");
349            assert_matches!(profile, TimelineDetails::Unavailable);
350            assert_matches!(
351                content,
352                TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Message(_), .. })
353            );
354            assert_matches!(state, LatestEventValueLocalState::IsSending);
355        })
356    }
357
358    #[async_test]
359    async fn test_local_has_been_sent() {
360        let server = MatrixMockServer::new().await;
361        let client = server.client_builder().build().await;
362        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
363
364        let base_value = BaseLatestEventValue::LocalHasBeenSent {
365            event_id: event_id!("$ev0").to_owned(),
366            value: LocalLatestEventValue {
367                timestamp: MilliSecondsSinceUnixEpoch(uint!(42)),
368                content: SerializableEventContent::new(&AnyMessageLikeEventContent::RoomMessage(
369                    RoomMessageEventContent::text_plain("raclette"),
370                ))
371                .unwrap(),
372            },
373        };
374        let value =
375            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
376
377        assert_matches!(value, LatestEventValue::Local { timestamp, sender, profile, content, state } => {
378            assert_eq!(u64::from(timestamp.get()), 42u64);
379            assert_eq!(sender, "@example:localhost");
380            assert_matches!(profile, TimelineDetails::Unavailable);
381            assert_matches!(
382                content,
383                TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Message(_), .. })
384            );
385            assert_matches!(state, LatestEventValueLocalState::HasBeenSent);
386        })
387    }
388
389    #[async_test]
390    async fn test_local_cannot_be_sent() {
391        let server = MatrixMockServer::new().await;
392        let client = server.client_builder().build().await;
393        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
394
395        let base_value = BaseLatestEventValue::LocalCannotBeSent(LocalLatestEventValue {
396            timestamp: MilliSecondsSinceUnixEpoch(uint!(42)),
397            content: SerializableEventContent::new(&AnyMessageLikeEventContent::RoomMessage(
398                RoomMessageEventContent::text_plain("raclette"),
399            ))
400            .unwrap(),
401        });
402        let value =
403            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
404
405        assert_matches!(value, LatestEventValue::Local { timestamp, sender, profile, content, state } => {
406            assert_eq!(u64::from(timestamp.get()), 42u64);
407            assert_eq!(sender, "@example:localhost");
408            assert_matches!(profile, TimelineDetails::Unavailable);
409            assert_matches!(
410                content,
411                TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Message(_), .. })
412            );
413            assert_matches!(state, LatestEventValueLocalState::CannotBeSent);
414        })
415    }
416
417    #[async_test]
418    async fn test_remote_edit() {
419        let server = MatrixMockServer::new().await;
420        let client = server.client_builder().build().await;
421        let room = server.sync_room(&client, JoinedRoomBuilder::new(room_id!("!r0"))).await;
422        let sender = user_id!("@mnt_io:matrix.org");
423        let event_factory = EventFactory::new();
424
425        let base_value = BaseLatestEventValue::Remote(RemoteLatestEventValue::from_plaintext(
426            event_factory
427                .server_ts(42)
428                .sender(sender)
429                .text_msg("bonjour")
430                .event_id(event_id!("$ev1"))
431                .edit(event_id!("$ev0"), RoomMessageEventContent::text_plain("fondue").into())
432                .into_raw_sync(),
433        ));
434        let value =
435            LatestEventValue::from_base_latest_event_value(base_value, &room, &client).await;
436
437        assert_matches!(value, LatestEventValue::Remote { timestamp, sender: received_sender, is_own, profile, content } => {
438            assert_eq!(u64::from(timestamp.get()), 42u64);
439            assert_eq!(received_sender, sender);
440            assert!(is_own.not());
441            assert_matches!(profile, TimelineDetails::Unavailable);
442            assert_matches!(
443                content,
444                TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Message(message), .. }) => {
445                    assert_eq!(message.body(), "fondue");
446                }
447            );
448        })
449    }
450}