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..b89ee1b9 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,93 @@ 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 UserProfileSlidingPane, and then after that, + // the UserProfileSlidingPane itself will 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(), + // TODO: provide the extra `via` parameters from `matrix_to_uri.via()`. + room_member: None, + }, + ); + 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 +913,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 +949,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 +1026,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 +1804,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..995d09b4 100644 --- a/src/profile/user_profile.rs +++ b/src/profile/user_profile.rs @@ -1,10 +1,86 @@ -use std::ops::Deref; +use std::{borrow::Cow, cell::RefCell, collections::{btree_map::Entry, BTreeMap}, ops::Deref, sync::Arc}; +use crossbeam_queue::SegQueue; use makepad_widgets::*; +use matrix_sdk::{room::RoomMember, ruma::{events::room::member::MembershipState, OwnedRoomId, OwnedUserId}}; use crate::{ - shared::avatar::{AvatarInfo, AvatarWidgetExt}, - utils, + shared::avatar::AvatarWidgetExt, sliding_sync::{submit_async_request, MatrixRequest}, 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::*; @@ -176,7 +252,7 @@ live_design! { spacing: 15, align: {x: 0.0, y: 0.0} - role_info_title_label =