Browse Source

session: Cache room and user profile in memory

Reduces the number of requests to the homeserver.
merge-requests/2003/head
Kévin Commaille 11 months ago
parent
commit
efe9189d76
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 39
      Cargo.lock
  2. 1
      Cargo.toml
  3. 4
      src/components/dialogs/room_preview.rs
  4. 68
      src/components/dialogs/user_profile.rs
  5. 6
      src/session/model/mod.rs
  6. 108
      src/session/model/remote/cache.rs
  7. 5
      src/session/model/remote/mod.rs
  8. 472
      src/session/model/remote/room.rs
  9. 155
      src/session/model/remote/user.rs
  10. 293
      src/session/model/remote_room.rs
  11. 67
      src/session/model/remote_user.rs
  12. 6
      src/session/model/room/join_rule.rs
  13. 17
      src/session/model/session.rs
  14. 9
      src/session/view/content/room_details/invite_subpage/list.rs
  15. 16
      src/session/view/content/room_details/permissions/privileged_members.rs
  16. 10
      src/utils/matrix/mod.rs
  17. 42
      src/utils/mod.rs

39
Cargo.lock generated

@ -392,6 +392,12 @@ version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
[[package]]
name = "bit-vec"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -450,6 +456,17 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bloomfilter"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c541c70a910b485670304fd420f0eab8f7bde68439db6a8d98819c3d2774d7e2"
dependencies = [
"bit-vec",
"getrandom 0.2.15",
"siphasher",
]
[[package]]
name = "blurhash"
version = "0.2.3"
@ -653,6 +670,16 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "count-min-sketch"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fef0a447ef2e9e6bd57e379f88702c58c4a4253ba82fb175bd7db012192311a"
dependencies = [
"rand 0.8.5",
"siphasher",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@ -1265,6 +1292,7 @@ dependencies = [
"tracing-subscriber",
"url",
"webp",
"wtinylfu",
"zeroize",
]
@ -5836,6 +5864,17 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "wtinylfu"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60467db864b7ccbac14ff59595c4e24945243508683f49a4e21eaf747399544f"
dependencies = [
"bloomfilter",
"count-min-sketch",
"lru",
]
[[package]]
name = "x25519-dalek"
version = "2.0.1"

1
Cargo.toml

@ -55,6 +55,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
webp = { version = "0.3", default-features = false }
wtinylfu = "0.2"
zeroize = "1"
# gtk-rs project and dependents. These usually need to be updated together.

4
src/components/dialogs/room_preview.rs

@ -246,7 +246,7 @@ mod imp {
self.go_back_btn.set_sensitive(true);
self.join_btn.set_is_loading(false);
let room = RemoteRoom::new(&session, uri);
let room = session.remote_cache().room(uri);
self.set_room(Some(room));
}
@ -258,7 +258,7 @@ mod imp {
self.room_name.set_label(&room.display_name());
let alias = room.alias();
let alias = room.canonical_alias();
if let Some(alias) = &alias {
self.room_alias.set_label(alias.as_str());
}

68
src/components/dialogs/user_profile.rs

@ -6,11 +6,13 @@ use super::ToastableDialog;
use crate::{
components::UserPage,
prelude::*,
session::model::{Member, RemoteUser, Session, User},
spawn,
session::model::{Member, Session, User},
utils::LoadingState,
};
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
@ -22,6 +24,7 @@ mod imp {
stack: TemplateChild<gtk::Stack>,
#[template_child]
user_page: TemplateChild<UserPage>,
user_loading_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
@ -39,31 +42,70 @@ mod imp {
}
}
impl ObjectImpl for UserProfileDialog {}
impl ObjectImpl for UserProfileDialog {
fn dispose(&self) {
self.reset();
}
}
impl WidgetImpl for UserProfileDialog {}
impl AdwDialogImpl for UserProfileDialog {}
impl ToastableDialogImpl for UserProfileDialog {}
impl UserProfileDialog {
/// Show the details page.
fn show_details(&self) {
self.stack.set_visible_child_name("details");
}
/// Load the user with the given session and user ID.
pub(super) fn load_user(&self, session: &Session, user_id: OwnedUserId) {
let user = RemoteUser::new(session, user_id);
self.reset();
let user = session.remote_cache().user(user_id);
self.user_page.set_user(Some(user.clone()));
spawn!(clone!(
#[weak(rename_to = imp)]
self,
async move {
user.load_profile().await;
imp.stack.set_visible_child_name("details");
}
));
if matches!(
user.loading_state(),
LoadingState::Initial | LoadingState::Loading
) {
let user_loading_handler = user.connect_loading_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |user| {
if !matches!(
user.loading_state(),
LoadingState::Initial | LoadingState::Loading
) {
if let Some(handler) = imp.user_loading_handler.take() {
user.disconnect(handler);
imp.show_details();
}
}
}
));
self.user_loading_handler
.replace(Some(user_loading_handler));
} else {
self.show_details();
}
}
/// Set the member to present.
pub(super) fn set_room_member(&self, member: Member) {
self.reset();
self.user_page.set_user(Some(member.upcast::<User>()));
self.stack.set_visible_child_name("details");
self.show_details();
}
/// Reset this dialog.
fn reset(&self) {
if let Some(handler) = self.user_loading_handler.take() {
if let Some(user) = self.user_page.user() {
user.disconnect(handler);
}
}
}
}
}

