diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 905296bc..a93e5d38 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -116,6 +116,8 @@ ui/content-verification-info-bar.ui ui/content.ui ui/context-menu-bin.ui + ui/create-dm-dialog-user-row.ui + ui/create-dm-dialog.ui ui/error-page.ui ui/event-menu.ui ui/event-source-dialog.ui @@ -149,3 +151,4 @@ ui/window.ui + diff --git a/data/resources/ui/create-dm-dialog-user-row.ui b/data/resources/ui/create-dm-dialog-user-row.ui new file mode 100644 index 00000000..cdb2e364 --- /dev/null +++ b/data/resources/ui/create-dm-dialog-user-row.ui @@ -0,0 +1,60 @@ + + + + + diff --git a/data/resources/ui/create-dm-dialog.ui b/data/resources/ui/create-dm-dialog.ui new file mode 100644 index 00000000..3f4a925d --- /dev/null +++ b/data/resources/ui/create-dm-dialog.ui @@ -0,0 +1,140 @@ + + + + + diff --git a/data/resources/ui/sidebar.ui b/data/resources/ui/sidebar.ui index 34155efb..e287a4cb 100644 --- a/data/resources/ui/sidebar.ui +++ b/data/resources/ui/sidebar.ui @@ -2,6 +2,10 @@
+ + New _Direct Chat + session.create-dm + _New Room session.room-creation @@ -165,3 +169,4 @@ + diff --git a/po/POTFILES.in b/po/POTFILES.in index 6a0859e8..6f4c605c 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -34,6 +34,7 @@ data/resources/ui/content-room-history.ui data/resources/ui/content-state-creation.ui data/resources/ui/content-state-tombstone.ui data/resources/ui/content.ui +data/resources/ui/create-dm-dialog.ui data/resources/ui/error-page.ui data/resources/ui/event-menu.ui data/resources/ui/event-source-dialog.ui @@ -93,6 +94,7 @@ src/session/content/room_history/typing_row.rs src/session/content/room_history/verification_info_bar.rs src/session/content/verification/identity_verification_widget.rs src/session/content/verification/session_verification.rs +src/session/create_dm_dialog/mod.rs src/session/join_room_dialog.rs src/session/media_viewer.rs src/session/mod.rs diff --git a/src/session/create_dm_dialog/dm_user.rs b/src/session/create_dm_dialog/dm_user.rs new file mode 100644 index 00000000..23620573 --- /dev/null +++ b/src/session/create_dm_dialog/dm_user.rs @@ -0,0 +1,161 @@ +use gtk::{glib, prelude::*, subclass::prelude::*}; +use log::{debug, error}; +use matrix_sdk::ruma::{ + api::client::room::create_room, + assign, + events::{room::encryption::RoomEncryptionEventContent, InitialStateEvent}, + MxcUri, UserId, +}; + +use crate::{ + session::{user::UserExt, Room, Session, User}, + spawn_tokio, +}; + +mod imp { + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default)] + pub struct DmUser { + pub dm_room: glib::WeakRef, + } + + #[glib::object_subclass] + impl ObjectSubclass for DmUser { + const NAME: &'static str = "CreateDmDialogUser"; + type Type = super::DmUser; + type ParentType = User; + } + + impl ObjectImpl for DmUser { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecObject::builder::("dm-room") + .explicit_notify() + .build()] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let obj = self.obj(); + + match pspec.name() { + "dm-room" => obj.set_dm_room(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + + match pspec.name() { + "dm-room" => obj.dm_room().to_value(), + _ => unimplemented!(), + } + } + } +} + +glib::wrapper! { + /// A User in the context of creating a direct chat. + pub struct DmUser(ObjectSubclass) @extends User; +} + +impl DmUser { + pub fn new( + session: &Session, + user_id: &UserId, + display_name: Option<&str>, + avatar_url: Option<&MxcUri>, + dm_room: Option<&Room>, + ) -> Self { + let obj: Self = glib::Object::builder() + .property("session", session) + .property("user-id", user_id.as_str()) + .property("display-name", display_name) + .property("dm-room", dm_room) + .build(); + // FIXME: we should make the avatar_url settable as property + obj.set_avatar_url(avatar_url.map(std::borrow::ToOwned::to_owned)); + obj + } + + /// Get the DM chat with this user, if any. + pub fn dm_room(&self) -> Option { + self.imp().dm_room.upgrade() + } + + /// Set the DM chat with this user. + pub fn set_dm_room(&self, dm_room: Option<&Room>) { + if self.dm_room().as_ref() == dm_room { + return; + } + + self.imp().dm_room.set(dm_room); + self.notify("dm-room"); + } + + /// Creates a new DM chat with this user + //// + /// If A DM chat exists already no new room is created and the existing one + /// is returned. + pub async fn start_chat(&self) -> Result { + let session = self.session(); + let client = session.client(); + let other_user = self.user_id(); + + if let Some(room) = self.dm_room() { + debug!( + "A Direct Chat with the user {other_user} exists already, not creating a new one" + ); + + // We can be sure that this room has only ourself and maybe the other user as + // member. + if room.matrix_room().active_members_count() < 2 { + room.invite(&[self.clone().upcast()]).await; + debug!("{other_user} left the chat, re-invite them"); + } + + return Ok(room); + } + + let handle = spawn_tokio!(async move { create_dm(client, other_user).await }); + + match handle.await.unwrap() { + Ok(matrix_room) => { + let room = session + .room_list() + .get_wait(matrix_room.room_id()) + .await + .expect("The newly created room was not found"); + self.set_dm_room(Some(&room)); + Ok(room) + } + Err(error) => { + error!("Couldn’t create a new Direct Chat: {error}"); + Err(error) + } + } + } +} + +async fn create_dm( + client: matrix_sdk::Client, + other_user: ruma::OwnedUserId, +) -> Result { + let request = assign!(create_room::v3::Request::new(), + { + is_direct: true, + invite: vec![other_user], + preset: Some(create_room::v3::RoomPreset::TrustedPrivateChat), + initial_state: vec![ + InitialStateEvent::new(RoomEncryptionEventContent::with_recommended_defaults()).to_raw_any(), + ], + }); + + client.create_room(request).await +} diff --git a/src/session/create_dm_dialog/dm_user_list.rs b/src/session/create_dm_dialog/dm_user_list.rs new file mode 100644 index 00000000..070fc2fd --- /dev/null +++ b/src/session/create_dm_dialog/dm_user_list.rs @@ -0,0 +1,341 @@ +use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; +use log::{debug, error}; +use matrix_sdk::ruma::{api::client::user_directory::search_users, OwnedUserId, UserId}; + +use super::DmUser; +use crate::{ + session::{room::Member, user::UserExt, Room, Session}, + spawn, spawn_tokio, +}; + +#[derive(Debug, Default, Eq, PartialEq, Clone, Copy, glib::Enum)] +#[repr(u32)] +#[enum_type(name = "ContentDmUserListState")] +pub enum DmUserListState { + #[default] + Initial = 0, + Loading = 1, + NoMatching = 2, + Matching = 3, + Error = 4, +} + +mod imp { + use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + }; + + use futures::future::AbortHandle; + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default)] + pub struct DmUserList { + pub list: RefCell>, + pub session: glib::WeakRef, + pub state: Cell, + pub search_term: RefCell>, + pub abort_handle: RefCell>, + pub dm_rooms: RefCell>>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for DmUserList { + const NAME: &'static str = "DmUserList"; + type Type = super::DmUserList; + type Interfaces = (gio::ListModel,); + } + + impl ObjectImpl for DmUserList { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder::("session") + .construct_only() + .build(), + glib::ParamSpecString::builder("search-term") + .explicit_notify() + .build(), + glib::ParamSpecEnum::builder::("state") + .read_only() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "session" => self.session.set(value.get().unwrap()), + "search-term" => self.obj().set_search_term(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + + match pspec.name() { + "session" => obj.session().to_value(), + "search-term" => obj.search_term().to_value(), + "state" => obj.state().to_value(), + _ => unimplemented!(), + } + } + } + + impl ListModelImpl for DmUserList { + fn item_type(&self) -> glib::Type { + DmUser::static_type() + } + fn n_items(&self) -> u32 { + self.list.borrow().len() as u32 + } + fn item(&self, position: u32) -> Option { + self.list + .borrow() + .get(position as usize) + .map(glib::object::Cast::upcast_ref::) + .cloned() + } + } +} + +glib::wrapper! { + /// List of users matching the `search term`. + pub struct DmUserList(ObjectSubclass) + @implements gio::ListModel; +} + +impl DmUserList { + pub fn new(session: &Session) -> Self { + glib::Object::builder().property("session", session).build() + } + + /// The session this list refers to. + pub fn session(&self) -> Session { + self.imp().session.upgrade().unwrap() + } + + /// Set the search term. + pub fn set_search_term(&self, search_term: Option) { + let imp = self.imp(); + let search_term = search_term.filter(|s| !s.is_empty()); + + if search_term.as_ref() == imp.search_term.borrow().as_ref() { + return; + } + + imp.search_term.replace(search_term); + + spawn!(clone!(@weak self as obj => async move { + obj.search_users().await; + })); + + self.notify("search_term"); + } + + /// The search term. + fn search_term(&self) -> Option { + self.imp().search_term.borrow().clone() + } + + /// Set the state of the list. + fn set_state(&self, state: DmUserListState) { + let imp = self.imp(); + + if state == self.state() { + return; + } + + imp.state.set(state); + self.notify("state"); + } + + /// The state of the list. + pub fn state(&self) -> DmUserListState { + self.imp().state.get() + } + + fn set_list(&self, users: Vec) { + let added = users.len(); + + let prev_users = self.imp().list.replace(users); + + self.items_changed(0, prev_users.len() as u32, added as u32); + } + + fn clear_list(&self) { + self.set_list(Vec::new()); + } + + async fn search_users(&self) { + let session = self.session(); + let client = session.client(); + let Some(search_term) = self.search_term() else { + self.set_state(DmUserListState::Initial); + return; + }; + + self.set_state(DmUserListState::Loading); + self.clear_list(); + + let search_term_clone = search_term.clone(); + let handle = spawn_tokio!(async move { client.search_users(&search_term_clone, 20).await }); + + let (future, handle) = futures::future::abortable(handle); + + if let Some(abort_handle) = self.imp().abort_handle.replace(Some(handle)) { + abort_handle.abort(); + } + + let response = if let Ok(result) = future.await { + result.unwrap() + } else { + return; + }; + + if Some(&search_term) != self.search_term().as_ref() { + return; + } + + match response { + Ok(mut response) => { + let mut add_custom = false; + // If the search term looks like an UserId and is not already in the response, + // insert it. + if let Ok(user_id) = UserId::parse(&search_term) { + if !response.results.iter().any(|item| item.user_id == user_id) { + let user = search_users::v3::User::new(user_id); + response.results.insert(0, user); + add_custom = true; + } + } + + self.load_dm_rooms().await; + let own_user_id = session.user().unwrap().user_id(); + let dm_rooms = self.imp().dm_rooms.borrow().clone(); + + let mut users: Vec = vec![]; + for item in response.results.into_iter() { + let other_user_id = &item.user_id; + let Some(rooms) = dm_rooms.get(other_user_id) else { + continue; + }; + + let mut final_rooms: Vec = vec![]; + for room in rooms { + let Some(room) = room.upgrade() else { continue; }; + let members = room.members(); + + if !room.is_joined() || room.matrix_room().active_members_count() > 2 { + continue; + } + + // Make sure we have all members loaded, in most cases members should + // already be loaded + room.load_members().await; + + if members.n_items() >= 1 { + let mut found_others = false; + for member in members.iter::() { + match member { + Ok(member) => { + if member.user_id() != own_user_id + && &member.user_id() != other_user_id + { + // We found other members in this room, let's ignore the + // room + found_others = true; + break; + } + } + Err(error) => { + debug!("Error iterating through room members: {error}"); + break; + } + } + } + + if found_others { + continue; + } + } + + final_rooms.push(room); + } + + let room = final_rooms + .into_iter() + .max_by(|x, y| x.latest_unread().cmp(&y.latest_unread())); + + let user = DmUser::new( + &session, + &item.user_id, + item.display_name.as_deref(), + item.avatar_url.as_deref(), + room.as_ref(), + ); + // If it is the "custom user" from the search term, fetch the avatar + // and display name + if add_custom && user.user_id() == search_term { + user.load_profile(); + } + users.push(user); + } + + match users.is_empty() { + true => self.set_state(DmUserListState::NoMatching), + false => self.set_state(DmUserListState::Matching), + } + self.set_list(users); + } + Err(error) => { + error!("Couldn’t load matching users: {error}"); + self.set_state(DmUserListState::Error); + self.clear_list(); + } + } + } + + async fn load_dm_rooms(&self) { + let client = self.session().client(); + let handle = spawn_tokio!(async move { + client + .account() + .account_data::() + .await? + .map(|c| c.deserialize()) + .transpose() + .map_err(matrix_sdk::Error::from) + }); + + match handle.await.unwrap() { + Ok(Some(list)) => { + let session = self.session(); + let room_list = session.room_list(); + let list = list + .into_iter() + .map(|(user_id, room_ids)| { + let rooms = room_ids + .iter() + .filter_map(|room_id| Some(room_list.get(room_id)?.downgrade())) + .collect(); + (user_id, rooms) + }) + .collect(); + self.imp().dm_rooms.replace(list); + } + Ok(None) => { + self.imp().dm_rooms.take(); + } + Err(error) => { + error!("Can’t read account data: {error}"); + self.imp().dm_rooms.take(); + } + }; + } +} diff --git a/src/session/create_dm_dialog/dm_user_row.rs b/src/session/create_dm_dialog/dm_user_row.rs new file mode 100644 index 00000000..6d9829e0 --- /dev/null +++ b/src/session/create_dm_dialog/dm_user_row.rs @@ -0,0 +1,90 @@ +use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; + +use super::DmUser; + +mod imp { + use std::cell::RefCell; + + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/create-dm-dialog-user-row.ui")] + pub struct DmUserRow { + pub user: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for DmUserRow { + const NAME: &'static str = "CreateDmDialogUserRow"; + type Type = super::DmUserRow; + type ParentType = gtk::ListBoxRow; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for DmUserRow { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecObject::builder::("user") + .explicit_notify() + .build()] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "user" => self.obj().set_user(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "user" => self.obj().user().to_value(), + _ => unimplemented!(), + } + } + } + impl WidgetImpl for DmUserRow {} + impl ListBoxRowImpl for DmUserRow {} +} + +glib::wrapper! { + pub struct DmUserRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; +} + +impl DmUserRow { + pub fn new(user: &DmUser) -> Self { + glib::Object::builder().property("user", user).build() + } + + /// The user displayed by this row. + pub fn user(&self) -> Option { + self.imp().user.borrow().clone() + } + + /// Set the user displayed by this row. + pub fn set_user(&self, user: Option) { + let imp = self.imp(); + let prev_user = self.user(); + + if prev_user == user { + return; + } + + imp.user.replace(user); + self.notify("user"); + } +} diff --git a/src/session/create_dm_dialog/mod.rs b/src/session/create_dm_dialog/mod.rs new file mode 100644 index 00000000..7981840e --- /dev/null +++ b/src/session/create_dm_dialog/mod.rs @@ -0,0 +1,199 @@ +use adw::subclass::prelude::*; +use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate}; + +mod dm_user; +use self::dm_user::DmUser; +mod dm_user_list; +mod dm_user_row; +use self::{ + dm_user_list::{DmUserList, DmUserListState}, + dm_user_row::DmUserRow, +}; +use crate::{ + gettext, + session::{user::UserExt, Session}, + spawn, +}; + +mod imp { + use glib::{object::WeakRef, subclass::InitializingObject}; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/create-dm-dialog.ui")] + pub struct CreateDmDialog { + pub session: WeakRef, + #[template_child] + pub list_box: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for CreateDmDialog { + const NAME: &'static str = "CreateDmDialog"; + type Type = super::CreateDmDialog; + type ParentType = adw::Window; + + fn class_init(klass: &mut Self::Class) { + DmUserRow::static_type(); + Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); + + klass.add_binding( + gdk::Key::Escape, + gdk::ModifierType::empty(), + |obj, _| { + obj.close(); + true + }, + None, + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for CreateDmDialog { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecObject::builder::("session") + .explicit_notify() + .build()] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "session" => self.obj().set_session(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => self.obj().session().to_value(), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for CreateDmDialog {} + impl WindowImpl for CreateDmDialog {} + impl AdwWindowImpl for CreateDmDialog {} +} + +glib::wrapper! { + /// Preference Window to display and update room details. + pub struct CreateDmDialog(ObjectSubclass) + @extends gtk::Widget, gtk::Window, adw::Window, adw::Bin, @implements gtk::Accessible; +} + +#[gtk::template_callbacks] +impl CreateDmDialog { + pub fn new(parent_window: Option<&impl IsA>, session: &Session) -> Self { + glib::Object::builder() + .property("transient-for", parent_window) + .property("session", session) + .build() + } + + /// The current session. + pub fn session(&self) -> Option { + self.imp().session.upgrade() + } + + /// Set the current session. + pub fn set_session(&self, session: Option) { + let imp = self.imp(); + + if self.session() == session { + return; + } + + if let Some(ref session) = session { + let user_list = DmUserList::new(session); + + // We don't need to disconnect this signal since the `DmUserList` will be + // disposed once unbound from the `gtk::ListBox` + user_list.connect_notify_local( + Some("state"), + clone!(@weak self as obj => move |model, _| { + obj.update_view(model); + }), + ); + + imp.search_entry + .bind_property("text", &user_list, "search-term") + .flags(glib::BindingFlags::SYNC_CREATE) + .build(); + + imp.list_box.bind_model(Some(&user_list), |user| { + DmUserRow::new( + user.downcast_ref::() + .expect("DmUserList must contain only `DmUser`"), + ) + .upcast() + }); + + self.update_view(&user_list); + } else { + imp.list_box.unbind_model(); + } + + imp.session.set(session.as_ref()); + self.notify("session"); + } + + fn update_view(&self, model: &DmUserList) { + let visible_child_name = match model.state() { + DmUserListState::Initial => "no-search-page", + DmUserListState::Loading => "loading-page", + DmUserListState::NoMatching => "no-matching-page", + DmUserListState::Matching => "matching-page", + DmUserListState::Error => { + self.show_error(&gettext("An error occurred while searching for users")); + return; + } + }; + + self.imp().stack.set_visible_child_name(visible_child_name); + } + + fn show_error(&self, message: &str) { + self.imp().error_page.set_description(Some(message)); + self.imp().stack.set_visible_child_name("error-page"); + } + + #[template_callback] + fn row_activated_cb(&self, row: gtk::ListBoxRow) { + let Some(user): Option = row.downcast::().ok().and_then(|r| r.user()) else { return; }; + + // TODO: For now we show the loading page while we create the room, + // ideally we would like to have the same behavior as Element: + // Create the room only once the user sends a message + self.imp().stack.set_visible_child_name("loading-page"); + self.imp().search_entry.set_sensitive(false); + spawn!(clone!(@weak self as obj, @weak user => async move { + match user.start_chat().await { + Ok(room) => { + user.session().select_room(Some(room)); + obj.close(); + } + Err(_) => { + obj.show_error(&gettext("Failed to create a new Direct Chat")); + } + } + })); + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 29be5948..f376f42f 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,6 +1,7 @@ mod account_settings; mod avatar; mod content; +mod create_dm_dialog; mod event_source_dialog; mod join_room_dialog; mod media_viewer; @@ -55,6 +56,7 @@ use self::{ pub use self::{ avatar::{AvatarData, AvatarImage, AvatarUriSource}, content::verification::SessionVerification, + create_dm_dialog::CreateDmDialog, room::{Event, Room}, room_creation::RoomCreation, settings::SessionSettings, @@ -170,6 +172,10 @@ mod imp { })); }); + klass.install_action("session.create-dm", None, move |session, _, _| { + session.show_create_dm_dialog(); + }); + klass.add_binding_action( gdk::Key::Escape, gdk::ModifierType::empty(), @@ -681,6 +687,11 @@ impl Session { window.present(); } + fn show_create_dm_dialog(&self) { + let window = CreateDmDialog::new(self.parent_window().as_ref(), self); + window.present(); + } + async fn show_join_room_dialog(&self) { let dialog = JoinRoomDialog::new(self.parent_window().as_ref(), self); dialog.present(); diff --git a/src/session/user.rs b/src/session/user.rs index 89fb447d..3104a760 100644 --- a/src/session/user.rs +++ b/src/session/user.rs @@ -255,6 +255,29 @@ pub trait UserExt: IsA { let uri = self.user_id().matrix_to_uri(); format!("{}", self.display_name()) } + + /// Load the user profile from the homeserver. + /// + /// This overwrites the already loaded display name and avatar. + fn load_profile(&self) { + let client = self.session().client(); + let user_id = self.user_id(); + let user = self.upcast_ref::(); + + let handle = spawn_tokio!(async move { client.get_profile(&user_id).await }); + + spawn!(clone!(@weak user => async move { + match handle.await.unwrap() { + Ok(response) => { + user.set_display_name(response.displayname); + user.set_avatar_url(response.avatar_url); + }, + Err(error) => { + error!("Failed to load user profile for {}: {}", user.user_id(), error); + } + }; + })); + } } impl> UserExt for T {}