Skip to main content

fractal/session/remote/
room.rs

1use std::{cell::RefCell, time::Duration};
2
3use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
4use matrix_sdk::reqwest::StatusCode;
5use ruma::{
6    OwnedRoomAliasId, OwnedRoomId,
7    api::client::{room::get_summary, space::get_hierarchy},
8    assign,
9    room::{JoinRuleSummary, RoomSummary},
10    uint,
11};
12use tracing::{debug, warn};
13
14use crate::{
15    components::{AvatarImage, AvatarUriSource, PillSource},
16    prelude::*,
17    session::{RoomListRoomInfo, Session},
18    spawn, spawn_tokio,
19    utils::{AbortableHandle, LoadingState, matrix::MatrixRoomIdUri, string::linkify},
20};
21
22/// The time after which the data of a room is assumed to be stale.
23///
24/// This matches 1 day.
25const DATA_VALIDITY_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
26
27mod imp {
28    use std::{
29        cell::{Cell, OnceCell},
30        time::Instant,
31    };
32
33    use super::*;
34
35    #[derive(Default, glib::Properties)]
36    #[properties(wrapper_type = super::RemoteRoom)]
37    pub struct RemoteRoom {
38        /// The current session.
39        #[property(get, set = Self::set_session, construct_only)]
40        session: glib::WeakRef<Session>,
41        /// The Matrix URI of this room.
42        uri: OnceCell<MatrixRoomIdUri>,
43        /// The ID of this room.
44        room_id: RefCell<Option<OwnedRoomId>>,
45        /// The canonical alias of this room.
46        canonical_alias: RefCell<Option<OwnedRoomAliasId>>,
47        /// The name that is set for this room.
48        ///
49        /// This can be empty, the display name should be used instead in the
50        /// interface.
51        #[property(get)]
52        name: RefCell<Option<String>>,
53        /// The topic of this room.
54        #[property(get)]
55        topic: RefCell<Option<String>>,
56        /// The linkified topic of this room.
57        ///
58        /// This is the string that should be used in the interface when markup
59        /// is allowed.
60        #[property(get)]
61        topic_linkified: RefCell<Option<String>>,
62        /// The number of joined members in the room.
63        #[property(get)]
64        joined_members_count: Cell<u32>,
65        /// Whether we can knock on the room.
66        #[property(get)]
67        can_knock: Cell<bool>,
68        /// The information about this room in the room list.
69        #[property(get)]
70        room_list_info: RoomListRoomInfo,
71        /// The loading state.
72        #[property(get, builder(LoadingState::default()))]
73        loading_state: Cell<LoadingState>,
74        /// The time of the last request.
75        last_request_time: Cell<Option<Instant>>,
76        request_abort_handle: AbortableHandle,
77    }
78
79    #[glib::object_subclass]
80    impl ObjectSubclass for RemoteRoom {
81        const NAME: &'static str = "RemoteRoom";
82        type Type = super::RemoteRoom;
83        type ParentType = PillSource;
84    }
85
86    #[glib::derived_properties]
87    impl ObjectImpl for RemoteRoom {}
88
89    impl PillSourceImpl for RemoteRoom {
90        fn identifier(&self) -> String {
91            self.uri().id.to_string()
92        }
93    }
94
95    impl RemoteRoom {
96        /// Set the current session.
97        fn set_session(&self, session: &Session) {
98            self.session.set(Some(session));
99
100            self.obj().avatar_data().set_image(Some(AvatarImage::new(
101                session,
102                AvatarUriSource::Room,
103                None,
104                None,
105            )));
106
107            self.room_list_info.set_room_list(session.room_list());
108        }
109
110        /// Set the Matrix URI of this room.
111        pub(super) fn set_uri(&self, uri: MatrixRoomIdUri) {
112            if let Ok(room_id) = uri.id.clone().try_into() {
113                self.set_room_id(room_id);
114            }
115
116            self.uri
117                .set(uri)
118                .expect("Matrix URI should be uninitialized");
119
120            self.update_identifiers();
121            self.update_display_name();
122        }
123
124        /// The Matrix URI of this room.
125        pub(super) fn uri(&self) -> &MatrixRoomIdUri {
126            self.uri.get().expect("Matrix URI should be initialized")
127        }
128
129        /// Set the ID of this room.
130        fn set_room_id(&self, room_id: OwnedRoomId) {
131            self.room_id.replace(Some(room_id));
132        }
133
134        /// The ID of this room.
135        pub(super) fn room_id(&self) -> Option<OwnedRoomId> {
136            self.room_id.borrow().clone()
137        }
138
139        /// Set the canonical alias of this room.
140        fn set_canonical_alias(&self, alias: Option<OwnedRoomAliasId>) {
141            if *self.canonical_alias.borrow() == alias {
142                return;
143            }
144
145            self.canonical_alias.replace(alias);
146            self.update_display_name();
147        }
148
149        /// The canonical alias of this room.
150        pub(super) fn canonical_alias(&self) -> Option<OwnedRoomAliasId> {
151            self.canonical_alias
152                .borrow()
153                .clone()
154                .or_else(|| self.uri().id.clone().try_into().ok())
155        }
156
157        /// Update the identifiers to watch in the room list.
158        fn update_identifiers(&self) {
159            let id = self.uri().id.clone();
160            let room_id = self
161                .room_id()
162                .filter(|room_id| room_id.as_str() != id.as_str())
163                .map(Into::into);
164            let canonical_alias = self
165                .canonical_alias()
166                .filter(|alias| alias.as_str() != id.as_str())
167                .map(Into::into);
168
169            let identifiers = room_id
170                .into_iter()
171                .chain(canonical_alias)
172                .chain(Some(id))
173                .collect();
174
175            self.room_list_info.set_identifiers(identifiers);
176        }
177
178        /// Set the name of this room.
179        fn set_name(&self, name: Option<String>) {
180            if *self.name.borrow() == name {
181                return;
182            }
183
184            self.name.replace(name);
185
186            self.obj().notify_name();
187            self.update_display_name();
188        }
189
190        /// The display name of this room.
191        pub(super) fn update_display_name(&self) {
192            let display_name = self
193                .name
194                .borrow()
195                .clone()
196                .or_else(|| {
197                    self.canonical_alias
198                        .borrow()
199                        .as_ref()
200                        .map(ToString::to_string)
201                })
202                .unwrap_or_else(|| self.identifier());
203
204            self.obj().set_display_name(display_name);
205        }
206
207        /// Set the topic of this room.
208        fn set_topic(&self, topic: Option<String>) {
209            let topic =
210                topic.filter(|s| !s.is_empty() && s.find(|c: char| !c.is_whitespace()).is_some());
211
212            if *self.topic.borrow() == topic {
213                return;
214            }
215
216            let topic_linkified = topic.as_deref().map(|t| {
217                // Detect links.
218                let mut s = linkify(t);
219                // Remove trailing spaces.
220                s.truncate_end_whitespaces();
221                s
222            });
223
224            self.topic.replace(topic);
225            self.topic_linkified.replace(topic_linkified);
226
227            let obj = self.obj();
228            obj.notify_topic();
229            obj.notify_topic_linkified();
230        }
231
232        /// Set the number of joined members in the room.
233        fn set_joined_members_count(&self, count: u32) {
234            if self.joined_members_count.get() == count {
235                return;
236            }
237
238            self.joined_members_count.set(count);
239            self.obj().notify_joined_members_count();
240        }
241
242        /// Set the join rule of the room.
243        fn set_join_rule(&self, join_rule: &JoinRuleSummary) {
244            let can_knock = matches!(
245                join_rule,
246                JoinRuleSummary::Knock | JoinRuleSummary::KnockRestricted(_)
247            );
248
249            if self.can_knock.get() == can_knock {
250                return;
251            }
252
253            self.can_knock.set(can_knock);
254            self.obj().notify_can_knock();
255        }
256
257        /// Set the loading state.
258        pub(super) fn set_loading_state(&self, loading_state: LoadingState) {
259            if self.loading_state.get() == loading_state {
260                return;
261            }
262
263            self.loading_state.set(loading_state);
264
265            if loading_state == LoadingState::Error {
266                // Reset the request time so we try it again the next time.
267                self.last_request_time.take();
268            }
269
270            self.obj().notify_loading_state();
271        }
272
273        /// Set the room data.
274        pub(super) fn set_data(&self, data: RoomSummary) {
275            self.set_room_id(data.room_id);
276            self.set_canonical_alias(data.canonical_alias);
277            self.set_name(data.name.into_clean_string());
278            self.set_topic(data.topic.into_clean_string());
279            self.set_joined_members_count(data.num_joined_members.try_into().unwrap_or(u32::MAX));
280            self.set_join_rule(&data.join_rule);
281
282            if let Some(image) = self.obj().avatar_data().image() {
283                image.set_uri_and_info(data.avatar_url, None);
284            }
285
286            self.update_identifiers();
287            self.set_loading_state(LoadingState::Ready);
288        }
289
290        /// Whether the data of the room is considered to be stale.
291        pub(super) fn is_data_stale(&self) -> bool {
292            self.last_request_time
293                .get()
294                .is_none_or(|last_time| last_time.elapsed() > DATA_VALIDITY_DURATION)
295        }
296
297        /// Update the last request time to now.
298        pub(super) fn update_last_request_time(&self) {
299            self.last_request_time.set(Some(Instant::now()));
300        }
301
302        /// Request the data of this room.
303        pub(super) async fn load_data(&self) {
304            let Some(session) = self.session.upgrade() else {
305                self.last_request_time.take();
306                return;
307            };
308
309            self.set_loading_state(LoadingState::Loading);
310
311            // Try to load data from the summary endpoint first, and if it is not supported
312            // try the space hierarchy endpoint.
313            if !self.load_data_from_summary(&session).await {
314                self.load_data_from_space_hierarchy(&session).await;
315            }
316        }
317
318        /// Load the data of this room using the room summary endpoint.
319        ///
320        /// At the time of writing this code, MSC3266 has been accepted but the
321        /// endpoint is not part of a Matrix spec release.
322        ///
323        /// Returns `false` if the endpoint is not supported by the homeserver.
324        async fn load_data_from_summary(&self, session: &Session) -> bool {
325            let uri = self.uri();
326            let client = session.client();
327
328            let request = get_summary::v1::Request::new(uri.id.clone(), uri.via.clone());
329            let handle = spawn_tokio!(async move { client.send(request).await });
330
331            let Some(result) = self.request_abort_handle.await_task(handle).await else {
332                // The task was aborted, which means that the object was dropped.
333                return true;
334            };
335
336            match result {
337                Ok(response) => {
338                    self.set_data(response.summary);
339                    true
340                }
341                Err(error) => {
342                    if error
343                        .as_client_api_error()
344                        .is_some_and(|error| error.status_code == StatusCode::NOT_FOUND)
345                    {
346                        return false;
347                    }
348
349                    warn!(
350                        "Could not get room details from summary endpoint for room `{}`: {error}",
351                        uri.id
352                    );
353                    self.set_loading_state(LoadingState::Error);
354                    true
355                }
356            }
357        }
358
359        /// Load the data of this room using the space hierarchy endpoint.
360        ///
361        /// This endpoint should work for any room already known by the
362        /// homeserver.
363        async fn load_data_from_space_hierarchy(&self, session: &Session) {
364            let uri = self.uri();
365            let client = session.client();
366
367            // The endpoint only works with a room ID.
368            let room_id = match OwnedRoomId::try_from(uri.id.clone()) {
369                Ok(room_id) => room_id,
370                Err(alias) => {
371                    let client_clone = client.clone();
372                    let handle =
373                        spawn_tokio!(async move { client_clone.resolve_room_alias(&alias).await });
374
375                    let Some(result) = self.request_abort_handle.await_task(handle).await else {
376                        // The task was aborted, which means that the object was dropped.
377                        return;
378                    };
379
380                    match result {
381                        Ok(response) => response.room_id,
382                        Err(error) => {
383                            warn!("Could not resolve room alias `{}`: {error}", uri.id);
384                            self.set_loading_state(LoadingState::Error);
385                            return;
386                        }
387                    }
388                }
389            };
390
391            let request = assign!(get_hierarchy::v1::Request::new(room_id.clone()), {
392                // We are only interested in the single room.
393                limit: Some(uint!(1))
394            });
395            let handle = spawn_tokio!(async move { client.send(request).await });
396
397            let Some(result) = self.request_abort_handle.await_task(handle).await else {
398                // The task was aborted, which means that the object was dropped.
399                return;
400            };
401
402            match result {
403                Ok(response) => {
404                    if let Some(chunk) = response
405                        .rooms
406                        .into_iter()
407                        .next()
408                        .filter(|c| c.summary.room_id == room_id)
409                    {
410                        self.set_data(chunk.summary);
411                    } else {
412                        debug!("Space hierarchy endpoint did not return requested room");
413                        self.set_loading_state(LoadingState::Error);
414                    }
415                }
416                Err(error) => {
417                    warn!(
418                        "Could not get room details from space hierarchy endpoint for room `{}`: {error}",
419                        uri.id
420                    );
421                    self.set_loading_state(LoadingState::Error);
422                }
423            }
424        }
425    }
426}
427
428glib::wrapper! {
429    /// A Room that can only be updated by making remote calls, i.e. it won't be updated via sync.
430    pub struct RemoteRoom(ObjectSubclass<imp::RemoteRoom>)
431        @extends PillSource;
432}
433
434impl RemoteRoom {
435    /// Construct a new `RemoteRoom` for the given URI, without any data.
436    fn without_data(session: &Session, uri: MatrixRoomIdUri) -> Self {
437        let obj = glib::Object::builder::<Self>()
438            .property("session", session)
439            .build();
440        obj.imp().set_uri(uri);
441        obj
442    }
443
444    /// Construct a new `RemoteRoom` for the given URI.
445    ///
446    /// This method automatically makes a request to load the room's data.
447    pub(super) fn new(session: &Session, uri: MatrixRoomIdUri) -> Self {
448        let obj = Self::without_data(session, uri);
449        obj.load_data_if_stale();
450        obj
451    }
452
453    /// Construct a new `RemoteRoom` for the given URI and data.
454    pub(crate) fn with_data(
455        session: &Session,
456        uri: MatrixRoomIdUri,
457        data: impl Into<RoomSummary>,
458    ) -> Self {
459        let obj = Self::without_data(session, uri);
460        obj.imp().set_data(data.into());
461
462        obj
463    }
464
465    /// The Matrix URI of this room.
466    pub(crate) fn uri(&self) -> &MatrixRoomIdUri {
467        self.imp().uri()
468    }
469
470    /// The ID of this room.
471    pub(crate) fn room_id(&self) -> Option<OwnedRoomId> {
472        self.imp().room_id()
473    }
474
475    /// The canonical alias of this room.
476    pub(crate) fn canonical_alias(&self) -> Option<OwnedRoomAliasId> {
477        self.imp().canonical_alias()
478    }
479
480    /// Load the data of this room if it is considered to be stale.
481    pub(super) fn load_data_if_stale(&self) {
482        let imp = self.imp();
483
484        if !imp.is_data_stale() {
485            // The data is still valid, nothing to do.
486            return;
487        }
488
489        // Set the request time right away, to prevent several requests at the same
490        // time.
491        imp.update_last_request_time();
492
493        spawn!(clone!(
494            #[weak]
495            imp,
496            async move {
497                imp.load_data().await;
498            }
499        ));
500    }
501}