6
src/session/model/mod.rs

@ -1,7 +1,6 @@
mod ignored_users;
mod notifications;
mod remote_room;
mod remote_user;
mod remote;
mod room;
mod room_list;
mod security;
@ -17,8 +16,7 @@ pub(crate) use self::{
notifications::{
Notifications, NotificationsGlobalSetting, NotificationsRoomSetting, NotificationsSettings,
},
remote_room::RemoteRoom,
remote_user::RemoteUser,
remote::*,
room::*,
room_list::RoomList,
security::*,

108
src/session/model/remote/cache.rs

@ -0,0 +1,108 @@
use std::{cell::RefCell, fmt, rc::Rc};
use ruma::{OwnedRoomOrAliasId, OwnedUserId, RoomId};
use wtinylfu::WTinyLfuCache;
use super::{RemoteRoom, RemoteUser};
use crate::{session::model::Session, utils::matrix::MatrixRoomIdUri};
/// The data of the [`RemoteCache`].
struct RemoteCacheData {
/// Remote rooms.
rooms: RefCell<WTinyLfuCache<OwnedRoomOrAliasId, RemoteRoom>>,
/// Remote users.
users: RefCell<WTinyLfuCache<OwnedUserId, RemoteUser>>,
}
/// An API to query remote data and cache it.
#[derive(Clone)]
pub(crate) struct RemoteCache {
session: Session,
data: Rc<RemoteCacheData>,
}
impl RemoteCache {
/// Construct a new `RemoteCache` for the given session.
pub(crate) fn new(session: Session) -> Self {
Self {
session,
data: RemoteCacheData {
rooms: WTinyLfuCache::new(30, 10).into(),
users: WTinyLfuCache::new(30, 10).into(),
}
.into(),
}
}
/// Get the remote room for the given URI.
pub(crate) fn room(&self, uri: MatrixRoomIdUri) -> RemoteRoom {
let mut rooms = self.data.rooms.borrow_mut();
// Check if the room is in the cache.
if let Some(room) = rooms.get(&uri.id) {
room.load_data_if_stale();
return room.clone();
}
// Check if the alias or ID matches a room in the cache, in case the URI uses
// another ID than the one we used as a key for the cache.
let mut found_id = None;
let id_or_alias = <&RoomId>::try_from(&*uri.id);
for (id, room) in rooms.iter() {
match id_or_alias {
Ok(room_id) => {
if room.room_id().is_some_and(|id| id == room_id) {
found_id = Some(id.clone());
break;
}
}
Err(room_alias) => {
if room
.canonical_alias()
.is_some_and(|alias| alias == room_alias)
{
found_id = Some(id.clone());
break;
}
}
}
}
if let Some(id) = found_id {
let room = rooms.get(&id).expect("room should be in cache");
room.load_data_if_stale();
return room.clone();
}
// We did not find it, create the room.
let id = uri.id.clone();
let room = RemoteRoom::new(&self.session, uri);
rooms.push(id, room.clone());
room
}
/// Get the remote user for the given ID.
pub(crate) fn user(&self, user_id: OwnedUserId) -> RemoteUser {
let mut users = self.data.users.borrow_mut();
// Check if the user is in the cache.
if let Some(user) = users.get(&user_id) {
user.load_profile_if_stale();
return user.clone();
}
// We did not find it, create the user.
let user = RemoteUser::new(&self.session, user_id.clone());
users.push(user_id, user.clone());
user
}
}
impl fmt::Debug for RemoteCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteCache").finish_non_exhaustive()
}
}

5
src/session/model/remote/mod.rs

@ -0,0 +1,5 @@
mod cache;
mod room;
mod user;
pub(crate) use self::{cache::*, room::*, user::*};

472
src/session/model/remote/room.rs

