fractal/session/model/room_list/
mod.rs

1use std::{
2    cell::Cell,
3    collections::{HashMap, HashSet},
4};
5
6use gtk::{
7    gio, glib,
8    glib::{clone, closure_local},
9    prelude::*,
10    subclass::prelude::*,
11};
12use indexmap::IndexMap;
13use matrix_sdk::sync::RoomUpdates;
14use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomOrAliasId, UserId};
15use tracing::{error, warn};
16
17mod room_list_metainfo;
18
19use self::room_list_metainfo::RoomListMetainfo;
20pub use self::room_list_metainfo::RoomMetainfo;
21use crate::{
22    gettext_f,
23    prelude::*,
24    session::model::{Room, Session},
25    spawn_tokio,
26};
27
28mod imp {
29    use std::{cell::RefCell, sync::LazyLock};
30
31    use glib::subclass::Signal;
32
33    use super::*;
34
35    #[derive(Debug, Default, glib::Properties)]
36    #[properties(wrapper_type = super::RoomList)]
37    pub struct RoomList {
38        /// The list of rooms.
39        pub(super) list: RefCell<IndexMap<OwnedRoomId, Room>>,
40        /// The list of rooms we are currently joining.
41        pub(super) pending_rooms: RefCell<HashSet<OwnedRoomOrAliasId>>,
42        /// The list of rooms that were upgraded and for which we haven't joined
43        /// the successor yet.
44        tombstoned_rooms: RefCell<HashSet<OwnedRoomId>>,
45        /// The current session.
46        #[property(get, construct_only)]
47        session: glib::WeakRef<Session>,
48        /// The rooms metainfo that allow to restore this `RoomList` from its
49        /// previous state.
50        metainfo: RoomListMetainfo,
51    }
52
53    #[glib::object_subclass]
54    impl ObjectSubclass for RoomList {
55        const NAME: &'static str = "RoomList";
56        type Type = super::RoomList;
57        type Interfaces = (gio::ListModel,);
58    }
59
60    #[glib::derived_properties]
61    impl ObjectImpl for RoomList {
62        fn signals() -> &'static [Signal] {
63            static SIGNALS: LazyLock<Vec<Signal>> =
64                LazyLock::new(|| vec![Signal::builder("pending-rooms-changed").build()]);
65            SIGNALS.as_ref()
66        }
67
68        fn constructed(&self) {
69            self.parent_constructed();
70            self.metainfo.set_room_list(&self.obj());
71        }
72    }
73
74    impl ListModelImpl for RoomList {
75        fn item_type(&self) -> glib::Type {
76            Room::static_type()
77        }
78
79        fn n_items(&self) -> u32 {
80            self.list.borrow().len() as u32
81        }
82
83        fn item(&self, position: u32) -> Option<glib::Object> {
84            self.list
85                .borrow()
86                .get_index(position as usize)
87                .map(|(_, v)| v.upcast_ref::<glib::Object>())
88                .cloned()
89        }
90    }
91
92    impl RoomList {
93        /// Get the room with the given room ID, if any.
94        pub(super) fn get(&self, room_id: &RoomId) -> Option<Room> {
95            self.list.borrow().get(room_id).cloned()
96        }
97
98        /// Whether this list contains the room with the given ID.
99        fn contains(&self, room_id: &RoomId) -> bool {
100            self.list.borrow().contains_key(room_id)
101        }
102
103        /// Remove the given room identifier from the pending rooms.
104        fn remove_pending_room(&self, identifier: &RoomOrAliasId) {
105            self.pending_rooms.borrow_mut().remove(identifier);
106            self.obj().emit_by_name::<()>("pending-rooms-changed", &[]);
107        }
108
109        /// Add the given room identified to the pending rooms.
110        fn add_pending_room(&self, identifier: OwnedRoomOrAliasId) {
111            self.pending_rooms.borrow_mut().insert(identifier);
112            self.obj().emit_by_name::<()>("pending-rooms-changed", &[]);
113        }
114
115        /// Add a room that was tombstoned but for which we haven't joined the
116        /// successor yet.
117        pub(super) fn add_tombstoned_room(&self, room_id: OwnedRoomId) {
118            self.tombstoned_rooms.borrow_mut().insert(room_id);
119        }
120
121        /// Remove the given room identifier from the pending rooms and replace
122        /// it with the given room ID if the room is not in the list yet.
123        fn remove_or_replace_pending_room(&self, identifier: &RoomOrAliasId, room_id: &RoomId) {
124            {
125                let mut pending_rooms = self.pending_rooms.borrow_mut();
126                pending_rooms.remove(identifier);
127                if !self.contains(room_id) {
128                    pending_rooms.insert(room_id.to_owned().into());
129                }
130            }
131            self.obj().emit_by_name::<()>("pending-rooms-changed", &[]);
132        }
133
134        /// Handle when items were added to the list.
135        fn items_added(&self, added: usize) {
136            let position = {
137                let list = self.list.borrow();
138
139                let position = list.len().saturating_sub(added);
140
141                let mut tombstoned_rooms_to_remove = Vec::new();
142                for (_room_id, room) in list.iter().skip(position) {
143                    room.connect_room_forgotten(clone!(
144                        #[weak(rename_to = imp)]
145                        self,
146                        move |room| {
147                            imp.remove(room.room_id());
148                        }
149                    ));
150
151                    // Check if the new room is the successor to a tombstoned room.
152                    if let Some(predecessor_id) = room.predecessor_id() {
153                        if self.tombstoned_rooms.borrow().contains(predecessor_id) {
154                            if let Some(room) = self.get(predecessor_id) {
155                                room.update_successor();
156                                tombstoned_rooms_to_remove.push(predecessor_id.clone());
157                            }
158                        }
159                    }
160                }
161
162                if !tombstoned_rooms_to_remove.is_empty() {
163                    let mut tombstoned_rooms = self.tombstoned_rooms.borrow_mut();
164                    for room_id in tombstoned_rooms_to_remove {
165                        tombstoned_rooms.remove(&room_id);
166                    }
167                }
168
169                position
170            };
171
172            self.obj().items_changed(position as u32, 0, added as u32);
173        }
174
175        /// Remove the room with the given ID.
176        fn remove(&self, room_id: &RoomId) {
177            let removed = {
178                let mut list = self.list.borrow_mut();
179
180                list.shift_remove_full(room_id)
181            };
182
183            self.tombstoned_rooms.borrow_mut().remove(room_id);
184
185            if let Some((position, ..)) = removed {
186                self.obj().items_changed(position as u32, 1, 0);
187            }
188        }
189
190        /// Load the list of rooms from the `Store`.
191        pub(super) async fn load(&self) {
192            let rooms = self.metainfo.load_rooms().await;
193            let added = rooms.len();
194            self.list.borrow_mut().extend(rooms);
195
196            self.items_added(added);
197        }
198
199        /// Handle room updates received via sync.
200        pub(super) fn handle_room_updates(&self, rooms: RoomUpdates) {
201            let Some(session) = self.session.upgrade() else {
202                return;
203            };
204            let client = session.client();
205
206            let mut new_rooms = HashMap::new();
207
208            for (room_id, left_room) in rooms.leave {
209                let room = if let Some(room) = self.get(&room_id) {
210                    room
211                } else if let Some(matrix_room) = client.get_room(&room_id) {
212                    new_rooms
213                        .entry(room_id.clone())
214                        .or_insert_with(|| Room::new(&session, matrix_room, None))
215                        .clone()
216                } else {
217                    warn!("Could not find left room {room_id}");
218                    continue;
219                };
220
221                self.remove_pending_room((*room_id).into());
222                room.handle_ambiguity_changes(left_room.ambiguity_changes.values());
223            }
224
225            for (room_id, joined_room) in rooms.join {
226                let room = if let Some(room) = self.get(&room_id) {
227                    room
228                } else if let Some(matrix_room) = client.get_room(&room_id) {
229                    new_rooms
230                        .entry(room_id.clone())
231                        .or_insert_with(|| Room::new(&session, matrix_room, None))
232                        .clone()
233                } else {
234                    warn!("Could not find joined room {room_id}");
235                    continue;
236                };
237
238                self.remove_pending_room((*room_id).into());
239                self.metainfo.watch_room(&room);
240                room.handle_ambiguity_changes(joined_room.ambiguity_changes.values());
241            }
242
243            for (room_id, _invited_room) in rooms.invite {
244                let room = if let Some(room) = self.get(&room_id) {
245                    room
246                } else if let Some(matrix_room) = client.get_room(&room_id) {
247                    new_rooms
248                        .entry(room_id.clone())
249                        .or_insert_with(|| Room::new(&session, matrix_room, None))
250                        .clone()
251                } else {
252                    warn!("Could not find invited room {room_id}");
253                    continue;
254                };
255
256                self.remove_pending_room((*room_id).into());
257                self.metainfo.watch_room(&room);
258            }
259
260            if !new_rooms.is_empty() {
261                let added = new_rooms.len();
262                self.list.borrow_mut().extend(new_rooms);
263                self.items_added(added);
264            }
265        }
266
267        /// Join the room with the given identifier.
268        pub(super) async fn join_by_id_or_alias(
269            &self,
270            identifier: OwnedRoomOrAliasId,
271            via: Vec<OwnedServerName>,
272        ) -> Result<OwnedRoomId, String> {
273            let Some(session) = self.session.upgrade() else {
274                return Err("Could not upgrade Session".to_owned());
275            };
276            let client = session.client();
277            let identifier_clone = identifier.clone();
278
279            self.add_pending_room(identifier.clone());
280
281            let handle = spawn_tokio!(async move {
282                client
283                    .join_room_by_id_or_alias(&identifier_clone, &via)
284                    .await
285            });
286
287            match handle.await.expect("task was not aborted") {
288                Ok(matrix_room) => {
289                    self.remove_or_replace_pending_room(&identifier, matrix_room.room_id());
290                    Ok(matrix_room.room_id().to_owned())
291                }
292                Err(error) => {
293                    self.remove_pending_room(&identifier);
294                    error!("Joining room {identifier} failed: {error}");
295
296                    let error = gettext_f(
297                        // Translators: Do NOT translate the content between '{' and '}', this is a
298                        // variable name.
299                        "Could not join room {room_name}",
300                        &[("room_name", identifier.as_str())],
301                    );
302
303                    Err(error)
304                }
305            }
306        }
307    }
308}
309
310glib::wrapper! {
311    /// List of all rooms known by the user.
312    ///
313    /// This is the parent `GListModel` of the sidebar from which all other models
314    /// are derived.
315    ///
316    /// The `RoomList` also takes care of, so called *pending rooms*, i.e.
317    /// rooms the user requested to join, but received no response from the
318    /// server yet.
319    pub struct RoomList(ObjectSubclass<imp::RoomList>)
320        @implements gio::ListModel;
321}
322
323impl RoomList {
324    pub fn new(session: &Session) -> Self {
325        glib::Object::builder().property("session", session).build()
326    }
327
328    /// Load the list of rooms from the `Store`.
329    pub(crate) async fn load(&self) {
330        self.imp().load().await;
331    }
332
333    /// Get a snapshot of the rooms list.
334    pub(crate) fn snapshot(&self) -> Vec<Room> {
335        self.imp().list.borrow().values().cloned().collect()
336    }
337
338    /// Whether the room with the given identifier is pending.
339    pub(crate) fn is_pending_room(&self, identifier: &RoomOrAliasId) -> bool {
340        self.imp().pending_rooms.borrow().contains(identifier)
341    }
342
343    /// Get the room with the given room ID, if any.
344    pub(crate) fn get(&self, room_id: &RoomId) -> Option<Room> {
345        self.imp().get(room_id)
346    }
347
348    /// Get the room with the given identifier, if any.
349    pub(crate) fn get_by_identifier(&self, identifier: &RoomOrAliasId) -> Option<Room> {
350        let room_alias = match <&RoomId>::try_from(identifier) {
351            Ok(room_id) => return self.get(room_id),
352            Err(room_alias) => room_alias,
353        };
354
355        let mut matches = self
356            .imp()
357            .list
358            .borrow()
359            .iter()
360            .filter(|(_, room)| {
361                // We don't want a room that is not joined, it might not be the proper room for
362                // the given alias anymore.
363                if !room.is_joined() {
364                    return false;
365                }
366
367                let matrix_room = room.matrix_room();
368                matrix_room.canonical_alias().as_deref() == Some(room_alias)
369                    || matrix_room.alt_aliases().iter().any(|a| a == room_alias)
370            })
371            .map(|(room_id, room)| (room_id.clone(), room.clone()))
372            .collect::<HashMap<_, _>>();
373
374        if matches.len() <= 1 {
375            return matches.into_values().next();
376        }
377
378        // The alias is shared between upgraded rooms. We want the latest room, so
379        // filter out those that are predecessors.
380        let predecessors = matches
381            .iter()
382            .filter_map(|(_, room)| room.predecessor_id().cloned())
383            .collect::<Vec<_>>();
384        for room_id in predecessors {
385            matches.remove(&room_id);
386        }
387
388        if matches.len() <= 1 {
389            return matches.into_values().next();
390        }
391
392        // Ideally this should not happen, return the one with the latest activity.
393        matches
394            .into_values()
395            .fold(None::<Room>, |latest_room, room| {
396                latest_room
397                    .filter(|r| r.latest_activity() >= room.latest_activity())
398                    .or(Some(room))
399            })
400    }
401
402    /// Wait till the room with the given ID becomes available.
403    pub(crate) async fn get_wait(&self, room_id: &RoomId) -> Option<Room> {
404        if let Some(room) = self.get(room_id) {
405            return Some(room);
406        }
407
408        let (sender, receiver) = futures_channel::oneshot::channel();
409
410        let room_id = room_id.to_owned();
411        let sender = Cell::new(Some(sender));
412        // FIXME: add a timeout
413        let handler_id = self.connect_items_changed(move |obj, _, _, _| {
414            if let Some(room) = obj.get(&room_id) {
415                if let Some(sender) = sender.take() {
416                    let _ = sender.send(Some(room));
417                }
418            }
419        });
420
421        let room = receiver.await.ok().flatten();
422
423        self.disconnect(handler_id);
424
425        room
426    }
427
428    /// Get the joined room that is a direct chat with the user with the given
429    /// ID.
430    ///
431    /// If several rooms are found, returns the room with the latest activity.
432    pub(crate) fn direct_chat(&self, user_id: &UserId) -> Option<Room> {
433        self.imp()
434            .list
435            .borrow()
436            .values()
437            .filter(|r| {
438                // A joined room where the direct member is the given user.
439                r.is_joined() && r.direct_member().as_ref().map(|m| &**m.user_id()) == Some(user_id)
440            })
441            // Take the room with the latest activity.
442            .max_by(|x, y| x.latest_activity().cmp(&y.latest_activity()))
443            .cloned()
444    }
445
446    /// Add a room that was tombstoned but for which we haven't joined the
447    /// successor yet.
448    pub(crate) fn add_tombstoned_room(&self, room_id: OwnedRoomId) {
449        self.imp().add_tombstoned_room(room_id);
450    }
451
452    /// Handle room updates received via sync.
453    pub(crate) fn handle_room_updates(&self, rooms: RoomUpdates) {
454        self.imp().handle_room_updates(rooms);
455    }
456
457    /// Join the room with the given identifier.
458    pub(crate) async fn join_by_id_or_alias(
459        &self,
460        identifier: OwnedRoomOrAliasId,
461        via: Vec<OwnedServerName>,
462    ) -> Result<OwnedRoomId, String> {
463        self.imp().join_by_id_or_alias(identifier, via).await
464    }
465
466    /// Connect to the signal emitted when the pending rooms changed.
467    pub fn connect_pending_rooms_changed<F: Fn(&Self) + 'static>(
468        &self,
469        f: F,
470    ) -> glib::SignalHandlerId {
471        self.connect_closure(
472            "pending-rooms-changed",
473            true,
474            closure_local!(move |obj: Self| {
475                f(&obj);
476            }),
477        )
478    }
479}