Browse Source

user-sessions-page: Redesign

pipelines/816197
Titouan Real 1 year ago committed by Kévin Commaille
parent
commit
4b846fd9d7
  1. 4
      po/POTFILES.in
  2. 18
      src/session/model/user_sessions_list/mod.rs
  3. 10
      src/session/model/user_sessions_list/other_sessions_list.rs
  4. 181
      src/session/model/user_sessions_list/user_session.rs
  5. 56
      src/session/view/account_settings/mod.rs
  6. 1
      src/session/view/account_settings/mod.ui
  7. 84
      src/session/view/account_settings/user_sessions_page/mod.rs
  8. 4
      src/session/view/account_settings/user_sessions_page/mod.ui
  9. 302
      src/session/view/account_settings/user_sessions_page/user_session_row.rs
  10. 179
      src/session/view/account_settings/user_sessions_page/user_session_row.ui
  11. 227
      src/session/view/account_settings/user_sessions_page/user_session_subpage.rs
  12. 139
      src/session/view/account_settings/user_sessions_page/user_session_subpage.ui
  13. 1
      src/ui-resources.gresource.xml

4
po/POTFILES.in

@ -79,6 +79,7 @@ src/session/model/room/permissions.rs
src/session/model/room_list/mod.rs
src/session/model/sidebar_data/section/name.rs
src/session/model/sidebar_data/icon_item.rs
src/session/model/user_sessions_list/user_session.rs
src/session/view/account_settings/general_page/change_password_subpage.rs
src/session/view/account_settings/general_page/change_password_subpage.ui
src/session/view/account_settings/general_page/deactivate_account_subpage.rs
@ -98,8 +99,9 @@ src/session/view/account_settings/security_page/import_export_keys_subpage.ui
src/session/view/account_settings/security_page/mod.rs
src/session/view/account_settings/security_page/mod.ui
src/session/view/account_settings/user_sessions_page/mod.ui
src/session/view/account_settings/user_sessions_page/user_session_row.rs
src/session/view/account_settings/user_sessions_page/user_session_row.ui
src/session/view/account_settings/user_sessions_page/user_session_subpage.rs
src/session/view/account_settings/user_sessions_page/user_session_subpage.ui
src/session/view/content/explore/mod.ui
src/session/view/content/explore/public_room_row.rs
src/session/view/content/explore/servers_popover.ui

18
src/session/model/user_sessions_list/mod.rs