@ -0,0 +1,472 @@
use std::{cell::RefCell, time::Duration};
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
use matrix_sdk::reqwest::StatusCode;
use ruma::{
api::client::{
room::get_summary,
space::{get_hierarchy, SpaceHierarchyRoomsChunk},
},
assign, uint, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId,
};
use tracing::{debug, warn};
use crate::{
components::{AvatarImage, AvatarUriSource, PillSource},
prelude::*,
session::model::Session,
spawn, spawn_tokio,
utils::{matrix::MatrixRoomIdUri, string::linkify, AbortableHandle, LoadingState},
};
/// The time after which the data of a room is assumed to be stale.
///
/// This matches 1 day.
const DATA_VALIDITY_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
mod imp {
use std::{
cell::{Cell, OnceCell},
time::Instant,
};
use super::*;
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::RemoteRoom)]
pub struct RemoteRoom {
/// The current session.
#[property(get, set = Self::set_session, construct_only)]
session: glib::WeakRef<Session>,
/// The Matrix URI of this room.
uri: OnceCell<MatrixRoomIdUri>,
/// The ID of this room.
room_id: RefCell<Option<OwnedRoomId>>,
/// The canonical alias of this room.
canonical_alias: RefCell<Option<OwnedRoomAliasId>>,
/// The name that is set for this room.
///
/// This can be empty, the display name should be used instead in the
/// interface.
#[property(get)]
name: RefCell<Option<String>>,
/// The topic of this room.
#[property(get)]
topic: RefCell<Option<String>>,
/// The linkified topic of this room.
///
/// This is the string that should be used in the interface when markup
/// is allowed.
#[property(get)]
topic_linkified: RefCell<Option<String>>,
/// The number of joined members in the room.
#[property(get)]
joined_members_count: Cell<u32>,
/// The loading state.
#[property(get, builder(LoadingState::default()))]
loading_state: Cell<LoadingState>,
// The time of the last request.
last_request_time: Cell<Option<Instant>>,
request_abort_handle: AbortableHandle,
}
#[glib::object_subclass]
impl ObjectSubclass for RemoteRoom {
const NAME: &'static str = "RemoteRoom";
type Type = super::RemoteRoom;
type ParentType = PillSource;
}
#[glib::derived_properties]
impl ObjectImpl for RemoteRoom {}
impl PillSourceImpl for RemoteRoom {
fn identifier(&self) -> String {
self.uri.get().unwrap().id.to_string()
}
}
impl RemoteRoom {
/// Set the current session.
fn set_session(&self, session: &Session) {
self.session.set(Some(session));
self.obj().avatar_data().set_image(Some(AvatarImage::new(
session,
AvatarUriSource::Room,
None,
None,
)));
}
/// Set the Matrix URI of this room.
pub(super) fn set_uri(&self, uri: MatrixRoomIdUri) {
if let Ok(room_id) = uri.id.clone().try_into() {
self.set_room_id(room_id);
}
self.uri
.set(uri)
.expect("Matrix URI should be uninitialized");
self.update_display_name();
}
/// The Matrix URI of this room.
pub(super) fn uri(&self) -> &MatrixRoomIdUri {
self.uri.get().expect("Matrix URI should be initialized")
}
/// Set the ID of this room.
fn set_room_id(&self, room_id: OwnedRoomId) {
self.room_id.replace(Some(room_id));
}
/// The ID of this room.
pub(super) fn room_id(&self) -> Option<OwnedRoomId> {
self.room_id.borrow().clone()
}
/// Set the canonical alias of this room.
fn set_canonical_alias(&self, alias: Option<OwnedRoomAliasId>) {
if *self.canonical_alias.borrow() == alias {
return;
}
self.canonical_alias.replace(alias);
self.update_display_name();
}
/// The canonical alias of this room.
pub(super) fn canonical_alias(&self) -> Option<OwnedRoomAliasId> {
self.canonical_alias
.borrow()
.clone()
.or_else(|| self.uri().id.clone().try_into().ok())
}
/// Set the name of this room.
fn set_name(&self, name: Option<String>) {
if *self.name.borrow() == name {
return;
}
self.name.replace(name);
self.obj().notify_name();
self.update_display_name();
}
/// The display name of this room.
pub(super) fn update_display_name(&self) {
let display_name = self
.name
.borrow()
.clone()
.or_else(|| {
self.canonical_alias
.borrow()
.as_ref()
.map(ToString::to_string)
})
.unwrap_or_else(|| self.identifier());
self.obj().set_display_name(display_name);
}
/// Set the topic of this room.
fn set_topic(&self, topic: Option<String>) {
let topic =
topic.filter(|s| !s.is_empty() && s.find(|c: char| !c.is_whitespace()).is_some());
if *self.topic.borrow() == topic {
return;
}
let topic_linkified = topic.as_deref().map(|t| {
// Detect links.
let mut s = linkify(t);
// Remove trailing spaces.
s.truncate_end_whitespaces();
s
});
self.topic.replace(topic);
self.topic_linkified.replace(topic_linkified);
let obj = self.obj();
obj.notify_topic();
obj.notify_topic_linkified();
}
/// Set the loading state.
fn set_joined_members_count(&self, count: u32) {
if self.joined_members_count.get() == count {
return;
}
self.joined_members_count.set(count);
self.obj().notify_joined_members_count();
}
/// Set the loading state.
pub(super) fn set_loading_state(&self, loading_state: LoadingState) {
if self.loading_state.get() == loading_state {
return;
}
self.loading_state.set(loading_state);
if loading_state == LoadingState::Error {
// Reset the request time so we try it again the next time.
self.last_request_time.take();
}
self.obj().notify_loading_state();
}
/// Set the room data.
pub(super) fn set_data(&self, data: RemoteRoomData) {
self.set_room_id(data.room_id);
self.set_canonical_alias(data.canonical_alias);
self.set_name(data.name);
self.set_topic(data.topic);
self.set_joined_members_count(data.joined_members_count);
if let Some(image) = self.obj().avatar_data().image() {
image.set_uri_and_info(data.avatar_url, None);
}
self.set_loading_state(LoadingState::Ready);
}
/// Whether the data of the room is considered to be stale.
pub(super) fn is_data_stale(&self) -> bool {
self.last_request_time
.get()
.is_none_or(|last_time| last_time.elapsed() > DATA_VALIDITY_DURATION)
}
/// Update the last request time to now.
pub(super) fn update_last_request_time(&self) {
self.last_request_time.set(Some(Instant::now()));
}
/// Request the data of this room.
pub(super) async fn load_data(&self) {
let Some(session) = self.session.upgrade() else {
self.last_request_time.take();
return;
};
self.set_loading_state(LoadingState::Loading);
// Try to load data from the summary endpoint first, and if it is not supported
// try the space hierarchy endpoint.
if !self.load_data_from_summary(&session).await {
self.load_data_from_space_hierarchy(&session).await;
}
}
/// Load the data of this room using the room summary endpoint.
///
/// At the time of writing this code, MSC3266 has been accepted but the
/// endpoint is not part of a Matrix spec release.
///
/// Returns `false` if the endpoint is not supported by the homeserver.
async fn load_data_from_summary(&self, session: &Session) -> bool {
let uri = self.uri();
let client = session.client();
let request = get_summary::msc3266::Request::new(uri.id.clone(), uri.via.clone());
let handle = spawn_tokio!(async move { client.send(request).await });
let Some(result) = self.request_abort_handle.await_task(handle).await else {
// The task was aborted, which means that the object was dropped.
return true;
};
match result {
Ok(response) => {
self.set_data(response.into());
true
}
Err(error) => {
if error
.as_client_api_error()
.is_some_and(|error| error.status_code == StatusCode::NOT_FOUND)
{
return false;
}
warn!(
"Could not get room details from summary endpoint for room `{}`: {error}",
uri.id
);
self.set_loading_state(LoadingState::Error);
true
}
}
}
/// Load the data of this room using the space hierarchy endpoint.
///
/// This endpoint should work for any room already known by the
/// homeserver.
async fn load_data_from_space_hierarchy(&self, session: &Session) {
let uri = self.uri();
let client = session.client();
// The endpoint only works with a room ID.
let room_id = match OwnedRoomId::try_from(uri.id.clone()) {
Ok(room_id) => room_id,
Err(alias) => {
let client_clone = client.clone();
let handle =
spawn_tokio!(async move { client_clone.resolve_room_alias(&alias).await });
let Some(result) = self.request_abort_handle.await_task(handle).await else {
// The task was aborted, which means that the object was dropped.
return;
};
match result {
Ok(response) => response.room_id,
Err(error) => {
warn!("Could not resolve room alias `{}`: {error}", uri.id);
self.set_loading_state(LoadingState::Error);
return;
}
}
}
};
let request = assign!(get_hierarchy::v1::Request::new(room_id.clone()), {
// We are only interested in the single room.
limit: Some(uint!(1))
});
let handle = spawn_tokio!(async move { client.send(request).await });
let Some(result) = self.request_abort_handle.await_task(handle).await else {
// The task was aborted, which means that the object was dropped.
return;
};
match result {
Ok(response) => {
if let Some(chunk) = response
.rooms
.into_iter()
.next()
.filter(|c| c.room_id == room_id)
{
self.set_data(chunk.into());
} else {
debug!("Space hierarchy endpoint did not return requested room");
self.set_loading_state(LoadingState::Error);
}
}
Err(error) => {
warn!(
"Could not get room details from space hierarchy endpoint for room `{}`: {error}",
uri.id
);
self.set_loading_state(LoadingState::Error);
}
}
}
}
}
glib::wrapper! {
/// A Room that can only be updated by making remote calls, i.e. it won't be updated via sync.
pub struct RemoteRoom(ObjectSubclass<imp::RemoteRoom>)
@extends PillSource;
}
impl RemoteRoom {
pub(super) fn new(session: &Session, uri: MatrixRoomIdUri) -> Self {
let obj = glib::Object::builder::<Self>()
.property("session", session)
.build();
obj.imp().set_uri(uri);
obj.load_data_if_stale();
obj
}
/// The Matrix URI of this room.
pub(crate) fn uri(&self) -> &MatrixRoomIdUri {
self.imp().uri()
}
/// The ID of this room.
pub(crate) fn room_id(&self) -> Option<OwnedRoomId> {
self.imp().room_id()
}
/// The canonical alias of this room.
pub(crate) fn canonical_alias(&self) -> Option<OwnedRoomAliasId> {
self.imp().canonical_alias()
}
/// Load the data of this room if it is considered to be stale.
pub(super) fn load_data_if_stale(&self) {
let imp = self.imp();
if !imp.is_data_stale() {
// The data is still valid, nothing to do.
return;
}
// Set the request time right away, to prevent several requests at the same
// time.
imp.update_last_request_time();
spawn!(clone!(
#[weak]
imp,
async move {
imp.load_data().await;
}
));
}
}
/// The remote room data.
#[derive(Debug)]
struct RemoteRoomData {
room_id: OwnedRoomId,
canonical_alias: Option<OwnedRoomAliasId>,
name: Option<String>,
topic: Option<String>,
avatar_url: Option<OwnedMxcUri>,
joined_members_count: u32,
}
impl From<get_summary::msc3266::Response> for RemoteRoomData {
fn from(value: get_summary::msc3266::Response) -> Self {
Self {
room_id: value.room_id,
canonical_alias: value.canonical_alias,
name: value.name,
topic: value.topic,
avatar_url: value.avatar_url,
joined_members_count: value.num_joined_members.try_into().unwrap_or(u32::MAX),
}
}
}
impl From<SpaceHierarchyRoomsChunk> for RemoteRoomData {
fn from(value: SpaceHierarchyRoomsChunk) -> Self {
Self {
room_id: value.room_id,
canonical_alias: value.canonical_alias,
name: value.name,
topic: value.topic,
avatar_url: value.avatar_url,
joined_members_count: value.num_joined_members.try_into().unwrap_or(u32::MAX),
}
}
}

