diff --git a/Cargo.toml b/Cargo.toml index 1fa15660..d7ba5045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ rangemap = "1.5.0" tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } tracing-subscriber = "0.3.17" unicode-segmentation = "1.10.1" -url = "2.2.2" +url = "2.5.0" emojis = "0.6.1" diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 43dd62b9..32a3231f 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -11,8 +11,9 @@ use matrix_sdk::ruma::{ guest_access::GuestAccess, history_visibility::HistoryVisibility, join_rules::JoinRule, message::{MessageFormat, MessageType, RoomMessageEventContent}, MediaSource, - }, AnySyncMessageLikeEvent, AnySyncTimelineEvent, FullStateEventContent, SyncMessageLikeEvent - }, uint, MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId, + }, + AnySyncMessageLikeEvent, AnySyncTimelineEvent, FullStateEventContent, SyncMessageLikeEvent, + }, matrix_uri::MatrixId, uint, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId, }; use matrix_sdk_ui::timeline::{ self, AnyOtherFullStateEventContent, BundledReactions, EventTimelineItem, MemberProfileChange, MembershipChange, RoomMembershipChange, TimelineDetails, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem @@ -21,7 +22,7 @@ use matrix_sdk_ui::timeline::{ use rangemap::RangeSet; use crate::{ media_cache::{MediaCache, MediaCacheEntry, AVATAR_CACHE}, - profile::user_profile::{ShowUserProfileAction, UserProfileInfo, UserProfileSlidingPaneWidgetExt}, + profile::user_profile::{AvatarInfo, ShowUserProfileAction, UserProfile, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, shared::{avatar::{AvatarRef, AvatarWidgetRefExt}, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, text_or_image::TextOrImageWidgetRefExt}, sliding_sync::{submit_async_request, take_timeline_update_receiver, MatrixRequest}, utils::{self, unix_time_millis_to_datetime, MediaFormatConst}, @@ -558,6 +559,7 @@ impl Widget for RoomScreen { // Handle events and actions at the RoomScreen level. fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope){ let pane = self.user_profile_sliding_pane(id!(user_profile_sliding_pane)); + let timeline = self.timeline(id!(timeline)); if let Event::Actions(actions) = event { // Handle the send message button being clicked. @@ -586,14 +588,99 @@ impl Widget for RoomScreen { for action in actions { // Handle the action that requests to show the user profile sliding pane. if let ShowUserProfileAction::ShowUserProfile(avatar_info) = action.as_widget_action().cast() { - pane.set_info(UserProfileInfo { - avatar_info, - room_name: self.room_name.clone(), - }); - pane.show(cx); - // TODO: Hack for error that when you first open the modal, doesnt draw until an event - // this forces the entire ui to rerender, still weird that only happens the first time. - self.redraw(cx); + timeline.show_user_profile( + cx, + &pane, + UserProfilePaneInfo { + avatar_info, + room_name: self.room_name.clone(), + room_member: None, + }, + ); + } + + // Handle a link being clicked. + if let HtmlLinkAction::Clicked { url, .. } = action.as_widget_action().cast() { + let mut link_was_handled = false; + if let Ok(matrix_to_uri) = MatrixToUri::parse(&url) { + match matrix_to_uri.id() { + MatrixId::Room(room_id) => { + log!("TODO: open room {}", room_id); + link_was_handled = true; + } + MatrixId::RoomAlias(room_alias) => { + log!("TODO: open room alias {}", room_alias); + link_was_handled = true; + } + MatrixId::User(user_id) => { + log!("Opening matrix.to user link for {}", user_id); + + // There is no synchronous way to get the user's full profile info + // including the details of their room membership, + // so we fill in with the details we *do* know currently, + // show the user profile sliding pane, and then fire off + // an async request to get the rest of the details. + timeline.show_user_profile( + cx, + &pane, + UserProfilePaneInfo { + avatar_info: AvatarInfo { + user_profile: UserProfile { + user_id: user_id.to_owned(), + username: None, + avatar_img_data: None, + }, + room_id: self.room_id.clone().unwrap(), + }, + room_name: self.room_name.clone(), + room_member: None, + }, + ); + + // TODO: use the extra `via` parameters from `matrix_to_uri.via()`. + submit_async_request(MatrixRequest::GetUserProfile { + user_id: user_id.to_owned(), + room_id: self.room_id.clone(), + local_only: false, + }); + + link_was_handled = true; + } + MatrixId::Event(room_id, event_id) => { + log!("TODO: open event {} in room {}", event_id, room_id); + link_was_handled = true; + } + _ => { } + } + } + + if let Ok(matrix_uri) = MatrixUri::parse(&url) { + match matrix_uri.id() { + MatrixId::Room(room_id) => { + log!("TODO: open room {}", room_id); + link_was_handled = true; + } + MatrixId::RoomAlias(room_alias) => { + log!("TODO: open room alias {}", room_alias); + link_was_handled = true; + } + MatrixId::User(user_id) => { + log!("TODO: open user {}", user_id); + link_was_handled = true; + } + MatrixId::Event(room_id, event_id) => { + log!("TODO: open event {} in room {}", event_id, room_id); + link_was_handled = true; + } + _ => { } + } + } + + if !link_was_handled { + if let Err(e) = robius_open::Uri::new(&url).open() { + error!("Failed to open URL {:?}. Error: {:?}", url, e); + } + } } } } @@ -832,6 +919,20 @@ impl TimelineRef { let Some(mut timeline) = self.borrow_mut() else { return }; timeline.room_id = Some(room_id); } + + /// Shows the user profile sliding pane with the given avatar info. + fn show_user_profile( + &self, + cx: &mut Cx, + pane: &UserProfileSlidingPaneRef, + info: UserProfilePaneInfo, + ) { + let Some(mut inner) = self.borrow_mut() else { return }; + pane.set_info(info); + pane.show(cx); + // Not sure if this redraw is necessary + inner.redraw(cx); + } } impl Widget for Timeline { @@ -854,25 +955,6 @@ impl Widget for Timeline { | StackNavigationTransitionAction::None => { } } - // Handle a link being clicked. - if let HtmlLinkAction::Clicked { url, .. } = action.as_widget_action().cast() { - if url.starts_with("https://matrix.to/#/") { - log!("TODO: handle Matrix link internally: {url:?}"); - // TODO: show a pop-up pane with the user's profile, or a room preview pane. - // - // There are four kinds of matrix.to schemes: - // See here: - // 1. Rooms: https://matrix.to/#/#matrix:matrix.org - // 2. Rooms by ID: https://matrix.to/#/!cURbafjkfsMDVwdRDQ:matrix.org - // 3. Users: https://matrix.to/#/@matthew:matrix.org - // 4. Messages: https://matrix.to/#/#matrix:matrix.org/$1448831580433WbpiJ:jki.re - } else { - if let Err(e) = robius_open::Uri::new(&url).open() { - error!("Failed to open URL {:?}. Error: {:?}", url, e); - } - } - } - // Handle other actions here // TODO: handle actions upon an item being clicked. // for (item_id, item) in self.list.items_with_actions(&actions) { @@ -950,6 +1032,7 @@ impl Widget for Timeline { self.view.handle_event(cx, event, scope); } + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { let Some(tl_state) = self.tl_state.as_mut() else { return DrawStep::done() @@ -1727,36 +1810,36 @@ fn set_avatar_and_get_username( }; // Set the username to the display name if available, otherwise the user ID after the '@'. - username = profile.display_name - .as_ref() - .cloned() - .unwrap_or_else(|| user_id.to_string()); + let username_opt = profile.display_name.clone(); + username = username_opt.clone().unwrap_or_else(|| user_id.to_string()); // Draw the avatar image if available, otherwise set the avatar to text. let drew_avatar_img = avatar_img.map(|data| avatar.show_image( - Some((username.clone(), user_id.to_owned(), room_id.to_owned(), data.clone())), + Some((user_id.to_owned(), username_opt.clone(), room_id.to_owned(), data.clone())), |img| utils::load_png_or_jpg(&img, cx, &data) ).is_ok() ).unwrap_or(false); if !drew_avatar_img { avatar.show_text( - Some((user_id.to_owned(), room_id.to_owned())), - username.clone(), + Some((user_id.to_owned(), username_opt, room_id.to_owned())), + &username, ); } } - other => { - // log!("populate_message_view(): sender profile not ready yet for event {_other:?}"); + + // If the profile is not ready, use the user ID for both the username and the avatar. + not_ready => { + // log!("populate_message_view(): sender profile not ready yet for event {not_ready:?}"); username = user_id.to_string(); avatar.show_text( - Some((user_id.to_owned(), room_id.to_owned())), - username.clone(), + Some((user_id.to_owned(), None, room_id.to_owned())), + &username, ); // If there was an error fetching the profile, treat that condition as fully drawn, // since we don't yet have a good way to re-request profile information. - profile_drawn = matches!(other, TimelineDetails::Error(_)); + profile_drawn = matches!(not_ready, TimelineDetails::Error(_)); } } diff --git a/src/media_cache.rs b/src/media_cache.rs index f46cf722..56877f7b 100644 --- a/src/media_cache.rs +++ b/src/media_cache.rs @@ -3,10 +3,73 @@ use makepad_widgets::{error, log}; use matrix_sdk::{ruma::{OwnedMxcUri, events::room::MediaSource}, media::{MediaRequest, MediaFormat}}; use crate::{home::room_screen::TimelineUpdate, sliding_sync::{self, MatrixRequest}, utils::{MediaFormatConst, MEDIA_THUMBNAIL_FORMAT}}; -pub static AVATAR_CACHE: Mutex = Mutex::new(MediaCache::new(MEDIA_THUMBNAIL_FORMAT, None)); - pub type MediaCacheEntryRef = Arc>; +pub static AVATAR_CACHE: MediaCacheLocked = MediaCacheLocked(Mutex::new(MediaCache::new(MEDIA_THUMBNAIL_FORMAT, None))); + +pub struct MediaCacheLocked(Mutex); +impl Deref for MediaCacheLocked { + type Target = Mutex; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl MediaCacheLocked { + /// Similar to [`Self::try_get_media_or_fetch()`], but immediately fires off an async request + /// on the current task to fetch the media, blocking until the request completes. + /// + /// Unlike other functions, this is intended for use in background tasks or other async contexts + /// where it is not latency-sensitive, and safe to block on the async request. + /// Thus, it must be implemented on the `MediaCacheLocked` type, which is safe to hold a reference to + /// across an await point, whereas a mutable reference to a locked `MediaCache` is not (i.e., a `MutexGuard`). + pub async fn get_media_or_fetch_async( + &self, + client: &matrix_sdk::Client, + mxc_uri: OwnedMxcUri, + media_format: Option, + ) -> Option> { + let destination = { + match self.lock().unwrap().entry(mxc_uri.clone()) { + Entry::Vacant(vacant) => vacant + .insert(Arc::new(Mutex::new(MediaCacheEntry::Requested))) + .clone(), + Entry::Occupied(occupied) => match occupied.get().lock().unwrap().deref() { + MediaCacheEntry::Loaded(data) => return Some(data.clone()), + MediaCacheEntry::Failed => return None, + // If already requested (a fetch is in process), + // we return None for now and allow the `insert_into_cache` function + // emit a UI Signal when the fetch completes, + // which will trigger a re-draw of the UI, + // and thus a re-fetch of any visible avatars. + MediaCacheEntry::Requested => return None, + } + } + }; + + let media_request = MediaRequest { + source: MediaSource::Plain(mxc_uri), + format: media_format.unwrap_or_else(|| self.lock().unwrap().default_format.clone().into()), + }; + + let res = client + .media() + .get_media_content(&media_request, true) + .await; + let res: matrix_sdk::Result> = res.map(|d| d.into()); + let retval = res + .as_ref() + .ok() + .cloned(); + insert_into_cache( + &destination, + media_request, + res, + None, + ); + retval + } +} + /// An entry in the media cache. #[derive(Debug, Clone)] pub enum MediaCacheEntry { @@ -97,18 +160,18 @@ impl MediaCache { ); MediaCacheEntry::Requested } - } /// Insert data into a previously-requested media cache entry. -fn insert_into_cache( +fn insert_into_cache>>( value_ref: &Mutex, _request: MediaRequest, - data: matrix_sdk::Result>, + data: matrix_sdk::Result, update_sender: Option>, ) { let new_value = match data { Ok(data) => { + let data = data.into(); // debugging: dump out the media image to disk if false { @@ -127,7 +190,7 @@ fn insert_into_cache( } } - MediaCacheEntry::Loaded(data.into()) + MediaCacheEntry::Loaded(data) } Err(e) => { error!("Failed to fetch media for {:?}: {e:?}", _request.source); diff --git a/src/profile/user_profile.rs b/src/profile/user_profile.rs index 959bed9b..15d7c29d 100644 --- a/src/profile/user_profile.rs +++ b/src/profile/user_profile.rs @@ -1,10 +1,87 @@ -use std::ops::Deref; +use std::{cell::RefCell, collections::{btree_map::Entry, BTreeMap}, ops::Deref, sync::Arc}; +use crossbeam_queue::SegQueue; use makepad_widgets::*; +use matrix_sdk::{room::RoomMember, ruma::{OwnedRoomId, OwnedUserId}}; use crate::{ - shared::avatar::{AvatarInfo, AvatarWidgetExt}, + shared::avatar::AvatarWidgetExt, utils, }; +thread_local! { + /// A cache of each user's profile and the rooms they are a member of, indexed by user ID. + static USER_PROFILE_CACHE: RefCell> = RefCell::new(BTreeMap::new()); +} +struct UserProfileCacheEntry { + user_profile: UserProfile, + room_members: BTreeMap, +} + +/// The queue of user profile updates waiting to be processed by the UI thread's event handler. +static PENDING_USER_PROFILE_UPDATES: SegQueue = SegQueue::new(); + +/// Enqueues a new user profile update and signals the UI +/// such that the new update will be handled by the user profile sliding pane widget. +pub fn enqueue_user_profile_update(update: UserProfileUpdate) { + PENDING_USER_PROFILE_UPDATES.push(update); + SignalToUI::set_ui_signal(); +} + +/// A fully-fetched user profile, with info about the user's membership in a given room. +pub struct UserProfileUpdate { + pub new_profile: UserProfile, + pub room_member: Option<(OwnedRoomId, RoomMember)>, +} +impl Deref for UserProfileUpdate { + type Target = UserProfile; + fn deref(&self) -> &Self::Target { + &self.new_profile + } +} + +/// Information retrieved about a user: their displayable name, ID, and avatar image. +#[derive(Clone, Debug)] +pub struct UserProfile { + pub user_id: OwnedUserId, + pub username: Option, + pub avatar_img_data: Option>, +} +impl UserProfile { + /// Returns the user's displayable name, using the user ID as a fallback. + pub fn displayable_name(&self) -> &str { + if let Some(un) = self.username.as_ref() { + if !un.is_empty() { + return un.as_str(); + } + } + self.user_id.as_str() + } + + /// Returns the first "letter" (Unicode grapheme) of the user's name or user ID, + /// skipping any leading "@" characters. + #[allow(unused)] + pub fn first_letter(&self) -> &str { + self.username.as_deref() + .and_then(|un| utils::user_name_first_letter(un)) + .or_else(|| utils::user_name_first_letter(self.user_id.as_str())) + .unwrap_or_default() + } +} + + +/// Information needed to display an avatar widget: a user profile and room ID. +#[derive(Clone, Debug)] +pub struct AvatarInfo { + pub user_profile: UserProfile, + pub room_id: OwnedRoomId, +} +impl Deref for AvatarInfo { + type Target = UserProfile; + fn deref(&self) -> &Self::Target { + &self.user_profile + } +} + + live_design! { import makepad_draw::shader::std::*; import makepad_widgets::base::*; @@ -337,18 +414,20 @@ pub enum ShowUserProfileAction { None, } +/// Information needed to populate/display the user profile sliding pane. #[derive(Clone, Debug)] -pub struct UserProfileInfo { +pub struct UserProfilePaneInfo { pub avatar_info: AvatarInfo, pub room_name: String, + pub room_member: Option, } -impl Deref for UserProfileInfo { +impl Deref for UserProfilePaneInfo { type Target = AvatarInfo; fn deref(&self) -> &Self::Target { &self.avatar_info } } -impl UserProfileInfo { +impl UserProfilePaneInfo { fn role_in_room_title(&self) -> String { if self.room_name.is_empty() { format!("Role in Room ID: {}", self.room_id.as_str()) @@ -359,6 +438,7 @@ impl UserProfileInfo { fn role_in_room(&self) -> &str { // TODO: acquire a user's role in the room to set their `role_info_label`` + // Also, note that the user may not even be a member of the room. "" } } @@ -368,7 +448,7 @@ pub struct UserProfileSlidingPane { #[deref] view: View, #[animator] animator: Animator, - #[rust] info: Option, + #[rust] info: Option, } impl Widget for UserProfileSlidingPane { @@ -393,6 +473,56 @@ impl Widget for UserProfileSlidingPane { self.view(id!(bg_view)).set_visible(false); } + // Handle the user profile info being updated by a background task. + let mut redraw_this_pane = false; + if let Event::Signal = event { + USER_PROFILE_CACHE.with_borrow_mut(|cache| { + while let Some(update) = PENDING_USER_PROFILE_UPDATES.pop() { + // If the `update` applies to the info being displayed by this + // user profile sliding pane, update its `info. + if let Some(our_info) = self.info.as_mut() { + if our_info.user_profile.user_id == update.user_id { + our_info.avatar_info.user_profile = update.new_profile.clone(); + // If the update also includes room member info for the room + // that we're currently displaying the user profile info for, update that too. + if let Some((room_id, room_member)) = update.room_member.clone() { + if room_id == our_info.room_id { + our_info.room_member = Some(room_member); + } + } + redraw_this_pane = true; + } + } + // Insert the updated info into the cache + match cache.entry(update.user_id.clone()) { + Entry::Occupied(mut entry) => { + let entry_mut = entry.get_mut(); + entry_mut.user_profile = update.new_profile; + if let Some((room_id, room_member)) = update.room_member { + entry_mut.room_members.insert(room_id, room_member); + } + } + Entry::Vacant(entry) => { + let mut room_members_map = BTreeMap::new(); + if let Some((room_id, room_member)) = update.room_member { + room_members_map.insert(room_id, room_member); + } + entry.insert( + UserProfileCacheEntry { + user_profile: update.new_profile, + room_members: room_members_map, + } + ); + } + } + } + }); + } + + if redraw_this_pane { + self.redraw(cx); + } + // TODO: handle button clicks for things like: // * direct_message_button // * copy_link_to_user_button @@ -410,7 +540,7 @@ impl Widget for UserProfileSlidingPane { self.visible = true; // Set the user name, using the user ID as a fallback. - self.label(id!(user_name)).set_text(info.user_name()); + self.label(id!(user_name)).set_text(info.displayable_name()); self.label(id!(user_id)).set_text(info.user_id.as_str()); self.label(id!(role_info_title_label)).set_text(&info.role_in_room_title()); @@ -418,7 +548,7 @@ impl Widget for UserProfileSlidingPane { let avatar_ref = self.avatar(id!(avatar)); info.avatar_img_data.as_ref() .and_then(|data| avatar_ref.show_image(None, |img| utils::load_png_or_jpg(&img, cx, &data)).ok()) - .unwrap_or_else(|| avatar_ref.show_text(None, info.user_name())); + .unwrap_or_else(|| avatar_ref.show_text(None, info.displayable_name())); self.label(id!(role_info_label)).set_text(info.role_in_room()); @@ -435,7 +565,7 @@ impl UserProfileSlidingPaneRef { ) } - pub fn set_info(&self, info: UserProfileInfo) { + pub fn set_info(&self, info: UserProfilePaneInfo) { if let Some(mut inner) = self.borrow_mut() { inner.info = Some(info); } diff --git a/src/shared/avatar.rs b/src/shared/avatar.rs index c780239e..834f895f 100644 --- a/src/shared/avatar.rs +++ b/src/shared/avatar.rs @@ -12,7 +12,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId}; use crate::{ - profile::user_profile::ShowUserProfileAction, + profile::user_profile::{AvatarInfo, ShowUserProfileAction, UserProfile}, utils, }; @@ -88,32 +88,6 @@ live_design! { } -#[derive(Clone, Debug)] -pub struct AvatarInfo { - pub user_name: String, - pub user_id: OwnedUserId, - pub room_id: OwnedRoomId, - pub avatar_img_data: Option>, -} -impl AvatarInfo { - pub fn user_name(&self) -> &str { - if self.user_name.is_empty() { - self.user_id.as_str() - } else { - self.user_name.as_str() - } - } - - /// Returns the first "letter" (Unicode grapheme) of the user's name or user ID, - /// skipping any leading "@" characters. - pub fn first_letter(&self) -> &str { - utils::user_name_first_letter(&self.user_name) - .or_else(|| utils::user_name_first_letter(self.user_id.as_str())) - .unwrap_or_default() - } -} - - #[derive(LiveHook, Live, Widget)] pub struct Avatar { #[deref] view: View, @@ -163,23 +137,28 @@ impl Avatar { /// and the image invisible. /// /// ## Arguments - /// * `info`: information about the user represented by this avatar. + /// * `info`: information about the user represented by this avatar, including a tuple of + /// the user ID, displayable user name, and room ID. /// * Set this to `Some` to enable a user to click/tap on the Avatar itself. /// * Set this to `None` to disable the click/tap action. - /// * `user_name`: the displayable user name for this avatar. + /// * `username`: the displayable text for this avatar, either a user name or user ID. /// Only the first non-`@` letter (Unicode grapheme) is displayed. pub fn show_text>( &mut self, - info: Option<(OwnedUserId, OwnedRoomId)>, - user_name: T, + info: Option<(OwnedUserId, Option, OwnedRoomId)>, + username: T, ) { - self.info = info.map(|(user_id, room_id)| AvatarInfo { - user_name: user_name.as_ref().to_string(), - user_id, - room_id, - avatar_img_data: None, - }); - self.set_text(user_name.as_ref()); + self.info = info.map(|(user_id, username, room_id)| + AvatarInfo { + user_profile: UserProfile { + user_id, + username, + avatar_img_data: None, + }, + room_id, + } + ); + self.set_text(username.as_ref()); } /// Sets the image content of this avatar, making the image visible @@ -196,23 +175,27 @@ impl Avatar { /// If `image_set_function` returns an error, no change is made to the avatar. pub fn show_image( &mut self, - info: Option<(String, OwnedUserId, OwnedRoomId, Arc<[u8]>)>, + info: Option<(OwnedUserId, Option, OwnedRoomId, Arc<[u8]>)>, image_set_function: F, ) -> Result<(), E> where F: FnOnce(ImageRef) -> Result<(), E> { - self.info = info.map(|(user_name, user_id, room_id, img_data)| AvatarInfo { - user_name, - user_id, - room_id, - avatar_img_data: Some(img_data), - }); - let img_ref = self.image(id!(img_view.img)); let res = image_set_function(img_ref); if res.is_ok() { self.view(id!(img_view)).set_visible(true); self.view(id!(text_view)).set_visible(false); + + self.info = info.map(|(user_id, username, room_id, img_data)| + AvatarInfo { + user_profile: UserProfile { + user_id, + username, + avatar_img_data: Some(img_data), + }, + room_id, + } + ); } res } @@ -231,18 +214,18 @@ impl AvatarRef { /// See [`Avatar::show_text()`]. pub fn show_text>( &self, - info: Option<(OwnedUserId, OwnedRoomId)>, - user_name: T, + info: Option<(OwnedUserId, Option, OwnedRoomId)>, + username: T, ) { if let Some(mut inner) = self.borrow_mut() { - inner.show_text(info, user_name); + inner.show_text(info, username); } } /// See [`Avatar::show_image()`]. pub fn show_image( &self, - info: Option<(String, OwnedUserId, OwnedRoomId, Arc<[u8]>)>, + info: Option<(OwnedUserId, Option, OwnedRoomId, Arc<[u8]>)>, image_set_function: F, ) -> Result<(), E> where F: FnOnce(ImageRef) -> Result<(), E> diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index e422a347..567c132b 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -5,21 +5,12 @@ use futures_util::{StreamExt, pin_mut}; use imbl::Vector; use makepad_widgets::{SignalToUI, error, log}; use matrix_sdk::{ - Client, - ruma::{ - assign, - OwnedRoomId, + config::RequestConfig, media::MediaRequest, ruma::{ api::client::{ session::get_login_types::v3::LoginType, - sync::sync_events::v4::{SyncRequestListFilters, self}, - }, - events::{StateEventType, FullStateEventContent, room::message::RoomMessageEventContent}, MilliSecondsSinceUnixEpoch, UInt, - }, - SlidingSyncList, - SlidingSyncMode, - config::RequestConfig, - media::MediaRequest, - Room, + sync::sync_events::v4::{self, SyncRequestListFilters}, + }, assign, events::{room::message::RoomMessageEventContent, FullStateEventContent, StateEventType}, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomId, OwnedUserId, UInt + }, Client, Room, SlidingSyncList, SlidingSyncMode }; use matrix_sdk_ui::{timeline::{AnyOtherFullStateEventContent, BackPaginationStatus, PaginationOptions, SlidingSyncRoomExt, TimelineItem, TimelineItemContent}, Timeline}; use tokio::{ @@ -30,7 +21,7 @@ use unicode_segmentation::UnicodeSegmentation; use std::{cmp::{max, min}, collections::BTreeMap, ops::Range, sync::{Arc, Mutex, OnceLock}}; use url::Url; -use crate::{home::{rooms_list::{self, RoomPreviewEntry, RoomsListUpdate, RoomPreviewAvatar, enqueue_rooms_list_update}, room_screen::TimelineUpdate}, media_cache::MediaCacheEntry, utils::MEDIA_THUMBNAIL_FORMAT}; +use crate::{home::{room_screen::TimelineUpdate, rooms_list::{self, enqueue_rooms_list_update, RoomPreviewAvatar, RoomPreviewEntry, RoomsListUpdate}}, media_cache::{MediaCacheEntry, AVATAR_CACHE}, profile::user_profile::{enqueue_user_profile_update, UserProfile, UserProfileUpdate}, utils::MEDIA_THUMBNAIL_FORMAT}; use crate::message_display::DisplayerExt; @@ -129,6 +120,13 @@ pub enum MatrixRequest { FetchRoomMembers { room_id: OwnedRoomId, }, + /// Request to fetch profile information for a specific user. + GetUserProfile { + user_id: OwnedUserId, + room_id: Option, + /// If `true`, we will not attempt to fetch missing details from the server. + local_only: bool, + }, /// Request to fetch media from the server. /// Upon completion of the async media request, the `on_fetched` function /// will be invoked with four arguments: the `destination`, the `media_request`, @@ -237,6 +235,74 @@ async fn async_worker(mut receiver: UnboundedReceiver) -> Result< }); } + MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { + let Some(client) = CLIENT.get() else { continue }; + let _fetch_task = Handle::current().spawn(async move { + log!("Sending get user profile request: user: {user_id}, \ + room: {room_id:?}, local_only: {local_only}...", + ); + let mut avatar_url: Option = None; + let mut update = None; + if let Some(room_id) = room_id.as_ref() { + if let Some(room) = client.get_room(room_id) { + let member = if local_only { + room.get_member_no_sync(&user_id).await + } else { + room.get_member(&user_id).await + }; + if let Ok(Some(room_member)) = member { + avatar_url = room_member.avatar_url().map(|u| u.to_owned()); + update = Some(UserProfileUpdate { + new_profile: UserProfile { + username: room_member.display_name().map(|u| u.to_owned()), + user_id: user_id.clone(), + avatar_img_data: None, // will be fetched below + }, + room_member: Some((room_id.to_owned(), room_member)), + }); + } else { + log!("User profile request: user {user_id} was not a member of room {room_id}"); + } + } else { + log!("User profile request: client could not get room with ID {room_id}"); + } + } + + if update.is_none() && !local_only { + // Note: in newer versions of the SDK, `client.get_profile()` is now `client.account().get_profile()`. + if let Ok(response) = client.get_profile(&user_id).await { + avatar_url = response.avatar_url; + update = Some(UserProfileUpdate { + new_profile: UserProfile { + username: response.displayname, + user_id: user_id.clone(), + avatar_img_data: None, // will be fetched below + }, + room_member: None, + }); + } else { + log!("User profile request: client could not get user with ID {user_id}"); + } + } + + if let Some(mut u) = update { + log!("Successfully completed get user profile request: user: {user_id}, room: {room_id:?}, local_only: {local_only}."); + if let Some(uri) = avatar_url { + let avatar_img_data = AVATAR_CACHE + .get_media_or_fetch_async(client, uri, None) + .await; + u.new_profile.avatar_img_data = avatar_img_data; + } + enqueue_user_profile_update(u); + SignalToUI::set_ui_signal(); + } else { + log!("Failed to get user profile: user: {user_id}, room: {room_id:?}, local_only: {local_only}."); + } + }); + } + + + MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = CLIENT.get() else { continue }; let media = client.media();