Skip to main content

fractal/session/user_sessions_list/
user_session.rs

1use gettextrs::gettext;
2use gtk::{
3    glib,
4    glib::{clone, closure_local},
5    prelude::*,
6    subclass::prelude::*,
7};
8use matrix_sdk::{HttpError, encryption::identities::Device as CryptoDevice};
9use ruma::{DeviceId, OwnedDeviceId, api::client::device::Device as DeviceData};
10use tracing::{debug, error};
11
12use crate::{
13    Application,
14    components::{AuthDialog, AuthError},
15    prelude::*,
16    session::Session,
17    spawn_tokio,
18    system_settings::ClockFormat,
19    utils::matrix::timestamp_to_date,
20};
21
22/// The possible sources of the user data.
23#[derive(Debug, Clone)]
24pub(super) enum UserSessionData {
25    /// The data comes from the `/devices` API.
26    DevicesApi(DeviceData),
27    /// The data comes from the crypto store.
28    Crypto(CryptoDevice),
29    /// The data comes from both sources.
30    Both {
31        api: DeviceData,
32        crypto: CryptoDevice,
33    },
34}
35
36impl UserSessionData {
37    /// The ID of the user session.
38    pub(super) fn device_id(&self) -> &DeviceId {
39        match self {
40            UserSessionData::DevicesApi(api) | UserSessionData::Both { api, .. } => &api.device_id,
41            UserSessionData::Crypto(crypto) => crypto.device_id(),
42        }
43    }
44
45    /// Set the display name of user session.
46    fn set_display_name(&mut self, name: String) {
47        match self {
48            UserSessionData::DevicesApi(api) | UserSessionData::Both { api, .. } => {
49                api.display_name = Some(name);
50            }
51            UserSessionData::Crypto(crypto) => {
52                *self = UserSessionData::Both {
53                    api: DeviceData::new(crypto.device_id().into()),
54                    crypto: crypto.to_owned(),
55                }
56            }
57        }
58    }
59
60    /// The `/devices` API data.
61    fn api(&self) -> Option<&DeviceData> {
62        match self {
63            UserSessionData::DevicesApi(api) | UserSessionData::Both { api, .. } => Some(api),
64            UserSessionData::Crypto(_) => None,
65        }
66    }
67
68    /// The crypto API.
69    fn crypto(&self) -> Option<&CryptoDevice> {
70        match self {
71            UserSessionData::Crypto(crypto) | UserSessionData::Both { crypto, .. } => Some(crypto),
72            UserSessionData::DevicesApi(_) => None,
73        }
74    }
75}
76
77mod imp {
78    use std::{
79        cell::{Cell, OnceCell, RefCell},
80        marker::PhantomData,
81        sync::LazyLock,
82    };
83
84    use glib::subclass::Signal;
85
86    use super::*;
87
88    #[derive(Debug, Default, glib::Properties)]
89    #[properties(wrapper_type = super::UserSession)]
90    pub struct UserSession {
91        /// The current session.
92        #[property(get, construct_only)]
93        session: glib::WeakRef<Session>,
94        /// The ID of the user session.
95        device_id: OnceCell<OwnedDeviceId>,
96        /// The user session data.
97        data: RefCell<Option<UserSessionData>>,
98        /// Whether this is the current user session.
99        #[property(get)]
100        is_current: Cell<bool>,
101        /// The ID of the user session, as a string.
102        #[property(get = Self::device_id_string)]
103        device_id_string: PhantomData<String>,
104        /// The display name of the device.
105        #[property(get = Self::display_name)]
106        display_name: PhantomData<String>,
107        /// The display name of the device, or the device id as a fallback.
108        #[property(get = Self::display_name_or_device_id)]
109        display_name_or_device_id: PhantomData<String>,
110        /// The last IP address used by the user session.
111        #[property(get = Self::last_seen_ip)]
112        last_seen_ip: PhantomData<Option<String>>,
113        /// The last time the user session was used, as the number of
114        /// milliseconds since Unix EPOCH.
115        #[property(get = Self::last_seen_ts)]
116        last_seen_ts: PhantomData<u64>,
117        /// The last time the user session was used, as a `GDateTime`.
118        #[property(get = Self::last_seen_datetime)]
119        last_seen_datetime: PhantomData<Option<glib::DateTime>>,
120        /// The last time the user session was used, as a formatted string.
121        #[property(get = Self::last_seen_datetime_string)]
122        last_seen_datetime_string: PhantomData<Option<String>>,
123        /// Whether this user session is verified.
124        #[property(get = Self::verified)]
125        verified: PhantomData<bool>,
126        system_settings_handler: RefCell<Option<glib::SignalHandlerId>>,
127    }
128
129    #[glib::object_subclass]
130    impl ObjectSubclass for UserSession {
131        const NAME: &'static str = "UserSession";
132        type Type = super::UserSession;
133    }
134
135    #[glib::derived_properties]
136    impl ObjectImpl for UserSession {
137        fn constructed(&self) {
138            self.parent_constructed();
139
140            let obj = self.obj();
141            let system_settings = Application::default().system_settings();
142            let system_settings_handler = system_settings.connect_clock_format_notify(clone!(
143                #[weak]
144                obj,
145                move |_| {
146                    obj.notify_last_seen_datetime_string();
147                }
148            ));
149            self.system_settings_handler
150                .replace(Some(system_settings_handler));
151        }
152
153        fn dispose(&self) {
154            if let Some(handler) = self.system_settings_handler.take() {
155                Application::default().system_settings().disconnect(handler);
156            }
157        }
158
159        fn signals() -> &'static [Signal] {
160            static SIGNALS: LazyLock<Vec<Signal>> =
161                LazyLock::new(|| vec![Signal::builder("disconnected").build()]);
162            SIGNALS.as_ref()
163        }
164    }
165
166    impl UserSession {
167        /// She the ID of this user session.
168        pub(super) fn set_device_id(&self, device_id: OwnedDeviceId) {
169            let device_id = self.device_id.get_or_init(|| device_id);
170
171            if let Some(session) = self.session.upgrade() {
172                let is_current = session.device_id() == device_id;
173                self.is_current.set(is_current);
174            }
175        }
176
177        /// The ID of this user session.
178        pub(super) fn device_id(&self) -> &OwnedDeviceId {
179            self.device_id
180                .get()
181                .expect("device ID should be initialized")
182        }
183
184        /// Set the user session data.
185        pub(super) fn set_data(&self, data: UserSessionData) {
186            let old_display_name = self.display_name();
187            let old_last_seen_ip = self.last_seen_ip();
188            let old_last_seen_ts = self.last_seen_ts();
189            let old_verified = self.verified();
190
191            self.data.replace(Some(data));
192
193            let obj = self.obj();
194            if self.display_name() != old_display_name {
195                obj.notify_display_name();
196                obj.notify_display_name_or_device_id();
197            }
198            if self.last_seen_ip() != old_last_seen_ip {
199                obj.notify_last_seen_ip();
200            }
201            if self.last_seen_ts() != old_last_seen_ts {
202                obj.notify_last_seen_ts();
203                obj.notify_last_seen_datetime();
204                obj.notify_last_seen_datetime_string();
205            }
206            if self.verified() != old_verified {
207                obj.notify_verified();
208            }
209        }
210
211        /// The ID of this user session, as a string.
212        fn device_id_string(&self) -> String {
213            self.device_id().to_string()
214        }
215
216        /// The display name of the device.
217        fn display_name(&self) -> String {
218            self.data
219                .borrow()
220                .as_ref()
221                .and_then(UserSessionData::api)
222                .and_then(|d| d.display_name.clone())
223                .unwrap_or_default()
224        }
225
226        /// Set the display name of the device.
227        pub(super) fn set_display_name(&self, name: String) {
228            if let Some(data) = &mut *self.data.borrow_mut() {
229                data.set_display_name(name);
230            }
231
232            self.obj().notify_display_name();
233            self.obj().notify_display_name_or_device_id();
234        }
235
236        /// The display name of the device, or the device id as a fallback.
237        fn display_name_or_device_id(&self) -> String {
238            if let Some(display_name) = self
239                .data
240                .borrow()
241                .as_ref()
242                .and_then(UserSessionData::api)
243                .and_then(|d| d.display_name.as_ref().map(|s| s.trim()))
244                .filter(|s| !s.is_empty())
245                .map(ToOwned::to_owned)
246            {
247                display_name
248            } else {
249                self.device_id_string()
250            }
251        }
252
253        /// The last IP address used by the user session.
254        fn last_seen_ip(&self) -> Option<String> {
255            self.data.borrow().as_ref()?.api()?.last_seen_ip.clone()
256        }
257
258        /// The last time the user session was used, as the number of
259        /// milliseconds since Unix EPOCH.
260        ///
261        /// Defaults to `0` if the timestamp is unknown.
262        fn last_seen_ts(&self) -> u64 {
263            self.data
264                .borrow()
265                .as_ref()
266                .and_then(UserSessionData::api)
267                .and_then(|s| s.last_seen_ts)
268                .map(|ts| ts.0.into())
269                .unwrap_or_default()
270        }
271
272        /// The last time the user session was used, as a `GDateTime`.
273        fn last_seen_datetime(&self) -> Option<glib::DateTime> {
274            self.data
275                .borrow()
276                .as_ref()?
277                .api()?
278                .last_seen_ts
279                .map(timestamp_to_date)
280        }
281
282        /// The last time the user session was used, as a localized formatted
283        /// string.
284        pub(super) fn last_seen_datetime_string(&self) -> Option<String> {
285            let datetime = self.last_seen_datetime()?;
286
287            let clock_format = Application::default().system_settings().clock_format();
288            let use_24 = clock_format == ClockFormat::TwentyFourHours;
289
290            // This was ported from Nautilus and simplified for our use case.
291            // See: https://gitlab.gnome.org/GNOME/nautilus/-/blob/1c5bd3614a35cfbb49de087bc10381cdef5a218f/src/nautilus-file.c#L5001
292            let now = glib::DateTime::now_local().unwrap();
293            let format;
294            let days_ago = {
295                let today_midnight = glib::DateTime::from_local(
296                    now.year(),
297                    now.month(),
298                    now.day_of_month(),
299                    0,
300                    0,
301                    0f64,
302                )
303                .expect("constructing GDateTime works");
304
305                let date = glib::DateTime::from_local(
306                    datetime.year(),
307                    datetime.month(),
308                    datetime.day_of_month(),
309                    0,
310                    0,
311                    0f64,
312                )
313                .expect("constructing GDateTime works");
314
315                today_midnight.difference(&date).as_days()
316            };
317
318            // Show only the time if date is on today
319            if days_ago == 0 {
320                if use_24 {
321                    // Translators: Time in 24h format, i.e. "23:04".
322                    // Do not change the time format as it will follow the system settings.
323                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
324                    format = gettext("Last seen at %H:%M");
325                } else {
326                    // Translators: Time in 12h format, i.e. "11:04 PM".
327                    // Do not change the time format as it will follow the system settings.
328                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
329                    format = gettext("Last seen at %I:%M %p");
330                }
331            }
332            // Show the word "Yesterday" and time if date is on yesterday
333            else if days_ago == 1 {
334                if use_24 {
335                    // Translators: this a time in 24h format, i.e. "Last seen yesterday at 23:04".
336                    // Do not change the time format as it will follow the system settings.
337                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
338                    // xgettext:no-c-format
339                    format = gettext("Last seen yesterday at %H:%M");
340                } else {
341                    // Translators: this is a time in 12h format, i.e. "Last seen Yesterday at 11:04
342                    // PM".
343                    // Do not change the time format as it will follow the system settings.
344                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
345                    // xgettext:no-c-format
346                    format = gettext("Last seen yesterday at %I:%M %p");
347                }
348            }
349            // Show a week day and time if date is in the last week
350            else if days_ago > 1 && days_ago < 7 {
351                if use_24 {
352                    // Translators: this is the name of the week day followed by a time in 24h
353                    // format, i.e. "Last seen Monday at 23:04".
354                    // Do not change the time format as it will follow the system settings.
355                    //  See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
356                    // xgettext:no-c-format
357                    format = gettext("Last seen %A at %H:%M");
358                } else {
359                    // Translators: this is the week day name followed by a time in 12h format, i.e.
360                    // "Last seen Monday at 11:04 PM".
361                    // Do not change the time format as it will follow the system settings.
362                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
363                    // xgettext:no-c-format
364                    format = gettext("Last seen %A at %I:%M %p");
365                }
366            } else if datetime.year() == now.year() {
367                if use_24 {
368                    // Translators: this is the month and day and the time in 24h format, i.e. "Last
369                    // seen February 3 at 23:04".
370                    // Do not change the time format as it will follow the system settings.
371                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
372                    // xgettext:no-c-format
373                    format = gettext("Last seen %B %-e at %H:%M");
374                } else {
375                    // Translators: this is the month and day and the time in 12h format, i.e. "Last
376                    // seen February 3 at 11:04 PM".
377                    // Do not change the time format as it will follow the system settings.
378                    // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
379                    // xgettext:no-c-format
380                    format = gettext("Last seen %B %-e at %I:%M %p");
381                }
382            } else if use_24 {
383                // Translators: this is the full date and the time in 24h format, i.e. "Last
384                // seen February 3 2015 at 23:04".
385                // Do not change the time format as it will follow the system settings.
386                // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
387                // xgettext:no-c-format
388                format = gettext("Last seen %B %-e %Y at %H:%M");
389            } else {
390                // Translators: this is the full date and the time in 12h format, i.e. "Last
391                // seen February 3 2015 at 11:04 PM".
392                // Do not change the time format as it will follow the system settings.
393                // See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
394                // xgettext:no-c-format
395                format = gettext("Last seen %B %-e %Y at %I:%M %p");
396            }
397
398            Some(
399                datetime
400                    .format(&format)
401                    .expect("formatting GDateTime works")
402                    .into(),
403            )
404        }
405
406        /// Whether this device is verified.
407        fn verified(&self) -> bool {
408            self.data
409                .borrow()
410                .as_ref()
411                .and_then(UserSessionData::crypto)
412                .is_some_and(CryptoDevice::is_verified)
413        }
414    }
415}
416
417glib::wrapper! {
418    /// A user's session.
419    pub struct UserSession(ObjectSubclass<imp::UserSession>);
420}
421
422impl UserSession {
423    pub(super) fn new(session: &Session, device_id: OwnedDeviceId) -> Self {
424        let obj = glib::Object::builder::<Self>()
425            .property("session", session)
426            .build();
427
428        obj.imp().set_device_id(device_id);
429
430        obj
431    }
432
433    /// The ID of this user session.
434    pub(crate) fn device_id(&self) -> &OwnedDeviceId {
435        self.imp().device_id()
436    }
437
438    /// Set the user session data.
439    pub(super) fn set_data(&self, data: UserSessionData) {
440        self.imp().set_data(data);
441    }
442
443    /// Renames the user session.
444    pub(crate) async fn rename(&self, display_name: String) -> Result<(), HttpError> {
445        let Some(client) = self.session().map(|s| s.client()) else {
446            return Ok(());
447        };
448        let device_id = self.imp().device_id().clone();
449
450        let cloned_display_name = display_name.clone();
451        let res =
452            spawn_tokio!(
453                async move { client.rename_device(&device_id, &cloned_display_name).await }
454            )
455            .await
456            .expect("task was not aborted");
457
458        match res {
459            Ok(_) => {
460                self.imp().set_display_name(display_name);
461                Ok(())
462            }
463            Err(error) => {
464                let device_id = self.device_id();
465                error!("Could not rename user session {device_id}: {error}");
466                Err(error)
467            }
468        }
469    }
470
471    /// Deletes the `UserSession`.
472    ///
473    /// Requires a widget because it might show a dialog for UIAA.
474    pub(crate) async fn delete(&self, parent: &impl IsA<gtk::Widget>) -> Result<(), AuthError> {
475        let Some(session) = self.session() else {
476            return Err(AuthError::Unknown);
477        };
478        let device_id = self.imp().device_id().clone();
479
480        let dialog = AuthDialog::new(&session);
481
482        let res = dialog
483            .authenticate(parent, move |client, auth| {
484                let device_id = device_id.clone();
485                async move {
486                    client
487                        .delete_devices(&[device_id], auth)
488                        .await
489                        .map_err(Into::into)
490                }
491            })
492            .await;
493
494        match res {
495            Ok(_) => Ok(()),
496            Err(error) => {
497                let device_id = self.imp().device_id();
498
499                if matches!(error, AuthError::UserCancelled) {
500                    debug!("Deletion of user session {device_id} cancelled by user");
501                } else {
502                    error!("Could not delete user session {device_id}: {error}");
503                }
504                Err(error)
505            }
506        }
507    }
508
509    /// Signal that this session was disconnected.
510    pub(super) fn emit_disconnected(&self) {
511        self.emit_by_name::<()>("disconnected", &[]);
512    }
513
514    /// Connect to the signal emitted when this session is disconnected.
515    pub fn connect_disconnected<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
516        self.connect_closure(
517            "disconnected",
518            true,
519            closure_local!(|obj: Self| {
520                f(&obj);
521            }),
522        )
523    }
524}