155
src/session/model/remote/user.rs

@ -0,0 +1,155 @@
use std::time::{Duration, Instant};
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
use matrix_sdk::ruma::OwnedUserId;
use tracing::error;
use crate::{
components::PillSource,
prelude::*,
session::model::{Session, User},
spawn, spawn_tokio,
utils::{AbortableHandle, LoadingState},
};
/// The time after which the profile of a user is assumed to be stale.
///
/// This matches 1 hour.
const PROFILE_VALIDITY_DURATION: Duration = Duration::from_secs(60 * 60);
mod imp {
use std::cell::Cell;
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::RemoteUser)]
pub struct RemoteUser {
// The loading state of the profile.
#[property(get, builder(LoadingState::default()))]
loading_state: Cell<LoadingState>,
// The time of the last request.
last_request_time: Cell<Option<Instant>>,
request_abort_handle: AbortableHandle,
}
#[glib::object_subclass]
impl ObjectSubclass for RemoteUser {
const NAME: &'static str = "RemoteUser";
type Type = super::RemoteUser;
type ParentType = User;
}
#[glib::derived_properties]
impl ObjectImpl for RemoteUser {}
impl PillSourceImpl for RemoteUser {
fn identifier(&self) -> String {
self.obj().upcast_ref::<User>().user_id_string()
}
}
impl RemoteUser {
/// Set the loading state.
pub(super) fn set_loading_state(&self, loading_state: LoadingState) {
if self.loading_state.get() == loading_state {
return;
}
self.loading_state.set(loading_state);
if loading_state == LoadingState::Error {
// Reset the request time so we try it again the next time.
self.last_request_time.take();
}
self.obj().notify_loading_state();
}
/// Whether the profile of the user is considered to be stale.
pub(super) fn is_profile_stale(&self) -> bool {
self.last_request_time
.get()
.is_none_or(|last_time| last_time.elapsed() > PROFILE_VALIDITY_DURATION)
}
/// Update the last request time to now.
pub(super) fn update_last_request_time(&self) {
self.last_request_time.set(Some(Instant::now()));
}
/// Request the profile of this user.
pub(super) async fn load_profile(&self) {
let obj = self.obj();
self.set_loading_state(LoadingState::Loading);
let user_id = obj.user_id();
let client = obj.session().client();
let user_id_clone = user_id.clone();
let handle = spawn_tokio!(async move {
client.account().fetch_user_profile_of(&user_id_clone).await
});
let Some(result) = self.request_abort_handle.await_task(handle).await else {
// The task was aborted, which means that the object was dropped.
return;
};
let profile = match result {
Ok(profile) => profile,
Err(error) => {
error!("Could not load profile for user `{user_id}`: {error}");
self.set_loading_state(LoadingState::Error);
return;
}
};
obj.set_name(profile.displayname);
obj.set_avatar_url(profile.avatar_url);
self.set_loading_state(LoadingState::Ready);
}
}
}
glib::wrapper! {
/// A User that can only be updated by making remote calls, i.e. it won't be updated via sync.
pub struct RemoteUser(ObjectSubclass<imp::RemoteUser>) @extends PillSource, User;
}
impl RemoteUser {
pub(super) fn new(session: &Session, user_id: OwnedUserId) -> Self {
let obj = glib::Object::builder::<Self>()
.property("session", session)
.build();
obj.upcast_ref::<User>().imp().set_user_id(user_id);
obj.load_profile_if_stale();
obj
}
/// Request this user's profile from the homeserver if it is considered to
/// be stale.
pub(super) fn load_profile_if_stale(&self) {
let imp = self.imp();
if !imp.is_profile_stale() {
// The data is still valid, nothing to do.
return;
}
// Set the request time right away, to prevent several requests at the same
// time.
imp.update_last_request_time();
spawn!(clone!(
#[weak]
imp,
async move {
imp.load_profile().await;
}
));
}
}