@ -1,7 +1,7 @@
use futures_util::StreamExt;
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
use matrix_sdk::encryption::identities::UserDevices;
use ruma::OwnedUserId;
use ruma::{OwnedDeviceId, OwnedUserId};
use tokio::task::AbortHandle;
use tracing::error;
@ -259,6 +259,17 @@ mod imp {
self.set_loading_state(LoadingState::Ready);
}
/// Find the user session with the given device ID, if any.
pub(super) fn get(&self, device_id: &OwnedDeviceId) -> Option<UserSession> {
if let Some(current_session) = self.current_session.borrow().as_ref() {
if current_session.device_id() == device_id {
return Some(current_session.clone());
}
}
self.other_sessions.get(device_id)
}
/// Set the loading state of the list.
fn set_loading_state(&self, loading_state: LoadingState) {
if self.loading_state.get() == loading_state {
@ -296,6 +307,11 @@ impl UserSessionsList {
pub(crate) async fn load(&self) {
self.imp().load().await;
}
/// Find the user session with the given device ID, if any.
pub(crate) fn get(&self, device_id: &OwnedDeviceId) -> Option<UserSession> {
self.imp().get(device_id)
}
}
impl Default for UserSessionsList {

10
src/session/model/user_sessions_list/other_sessions_list.rs

@ -105,6 +105,11 @@ mod imp {
session.emit_disconnected();
}
}
/// Find the user session with the given device ID, if any.
pub(super) fn get(&self, device_id: &OwnedDeviceId) -> Option<UserSession> {
self.map.borrow().get(device_id).cloned()
}
}
}
@ -123,6 +128,11 @@ impl OtherSessionsList {
pub(super) fn update(&self, session: &Session, data_list: Vec<UserSessionData>) {
self.imp().update(session, data_list);
}
/// Find the user session with the given device ID, if any.
pub(super) fn get(&self, device_id: &OwnedDeviceId) -> Option<UserSession> {
self.imp().get(device_id)
}
}
impl Default for OtherSessionsList {

181
src/session/model/user_sessions_list/user_session.rs

@ -1,4 +1,10 @@
use gtk::{glib, glib::closure_local, prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
glib,
glib::{clone, closure_local},
prelude::*,
subclass::prelude::*,
};
use matrix_sdk::encryption::identities::Device as CryptoDevice;
use ruma::{api::client::device::Device as DeviceData, DeviceId, OwnedDeviceId};
use tracing::{debug, error};
@ -7,7 +13,9 @@ use crate::{
components::{AuthDialog, AuthError},
prelude::*,
session::model::Session,
system_settings::ClockFormat,
utils::matrix::timestamp_to_date,
Application,
};
/// The possible sources of the user data.
@ -77,9 +85,12 @@ mod imp {
/// The ID of the user session, as a string.
#[property(get = Self::device_id_string)]
device_id_string: PhantomData<String>,
/// The display name of the user session.
/// The display name of the device.
#[property(get = Self::display_name)]
display_name: PhantomData<String>,
/// The display name of the device, or the device id as a fallback.
#[property(get = Self::display_name_or_device_id)]
display_name_or_device_id: PhantomData<String>,
/// The last IP address used by the user session.
#[property(get = Self::last_seen_ip)]
last_seen_ip: PhantomData<Option<String>>,
@ -90,9 +101,13 @@ mod imp {
/// The last time the user session was used, as a `GDateTime`.
#[property(get = Self::last_seen_datetime)]
last_seen_datetime: PhantomData<Option<glib::DateTime>>,
/// The last time the user session was used, as a formatted string.
#[property(get = Self::last_seen_datetime_string)]
last_seen_datetime_string: PhantomData<Option<String>>,
/// Whether this user session is verified.
#[property(get = Self::verified)]
verified: PhantomData<bool>,
system_settings_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
@ -103,6 +118,28 @@ mod imp {
#[glib::derived_properties]
impl ObjectImpl for UserSession {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
let system_settings = Application::default().system_settings();
let system_settings_handler = system_settings.connect_clock_format_notify(clone!(
#[weak]
obj,
move |_| {
obj.notify_last_seen_datetime_string();
}
));
self.system_settings_handler
.replace(Some(system_settings_handler));
}
fn dispose(&self) {
if let Some(handler) = self.system_settings_handler.take() {
Application::default().system_settings().disconnect(handler);
}
}
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> =
LazyLock::new(|| vec![Signal::builder("disconnected").build()]);
@ -140,12 +177,15 @@ mod imp {
let obj = self.obj();
if self.display_name() != old_display_name {
obj.notify_display_name();
obj.notify_display_name_or_device_id();
}
if self.last_seen_ip() != old_last_seen_ip {
obj.notify_last_seen_ip();
}
if self.last_seen_ts() != old_last_seen_ts {
obj.notify_last_seen_ts();
obj.notify_last_seen_datetime();
obj.notify_last_seen_datetime_string();
}
if self.verified() != old_verified {
obj.notify_verified();
@ -159,12 +199,24 @@ mod imp {
/// The display name of the device.
fn display_name(&self) -> String {
self.data
.borrow()
.as_ref()
.and_then(UserSessionData::api)
.and_then(|d| d.display_name.clone())
.unwrap_or_default()
}
/// The display name of the device, or the device id as a fallback.
fn display_name_or_device_id(&self) -> String {
if let Some(display_name) = self
.data
.borrow()
.as_ref()
.and_then(UserSessionData::api)
.and_then(|d| d.display_name.clone())
.and_then(|d| d.display_name.as_ref().map(|s| s.trim()))
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
{
display_name
} else {
@ -201,6 +253,129 @@ mod imp {
.map(timestamp_to_date)
}
/// The last time the user session was used, as a `GDateTime`.
pub(super) fn last_seen_datetime_string(&self) -> Option<String> {
let datetime = self.last_seen_datetime()?;
let clock_format = Application::default().system_settings().clock_format();
let use_24 = clock_format == ClockFormat::TwentyFourHours;
// This was ported from Nautilus and simplified for our use case.
// See: https://gitlab.gnome.org/GNOME/nautilus/-/blob/1c5bd3614a35cfbb49de087bc10381cdef5a218f/src/nautilus-file.c#L5001
let now = glib::DateTime::now_local().unwrap();
let format;
let days_ago = {
let today_midnight = glib::DateTime::from_local(
now.year(),
now.month(),
now.day_of_month(),
0,
0,
0f64,
)
.expect("constructing GDateTime works");
let date = glib::DateTime::from_local(
datetime.year(),
datetime.month(),
datetime.day_of_month(),
0,
0,
0f64,
)
.expect("constructing GDateTime works");
today_midnight.difference(&date).as_days()
};
// Show only the time if date is on today
if days_ago == 0 {
if use_24 {
// Translators: Time in 24h format, i.e. "23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
format = gettext("Last seen at %H:%M");
} else {
// Translators: Time in 12h format, i.e. "11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
format = gettext("Last seen at %I:%M %p");
}
}
// Show the word "Yesterday" and time if date is on yesterday
else if days_ago == 1 {
if use_24 {
// Translators: this a time in 24h format, i.e. "Last seen yesterday at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen yesterday at %H:%M");
} else {
// Translators: this is a time in 12h format, i.e. "Last seen Yesterday at 11:04
// PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen yesterday at %I:%M %p");
}
}
// Show a week day and time if date is in the last week
else if days_ago > 1 && days_ago < 7 {
if use_24 {
// Translators: this is the name of the week day followed by a time in 24h
// format, i.e. "Last seen Monday at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %A at %H:%M");
} else {
// Translators: this is the week day name followed by a time in 12h format, i.e.
// "Last seen Monday at 11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %A at %I:%M %p");
}
} else if datetime.year() == now.year() {
if use_24 {
// Translators: this is the month and day and the time in 24h format, i.e. "Last
// seen February 3 at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e at %H:%M");
} else {
// Translators: this is the month and day and the time in 12h format, i.e. "Last
// seen February 3 at 11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e at %I:%M %p");
}
} else if use_24 {
// Translators: this is the full date and the time in 24h format, i.e. "Last
// seen February 3 2015 at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e %Y at %H:%M");
} else {
// Translators: this is the full date and the time in 12h format, i.e. "Last
// seen February 3 2015 at 11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e %Y at %I:%M %p");
}
Some(
datetime
.format(&format)
.expect("formatting GDateTime works")
.into(),
)
}
/// Whether this device is verified.
fn verified(&self) -> bool {
self.data

56
src/session/view/account_settings/mod.rs

@ -4,6 +4,7 @@ use gtk::{
glib::{clone, closure_local},
CompositeTemplate,
};
use tracing::error;
use url::Url;
mod general_page;
@ -17,7 +18,7 @@ use self::{
security_page::{
IgnoredUsersSubpage, ImportExportKeysSubpage, ImportExportKeysSubpageMode, SecurityPage,
},
user_sessions_page::UserSessionsPage,
user_sessions_page::{UserSessionSubpage, UserSessionsPage},
};
use crate::{
components::crypto::{CryptoIdentitySetupView, CryptoRecoverySetupView},
@ -95,6 +96,26 @@ mod imp {
},
);
klass.install_action(
"account-settings.show-session-subpage",
Some(&String::static_variant_type()),
|obj, _, param| {
obj.show_session_subpage(
&param
.and_then(glib::Variant::get::<String>)
.expect("The parameter should be a string"),
);
},
);
klass.install_action_async(
"account-settings.reload-user-sessions",
None,
|obj, _, _| async move {
obj.imp().reload_user_sessions().await;
},
);
klass.install_action("account-settings.close", None, |obj, _, _| {
obj.close();
});
@ -145,10 +166,10 @@ mod imp {
// Refresh the list of sessions.
spawn!(clone!(
#[weak]
session,
#[weak(rename_to = imp)]
self,
async move {
session.user_sessions().load().await;
imp.reload_user_sessions().await;
}
));
@ -184,6 +205,15 @@ mod imp {
pub(super) fn account_management_url(&self) -> Option<Url> {
self.account_management_url.borrow().clone()
}
/// Reload the sessions from the server.
async fn reload_user_sessions(&self) {
let Some(session) = self.session.obj() else {
return;
};
session.user_sessions().load().await;
}
}
}
@ -276,6 +306,24 @@ impl AccountSettings {
self.push_subpage(&page);
}
/// Show a subpage with the session details of the given session ID.
pub(crate) fn show_session_subpage(&self, device_id: &str) {
let Some(session) = self.session() else {
return;
};
let user_session = session.user_sessions().get(&device_id.into());
let Some(user_session) = user_session else {
error!("ID {device_id} is not associated to any device");
return;
};
let page = UserSessionSubpage::new(&user_session, self);
self.push_subpage(&page);
}
/// Connect to the signal emitted when the account management URL changed.
pub fn connect_account_management_url_changed<F: Fn(&Self) + 'static>(
&self,

1
src/session/view/account_settings/mod.ui

@ -30,7 +30,6 @@
<lookup name="session">AccountSettings</lookup>
</lookup>
</binding>
<property name="account-settings">AccountSettings</property>
</object>
</child>
<child>

84
src/session/view/account_settings/user_sessions_page/mod.rs

@ -3,8 +3,10 @@ use gtk::{gio, glib, glib::clone, prelude::*, CompositeTemplate};
use tracing::error;
mod user_session_row;
mod user_session_subpage;
use self::user_session_row::UserSessionRow;
pub use self::user_session_subpage::UserSessionSubpage;
use super::AccountSettings;
use crate::{
session::model::{UserSession, UserSessionsList},
@ -34,9 +36,6 @@ mod imp {
stack: TemplateChild<gtk::Stack>,
#[template_child]
other_sessions: TemplateChild<gtk::ListBox>,
/// The ancestor [`AccountSettings`].
#[property(get, set = Self::set_account_settings, explicit_notify, nullable)]
account_settings: glib::WeakRef<AccountSettings>,
/// The list of user sessions.
#[property(get, set = Self::set_user_sessions, explicit_notify, nullable)]
user_sessions: BoundObject<UserSessionsList>,
@ -52,7 +51,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
Self::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -62,6 +61,12 @@ mod imp {
#[glib::derived_properties]
impl ObjectImpl for UserSessionsPage {
fn constructed(&self) {
self.parent_constructed();
self.init_other_sessions();
}
fn dispose(&self) {
if let Some(user_sessions) = self.user_sessions.obj() {
if let Some(handler) = self.other_sessions_handler.take() {
@ -77,16 +82,8 @@ mod imp {
impl WidgetImpl for UserSessionsPage {}
impl PreferencesPageImpl for UserSessionsPage {}
#[gtk::template_callbacks]
impl UserSessionsPage {
/// Set the ancestor [`AccountSettings`].
fn set_account_settings(&self, account_settings: Option<&AccountSettings>) {
self.account_settings.set(account_settings);
if let Some(account_settings) = account_settings {
self.init_other_sessions(account_settings);
}
}
/// Set the list of user sessions.
fn set_user_sessions(&self, user_sessions: Option<UserSessionsList>) {
let prev_user_sessions = self.user_sessions.obj();
@ -162,7 +159,7 @@ mod imp {
}
/// Initialize the list of other sessions.
fn init_other_sessions(&self, account_settings: &AccountSettings) {
fn init_other_sessions(&self) {
let last_seen_ts_sorter = gtk::NumericSorter::builder()
.expression(UserSession::this_expression("last-seen-ts"))
.sort_order(gtk::SortType::Descending)
@ -175,32 +172,20 @@ mod imp {
self.other_sessions_sorted_model
.set_sorter(Some(&multi_sorter));
self.other_sessions.bind_model(
Some(&self.other_sessions_sorted_model),
clone!(
#[weak]
account_settings,
#[upgrade_or_else]
|| adw::Bin::new().upcast(),
move |item| {
let Some(user_session) = item.downcast_ref::<UserSession>() else {
error!("Did not get a user session as an item of user session list");
return adw::Bin::new().upcast();
};
UserSessionRow::new(user_session, &account_settings).upcast()
}
),
);
self.other_sessions
.bind_model(Some(&self.other_sessions_sorted_model), move |item| {
let Some(user_session) = item.downcast_ref::<UserSession>() else {
error!("Did not get a user session as an item of user session list");
return adw::Bin::new().upcast();
};
UserSessionRow::new(user_session).upcast()
});
}
/// The current page of the other sessions stack according to the
/// current state.
fn current_other_sessions_page(&self) -> &str {
if self.account_settings.upgrade().is_none() {
return "loading";
}
let Some(user_sessions) = self.user_sessions.obj() else {
return "loading";
};
@ -227,11 +212,6 @@ mod imp {
self.current_session.remove(&child);
}
let Some(account_settings) = self.account_settings.upgrade() else {
self.current_session_group.set_visible(false);
return;
};
let current_session = self.user_sessions.obj().and_then(|s| s.current_session());
let Some(current_session) = current_session else {
self.current_session_group.set_visible(false);
@ -239,9 +219,20 @@ mod imp {
};
self.current_session
.append(&UserSessionRow::new(&current_session, &account_settings));
.append(&UserSessionRow::new(&current_session));
self.current_session_group.set_visible(true);
}
/// Show the session subpage.
#[template_callback]
fn show_session_subpage(&self, row: &UserSessionRow) {
let obj = self.obj();
let _ = obj.activate_action(
"account-settings.show-session-subpage",
Some(&row.user_session().unwrap().device_id_string().to_variant()),
);
}
}
}
@ -252,22 +243,11 @@ glib::wrapper! {
@implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl UserSessionsPage {
/// Construct a new empty `UserSessionsPage`.
pub fn new() -> Self {
glib::Object::new()
}
/// Reload the user sessions list.
#[template_callback]
async fn reload_list(&self) {
let Some(user_sessions) = self.user_sessions() else {
return;
};
user_sessions.load().await;
}
}
impl Default for UserSessionsPage {

4
src/session/view/account_settings/user_sessions_page/mod.ui

@ -12,6 +12,7 @@
<accessibility>
<property name="label" translatable="yes">Current Session</property>
</accessibility>
<signal name="row-activated" handler="show_session_subpage" swapped="yes"/>
<style>
<class name="content"/>
</style>
@ -47,6 +48,7 @@
<accessibility>
<property name="label" translatable="yes">Other Active Sessions</property>
</accessibility>
<signal name="row-activated" handler="show_session_subpage" swapped="yes"/>
<style>
<class name="content"/>
</style>
@ -78,7 +80,7 @@
<property name="can-shrink">true</property>
<property name="label" translatable="yes">Try Again</property>
<property name="halign">center</property>
<signal name="clicked" handler="reload_list" swapped="yes"/>
<property name="action-name">account-settings.reload-user-sessions</property>
<style>
<class name="suggested-action"/>
<class name="standalone-button"/>

302
src/session/view/account_settings/user_sessions_page/user_session_row.rs

@ -1,19 +1,7 @@
use adw::prelude::*;
use gettextrs::gettext;
use gtk::{glib, glib::clone, subclass::prelude::*, CompositeTemplate};
use tracing::error;
use url::Url;
use gtk::{glib, subclass::prelude::*, CompositeTemplate};
use super::AccountSettings;
use crate::{
components::{AuthError, LoadingButton},
gettext_f,
session::{model::UserSession, view::account_settings::AccountSettingsSubpage},
system_settings::ClockFormat,
toast,
utils::{oauth, template_callbacks::TemplateCallbacks, BoundConstructOnlyObject, BoundObject},
Application,
};
use crate::{session::model::UserSession, utils::template_callbacks::TemplateCallbacks};
mod imp {
use std::cell::RefCell;
@ -28,30 +16,14 @@ mod imp {
)]
#[properties(wrapper_type = super::UserSessionRow)]
pub struct UserSessionRow {
#[template_child]
display_name: TemplateChild<gtk::Label>,
#[template_child]
verified_icon: TemplateChild<gtk::Image>,
#[template_child]
last_seen_ip: TemplateChild<gtk::Label>,
#[template_child]
last_seen_ts: TemplateChild<gtk::Label>,
#[template_child]
loading_disconnect_button: TemplateChild<LoadingButton>,
#[template_child]
open_url_disconnect_button: TemplateChild<gtk::Button>,
/// The user session displayed by this row.
#[property(get, set = Self::set_user_session, construct_only)]
user_session: BoundObject<UserSession>,
/// The ancestor [`AccountSettings`].
#[property(get, set = Self::set_account_settings, construct_only)]
account_settings: BoundConstructOnlyObject<AccountSettings>,
system_settings_handler: RefCell<Option<glib::SignalHandlerId>>,
user_session: RefCell<Option<UserSession>>,
}
#[glib::object_subclass]
impl ObjectSubclass for UserSessionRow {
const NAME: &'static str = "AccountSettingsUserSessionRow";
const NAME: &'static str = "UserSessionRow";
type Type = super::UserSessionRow;
type ParentType = gtk::ListBoxRow;
@ -67,30 +39,7 @@ mod imp {
}
#[glib::derived_properties]
impl ObjectImpl for UserSessionRow {
fn constructed(&self) {
self.parent_constructed();
self.update_disconnect_button();
let system_settings = Application::default().system_settings();
let system_settings_handler = system_settings.connect_clock_format_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_last_seen_ts();
}
));
self.system_settings_handler
.replace(Some(system_settings_handler));
}
fn dispose(&self) {
if let Some(handler) = self.system_settings_handler.take() {
Application::default().system_settings().disconnect(handler);
}
}
}
impl ObjectImpl for UserSessionRow {}
impl WidgetImpl for UserSessionRow {}
impl ListBoxRowImpl for UserSessionRow {}
@ -101,245 +50,9 @@ mod imp {
fn set_user_session(&self, user_session: UserSession) {
let obj = self.obj();
obj.set_tooltip_text(Some(user_session.device_id().as_str()));
let disconnect_label = if user_session.is_current() {
gettext("Log Out")
} else {
gettext("Disconnect Session")
};
self.loading_disconnect_button
.set_content_label(disconnect_label);
let last_seen_ts_handler = user_session.connect_last_seen_ts_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_last_seen_ts();
}
));
self.user_session
.set(user_session, vec![last_seen_ts_handler]);
self.user_session.replace(Some(user_session));
obj.notify_user_session();
self.update_last_seen_ts();
}
/// Update the last seen timestamp according to the current user session
/// and clock format setting.
fn update_last_seen_ts(&self) {
let Some(datetime) = self.user_session.obj().and_then(|s| s.last_seen_datetime())
else {
self.last_seen_ts.set_visible(false);
return;
};
let clock_format = Application::default().system_settings().clock_format();
let use_24 = clock_format == ClockFormat::TwentyFourHours;
// This was ported from Nautilus and simplified for our use case.
// See: https://gitlab.gnome.org/GNOME/nautilus/-/blob/1c5bd3614a35cfbb49de087bc10381cdef5a218f/src/nautilus-file.c#L5001
let now = glib::DateTime::now_local().unwrap();
let format;
let days_ago = {
let today_midnight = glib::DateTime::from_local(
now.year(),
now.month(),
now.day_of_month(),
0,
0,
0f64,
)
.expect("constructing GDateTime works");
let date = glib::DateTime::from_local(
datetime.year(),
datetime.month(),
datetime.day_of_month(),
0,
0,
0f64,
)
.expect("constructing GDateTime works");
today_midnight.difference(&date).as_days()
};
// Show only the time if date is on today
if days_ago == 0 {
if use_24 {
// Translators: Time in 24h format, i.e. "23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
format = gettext("Last seen at %H:%M");
} else {
// Translators: Time in 12h format, i.e. "11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
format = gettext("Last seen at %I:%M %p");
}
}
// Show the word "Yesterday" and time if date is on yesterday
else if days_ago == 1 {
if use_24 {
// Translators: this a time in 24h format, i.e. "Last seen yesterday at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen yesterday at %H:%M");
} else {
// Translators: this is a time in 12h format, i.e. "Last seen Yesterday at 11:04
// PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen yesterday at %I:%M %p");
}
}
// Show a week day and time if date is in the last week
else if days_ago > 1 && days_ago < 7 {
if use_24 {
// Translators: this is the name of the week day followed by a time in 24h
// format, i.e. "Last seen Monday at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %A at %H:%M");
} else {
// Translators: this is the week day name followed by a time in 12h format, i.e.
// "Last seen Monday at 11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %A at %I:%M %p");
}
} else if datetime.year() == now.year() {
if use_24 {
// Translators: this is the month and day and the time in 24h format, i.e. "Last
// seen February 3 at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e at %H:%M");
} else {
// Translators: this is the month and day and the time in 12h format, i.e. "Last
// seen February 3 at 11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e at %I:%M %p");
}
} else if use_24 {
// Translators: this is the full date and the time in 24h format, i.e. "Last
// seen February 3 2015 at 23:04".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e %Y at %H:%M");
} else {
// Translators: this is the full date and the time in 12h format, i.e. "Last
// seen February 3 2015 at 11:04 PM".
// Do not change the time format as it will follow the system settings.
// See `man strftime` or the documentation of g_date_time_format for the available specifiers: <https://docs.gtk.org/glib/method.DateTime.format.html>
// xgettext:no-c-format
format = gettext("Last seen %B %-e %Y at %I:%M %p");
}
let label = datetime
.format(&format)
.expect("formatting GDateTime works");
self.last_seen_ts.set_label(&label);
self.last_seen_ts.set_visible(true);
}
/// Set the ancestor [`AccountSettings`].
fn set_account_settings(&self, account_settings: AccountSettings) {
let handler = account_settings.connect_account_management_url_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_disconnect_button();
}
));
self.account_settings.set(account_settings, vec![handler]);
}
/// The account management URL of the authentication issuer, if any.
fn account_management_url(&self) -> Option<Url> {
self.account_settings.obj().account_management_url()
}
/// Update the visible disconnect button.
fn update_disconnect_button(&self) {
let Some(user_session) = self.user_session.obj() else {
return;
};
let use_account_management_url =
!user_session.is_current() && self.account_management_url().is_some();
self.loading_disconnect_button
.set_visible(!use_account_management_url);
self.open_url_disconnect_button
.set_visible(use_account_management_url);
}
/// Disconnect the user session by making a request to the homeserver.
#[template_callback]
async fn disconnect_with_request(&self) {
let Some(user_session) = self.user_session.obj() else {
return;
};
let obj = self.obj();
if user_session.is_current() {
let _ = obj.activate_action(
"account-settings.show-subpage",
Some(&AccountSettingsSubpage::LogOut.to_variant()),
);
return;
}
self.loading_disconnect_button.set_is_loading(true);
match user_session.delete(&*obj).await {
Ok(()) => obj.set_visible(false),
Err(AuthError::UserCancelled) => {}
Err(_) => {
let device_name = user_session.display_name();
// Translators: Do NOT translate the content between '{' and '}', this is a
// variable name.
let error_message = gettext_f(
"Could not disconnect device “{device_name}”",
&[("device_name", &device_name)],
);
toast!(obj, error_message);
}
}
self.loading_disconnect_button.set_is_loading(false);
}
// Open the account management URL to disconnect the session.
#[template_callback]
async fn open_disconnect_url(&self) {
let Some(device_id) = self.user_session.obj().map(|s| s.device_id().clone()) else {
return;
};
let Some(mut url) = self.account_management_url() else {
error!("Could not find open account management URL");
return;
};
oauth::AccountManagementAction::SessionEnd { device_id }
.add_to_account_management_url(&mut url);
if let Err(error) = gtk::UriLauncher::new(url.as_ref())
.launch_future(self.obj().root().and_downcast_ref::<gtk::Window>())
.await
{
error!("Could not launch account management URL: {error}");
}
}
}
}
@ -351,10 +64,9 @@ glib::wrapper! {
}
impl UserSessionRow {
pub fn new(user_session: &UserSession, account_settings: &AccountSettings) -> Self {
pub fn new(user_session: &UserSession) -> Self {
glib::Object::builder()
.property("user-session", user_session)
.property("account-settings", account_settings)
.build()
}
}

179
src/session/view/account_settings/user_sessions_page/user_session_row.ui

@ -1,127 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="AccountSettingsUserSessionRow">
<property name="activatable">False</property>
<template class="UserSessionRow" parent="GtkListBoxRow">
<property name="selectable">False</property>
<accessibility>
<relation name="labelled-by">header</relation>
</accessibility>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="halign">start</property>
<property name="spacing">6</property>
<property name="spacing">12</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<child>
<object class="GtkBox" id="header">
<property name="spacing">6</property>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="hexpand">True</property>
<property name="spacing">3</property>
<child>
<object class="GtkLabel" id="display_name">
<object class="GtkBox" id="header">
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="xalign">0.0</property>
<property name="ellipsize">end</property>
<style>
<class name="title"/>
</style>
<binding name="label">
<lookup name="display-name-or-device-id">
<lookup name="user-session">
UserSessionRow
</lookup>
</lookup>
</binding>
</object>
</child>
<child>
<object class="GtkImage">
<property name="icon-name">verified-symbolic</property>
<!-- Translators: As in 'The session is verified'. -->
<property name="tooltip-text" translatable="yes">Verified</property>
<style>
<class name="success"/>
</style>
<binding name="visible">
<lookup name="verified">
<lookup name="user-session">
UserSessionRow
</lookup>
</lookup>
</binding>
</object>
</child>
<child>
<object class="GtkImage">
<property name="icon-name">verified-warning-symbolic</property>
<!-- Translators: As in 'The session is not verified'. -->
<property name="tooltip-text" translatable="yes">Not verified</property>
<style>
<class name="error"/>
</style>
<binding name="visible">
<closure type="gboolean" function="invert_boolean">
<lookup name="verified">
<lookup name="user-session">
UserSessionRow
</lookup>
</lookup>
</closure>
</binding>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="xalign">0.0</property>
<property name="ellipsize">end</property>
<binding name="label">
<lookup name="display-name">
<lookup name="user-session">AccountSettingsUserSessionRow</lookup>
</lookup>
</binding>
<style>
<class name="heading"/>
<class name="subtitle"/>
</style>
</object>
</child>
<child>
<object class="GtkImage" id="verified_icon">
<binding name="label">
<closure type="gchararray" function="unwrap_string_or_empty">
<lookup name="last-seen-datetime-string">
<lookup name="user-session">UserSessionRow</lookup>
</lookup>
</closure>
</binding>
<binding name="visible">
<lookup name="verified">
<lookup name="user-session">AccountSettingsUserSessionRow</lookup>
</lookup>
<closure type="gboolean" function="string_not_empty">
<lookup name="last-seen-datetime-string">
<lookup name="user-session">UserSessionRow</lookup>
</lookup>
</closure>
</binding>
<property name="icon-name">verified-symbolic</property>
<!-- Translators: As in 'A verified session'. -->
<property name="tooltip-text" translatable="yes">Verified</property>
<style>
<class name="success"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel" id="last_seen_ts">
<property name="xalign">0.0</property>
<property name="ellipsize">end</property>
<style>
<class name="caption"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="last_seen_ip">
<binding name="visible">
<closure type="gboolean" function="string_not_empty">
<lookup name="last-seen-ip">
<lookup name="user-session">AccountSettingsUserSessionRow</lookup>
</lookup>
</closure>
</binding>
<property name="xalign">0.0</property>
<property name="ellipsize">end</property>
<binding name="label">
<lookup name="last-seen-ip">
<lookup name="user-session">AccountSettingsUserSessionRow</lookup>
</lookup>
</binding>
<style>
<class name="dim-label"/>
<class name="caption"/>
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="spacing">6</property>
<child>
<object class="LoadingButton" id="loading_disconnect_button">
<property name="visible">False</property>
<signal name="clicked" handler="disconnect_with_request" swapped="yes"/>
<style>
<class name="label-button"/>
<class name="destructive-action"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="open_url_disconnect_button">
<property name="visible">False</property>
<signal name="clicked" handler="open_disconnect_url" swapped="yes"/>
<style>
<class name="image-text-button"/>
<class name="destructive-action"/>
</style>
<property name="child">
<object class="GtkBox">
<property name="halign">center</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">Disconnect Session</property>
<property name="ellipsize">end</property>
<property name="mnemonic-widget">open_url_disconnect_button</property>
</object>
</child>
<child>
<object class="GtkImage">
<property name="icon-name">external-link-symbolic</property>
<property name="accessible-role">presentation</property>
<property name="valign">center</property>
</object>
</child>
</object>
</property>
</object>
</child>
<object class="GtkImage">
<property name="icon-name">go-next-symbolic</property>
<property name="accessible-role">presentation</property>
</object>
</child>
</object>

227
src/session/view/account_settings/user_sessions_page/user_session_subpage.rs

@ -0,0 +1,227 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{glib, glib::clone, CompositeTemplate};
use tracing::error;
use url::Url;
use super::AccountSettings;
use crate::{
components::{AuthError, LoadingButtonRow},
gettext_f,
session::model::UserSession,
toast,
utils::{oauth, template_callbacks::TemplateCallbacks, BoundConstructOnlyObject, BoundObject},
};
mod imp {
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/account_settings/user_sessions_page/user_session_subpage.ui"
)]
#[properties(wrapper_type = super::UserSessionSubpage)]
pub struct UserSessionSubpage {
#[template_child]
verified_status: TemplateChild<adw::ActionRow>,
#[template_child]
log_out_button: TemplateChild<adw::ButtonRow>,
#[template_child]
loading_disconnect_button: TemplateChild<LoadingButtonRow>,
#[template_child]
open_url_disconnect_button: TemplateChild<adw::ButtonRow>,
/// The user session displayed by this subpage.
#[property(get, set = Self::set_user_session, construct_only)]
user_session: BoundObject<UserSession>,
/// The ancestor [`AccountSettings`].
#[property(get, set = Self::set_account_settings, construct_only)]
account_settings: BoundConstructOnlyObject<AccountSettings>,
}
#[glib::object_subclass]
impl ObjectSubclass for UserSessionSubpage {
const NAME: &'static str = "UserSessionSubpage";
type Type = super::UserSessionSubpage;
type ParentType = adw::NavigationPage;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
TemplateCallbacks::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for UserSessionSubpage {
fn constructed(&self) {
self.parent_constructed();
self.update_disconnect_button();
}
}
impl WidgetImpl for UserSessionSubpage {}
impl NavigationPageImpl for UserSessionSubpage {}
#[gtk::template_callbacks]
impl UserSessionSubpage {
/// Set the user session displayed by this subpage.
fn set_user_session(&self, user_session: UserSession) {
let obj = self.obj();
let verified_handler = user_session.connect_verified_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_verified();
}
));
let disconnected_handler = user_session.connect_disconnected(clone!(
#[weak]
obj,
move |_| {
let _ = obj.activate_action("account-settings.close-subpage", None);
}
));
self.user_session
.set(user_session, vec![verified_handler, disconnected_handler]);
self.update_verified();
obj.notify_user_session();
}
fn update_verified(&self) {
let Some(user_session) = self.user_session.obj() else {
return;
};
self.verified_status.remove_css_class("success");
self.verified_status.remove_css_class("error");
if user_session.verified() {
// Translators: As in 'A verified session'.
self.verified_status.set_title(&gettext("Verified"));
self.verified_status.add_css_class("success");
} else {
// Translators: As in 'A verified session'.
self.verified_status.set_title(&gettext("Not verified"));
self.verified_status.add_css_class("error");
}
}
/// Set the ancestor [`AccountSettings`].
fn set_account_settings(&self, account_settings: AccountSettings) {
let handler = account_settings.connect_account_management_url_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_disconnect_button();
}
));
self.account_settings.set(account_settings, vec![handler]);
}
/// The account management URL of the authentication issuer, if any.
fn account_management_url(&self) -> Option<Url> {
self.account_settings.obj().account_management_url()
}
/// Update the visible disconnect button.
fn update_disconnect_button(&self) {
let Some(user_session) = self.user_session.obj() else {
return;
};
if user_session.is_current() {
self.log_out_button.set_visible(true);
self.loading_disconnect_button.set_visible(false);
self.open_url_disconnect_button.set_visible(false);
} else if self.account_management_url().is_some() {
self.log_out_button.set_visible(false);
self.loading_disconnect_button.set_visible(false);
self.open_url_disconnect_button.set_visible(true);
} else {
self.log_out_button.set_visible(false);
self.loading_disconnect_button.set_visible(true);
self.open_url_disconnect_button.set_visible(false);
}
}
/// Disconnect the user session by making a request to the homeserver.
#[template_callback]
async fn disconnect_with_request(&self) {
let obj = self.obj();
let Some(user_session) = self.user_session.obj() else {
return;
};
self.loading_disconnect_button.set_is_loading(true);
match user_session.delete(&*obj).await {
Ok(()) => {
let _ = obj.activate_action("account-settings.reload-user-sessions", None);
}
Err(AuthError::UserCancelled) => {
self.loading_disconnect_button.set_is_loading(false);
}
Err(_) => {
let device_name = user_session.display_name_or_device_id();
// Translators: Do NOT translate the content between '{' and '}', this is a
// variable name.
let error_message = gettext_f(
"Could not disconnect device “{device_name}”",
&[("device_name", &device_name)],
);
toast!(obj, error_message);
self.loading_disconnect_button.set_is_loading(false);
}
}
}
// Open the account management URL to disconnect the session.
#[template_callback]
async fn open_disconnect_url(&self) {
let Some(user_session) = self.user_session.obj() else {
return;
};
let device_id = user_session.device_id_string().into();
let Some(mut url) = self.account_management_url() else {
error!("Could not find open account management URL");
return;
};
oauth::AccountManagementAction::SessionEnd { device_id }
.add_to_account_management_url(&mut url);
if let Err(error) = gtk::UriLauncher::new(url.as_ref())
.launch_future(self.obj().root().and_downcast_ref::<gtk::Window>())
.await
{
error!("Could not launch account management URL: {error}");
}
}
}
}
glib::wrapper! {
/// Account settings subpage about a user session.
pub struct UserSessionSubpage(ObjectSubclass<imp::UserSessionSubpage>)
@extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
}
impl UserSessionSubpage {
pub fn new(user_session: &UserSession, account_settings: &AccountSettings) -> Self {
glib::Object::builder()
.property("user-session", user_session)
.property("account-settings", account_settings)
.build()
}
}

139
src/session/view/account_settings/user_sessions_page/user_session_subpage.ui

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="UserSessionSubpage" parent="AdwNavigationPage">
<binding name="title">
<lookup name="display-name-or-device-id">
<lookup name="user-session">
UserSessionSubpage
</lookup>
</lookup>
</binding>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="AdwPreferencesPage">
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="CopyableRow">
<property name="title" translatable="yes">Session ID</property>
<property name="main-title">subtitle</property>
<property name="copy-button-tooltip-text" translatable="yes">Copy Session ID</property>
<property name="toast-text" translatable="yes">Session ID copied to clipboard</property>
<binding name="subtitle">
<lookup name="device-id-string">
<lookup name="user-session">
UserSessionSubpage
</lookup>
</lookup>
</binding>
</object>
</child>
<child>
<object class="AdwActionRow">
<style>
<class name="property"/>
</style>
<property name="title" translatable="yes">Public Name</property>
<binding name="subtitle">
<lookup name="display-name">
<lookup name="user-session">
UserSessionSubpage
</lookup>
</lookup>
</binding>
</object>
</child>
<child>
<object class="AdwActionRow" id="verified_status"/>
</child>
<child>
<object class="AdwActionRow">
<style>
<class name="property"/>
</style>
<property name="title" translatable="yes">Last Seen</property>
<binding name="subtitle">
<closure type="gchararray" function="unwrap_string_or_empty">
<lookup name="last-seen-datetime-string">
<lookup name="user-session">UserSessionSubpage</lookup>
</lookup>
</closure>
</binding>
<binding name="visible">
<closure type="gboolean" function="string_not_empty">
<lookup name="last-seen-datetime-string">
<lookup name="user-session">UserSessionSubpage</lookup>
</lookup>
</closure>
</binding>
</object>
</child>
<child>
<object class="AdwActionRow">
<style>
<class name="property"/>
</style>
<property name="title" translatable="yes">Last Location</property>
<binding name="subtitle">
<closure type="gchararray" function="unwrap_string_or_empty">
<lookup name="last-seen-ip">
<lookup name="user-session">UserSessionSubpage</lookup>
</lookup>
</closure>
</binding>
<binding name="visible">
<closure type="gboolean" function="string_not_empty">
<lookup name="last-seen-ip">
<lookup name="user-session">UserSessionSubpage</lookup>
</lookup>
</closure>
</binding>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwButtonRow" id="log_out_button">
<property name="title" translatable="yes">Log Out</property>
<property name="end-icon-name">go-next-symbolic</property>
<property name="action-name">account-settings.show-subpage</property>
<property name="action-target">'log-out'</property>
<style>
<class name="destructive-action"/>
</style>
</object>
</child>
<child>
<object class="LoadingButtonRow" id="loading_disconnect_button">
<property name="title" translatable="yes">Disconnect</property>
<style>
<class name="destructive-action"/>
</style>
<signal name="activated" handler="disconnect_with_request" swapped="yes"/>
</object>
</child>
<child>
<object class="AdwButtonRow" id="open_url_disconnect_button">
<property name="visible">False</property>
<property name="title" translatable="yes">Disconnect</property>
<property name="end-icon-name">external-link-symbolic</property>
<style>
<class name="destructive-action"/>
</style>
<signal name="activated" handler="open_disconnect_url" swapped="yes"/>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</template>
</interface>

1
src/ui-resources.gresource.xml

@ -74,6 +74,7 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/security_page/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/user_sessions_page/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/user_sessions_page/user_session_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/user_sessions_page/user_session_subpage.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/explore/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/explore/public_room_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/explore/server_row.ui</file>

Loading…
Cancel
Save