293
src/session/model/remote_room.rs

@ -1,293 +0,0 @@
use std::cell::RefCell;
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
use ruma::{
api::client::space::{get_hierarchy, SpaceHierarchyRoomsChunk},
assign, uint, OwnedRoomAliasId, OwnedRoomId,
};
use tracing::{debug, warn};
use super::Session;
use crate::{
components::{AvatarImage, AvatarUriSource, PillSource},
prelude::*,
spawn, spawn_tokio,
utils::{matrix::MatrixRoomIdUri, string::linkify, LoadingState},
};
mod imp {
use std::cell::{Cell, OnceCell};
use super::*;
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::RemoteRoom)]
pub struct RemoteRoom {
/// The current session.
#[property(get, set = Self::set_session, construct_only)]
session: glib::WeakRef<Session>,
/// The Matrix URI of this room.
uri: OnceCell<MatrixRoomIdUri>,
/// The canonical alias of this room.
alias: RefCell<Option<OwnedRoomAliasId>>,
/// The name that is set for this room.
///
/// This can be empty, the display name should be used instead in the
/// interface.
#[property(get)]
name: RefCell<Option<String>>,
/// The topic of this room.
#[property(get)]
topic: RefCell<Option<String>>,
/// The linkified topic of this room.
///
/// This is the string that should be used in the interface when markup
/// is allowed.
#[property(get)]
topic_linkified: RefCell<Option<String>>,
/// The number of joined members in the room.
#[property(get)]
joined_members_count: Cell<u32>,
/// The loading state.
#[property(get, builder(LoadingState::default()))]
loading_state: Cell<LoadingState>,
}
#[glib::object_subclass]
impl ObjectSubclass for RemoteRoom {
const NAME: &'static str = "RemoteRoom";
type Type = super::RemoteRoom;
type ParentType = PillSource;
}
#[glib::derived_properties]
impl ObjectImpl for RemoteRoom {}
impl PillSourceImpl for RemoteRoom {
fn identifier(&self) -> String {
self.uri.get().unwrap().id.to_string()
}
}
impl RemoteRoom {
/// Set the current session.
fn set_session(&self, session: &Session) {
self.session.set(Some(session));
self.obj().avatar_data().set_image(Some(AvatarImage::new(
session,
AvatarUriSource::Room,
None,
None,
)));
}
/// Set the Matrix URI of this room.
pub(super) fn set_uri(&self, uri: MatrixRoomIdUri) {
self.uri
.set(uri)
.expect("Matrix URI should be uninitialized");
self.update_display_name();
spawn!(clone!(
#[weak(rename_to = imp)]
self,
async move {
imp.load().await;
}
));
}
/// The Matrix URI of this room.
pub(super) fn uri(&self) -> &MatrixRoomIdUri {
self.uri.get().expect("Matrix URI should be initialized")
}
/// Set the alias of this room.
fn set_alias(&self, alias: Option<OwnedRoomAliasId>) {
if *self.alias.borrow() == alias {
return;
}
self.alias.replace(alias);
self.update_display_name();
}
/// The canonical alias of this room.
pub(super) fn alias(&self) -> Option<OwnedRoomAliasId> {
self.alias
.borrow()
.clone()
.or_else(|| self.uri().id.clone().try_into().ok())
}
/// Set the name of this room.
fn set_name(&self, name: Option<String>) {
if *self.name.borrow() == name {
return;
}
self.name.replace(name);
self.obj().notify_name();
self.update_display_name();
}
/// The display name of this room.
pub(super) fn update_display_name(&self) {
let display_name = self
.name
.borrow()
.clone()
.or_else(|| self.alias.borrow().as_ref().map(ToString::to_string))
.unwrap_or_else(|| self.identifier());
self.obj().set_display_name(display_name);
}
/// Set the topic of this room.
fn set_topic(&self, topic: Option<String>) {
let topic =
topic.filter(|s| !s.is_empty() && s.find(|c: char| !c.is_whitespace()).is_some());
if *self.topic.borrow() == topic {
return;
}
let topic_linkified = topic.as_deref().map(|t| {
// Detect links.
let mut s = linkify(t);
// Remove trailing spaces.
s.truncate_end_whitespaces();
s
});
self.topic.replace(topic);
self.topic_linkified.replace(topic_linkified);
let obj = self.obj();
obj.notify_topic();
obj.notify_topic_linkified();
}
/// Set the loading state.
fn set_joined_members_count(&self, count: u32) {
if self.joined_members_count.get() == count {
return;
}
self.joined_members_count.set(count);
self.obj().notify_joined_members_count();
}
/// Set the loading state.
pub(super) fn set_loading_state(&self, loading_state: LoadingState) {
if self.loading_state.get() == loading_state {
return;
}
self.loading_state.set(loading_state);
self.obj().notify_loading_state();
}
/// Update the room data with the given response.
pub(super) fn update_data(&self, data: SpaceHierarchyRoomsChunk) {
self.set_alias(data.canonical_alias);
self.set_name(data.name);
self.set_topic(data.topic);
self.set_joined_members_count(data.num_joined_members.try_into().unwrap_or(u32::MAX));
if let Some(image) = self.obj().avatar_data().image() {
image.set_uri_and_info(data.avatar_url, None);
}
self.set_loading_state(LoadingState::Ready);
}
/// Load the data of this room.
async fn load(&self) {
let Some(session) = self.session.upgrade() else {
return;
};
self.set_loading_state(LoadingState::Loading);
let uri = self.uri();
let client = session.client();
let room_id = match OwnedRoomId::try_from(uri.id.clone()) {
Ok(room_id) => room_id,
Err(alias) => {
let client_clone = client.clone();
let handle =
spawn_tokio!(async move { client_clone.resolve_room_alias(&alias).await });
match handle.await.unwrap() {
Ok(response) => response.room_id,
Err(error) => {
warn!("Could not resolve room alias `{}`: {error}", uri.id);
self.set_loading_state(LoadingState::Error);
return;
}
}
}
};
// FIXME: The space hierarchy endpoint gives us the room details we want, but it
// doesn't work if the room is not known by the homeserver. We need MSC3266 for
// a proper endpoint.
let request = assign!(get_hierarchy::v1::Request::new(room_id.clone()), {
// We are only interested in the single room.
limit: Some(uint!(1))
});
let handle = spawn_tokio!(async move { client.send(request).await });
match handle.await.unwrap() {
Ok(response) => {
if let Some(chunk) = response
.rooms
.into_iter()
.next()
.filter(|c| c.room_id == room_id)
{
self.update_data(chunk);
} else {
debug!("Endpoint did not return requested room");
self.set_loading_state(LoadingState::Error);
}
}
Err(error) => {
warn!("Could not get room details for room `{}`: {error}", uri.id);
self.set_loading_state(LoadingState::Error);
}
}
}
}
}
glib::wrapper! {
/// A Room that can only be updated by making remote calls, i.e. it won't be updated via sync.
pub struct RemoteRoom(ObjectSubclass<imp::RemoteRoom>)
@extends PillSource;
}
impl RemoteRoom {
pub(crate) fn new(session: &Session, uri: MatrixRoomIdUri) -> Self {
let obj = glib::Object::builder::<Self>()
.property("session", session)
.build();
obj.imp().set_uri(uri);
obj
}
/// The Matrix URI of this room.
pub(crate) fn uri(&self) -> &MatrixRoomIdUri {
self.imp().uri()
}
/// The canonical alias of this room.
pub(crate) fn alias(&self) -> Option<OwnedRoomAliasId> {
self.imp().alias()
}
}

67
src/session/model/remote_user.rs

@ -1,67 +0,0 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use matrix_sdk::ruma::OwnedUserId;
use tracing::error;
use super::{Session, User};
use crate::{components::PillSource, prelude::*, spawn_tokio};
mod imp {
use super::*;
#[derive(Debug, Default)]
pub struct RemoteUser {}
#[glib::object_subclass]
impl ObjectSubclass for RemoteUser {
const NAME: &'static str = "RemoteUser";
type Type = super::RemoteUser;
type ParentType = User;
}
impl ObjectImpl for RemoteUser {}
impl PillSourceImpl for RemoteUser {
fn identifier(&self) -> String {
self.obj().upcast_ref::<User>().user_id_string()
}
}
}
glib::wrapper! {
/// A User that can only be updated by making remote calls, i.e. it won't be updated via sync.
pub struct RemoteUser(ObjectSubclass<imp::RemoteUser>) @extends PillSource, User;
}
impl RemoteUser {
pub fn new(session: &Session, user_id: OwnedUserId) -> Self {
let obj = glib::Object::builder::<Self>()
.property("session", session)
.build();
obj.upcast_ref::<User>().imp().set_user_id(user_id);
obj
}
/// Request this user's profile from the homeserver.
pub async fn load_profile(&self) {
let user_id = self.user_id();
let client = self.session().client();
let user_id_clone = user_id.clone();
let handle =
spawn_tokio!(
async move { client.account().fetch_user_profile_of(&user_id_clone).await }
);
let profile = match handle.await.unwrap() {
Ok(profile) => profile,
Err(error) => {
error!("Could not load profile for user `{user_id}`: {error}");
return;
}
};
self.set_name(profile.displayname);
self.set_avatar_url(profile.avatar_url);
}
}

6
src/session/model/room/join_rule.rs

@ -14,9 +14,7 @@ use ruma::{
use tracing::error;
use super::{Membership, Room};
use crate::{
components::PillSource, gettext_f, session::model::RemoteRoom, spawn_tokio, utils::BoundObject,
};
use crate::{components::PillSource, gettext_f, spawn_tokio, utils::BoundObject};
/// Supported values for the join rule.
#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
@ -221,7 +219,7 @@ mod imp {
let room: PillSource = if let Some(room) = session.room_list().get(&room_id) {
room.upcast()
} else {
RemoteRoom::new(&session, room_id.into()).upcast()
session.remote_cache().room(room_id.into()).upcast()
};
let display_name_handler = room.connect_display_name_notify(clone!(

17
src/session/model/session.rs

@ -18,8 +18,8 @@ use tokio_stream::wrappers::BroadcastStream;
use tracing::{debug, error, info};
use super::{
IgnoredUsers, Notifications, RoomList, SessionSecurity, SessionSettings, SidebarItemList,
SidebarListModel, User, UserSessionsList, VerificationList,
IgnoredUsers, Notifications, RemoteCache, RoomList, SessionSecurity, SessionSettings,
SidebarItemList, SidebarListModel, User, UserSessionsList, VerificationList,
};
use crate::{
components::AvatarData,
@ -103,6 +103,8 @@ mod imp {
/// Information about security for this session.
#[property(get)]
security: SessionSecurity,
/// The cache for remote data.
remote_cache: OnceCell<RemoteCache>,
session_changes_handle: RefCell<Option<AbortHandle>>,
sync_handle: RefCell<Option<AbortHandle>>,
network_monitor_handler_id: RefCell<Option<glib::SignalHandlerId>>,
@ -333,6 +335,12 @@ mod imp {
self.obj().notify_is_offline();
}
/// The cache for remote data.
pub(crate) fn remote_cache(&self) -> &RemoteCache {
self.remote_cache
.get_or_init(|| RemoteCache::new(self.obj().clone()))
}
/// Finish initialization of this session.
pub(super) async fn prepare(&self) {
spawn!(
@ -758,6 +766,11 @@ impl Session {
self.imp().client().clone()
}
/// The cache for remote data.
pub(crate) fn remote_cache(&self) -> &RemoteCache {
self.imp().remote_cache()
}
/// Log out of this session.
pub(crate) async fn log_out(&self) -> Result<(), String> {
debug!(

9
src/session/view/content/room_details/invite_subpage/list.rs

@ -13,7 +13,7 @@ use tracing::error;
use super::InviteItem;
use crate::{
prelude::*,
session::model::{Member, Membership, RemoteUser, Room, User},
session::model::{Member, Membership, Room, User},
spawn, spawn_tokio,
};
@ -293,19 +293,16 @@ mod imp {
continue;
}
// If it's the dummy result for the search term user ID, use a RemoteUser to
// If it's the dummy result for the search term user ID, use the remote cache to
// fetch its profile.
if search_term_user_id
.as_ref()
.is_some_and(|user_id| *user_id == result.user_id)
{
let user = RemoteUser::new(&session, result.user_id);
let user = session.remote_cache().user(result.user_id);
let item = self.create_item(&user, invite_exception);
list.push(item);
spawn!(async move { user.load_profile().await });
continue;
}

16
src/session/view/content/room_details/permissions/privileged_members.rs

@ -7,8 +7,7 @@ use ruma::{Int, OwnedUserId};
use super::MemberPowerLevel;
use crate::{
session::model::{Permissions, PowerLevel, RemoteUser, User},
spawn,
session::model::{Permissions, PowerLevel, User},
utils::BoundObjectWeakRef,
};
@ -111,17 +110,8 @@ mod imp {
.get(&user_id)
.and_upcast::<User>()
.unwrap_or_else(|| {
let user = RemoteUser::new(&session, user_id.clone());
spawn!(clone!(
#[strong]
user,
async move {
user.load_profile().await;
}
));
user.upcast()
// Fallback to the remote cache if the user is not in the room anymore.
session.remote_cache().user(user_id.clone()).upcast()
});
let member = MemberPowerLevel::new(&user, &permissions);

10
src/utils/matrix/mod.rs

@ -37,13 +37,7 @@ pub(crate) mod ext_traits;
mod media_message;
pub(crate) use self::media_message::*;
use crate::{
components::Pill,
gettext_f,
prelude::*,
secret::StoredSession,
session::model::{RemoteRoom, Room},
};
use crate::{components::Pill, gettext_f, prelude::*, secret::StoredSession, session::model::Room};
/// The result of a password validation.
#[derive(Debug, Default, Clone, Copy)]
@ -502,7 +496,7 @@ impl MatrixIdUri {
.get_by_identifier(&room_uri.id)
.as_ref()
.map(Pill::new)
.or_else(|| Some(Pill::new(&RemoteRoom::new(&session, room_uri))))
.or_else(|| Some(Pill::new(&session.remote_cache().room(room_uri))))
}
Self::User(user_id) => {
// We should have a strong reference to the list wherever we show a user pill,

42
src/utils/mod.rs

@ -18,6 +18,7 @@ use futures_util::{
use gtk::{gio, glib};
use regex::Regex;
use tempfile::NamedTempFile;
use tokio::task::{AbortHandle, JoinHandle};
pub(crate) mod expression;
mod expression_list_model;
@ -706,3 +707,44 @@ impl ChildPropertyExt for gtk::ListItem {
pub(crate) trait IsABin: IsA<adw::Bin> {}
impl IsABin for adw::Bin {}
/// A wrapper around [`JoinHandle`] that aborts the future if it is dropped
/// before the task ends.
///
/// The main API for this type is [`AbortableHandle::await_task()`].
#[derive(Debug, Default)]
pub(crate) struct AbortableHandle {
abort_handle: RefCell<Option<AbortHandle>>,
}
impl AbortableHandle {
/// Await the task of the given `JoinHandle`.
///
/// Aborts the previous task that was running, if any.
///
/// Returns `None` if the task was aborted before completion.
pub(crate) async fn await_task<T>(&self, join_handle: JoinHandle<T>) -> Option<T> {
self.abort();
self.abort_handle.replace(Some(join_handle.abort_handle()));
let result = join_handle.await.ok();
self.abort_handle.take();
result
}
/// Abort the current task, if possible.
pub(crate) fn abort(&self) {
if let Some(abort_handle) = self.abort_handle.take() {
abort_handle.abort();
}
}
}
impl Drop for AbortableHandle {
fn drop(&mut self) {
self.abort();
}
}

Loading…
Cancel
Save