From 3d391cc10429f94bb3fc548866c9b9deee909a5c Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Sun, 25 Oct 2026 22:24:11 +0100 Subject: [PATCH] support searching for people, rooms, spaces with loose matching AddRoom only accepted an exact room alias or ID (or a link), and we didn't have any way to find or add a user (to DM them). Now we have the same text input for searching rooms and spaces, but it's connected to the homeserver directory, as well as a new text input for searching for users. They both also now support partial input, where we try to figure out whether it's an alias or user or whatever. Joined rooms/spaces are also shown just as to not confuse the users. Barely related: `parse_matrix_link()` no longer panics if matrix.to links with empty segments Fixes #1174 Fixes #1184 --- src/directory_search.rs | 661 ++++++++++++++++++ src/home/add_room.rs | 1032 +++++++++++++++++++++++++---- src/home/room_screen.rs | 37 +- src/lib.rs | 1 + src/profile/user_profile.rs | 77 ++- src/profile/user_profile_cache.rs | 15 + src/room/mod.rs | 6 +- src/room/room_members_list.rs | 15 +- src/shared/avatar.rs | 31 +- src/shared/list_rows.rs | 45 +- src/shared/mention_popup.rs | 34 +- src/sliding_sync.rs | 75 ++- src/utils.rs | 62 +- 13 files changed, 1871 insertions(+), 220 deletions(-) create mode 100644 src/directory_search.rs diff --git a/src/directory_search.rs b/src/directory_search.rs new file mode 100644 index 000000000..cfc470ab8 --- /dev/null +++ b/src/directory_search.rs @@ -0,0 +1,661 @@ +//! Searching for rooms, spaces, and users by name, alias, ID, or Matrix link. +//! +//! A [`SearchQuery`] is parsed leniently, so a partial ID like `#robrix` or `alice` is completed with +//! our own homeserver's name, and we also search our homeserver's room and user directories for it. + +use futures_util::future::join_all; +use makepad_widgets::Cx; +use matrix_sdk::{Client, Room, RoomDisplayName, RoomState, room_preview::RoomPreview}; +use ruma::{ + OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomAliasId, RoomId, ServerName, UInt, UserId, + api::{client::{alias::get_alias, directory::get_public_rooms_filtered, profile::{AvatarUrl, DisplayName}, user_directory}, error::ErrorKind}, + directory::{PublicRoomsChunk, RoomTypeFilter}, matrix_uri::MatrixId, room::RoomType, +}; + +use crate::{profile::user_profile::UserProfile, room::FetchedRoomPreview, shared::avatar::AvatarState, utils::{self, RoomNameId}}; + +/// How many results we ask for at a time, which is the spec's default for user directory searches. +pub const SEARCH_PAGE_SIZE: u32 = 10; + +/// Whether a room directory search is for rooms or for spaces. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RoomKind { + Room, + Space, +} + +/// The text in a search box, parsed leniently such that it can be a partial ID. +#[derive(Clone, Debug, PartialEq)] +pub enum SearchQuery { + /// A link to a room, a space, or an event in a room. + RoomLink { + room_or_alias_id: OwnedRoomOrAliasId, + via: Vec, + }, + /// A link to a user. + UserLink(OwnedUserId), + /// Text starting with `#` or `!`, i.e., a room alias or room ID, maybe without its server name. + Room(String), + /// Text starting with `@`, i.e., a user ID, maybe without its server name. + User(String), + /// Any other text, which could be the name, alias, or ID of a room or a user. + Text(String), +} + +impl SearchQuery { + /// Parses the given search text, returning `None` if it's blank. + pub fn parse(text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + // ruma only accepts a matrix.to link that starts with exactly `https://matrix.to/#/`, + // so we also accept one that's missing its `https://`, has an extra `www.`, or isn't lowercase. + let lowercase = text.to_ascii_lowercase(); + let without_scheme = ["https://", "http://"].iter() + .find_map(|scheme| lowercase.strip_prefix(scheme)) + .unwrap_or(&lowercase); + let without_www = without_scheme.strip_prefix("www.").unwrap_or(without_scheme); + let matrix_link = match without_www.strip_prefix("matrix.to/#/") { + // An ASCII-lowercase string has the same length as the original, so this keeps the IDs' case. + Some(ids) => utils::parse_matrix_link(&format!("https://matrix.to/#/{}", &text[text.len() - ids.len()..])), + None => utils::parse_matrix_link(text), + }; + if let Some((matrix_id, via)) = matrix_link { + match matrix_id { + MatrixId::Room(room_id) => return Some(Self::RoomLink { room_or_alias_id: room_id.into(), via }), + MatrixId::RoomAlias(alias) => return Some(Self::RoomLink { room_or_alias_id: alias.into(), via }), + MatrixId::Event(room_or_alias_id, _) => return Some(Self::RoomLink { room_or_alias_id, via }), + MatrixId::User(user_id) => return Some(Self::UserLink(user_id)), + _ => {} + } + } + Some(match text.as_bytes()[0] { + b'#' | b'!' => Self::Room(text.to_owned()), + b'@' => Self::User(text.to_owned()), + _ => Self::Text(text.to_owned()), + }) + } + + /// Returns whether this query could match a room or space. + pub fn can_match_rooms(&self) -> bool { + !matches!(self, Self::User(_) | Self::UserLink(_)) + } + + /// Returns whether this query could match a user. + pub fn can_match_users(&self) -> bool { + !matches!(self, Self::Room(_) | Self::RoomLink { .. }) + } + + /// Returns the address of the room that this query fully spells out. + /// + /// That's a room link or a room ID with its server name, but not an alias, whose case might not match. + pub fn full_room_address(&self) -> Option<(OwnedRoomOrAliasId, Vec)> { + match self { + Self::RoomLink { room_or_alias_id, via } => Some((room_or_alias_id.clone(), via.clone())), + Self::Room(text) if text.starts_with('!') && text.contains(':') => { + OwnedRoomOrAliasId::try_from(text.as_str()).ok().map(|room_id| (room_id, Vec::new())) + } + _ => None, + } + } + + /// Returns whether this query is a link, or an alias or ID with its server name, + /// such that it can only be one room or user, unlike a partial ID or a name. + pub fn is_fully_qualified(&self) -> bool { + match self { + Self::RoomLink { .. } => true, + Self::Room(text) => text.contains(':') && OwnedRoomOrAliasId::try_from(text.as_str()).is_ok(), + Self::UserLink(_) | Self::User(_) => self.full_user_id().is_some(), + Self::Text(_) => false, + } + } + + /// Returns the room aliases and IDs that this query could exactly be, + /// using `own_server` for any that are missing their server name. + fn room_candidates(&self, own_server: Option<&ServerName>) -> Vec<(OwnedRoomOrAliasId, Vec)> { + let ids = match self { + Self::RoomLink { room_or_alias_id, via } => return vec![(room_or_alias_id.clone(), via.clone())], + Self::Room(text) if text.starts_with('!') => { + // Room IDs are case sensitive, and newer ones don't have a server name at all. + if text.len() == 1 || text.chars().any(char::is_whitespace) { + return Vec::new(); + } + let mut ids = vec![text.clone()]; + if !text.contains(':') && let Some(own_server) = own_server { + ids.push(format!("{text}:{own_server}")); + } + ids + } + Self::Room(text) => complete_ids('#', &text[1..], own_server), + Self::Text(text) => complete_ids('#', text, own_server), + Self::User(_) | Self::UserLink(_) => return Vec::new(), + }; + ids.into_iter() + .filter_map(|id| OwnedRoomOrAliasId::try_from(id).ok()) + .map(|id| (id, Vec::new())) + .collect() + } + + /// Returns the user IDs that this query could exactly be, + /// using `own_server` for any that are missing their server name. + fn user_candidates(&self, own_server: Option<&ServerName>) -> Vec { + let ids = match self { + Self::UserLink(user_id) => return vec![user_id.clone()], + Self::User(text) => complete_ids('@', &text[1..], own_server), + Self::Text(text) => complete_ids('@', text, own_server), + Self::Room(_) | Self::RoomLink { .. } => return Vec::new(), + }; + ids.into_iter().filter_map(|id| UserId::parse(id).ok()).collect() + } + + /// Returns the user ID that this query exactly spells out, if any. + fn full_user_id(&self) -> Option { + match self { + Self::UserLink(user_id) => Some(user_id.clone()), + Self::User(text) => UserId::parse(text).ok(), + _ => None, + } + } + + /// Returns the text to search for in the room directory, if any. + fn room_search_term(&self) -> Option<&str> { + match self { + // The directory matches an alias's localpart, so we leave out the sigil and server. + Self::Room(text) if text.starts_with('#') => text[1..].split(':').next().filter(|t| !t.is_empty()), + Self::Text(text) => Some(text), + _ => None, + } + } + + /// Returns the text to search for in the user directory, if any. + fn user_search_term(&self) -> Option<&str> { + match self { + Self::User(text) => Some(text[1..].trim_end_matches(':')).filter(|t| !t.is_empty()), + Self::Text(text) => Some(text), + _ => None, + } + } +} + +/// A room or space found by a search. +#[derive(Clone, Debug)] +pub struct RoomSearchResult { + /// Everything we know about this room, so we can show its preview as soon as the user picks it. + pub preview: FetchedRoomPreview, + /// Whether this room is exactly what was entered, rather than a room directory match. + pub is_exact_match: bool, +} + +impl RoomSearchResult { + pub fn room_id(&self) -> &OwnedRoomId { + self.preview.room_name_id.room_id() + } + + pub fn is_space(&self) -> bool { + self.preview.room_type == Some(RoomType::Space) + } + + /// Returns the alias to show for this room, if it has one. + pub fn alias(&self) -> Option<&str> { + self.preview.canonical_alias.as_ref().or(self.preview.alias.as_ref()).map(|alias| alias.as_str()) + } + + /// Converts the preview of a room that we looked up by the given alias or ID. + fn from_preview(preview: RoomPreview, address: OwnedRoomOrAliasId, via: Vec) -> Self { + let room_avatar = utils::avatar_from_room_name(preview.name.as_deref()); + let alias = <&RoomId>::try_from(&*address).err().map(ToOwned::to_owned); + Self { + preview: FetchedRoomPreview::from(preview, room_avatar, via, alias), + is_exact_match: true, + } + } + + /// Converts a room listed in our homeserver's room directory, which we can thus join via our homeserver. + /// + /// If we already know this room, e.g., because we've joined it, its preview says so, like a fetched one does. + async fn from_directory(public_room: PublicRoomsChunk, own_server: Option, known_room: Option) -> Self { + let (state, is_direct, local_name) = match &known_room { + Some(room) => { + let state = room.state(); + // Like a fetched preview, we only use our own copy of the name of a room that we're in or banned from. + let local_name = match state { + RoomState::Joined | RoomState::Banned => room.display_name().await.ok(), + _ => None, + }; + (Some(state), room.is_direct().await.ok(), local_name) + } + None => (None, None, None), + }; + let display_name = local_name + .or_else(|| public_room.name.clone().map(RoomDisplayName::Named)) + .unwrap_or(RoomDisplayName::Empty); + let room_name_id = RoomNameId::new(display_name, public_room.room_id); + Self { + preview: FetchedRoomPreview { + room_avatar: utils::avatar_from_room_name(room_name_id.name_for_avatar()), + room_name_id, + via: own_server.into_iter().collect(), + alias: None, + canonical_alias: public_room.canonical_alias, + topic: public_room.topic, + avatar_url: public_room.avatar_url.filter(|url| url.is_valid()), + num_joined_members: public_room.num_joined_members.into(), + num_active_members: None, + room_type: public_room.room_type, + join_rule: Some(public_room.join_rule.into()), + is_world_readable: Some(public_room.world_readable), + state, + is_direct, + heroes: None, + }, + is_exact_match: false, + } + } +} + +/// A user found by a search. +#[derive(Clone, Debug)] +pub struct UserSearchResult { + pub user_profile: UserProfile, + /// Whether this is exactly who was entered, rather than a user directory match. + /// + /// It's `false` for an entered user ID whose profile we couldn't see, since they might not exist. + pub is_exact_match: bool, +} + +/// The results of a search, posted by the functions in this module. +#[derive(Debug)] +pub enum DirectorySearchAction { + /// The rooms and spaces we've joined whose name or alias contains the given `query`. + JoinedRooms { + query: String, + rooms: Vec, + }, + /// The rooms and spaces that the given `query` could exactly be, e.g., its full or completed alias. + ExactRooms { + query: String, + rooms: Vec, + }, + /// The users that the given `query` could exactly be, e.g., their full or completed user ID. + ExactUsers { + query: String, + users: Vec, + }, + /// The room directory's matches for the given `query`, either rooms or spaces. + DirectoryRooms { + query: String, + kind: RoomKind, + rooms: Vec, + /// The token for getting the next page of matches, if there are more. + next_batch: Option, + /// Whether these rooms are another page of matches, rather than the first page. + is_next_page: bool, + error: Option, + }, + /// The user directory's matches for the given `query`. + DirectoryUsers { + query: String, + users: Vec, + /// The maximum number of users that we asked the user directory for. + limit: u32, + /// Whether the user directory has more matching users than it gave us. + is_limited: bool, + error: Option, + }, +} + +/// Finds the rooms and spaces we've joined that match the given query text, and the ones it could exactly be, +/// posting them as a [`DirectorySearchAction::JoinedRooms`] and then a [`DirectorySearchAction::ExactRooms`]. +pub async fn resolve_room_search_query(client: Client, query: String) { + let Some(search_query) = SearchQuery::parse(&query) else { return }; + let mut joined_rooms = Vec::new(); + if let Some(term) = search_query.room_search_term().map(str::to_lowercase) { + for room in client.joined_rooms() { + // An alias's server name would match every room on that server, so we only match its localpart. + let is_match = room.name().is_some_and(|name| name.to_lowercase().contains(&term)) + || room.canonical_alias().into_iter().chain(room.alt_aliases()) + .any(|alias| alias.alias().to_lowercase().contains(&term)); + // The other search box finds people. + if !is_match || room.is_direct().await.unwrap_or(false) { + continue; + } + // We're in this room, so its preview comes from our own copy of it. + let address = OwnedRoomOrAliasId::from(room.room_id().to_owned()); + if let Ok(preview) = client.get_room_preview(&address, Vec::new()).await { + joined_rooms.push(RoomSearchResult { is_exact_match: false, ..RoomSearchResult::from_preview(preview, address, Vec::new()) }); + } + } + } + // We post these first, so whoever has the exact matches has these too. + Cx::post_action(DirectorySearchAction::JoinedRooms { query: query.clone(), rooms: joined_rooms }); + + let own_server = client.user_id().map(|user_id| user_id.server_name().to_owned()); + // Like `RequestConfig::short_retry()`, but this keeps our client's longer timeout. + let request_config = client.request_config().retry_limit(3); + let exact_rooms = join_all(search_query.room_candidates(own_server.as_deref()).into_iter().map(|(address, via)| { + let client = &client; + async move { + // The SDK would resolve the alias with no limit on its retries, which could take many minutes. + if let Ok(alias) = <&RoomAliasId>::try_from(&*address) { + client.send(get_alias::v3::Request::new(alias.to_owned())) + .with_request_config(request_config) + .await + .ok()?; + } + let preview = client.get_room_preview(&address, via.clone()).await.ok()?; + Some(RoomSearchResult::from_preview(preview, address, via)) + } + })).await; + + let mut rooms: Vec = Vec::new(); + // An alias and its lowercase version can belong to the same room. + for room in exact_rooms.into_iter().flatten() { + if !rooms.iter().any(|r| r.room_id() == room.room_id()) { + rooms.push(room); + } + } + Cx::post_action(DirectorySearchAction::ExactRooms { query, rooms }); +} + +/// Looks up the users that the given query text could exactly be, +/// posting them as a [`DirectorySearchAction::ExactUsers`]. +pub async fn resolve_user_search_query(client: Client, query: String) { + let Some(search_query) = SearchQuery::parse(&query) else { return }; + let own_server = client.user_id().map(|user_id| user_id.server_name().to_owned()); + let full_user_id = search_query.full_user_id(); + let exact_users = join_all(search_query.user_candidates(own_server.as_deref()).into_iter().map(|user_id| { + let client = &client; + // Candidates have a lowercase server name. + let is_full_user_id = full_user_id.as_ref().is_some_and(|full| { + full.localpart() == user_id.localpart() + && full.server_name().as_str().eq_ignore_ascii_case(user_id.server_name().as_str()) + }); + async move { + match client.account().fetch_user_profile_of(&user_id).await { + Ok(response) => Some(UserSearchResult { + user_profile: UserProfile { + username: response.get_static::().ok().flatten(), + avatar_state: response.get_static::().ok().map_or(AvatarState::Unknown, AvatarState::Known), + user_id, + }, + is_exact_match: true, + }), + // A homeserver may refuse to show the profile of someone who doesn't share a room with us, + // so we still show an entered user ID, though we can't tell whether that user exists. + Err(e) if is_full_user_id && matches!(e.client_api_error_kind(), Some(ErrorKind::Forbidden)) => { + Some(UserSearchResult { + user_profile: UserProfile { user_id, username: None, avatar_state: AvatarState::Unknown }, + is_exact_match: false, + }) + } + Err(_) => None, + } + } + })).await; + let users = exact_users.into_iter().flatten().collect(); + Cx::post_action(DirectorySearchAction::ExactUsers { query, users }); +} + +/// Searches our homeserver's room directory for rooms or spaces matching the query, posting a [`DirectorySearchAction::DirectoryRooms`]. +/// +/// If `next_batch` is given, this gets that next page of matches. +pub async fn search_room_directory(client: Client, query: String, kind: RoomKind, next_batch: Option) { + let Some(search_query) = SearchQuery::parse(&query) else { return }; + let own_server = client.user_id().map(|user_id| user_id.server_name().to_owned()); + let is_next_page = next_batch.is_some(); + let result = match search_query.room_search_term() { + Some(term) => { + let mut request = get_public_rooms_filtered::v3::Request::new(); + request.filter.generic_search_term = Some(term.to_owned()); + request.filter.room_types = vec![match kind { + RoomKind::Room => RoomTypeFilter::Default, + RoomKind::Space => RoomTypeFilter::Space, + }]; + request.limit = Some(UInt::from(SEARCH_PAGE_SIZE)); + request.since = next_batch; + client.send(request) + .with_request_config(client.request_config().retry_limit(3)) + .await + .map(|response| (response.chunk, response.next_batch)) + } + None => Ok((Vec::new(), None)), + }; + let (rooms, next_batch, error) = match result { + Ok((public_rooms, next_batch)) => { + let mut rooms = Vec::with_capacity(public_rooms.len()); + for public_room in public_rooms { + let known_room = client.get_room(&public_room.room_id); + rooms.push(RoomSearchResult::from_directory(public_room, own_server.clone(), known_room).await); + } + (rooms, next_batch, None) + } + Err(e) => (Vec::new(), None, Some(format!( + "Couldn't search the room directory: {}", + utils::stringify_matrix_error(&e.into()), + ))), + }; + Cx::post_action(DirectorySearchAction::DirectoryRooms { query, kind, rooms, next_batch, is_next_page, error }); +} + +/// Searches our homeserver's user directory for the given query text, getting up to `limit` users, +/// and posts the results as a [`DirectorySearchAction::DirectoryUsers`]. +pub async fn search_user_directory(client: Client, query: String, limit: u32) { + let Some(search_query) = SearchQuery::parse(&query) else { return }; + let result = match search_query.user_search_term() { + Some(term) => { + let mut request = user_directory::search_users::v3::Request::new(term.to_owned()); + request.limit = UInt::from(limit); + client.send(request) + .with_request_config(client.request_config().retry_limit(3)) + .await + .map(|response| { + // Synapse gives at most 50 users but still says there are more, so we only offer more + // if we got more users than the previous, smaller search could have given us. + let is_limited = response.limited + && response.results.len() > (limit as usize).saturating_sub(SEARCH_PAGE_SIZE as usize); + (response.results, is_limited) + }) + } + None => Ok((Vec::new(), false)), + }; + let (users, is_limited, error) = match result { + Ok((users, is_limited)) => ( + users.into_iter() + .map(|user| UserSearchResult { + user_profile: UserProfile { + avatar_state: AvatarState::Known(user.avatar_url.filter(|url| url.is_valid())), + username: user.display_name, + user_id: user.user_id, + }, + is_exact_match: false, + }) + .collect(), + is_limited, + None, + ), + Err(e) => (Vec::new(), false, Some(format!( + "Couldn't search the user directory: {}", + utils::stringify_matrix_error(&e.into()), + ))), + }; + Cx::post_action(DirectorySearchAction::DirectoryUsers { query, users, limit, is_limited, error }); +} + +/// Returns the full IDs that `localpart_and_server` could be with the given `sigil`, using `own_server` if needed. +/// +/// IDs are case sensitive but almost always lowercase, so we try the lowercase one too. +fn complete_ids(sigil: char, localpart_and_server: &str, own_server: Option<&ServerName>) -> Vec { + let (localpart, server) = localpart_and_server.split_once(':').unwrap_or((localpart_and_server, "")); + let server = match (server, own_server) { + ("", Some(own_server)) => own_server.as_str().to_owned(), + ("", None) => return Vec::new(), + (server, _) => server.to_ascii_lowercase(), + }; + if localpart.is_empty() + || localpart_and_server.chars().any(char::is_whitespace) + || ServerName::parse(&server).is_err() + { + return Vec::new(); + } + let id = format!("{sigil}{localpart}:{server}"); + let lowercase_id = format!("{sigil}{}:{server}", localpart.to_lowercase()); + if lowercase_id == id { vec![id] } else { vec![id, lowercase_id] } +} + +#[cfg(test)] +mod tests_search_query { + use super::*; + + fn own_server() -> Option<&'static ServerName> { + Some(<&ServerName>::try_from("matrix.org").unwrap()) + } + + fn room_ids(query: &str) -> Vec { + SearchQuery::parse(query).unwrap() + .room_candidates(own_server()) + .into_iter() + .map(|(id, _)| id.to_string()) + .collect() + } + + fn user_ids(query: &str) -> Vec { + SearchQuery::parse(query).unwrap() + .user_candidates(own_server()) + .into_iter() + .map(|id| id.to_string()) + .collect() + } + + #[test] + fn parses_kinds_of_queries() { + assert_eq!(SearchQuery::parse(" "), None); + assert_eq!(SearchQuery::parse(" #robrix "), Some(SearchQuery::Room("#robrix".into()))); + assert_eq!(SearchQuery::parse("!abc"), Some(SearchQuery::Room("!abc".into()))); + assert_eq!(SearchQuery::parse("@alice"), Some(SearchQuery::User("@alice".into()))); + assert_eq!(SearchQuery::parse("Robrix Dev"), Some(SearchQuery::Text("Robrix Dev".into()))); + assert!(matches!( + SearchQuery::parse("https://matrix.to/#/#robrix:matrix.org"), + Some(SearchQuery::RoomLink { room_or_alias_id, .. }) if room_or_alias_id.as_str() == "#robrix:matrix.org" + )); + assert!(matches!( + SearchQuery::parse("matrix:u/alice:matrix.org"), + Some(SearchQuery::UserLink(id)) if id.as_str() == "@alice:matrix.org" + )); + } + + #[test] + fn parses_lenient_matrix_to_links() { + assert!(matches!( + SearchQuery::parse("matrix.to/#/@Alice:matrix.org"), + Some(SearchQuery::UserLink(id)) if id.as_str() == "@Alice:matrix.org" + )); + let Some(SearchQuery::RoomLink { room_or_alias_id, via }) = SearchQuery::parse("HTTP://WWW.Matrix.To/#/!abc:matrix.org?via=example.org") else { + panic!("expected a room link"); + }; + assert_eq!(room_or_alias_id.as_str(), "!abc:matrix.org"); + assert_eq!(via.iter().map(|s| s.as_str()).collect::>(), ["example.org"]); + // A link to an event gives us the event's room. + assert!(matches!( + SearchQuery::parse("https://matrix.to/#/#robrix:matrix.org/$event:matrix.org"), + Some(SearchQuery::RoomLink { room_or_alias_id, .. }) if room_or_alias_id.as_str() == "#robrix:matrix.org" + )); + // Something that's not quite a link is just searched for as text. + assert!(matches!(SearchQuery::parse("matrix.to/#/robrix"), Some(SearchQuery::Text(_)))); + } + + #[test] + fn says_which_sections_apply() { + let room = SearchQuery::parse("#robrix").unwrap(); + assert!(room.can_match_rooms() && !room.can_match_users()); + let user = SearchQuery::parse("https://matrix.to/#/@alice:matrix.org").unwrap(); + assert!(!user.can_match_rooms() && user.can_match_users()); + let text = SearchQuery::parse("alice").unwrap(); + assert!(text.can_match_rooms() && text.can_match_users()); + } + + #[test] + fn finds_full_room_addresses() { + let address = |query: &str| SearchQuery::parse(query).unwrap().full_room_address().map(|(id, _)| id.to_string()); + assert_eq!(address("!abc:matrix.org").as_deref(), Some("!abc:matrix.org")); + assert_eq!(address("https://matrix.to/#/#robrix:matrix.org").as_deref(), Some("#robrix:matrix.org")); + assert_eq!(address("!abc"), None); + assert_eq!(address("#robrix:matrix.org"), None); + assert_eq!(address("robrix"), None); + } + + #[test] + fn finds_fully_qualified_queries() { + let is_fully_qualified = |query: &str| SearchQuery::parse(query).unwrap().is_fully_qualified(); + assert!(is_fully_qualified("#robrix:matrix.org")); + assert!(is_fully_qualified("!abc:matrix.org")); + assert!(is_fully_qualified("@alice:matrix.org")); + assert!(is_fully_qualified("https://matrix.to/#/#robrix:matrix.org")); + assert!(is_fully_qualified("matrix:u/alice:matrix.org")); + assert!(!is_fully_qualified("#robrix")); + assert!(!is_fully_qualified("#robrix:")); + assert!(!is_fully_qualified("!abc")); + assert!(!is_fully_qualified("@alice")); + assert!(!is_fully_qualified("robrix")); + assert!(!is_fully_qualified("robrix:matrix.org")); + } + + #[test] + fn completes_partial_room_aliases() { + assert_eq!(room_ids("#robrix"), ["#robrix:matrix.org"]); + assert_eq!(room_ids("#robrix:"), ["#robrix:matrix.org"]); + assert_eq!(room_ids("robrix"), ["#robrix:matrix.org"]); + assert_eq!(room_ids("robrix:example.org"), ["#robrix:example.org"]); + assert_eq!(room_ids("#Robrix:Example.org"), ["#Robrix:example.org", "#robrix:example.org"]); + assert!(room_ids("#").is_empty()); + assert!(room_ids("#robrix:not a server").is_empty()); + assert!(room_ids("Robrix Dev").is_empty()); + assert!(room_ids("@alice").is_empty()); + } + + #[test] + fn completes_partial_room_ids() { + assert_eq!(room_ids("!abc"), ["!abc", "!abc:matrix.org"]); + assert_eq!(room_ids("!AbC:example.org"), ["!AbC:example.org"]); + assert!(room_ids("!").is_empty()); + assert!(room_ids("!abc def").is_empty()); + assert_eq!(room_ids("https://matrix.to/#/!abc:example.org?via=example.org"), ["!abc:example.org"]); + } + + #[test] + fn completes_partial_user_ids() { + assert_eq!(user_ids("@alice"), ["@alice:matrix.org"]); + assert_eq!(user_ids("alice"), ["@alice:matrix.org"]); + assert_eq!(user_ids("alice:Example.org"), ["@alice:example.org"]); + assert_eq!(user_ids("@Alice"), ["@Alice:matrix.org", "@alice:matrix.org"]); + assert_eq!(user_ids("matrix:u/bob:example.org"), ["@bob:example.org"]); + assert!(user_ids("@").is_empty()); + assert!(user_ids("alice smith").is_empty()); + assert!(user_ids("#robrix").is_empty()); + assert!(SearchQuery::User("@alice".into()).user_candidates(None).is_empty()); + } + + #[test] + fn finds_full_user_ids() { + let full_user_id = |query: &str| SearchQuery::parse(query).unwrap().full_user_id().map(|id| id.to_string()); + assert_eq!(full_user_id("@alice:example.org").as_deref(), Some("@alice:example.org")); + assert_eq!(full_user_id("https://matrix.to/#/@alice:example.org").as_deref(), Some("@alice:example.org")); + assert_eq!(full_user_id("@alice"), None); + assert_eq!(full_user_id("alice:example.org"), None); + } + + #[test] + fn picks_directory_search_terms() { + let terms = |query: &str| { + let query = SearchQuery::parse(query).unwrap(); + (query.room_search_term().map(str::to_owned), query.user_search_term().map(str::to_owned)) + }; + assert_eq!(terms("Robrix Dev"), (Some("Robrix Dev".into()), Some("Robrix Dev".into()))); + assert_eq!(terms("#robrix:matrix.org"), (Some("robrix".into()), None)); + assert_eq!(terms("#"), (None, None)); + assert_eq!(terms("!abc:matrix.org"), (None, None)); + assert_eq!(terms("@alice:"), (None, Some("alice".into()))); + assert_eq!(terms("@alice:matrix.org"), (None, Some("alice:matrix.org".into()))); + assert_eq!(terms("https://matrix.to/#/@alice:matrix.org"), (None, None)); + } +} diff --git a/src/home/add_room.rs b/src/home/add_room.rs index 5cbf48c73..46850a767 100644 --- a/src/home/add_room.rs +++ b/src/home/add_room.rs @@ -1,27 +1,89 @@ -//! A top-level view for adding (joining) or exploring new rooms and spaces. +//! A top-level view for adding (joining) or exploring new rooms and spaces, and for finding users. +use std::collections::HashSet; + use makepad_widgets::*; use matrix_sdk::RoomState; -use ruma::{IdParseError, OwnedRoomOrAliasId, OwnedServerName, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; +use ruma::{OwnedRoomOrAliasId, OwnedServerName, RoomId, room::{JoinRuleSummary, RoomType}}; -use crate::{app::AppStateAction, home::invite_screen::JoinRoomResultAction, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{avatar::AvatarWidgetRefExt, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{MatrixRequest, RoomPreviewResponseMode, submit_async_request}, utils}; +use crate::{app::AppStateAction, avatar_cache::{self, AvatarCacheEntry}, directory_search::{DirectorySearchAction, RoomKind, RoomSearchResult, SEARCH_PAGE_SIZE, SearchQuery, UserSearchResult}, home::invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, logout::logout_confirm_modal::LogoutAction, profile::{user_profile::{UserProfilePaneInfo, UserProfileSlidingPaneWidgetExt, UserProfileSlidingPaneWidgetRefExt}, user_profile_cache}, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{avatar::{AvatarWidgetRefExt, TextAvatarBgColor}, list_rows::{draw_flat_list_status_row, handle_row_actions}, popup_list::{PopupKind, enqueue_popup_notification}, styles::set_view_bg_color}, sliding_sync::{MatrixRequest, RoomPreviewResponseMode, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* - // The main view that allows the user to add (join) or explore new rooms/spaces. - mod.widgets.AddRoomScreen = #(AddRoomScreen::register_widget(vm)) { - ..mod.widgets.ScrollYView + // A row with a search box and a button that searches right away. + let SearchInputRow = View { + width: Fill, + height: Fit, + margin: Inset{ top: 3, bottom: 4 } + align: Align{y: 0.5} + spacing: 5 + flow: Right + } + + let SearchInput = RobrixTextInput { + margin: Inset{top: 0, left: 5, right: 5, bottom: 0}, + padding: Inset{left: 12, right: 12, top: 0, bottom: 0} + width: Fill { max: 400 } // same width as the above `help_info` + height: 40 + autocapitalize: None, + autocorrect: Disabled, + } + + let SearchButton = RobrixIconButton { + padding: Inset{top: 10, bottom: 10, left: 12, right: 14} + height: 40 + draw_icon.svg: (ICON_SEARCH) + icon_walk: Walk{width: 16, height: 16} + text: "Go" + } + + // Search results in sections, each with an optional header, its rows, and a "Load more" button. + // These rows have their own draw list, so they aren't redrawn whenever a text input's cursor blinks. + let SearchResultsList = View { + width: Fill, height: Fit + new_batch: true + list := FlatList { + width: Fill, height: Fit + margin: Inset{left: 5, right: 5, bottom: 5} + scroll_bars: ScrollBars { show_scroll_x: false, show_scroll_y: false } + section_header := SubsectionLabel { + margin: Inset{top: 12, bottom: 4} + draw_text +: { text_style: theme.font_bold { font_size: 11.5 } } + } + result_row := mod.widgets.AvatarListRow {} + // The rows below are left-aligned with the results' avatars. + loading_row := mod.widgets.ListLoadingRow { + align: Align{x: 0.0, y: 0.5} + padding: Inset{left: 10, right: 10, top: 13, bottom: 13} + } + empty_row := mod.widgets.ListEmptyRow { + padding: Inset{left: 10, right: 10, top: 13, bottom: 13} + empty_label +: { padding: theme.mspace_1{left: 0.}, align: Align{x: 0.0, y: 0.0} } + } + load_more_row := View { + width: Fill, height: Fit + padding: Inset{left: 10, right: 10, top: 5, bottom: 5} + load_more_button := RobrixNeutralIconButton { + padding: Inset{top: 8, bottom: 8, left: 14, right: 14} + text: "Load more" + } + } + } + } + + // The scrollable content of the AddRoomScreen, beneath its user profile pane. + let AddRoomScreenContent = ScrollYView { width: Fill, height: Fill, flow: Down, padding: Inset{top: 5, left: 15, right: 15, bottom: 0}, title := TitleLabel { - text: "Add/Explore Rooms and Spaces" + text: "Add/Explore Rooms, Spaces, and People" draw_text +: { text_style: theme.font_regular {font_size: 18}, } @@ -41,40 +103,21 @@ script_mod! { width: Fill, height: Fit font_size: 10. font_color: (MESSAGE_TEXT_COLOR) - body: "

You can enter a room/space address using either:

+ body: "

Search by name, or enter a room/space address using either:

  • An alias, starting with #, like #robrix:matrix.org.
  • An ID, starting with !, like !room_id:matrix.org.
  • -
  • A Matrix link, like matrix:... or https:matrix.to/....
  • +
  • A Matrix link, like matrix:... or https://matrix.to/....
+

Partial addresses work too: without a :server part, we look on your own homeserver.

" } - join_room_view := View { - width: Fill, - height: Fit, - margin: Inset{ top: 3, bottom: 4 } - align: Align{y: 0.5} - spacing: 5 - flow: Right - - room_alias_id_input := RobrixTextInput { - margin: Inset{top: 0, left: 5, right: 5, bottom: 0}, - padding: Inset{left: 12, right: 12, top: 0, bottom: 0} - width: Fill { max: 400 } // same width as the above `help_info` - height: 40 - empty_text: "Enter alias, ID, or Matrix link..." - autocapitalize: None, - autocorrect: Disabled, - } - - search_for_room_button := RobrixIconButton { - padding: Inset{top: 10, bottom: 10, left: 12, right: 14} - height: 40 - draw_icon.svg: (ICON_SEARCH) - icon_walk: Walk{width: 16, height: 16} - text: "Go" + join_room_view := SearchInputRow { + room_alias_id_input := SearchInput { + empty_text: "Enter a name, alias, ID, or Matrix link..." } + search_for_room_button := SearchButton {} } loading_room_view := View { @@ -246,25 +289,84 @@ script_mod! { } } + // The results of searching for rooms and spaces, in separate lists. + room_results := SearchResultsList {} + + LineH { padding: 10, margin: Inset{top: 15, right: 2} } + + SubsectionLabel { + text: "Find people:" + } + + user_help_info := MessageHtml { + padding: 7 + width: Fill, height: Fit + font_size: 10. + font_color: (MESSAGE_TEXT_COLOR) + body: "

Search by name, or enter a user ID like @alice:matrix.org. + Partial IDs work too: without a :server part, we look on your own homeserver.

+ " + } + + find_user_view := SearchInputRow { + user_search_input := SearchInput { + empty_text: "Enter a name or user ID..." + } + search_for_user_button := SearchButton {} + } + + user_results := SearchResultsList {} + View { width: Fill height: 20 } } + + // The main view that allows the user to add (join) or explore new rooms/spaces, and find people. + mod.widgets.AddRoomScreen = #(AddRoomScreen::register_widget(vm)) { + width: Fill, height: Fill + flow: Overlay + result_row_color: (COLOR_TRANSPARENT) + result_row_color_hover: (COLOR_LIST_ITEM_BG_HOVER) + + content := AddRoomScreenContent {} + + // Shown upon clicking a person in the search results, on top of all other content. + user_profile_sliding_pane := mod.widgets.UserProfileSlidingPane { } + } } -#[derive(Script, ScriptHook, Widget)] +/// Delay after the last keystroke before we search for rooms, spaces, or people. +const SEARCH_DELAY: f64 = 0.5; + +#[derive(Script, Widget)] pub struct AddRoomScreen { #[deref] view: View, + #[live] result_row_color: Vec4f, + #[live] result_row_color_hover: Vec4f, + #[rust] state: AddRoomState, /// The function to perform when the user clicks the `join_room_button`. #[rust(JoinButtonFunction::None)] join_function: JoinButtonFunction, + #[rust] room_search: Search, + #[rust] user_search: Search>, + /// The search box whose only match we'll open once we've looked it up, since the user pressed Enter before we had. + #[rust] only_match_to_open: Option, + #[rust] result_rows: ResultRows, +} + +impl ScriptHook for AddRoomScreen { + fn on_after_reload(&mut self, _vm: &mut ScriptVm) { + // Some reloads reset the rows' avatars to their template, so we populate every row again. + self.result_rows.populated.clear(); + } } #[derive(Default)] #[allow(clippy::large_enum_variant)] enum AddRoomState { - /// We're waiting for the user to input a room ID, alias, or matrix link. + /// We're waiting for the user to enter what to search for, or to pick a search result. #[default] WaitingOnUserInput, /// We successfully parsed the user input and have sent a request. @@ -346,23 +448,56 @@ impl AddRoomState { impl Widget for AddRoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - + let profile_pane = self.view.child(id!(user_profile_sliding_pane)).as_user_profile_sliding_pane(); + // While the profile pane is shown, it gets all of the user's input. + if utils::is_interactive_hit_event(event) && profile_pane.is_currently_shown(cx) { + profile_pane.handle_event(cx, event, scope); + } else { + self.view.handle_event(cx, event, scope); + } + + if self.room_search.timer.is_event(event).is_some() { + let query = self.view.text_input(cx, ids!(room_alias_id_input)).text(); + self.search_rooms(cx, query.trim()); + } + if self.user_search.timer.is_event(event).is_some() { + let query = self.view.text_input(cx, ids!(user_search_input)).text(); + self.search_users(cx, query.trim()); + } + + // An overlay can take a row's hover-out event, so we un-hover it ourselves. + if let Event::ClearHover = event && self.result_rows.hovered_key.take().is_some() { + self.redraw(cx); + } + if let Event::Actions(actions) = event { let room_alias_id_input = self.view.text_input(cx, ids!(room_alias_id_input)); let search_for_room_button = self.view.button(cx, ids!(search_for_room_button)); + let user_search_input = self.view.text_input(cx, ids!(user_search_input)); + let search_for_user_button = self.view.button(cx, ids!(search_for_user_button)); let cancel_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)); let join_room_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.join_room_button)); // Enable or disable the button based on if the text input is empty. if let Some(text) = room_alias_id_input.changed(actions) { search_for_room_button.set_enabled(cx, !text.trim().is_empty()); + self.room_search.handle_text_changed(cx, &text); + // Typing in either box means the user has moved on from any match they pressed Enter to open. + self.only_match_to_open = None; + // The user is looking for something else now, so we stop showing the room preview. + self.state = AddRoomState::WaitingOnUserInput; + self.redraw(cx); + } + if let Some(text) = user_search_input.changed(actions) { + search_for_user_button.set_enabled(cx, !text.trim().is_empty()); + self.user_search.handle_text_changed(cx, &text); + self.only_match_to_open = None; + self.redraw(cx); } // If the cancel button was clicked, hide the room preview and return to default state. if cancel_button.clicked(actions) { self.state = AddRoomState::WaitingOnUserInput; - room_alias_id_input.set_text(cx, ""); room_alias_id_input.set_key_focus(cx); self.redraw(cx); } @@ -396,12 +531,25 @@ impl Widget for AddRoomScreen { } } - // If the button was clicked or enter was pressed, try to parse the room address. - let new_room_query = search_for_room_button.clicked(actions) - .then(|| room_alias_id_input.text()) - .or_else(|| room_alias_id_input.returned(actions).map(|(t, _)| t)); - if let Some(t) = new_room_query { - self.search_for_room(cx, &t); + // If the button was clicked or enter was pressed, search right away. + if let Some(query) = get_submitted_query(&room_alias_id_input, &search_for_room_button, actions) { + self.search_rooms(cx, &query); + let only_match = self.room_search.results.as_ref() + .and_then(|results| get_only_match(&query, &[&results.rooms, &results.spaces])); + match (only_match, SearchQuery::parse(&query).and_then(|search_query| search_query.full_room_address())) { + // We already have the preview of the query's only match. + (Some(Some(key)), _) => self.open_room_result(cx, key), + (_, Some((room_or_alias_id, via))) => self.request_room_preview(cx, room_or_alias_id, via), + // Other text may match several rooms, so we wait to see whether it matches just one. + (None, None) => self.only_match_to_open = Some(SearchBox::Rooms), + // It matches no rooms, or several of them. + (Some(None), None) => self.only_match_to_open = None, + } + } + if let Some(query) = get_submitted_query(&user_search_input, &search_for_user_button, actions) { + self.search_users(cx, &query); + self.only_match_to_open = Some(SearchBox::People); + self.open_only_match(cx); } // If we're waiting for the room preview to be fetched (i.e., in the Parsed state), @@ -413,14 +561,7 @@ impl Widget for AddRoomScreen { if fetched_id == room_or_alias_id => { let room_or_alias_id = room_or_alias_id.clone(); - self.state = AddRoomState::FetchedRoomPreview { - frp: frp.clone(), - room_or_alias_id, - }; - // Reset the buttons' hover states when they are first shown. - join_room_button.reset_hover(cx); - cancel_button.reset_hover(cx); - self.redraw(cx); + self.show_room_preview(cx, frp.clone(), room_or_alias_id); break; } Some(RoomPreviewAction::Fetched { room_or_alias_id: fetched_id, result: Err(e) }) @@ -446,31 +587,13 @@ impl Widget for AddRoomScreen { // the user clicking the join button (e.g., knocked on or joined the room/space). let mut transition_to_knocked = false; let mut transition_to_joined = false; - if let AddRoomState::FetchedRoomPreview { frp, room_or_alias_id, .. } = &self.state { + if let AddRoomState::FetchedRoomPreview { frp, .. } = &self.state { for action in actions { - match action.downcast_ref() { - Some(KnockResultAction::Knocked { room, .. }) if room.room_id() == frp.room_name_id.room_id() => { - let room_type = match room.room_type() { - Some(RoomType::Space) => "space", - _ => "room", - }; - enqueue_popup_notification( - format!("Successfully knocked on {room_type} {}.", frp.room_name_id), - PopupKind::Success, - Some(4.0), - ); - transition_to_knocked = true; - break; - } - Some(KnockResultAction::Failed { error, room_or_alias_id: roai }) if room_or_alias_id == roai => { - enqueue_popup_notification( - format!("Failed to knock on room.\n\nError: {error}."), - PopupKind::Error, - None, - ); - break; - } - _ => { } + if let Some(KnockResultAction::Knocked { room, .. }) = action.downcast_ref() + && room.room_id() == frp.room_name_id.room_id() + { + transition_to_knocked = true; + break; } match action.downcast_ref() { @@ -505,8 +628,141 @@ impl Widget for AddRoomScreen { self.state.transition_to_loaded(*is_invite); self.redraw(cx); } + self.record_room_state(cx, room_name_id.room_id(), if *is_invite { RoomState::Invited } else { RoomState::Joined }); + } + // We show a found room's preview as soon as the user picks it, so we keep our membership in it up to date. + if let Some(JoinRoomResultAction::Joined { room_id }) = action.downcast_ref() { + self.record_room_state(cx, room_id, RoomState::Joined); + } + if let Some(KnockResultAction::Knocked { room, .. }) = action.downcast_ref() { + self.record_room_state(cx, room.room_id(), RoomState::Knocked); + } + if let Some(LeaveRoomResultAction::Left { room_id }) = action.downcast_ref() { + self.record_room_state(cx, room_id, RoomState::Left); + } + + match action.downcast_ref() { + Some(DirectorySearchAction::JoinedRooms { query, rooms }) if *query == self.room_search.query => { + if let Some(results) = self.room_search.results.as_mut() { + results.add_results(rooms, &mut self.result_rows); + } + self.redraw(cx); + } + Some(DirectorySearchAction::ExactRooms { query, rooms }) if *query == self.room_search.query => { + if let Some(results) = self.room_search.results.as_mut() { + results.add_results(rooms, &mut self.result_rows); + results.rooms.has_exact_matches = true; + results.spaces.has_exact_matches = true; + } + self.open_only_match(cx); + self.redraw(cx); + } + Some(DirectorySearchAction::ExactUsers { query, users }) if *query == self.user_search.query => { + if let Some(section) = self.user_search.results.as_mut() { + section.add_results(users, &mut self.result_rows); + section.has_exact_matches = true; + } + self.open_only_match(cx); + self.redraw(cx); + } + Some(DirectorySearchAction::DirectoryRooms { query, kind, rooms, next_batch, is_next_page, error }) + if *query == self.room_search.query => + { + let Some(results) = self.room_search.results.as_mut() else { continue }; + let section = results.section_mut(*kind); + // We've started a new search since asking for this next page. + if *is_next_page && !section.has_directory_matches { continue } + section.add_results(rooms, &mut self.result_rows); + section.has_directory_matches = true; + // Upon an error, we keep the old token so the user can try again. + if error.is_none() { + section.more = next_batch.clone().map(MoreResults::NextBatch); + } + section.error = error.clone(); + section.is_loading_more = false; + self.open_only_match(cx); + self.redraw(cx); + } + Some(DirectorySearchAction::DirectoryUsers { query, users, limit, is_limited, error }) + if *query == self.user_search.query => + { + let Some(section) = self.user_search.results.as_mut() else { continue }; + // A bigger search gives us the users we already have plus some more. + section.add_results(users, &mut self.result_rows); + section.has_directory_matches = true; + // Upon an error, we keep the old limit so the user can try again. + if error.is_none() { + section.more = is_limited.then(|| MoreResults::Limit(limit + SEARCH_PAGE_SIZE)); + } + section.error = error.clone(); + section.is_loading_more = false; + self.open_only_match(cx); + self.redraw(cx); + } + _ => {} + } + + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + // The next account to log in shouldn't see this account's searches. + cx.stop_timer(self.room_search.timer); + cx.stop_timer(self.user_search.timer); + self.room_search = Search::default(); + self.user_search = Search::default(); + self.only_match_to_open = None; + self.state = AddRoomState::WaitingOnUserInput; + room_alias_id_input.set_text(cx, ""); + user_search_input.set_text(cx, ""); + profile_pane.reset(cx); + self.redraw(cx); + } + } + + let room_items = self.view.flat_list(cx, ids!(room_results.list)).items_with_actions(actions); + for (key, item) in &room_items { + let kind = match *key { + key if key == ROOMS_SECTION.load_more_key => RoomKind::Room, + key if key == SPACES_SECTION.load_more_key => RoomKind::Space, + _ => continue, + }; + if !item.button(cx, ids!(load_more_button)).clicked(actions) { continue } + self.only_match_to_open = None; + let Some(section) = self.room_search.results.as_mut().map(|results| results.section_mut(kind)) else { continue }; + if let Some(MoreResults::NextBatch(next_batch)) = §ion.more { + submit_async_request(MatrixRequest::SearchRoomDirectory { + query: self.room_search.query.clone(), + kind, + next_batch: Some(next_batch.clone()), + }); + section.is_loading_more = true; + self.redraw(cx); + } + } + let (tapped_room, is_room_hover_changed) = handle_row_actions(room_items, false, actions, &mut self.result_rows.hovered_key); + if let Some(key) = tapped_room { + self.open_room_result(cx, key); + } + + let user_items = self.view.flat_list(cx, ids!(user_results.list)).items_with_actions(actions); + for (key, item) in &user_items { + if *key == PEOPLE_SECTION.load_more_key + && item.button(cx, ids!(load_more_button)).clicked(actions) + && let Some(section) = self.user_search.results.as_mut() + && let Some(MoreResults::Limit(limit)) = section.more + { + submit_async_request(MatrixRequest::SearchUserDirectory { query: self.user_search.query.clone(), limit }); + section.is_loading_more = true; + self.only_match_to_open = None; + self.redraw(cx); } } + let (tapped_user, is_user_hover_changed) = handle_row_actions(user_items, false, actions, &mut self.result_rows.hovered_key); + if let Some(key) = tapped_user { + self.open_user_result(cx, key); + } + + if is_room_hover_changed || is_user_hover_changed { + self.redraw(cx); + } } } @@ -548,8 +804,9 @@ impl Widget for AddRoomScreen { // Populate the content of the fetched room preview. let room_avatar = fetched_room_summary.avatar(cx, ids!(room_avatar)); match &frp.room_avatar { - FetchedRoomAvatar::Text(text) => { - room_avatar.show_text(cx, None, None, text); + // We may not have this avatar's image yet, e.g., for a search result, so the avatar cache fetches it. + FetchedRoomAvatar::Text(_) => { + room_avatar.show_room(cx, frp.room_name_id.room_id(), frp.avatar_url.as_ref(), frp.room_name_id.name_for_avatar()); } FetchedRoomAvatar::Image(avatar_image) => { let res = room_avatar.show_image( @@ -560,7 +817,7 @@ impl Widget for AddRoomScreen { if res.is_err() { room_avatar.show_text( cx, - None, + Some(TextAvatarBgColor::UnknownRoom), None, frp.room_name_id.name_for_avatar().unwrap_or("?"), ); @@ -718,26 +975,54 @@ impl Widget for AddRoomScreen { } } - self.view.draw_walk(cx, scope, walk) + let room_results_uid = self.view.child_by_path(ids!(content.room_results.list)).widget_uid(); + let (color, color_hover) = (self.result_row_color, self.result_row_color_hover); + while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { + // A borrowed widget's uid is always 0, so we must get it before borrowing the list. + let is_room_results = step.widget_uid() == room_results_uid; + let list_ref = step.as_flat_list(); + let Some(mut list) = list_ref.borrow_mut() else { continue }; + let rows = &mut self.result_rows; + if is_room_results { + // We hide the room search results while showing a room preview, but not while showing an error. + if matches!(self.state, AddRoomState::WaitingOnUserInput | AddRoomState::ParseError(_) | AddRoomState::FetchError(_)) { + match &self.room_search.results { + Some(results) => { + draw_search_section(cx, scope, &mut list, &results.rooms, &ROOMS_SECTION, rows, color, color_hover); + draw_search_section(cx, scope, &mut list, &results.spaces, &SPACES_SECTION, rows, color, color_hover); + } + None if !self.room_search.query.is_empty() => { + draw_flat_list_status_row(cx, scope, &mut list, id!(rooms_hint), false, "To find a person, search for them below."); + } + None => {} + } + } + } else { + match &self.user_search.results { + Some(section) => draw_search_section(cx, scope, &mut list, section, &PEOPLE_SECTION, rows, color, color_hover), + None if !self.user_search.query.is_empty() => { + draw_flat_list_status_row(cx, scope, &mut list, id!(people_hint), false, "To find a room or space, search for it above."); + } + None => {} + } + } + // Drop the rows that we didn't draw this time. + list.items.retain_visible(); + } + DrawStep::done() } } impl AddRoomScreen { /// Parses the given room address and starts fetching a preview of that room or space. fn search_for_room(&mut self, cx: &mut Cx, address: &str) { - match parse_address(address.trim()) { - Ok((room_or_alias_id, via)) => { - self.state = AddRoomState::Parsed { - room_or_alias_id: room_or_alias_id.clone(), - }; - submit_async_request(MatrixRequest::GetRoomPreview { - room_or_alias_id, - via, - response_mode: RoomPreviewResponseMode::Action, - }); - } - Err(e) => { - let err_str = format!("Could not parse the text as a valid room address.\nError: {e}."); + self.view.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)).reset(cx); + // We search for it too, so the search results match the input if the user cancels the room preview. + self.search_rooms(cx, address.trim()); + match SearchQuery::parse(address).and_then(|search_query| search_query.full_room_address()) { + Some((room_or_alias_id, via)) => self.request_room_preview(cx, room_or_alias_id, via), + None => { + let err_str = String::from("Could not parse the text as a valid room address."); enqueue_popup_notification( err_str.clone(), PopupKind::Error, @@ -749,6 +1034,146 @@ impl AddRoomScreen { } self.redraw(cx); } + + /// Starts fetching a preview of the given room or space, which we show once we get it. + fn request_room_preview(&mut self, cx: &mut Cx, room_or_alias_id: OwnedRoomOrAliasId, via: Vec) { + self.only_match_to_open = None; + self.state = AddRoomState::Parsed { + room_or_alias_id: room_or_alias_id.clone(), + }; + submit_async_request(MatrixRequest::GetRoomPreview { + room_or_alias_id, + via, + response_mode: RoomPreviewResponseMode::Action, + }); + self.redraw(cx); + } + + /// Shows the given preview of a room or space, which we found by `room_or_alias_id`. + fn show_room_preview(&mut self, cx: &mut Cx, frp: FetchedRoomPreview, room_or_alias_id: OwnedRoomOrAliasId) { + self.state = AddRoomState::FetchedRoomPreview { frp, room_or_alias_id }; + // Reset the buttons' hover states when they are first shown. + self.view.button(cx, ids!(fetched_room_summary.buttons_view.join_room_button)).reset_hover(cx); + self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)).reset_hover(cx); + // The preview replaces the results, which the user may have scrolled down, so we scroll back up to it. + self.view.child(id!(content)).as_view().set_scroll_pos(cx, Vec2d::default()); + self.redraw(cx); + } + + /// Starts searching for rooms and spaces that match the given query, unless we already have its results. + fn search_rooms(&mut self, cx: &mut Cx, query: &str) { + cx.stop_timer(self.room_search.timer); + let has_error = self.room_search.results.as_ref() + .is_some_and(|results| results.rooms.error.is_some() || results.spaces.error.is_some()); + if query == self.room_search.query && !has_error { + return; + } + let can_match_rooms = SearchQuery::parse(query).is_some_and(|search_query| search_query.can_match_rooms()); + if can_match_rooms { + submit_async_request(MatrixRequest::ResolveRoomSearchQuery { query: query.to_owned() }); + for kind in [RoomKind::Room, RoomKind::Space] { + submit_async_request(MatrixRequest::SearchRoomDirectory { query: query.to_owned(), kind, next_batch: None }); + } + } + self.room_search = Search { + query: query.to_owned(), + results: can_match_rooms.then(RoomResults::default), + ..Default::default() + }; + self.redraw(cx); + } + + /// Starts searching for people that match the given query, unless we already have its results. + fn search_users(&mut self, cx: &mut Cx, query: &str) { + cx.stop_timer(self.user_search.timer); + let has_error = self.user_search.results.as_ref().is_some_and(|section| section.error.is_some()); + if query == self.user_search.query && !has_error { + return; + } + let can_match_users = SearchQuery::parse(query).is_some_and(|search_query| search_query.can_match_users()); + if can_match_users { + submit_async_request(MatrixRequest::ResolveUserSearchQuery { query: query.to_owned() }); + submit_async_request(MatrixRequest::SearchUserDirectory { query: query.to_owned(), limit: SEARCH_PAGE_SIZE }); + } + self.user_search = Search { + query: query.to_owned(), + results: can_match_users.then(SectionResults::default), + ..Default::default() + }; + self.redraw(cx); + } + + /// Opens the only match in the search box that the user pressed Enter in, once we've looked up its matches. + fn open_only_match(&mut self, cx: &mut Cx) { + let Some(search_box) = self.only_match_to_open else { return }; + let only_match = match search_box { + SearchBox::Rooms => self.room_search.results.as_ref() + .and_then(|results| get_only_match(&self.room_search.query, &[&results.rooms, &results.spaces])), + SearchBox::People => self.user_search.results.as_ref() + .and_then(|section| get_only_match(&self.user_search.query, &[section])), + }; + // We're still looking up its matches. + let Some(only_match) = only_match else { return }; + self.only_match_to_open = None; + let Some(key) = only_match else { return }; + match search_box { + SearchBox::Rooms => self.open_room_result(cx, key), + SearchBox::People => self.open_user_result(cx, key), + } + } + + /// Shows the preview of the room or space search result with the given row key. + /// + /// We already have everything that its preview shows, so we needn't fetch it. + fn open_room_result(&mut self, cx: &mut Cx, key: LiveId) { + self.only_match_to_open = None; + let Some(mut frp) = self.room_search.results.as_ref() + .and_then(|results| results.rooms.items.iter().chain(&results.spaces.items).find(|room| room.row_key() == key)) + .map(|room| room.preview.clone()) + else { return }; + // Its row has usually fetched its avatar, which anything we open from the preview can show too. + if let Some(avatar_url) = &frp.avatar_url + && let AvatarCacheEntry::Loaded(data) = avatar_cache::get_or_fetch_avatar(cx, avatar_url) + { + frp.room_avatar = FetchedRoomAvatar::Image((avatar_url.clone(), data).into()); + } + let room_or_alias_id = frp.alias.clone().map_or_else(|| frp.room_name_id.room_id().clone().into(), Into::into); + self.show_room_preview(cx, frp, room_or_alias_id); + } + + /// Records our new membership in the given room, if it's one of the rooms or spaces that we found. + fn record_room_state(&mut self, cx: &mut Cx, room_id: &RoomId, state: RoomState) { + let Some(results) = self.room_search.results.as_mut() else { return }; + let mut is_changed = false; + for room in results.rooms.items.iter_mut().chain(&mut results.spaces.items) { + if room.room_id() == room_id && room.preview.state != Some(state) { + room.preview.state = Some(state); + // Its row shows whether we've joined it. + self.result_rows.populated.remove(&room.row_key()); + is_changed = true; + } + } + if is_changed { + self.redraw(cx); + } + } + + /// Opens the profile of the person search result with the given row key. + fn open_user_result(&mut self, cx: &mut Cx, key: LiveId) { + self.only_match_to_open = None; + let Some(user_profile) = self.user_search.results.as_ref() + .and_then(|section| section.items.iter().find(|user| user.row_key() == key)) + .map(|user| user.user_profile.clone()) + else { return }; + // We already have this user's profile, so the pane needn't fetch it again. + user_profile_cache::insert_user_profile_if_missing(cx, user_profile.clone()); + // The pane covers the rows and takes their hover-out events, so no row stays hovered. + self.result_rows.hovered_key = None; + let profile_pane = self.view.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); + profile_pane.set_info(cx, UserProfilePaneInfo { user_profile, room: None }); + profile_pane.show(cx); + self.redraw(cx); + } } impl AddRoomScreenRef { @@ -791,28 +1216,407 @@ pub enum KnockResultAction { } -/// Tries to extract a room address (Alias or ID) from the given text. -/// -/// This function is quite flexible and will attempt to parse `text` as: -/// * A Room ID (with a leading `!`). -/// * A Room Alias (with a leading `#`). -/// * A `https://matrix.to` URI, which includes either a room alias, or a room ID plus `via` servers. -/// * A `matrix:` scheme URI, which is similar to above. -fn parse_address(text: &str) -> Result<(OwnedRoomOrAliasId, Vec), IdParseError> { - match OwnedRoomOrAliasId::try_from(text) { - Ok(room_or_alias_id) => Ok((room_or_alias_id, Vec::new())), - Err(e) => { - if let Some((matrix_id, via)) = utils::parse_matrix_link(text) { - if let Some(room_or_alias_id) = match matrix_id { - MatrixId::Room(room_id) => Some(room_id.into()), - MatrixId::RoomAlias(alias) => Some(alias.into()), - MatrixId::Event(room_or_alias_id, _) => Some(room_or_alias_id), - _ => None, - } { - return Ok((room_or_alias_id, via)); +/// The text entered in one of the search boxes, and the results of searching for it. +#[derive(Default)] +struct Search { + /// The trimmed text that these results are for. + query: String, + /// This is `None` if the query can't match what this search box is for, e.g., a user ID in the room search. + results: Option, + /// The countdown to searching after the user stops typing. + timer: Timer, +} +impl Search { + /// Searches once the user stops typing, or clears the results if the search box is now empty. + fn handle_text_changed(&mut self, cx: &mut Cx, text: &str) { + cx.stop_timer(self.timer); + if text.trim().is_empty() { + *self = Self::default(); + } else { + self.timer = cx.start_timeout(SEARCH_DELAY); + } + } +} + +/// One of the two search boxes on this screen. +#[derive(Clone, Copy)] +enum SearchBox { + Rooms, + People, +} + +/// The results of searching for rooms and spaces, which we show in separate lists. +#[derive(Default)] +struct RoomResults { + rooms: SectionResults, + spaces: SectionResults, +} +impl RoomResults { + fn section_mut(&mut self, kind: RoomKind) -> &mut SectionResults { + match kind { + RoomKind::Room => &mut self.rooms, + RoomKind::Space => &mut self.spaces, + } + } + + /// Adds each of the given results to the rooms or spaces section. + fn add_results(&mut self, results: &[RoomSearchResult], rows: &mut ResultRows) { + let (spaces, rooms): (Vec<_>, Vec<_>) = results.iter().cloned().partition(RoomSearchResult::is_space); + self.rooms.add_results(&rooms, rows); + self.spaces.add_results(&spaces, rows); + } +} + +/// The results shown in one section of the search results, i.e., rooms, spaces, or people. +struct SectionResults { + /// The results in the order that we got them, so that a row never moves once it's shown. + items: Vec, + /// Whether we've looked up the results that the query could exactly be. + has_exact_matches: bool, + /// Whether we've searched our homeserver's directory for the query. + has_directory_matches: bool, + error: Option, + /// How to get more matches from the directory, if it has more. + more: Option, + is_loading_more: bool, +} +impl Default for SectionResults { + fn default() -> Self { + Self { + items: Vec::new(), + has_exact_matches: false, + has_directory_matches: false, + error: None, + more: None, + is_loading_more: false, + } + } +} +impl SectionResults { + /// Appends the given results, except for ones that we already have, + /// which an exact match replaces in place. + fn add_results(&mut self, results: &[T], rows: &mut ResultRows) { + for result in results { + let key = result.row_key(); + match self.items.iter_mut().find(|item| item.row_key() == key) { + Some(item) if result.is_exact_match() => { + *item = result.clone(); + rows.populated.remove(&key); } + Some(_) => {} + None => self.items.push(result.clone()), } - Err(e) } - } + } + + fn exact_match_keys(&self) -> impl Iterator { + self.items.iter().filter(|result| result.is_exact_match()).map(SearchResultRow::row_key) + } + + fn is_searching(&self) -> bool { + !self.has_exact_matches || !self.has_directory_matches + } +} + +/// How to get more matches from a directory for one section of the search results. +enum MoreResults { + /// Get the room directory's next page of matches, starting from this token. + NextBatch(String), + /// Search the user directory again for up to this many users. + Limit(u32), +} + +/// What we track about the rows that we draw in the list of search results. +#[derive(Default)] +struct ResultRows { + hovered_key: Option, + /// The keys of the rows whose content is up to date, which needn't be set again when drawn. + populated: HashSet, +} + +/// The texts and the list keys of one section of the search results. +struct SectionInfo { + header: Option<&'static str>, + searching_text: &'static str, + empty_text: &'static str, + header_key: LiveId, + loading_key: LiveId, + status_key: LiveId, + load_more_key: LiveId, +} + +const ROOMS_SECTION: SectionInfo = SectionInfo { + header: Some("Rooms"), + searching_text: "Searching for rooms...", + empty_text: "No rooms found.", + header_key: LiveId::from_str("rooms_header"), + loading_key: LiveId::from_str("rooms_loading"), + status_key: LiveId::from_str("rooms_status"), + load_more_key: LiveId::from_str("rooms_load_more"), +}; + +const SPACES_SECTION: SectionInfo = SectionInfo { + header: Some("Spaces"), + searching_text: "Searching for spaces...", + empty_text: "No spaces found.", + header_key: LiveId::from_str("spaces_header"), + loading_key: LiveId::from_str("spaces_loading"), + status_key: LiveId::from_str("spaces_status"), + load_more_key: LiveId::from_str("spaces_load_more"), +}; + +// The "Find people" label already says what this one section is. +const PEOPLE_SECTION: SectionInfo = SectionInfo { + header: None, + searching_text: "Searching for people...", + empty_text: "No people found.", + header_key: LiveId::from_str("people_header"), + loading_key: LiveId::from_str("people_loading"), + status_key: LiveId::from_str("people_status"), + load_more_key: LiveId::from_str("people_load_more"), +}; + +/// A search result that we show as a row in the list of search results. +trait SearchResultRow: Clone { + fn row_key(&self) -> LiveId; + + fn is_exact_match(&self) -> bool; + + /// Sets the content of the given row to show this result. + /// + /// Returns `false` if its avatar is still being fetched, in which case call this again later. + fn populate_row(&self, cx: &mut Cx, row: &WidgetRef) -> bool; +} + +impl SearchResultRow for RoomSearchResult { + fn row_key(&self) -> LiveId { + LiveId::from_str(self.room_id().as_str()) + } + + fn is_exact_match(&self) -> bool { + self.is_exact_match + } + + fn populate_row(&self, cx: &mut Cx, row: &WidgetRef) -> bool { + let room_name = self.preview.room_name_id.name_for_avatar(); + let name = room_name.or(self.alias()).unwrap_or(self.room_id().as_str()); + row.label(cx, ids!(info.title)).set_text(cx, name); + let members = match self.preview.num_joined_members { + 1 => "1 member".to_owned(), + n => format!("{n} members"), + }; + let mut subtitle = match self.alias() { + Some(alias) if room_name.is_some_and(|name| name != alias) => format!("{alias} · {members}"), + _ => members, + }; + // Like the space lobby, we mark the rooms that we're already in. + if self.preview.state == Some(RoomState::Joined) { + subtitle.insert_str(0, "✅ Joined · "); + } + row.label(cx, ids!(info.subtitle)).set_text(cx, &subtitle); + row.avatar(cx, ids!(avatar)).show_room(cx, self.room_id(), self.preview.avatar_url.as_ref(), Some(name)) + } +} + +impl SearchResultRow for UserSearchResult { + fn row_key(&self) -> LiveId { + LiveId::from_str(self.user_profile.user_id.as_str()) + } + + fn is_exact_match(&self) -> bool { + self.is_exact_match + } + + fn populate_row(&self, cx: &mut Cx, row: &WidgetRef) -> bool { + let name = self.user_profile.displayable_name(); + row.label(cx, ids!(info.title)).set_text(cx, name); + row.label(cx, ids!(info.subtitle)).set_text(cx, self.user_profile.user_id.as_str()); + row.avatar(cx, ids!(avatar)).show_user(cx, self.user_profile.avatar_state.uri(), name) + } +} + +/// Draws one section of the search results into the given list: +/// its header, then its results or status, then a button to load more results. +fn draw_search_section( + cx: &mut Cx2d, + scope: &mut Scope, + list: &mut FlatList, + section: &SectionResults, + info: &SectionInfo, + rows: &mut ResultRows, + color: Vec4f, + color_hover: Vec4f, +) { + if let Some(header_text) = info.header + && let Some(header) = list.item(cx, info.header_key, id!(section_header)) + { + header.set_text(cx, header_text); + header.draw_all(cx, scope); + } + + for result in §ion.items { + let key = result.row_key(); + // The list drops rows that it didn't draw last time, + // so a row that it creates again starts out empty and unhovered. + if !list.items.contains_key(&key) { + rows.populated.remove(&key); + if rows.hovered_key == Some(key) { + rows.hovered_key = None; + } + } + let Some(row) = list.item(cx, key, id!(result_row)) else { continue }; + if !rows.populated.contains(&key) && result.populate_row(cx, &row) { + rows.populated.insert(key); + } + set_view_bg_color(cx, &row.as_view(), if rows.hovered_key == Some(key) { color_hover } else { color }); + row.draw_all(cx, scope); + } + + if let Some(error) = §ion.error { + draw_flat_list_status_row(cx, scope, list, info.status_key, false, error); + } else if section.items.is_empty() && !section.is_searching() { + draw_flat_list_status_row(cx, scope, list, info.status_key, false, info.empty_text); + } + if section.is_searching() || section.is_loading_more { + draw_flat_list_status_row(cx, scope, list, info.loading_key, true, info.searching_text); + } else if section.more.is_some() + && let Some(load_more_row) = list.item(cx, info.load_more_key, id!(load_more_row)) + { + load_more_row.draw_all(cx, scope); + } +} + +/// Returns the text that was submitted by clicking the given button or pressing Enter in the given input, if any. +fn get_submitted_query(input: &TextInputRef, button: &ButtonRef, actions: &Actions) -> Option { + button.clicked(actions).then(|| input.text()) + .or_else(|| input.returned(actions).map(|(text, _)| text)) + .map(|text| text.trim().to_owned()) + .filter(|text| !text.is_empty()) +} + +/// Returns the only item of the given iterator, if it has exactly one. +fn get_only_item(mut items: impl Iterator) -> Option { + match (items.next(), items.next()) { + (Some(item), None) => Some(item), + _ => None, + } +} + +/// Returns `None` while we're still looking up matches for `query`, else the key of its only match in these sections, if any. +/// A fully-qualified query can only be one room or user, so for one we only need its exact matches. +fn get_only_match(query: &str, sections: &[&SectionResults]) -> Option> { + if SearchQuery::parse(query).is_some_and(|search_query| search_query.is_fully_qualified()) { + if !sections.iter().all(|section| section.has_exact_matches) { + return None; + } + return Some(get_only_item(sections.iter().flat_map(|section| section.exact_match_keys()))); + } + if sections.iter().any(|section| section.is_searching()) { + return None; + } + // A section that failed or that has more pages might have more matches. + if sections.iter().any(|section| section.error.is_some() || section.more.is_some()) { + return Some(None); + } + Some(get_only_item(sections.iter().flat_map(|section| §ion.items).map(SearchResultRow::row_key))) +} + +#[cfg(test)] +mod tests_section_results { + use ruma::UserId; + use crate::{profile::user_profile::UserProfile, shared::avatar::AvatarState}; + use super::*; + + fn user(raw_user_id: &str, is_exact_match: bool) -> UserSearchResult { + UserSearchResult { + user_profile: UserProfile { + user_id: UserId::parse(raw_user_id).unwrap(), + username: None, + avatar_state: AvatarState::Unknown, + }, + is_exact_match, + } + } + + fn user_ids(section: &SectionResults) -> Vec<&str> { + section.items.iter().map(|user| user.user_profile.user_id.as_str()).collect() + } + + #[test] + fn late_exact_matches_dont_move_rows() { + let mut rows = ResultRows::default(); + let mut section = SectionResults::default(); + section.add_results(&[user("@bob:x.org", false), user("@alice:x.org", false)], &mut rows); + section.add_results(&[user("@alice:x.org", true), user("@carol:x.org", true)], &mut rows); + assert_eq!(user_ids(§ion), ["@bob:x.org", "@alice:x.org", "@carol:x.org"]); + assert!(section.items[1].is_exact_match); + } + + #[test] + fn early_exact_matches_come_first() { + let mut rows = ResultRows::default(); + let mut section = SectionResults::default(); + section.add_results(&[user("@alice:x.org", true)], &mut rows); + section.add_results(&[user("@bob:x.org", false), user("@alice:x.org", false)], &mut rows); + assert_eq!(user_ids(§ion), ["@alice:x.org", "@bob:x.org"]); + // A directory match doesn't replace an exact match. + assert!(section.items[0].is_exact_match); + } + + #[test] + fn replaced_rows_are_populated_again() { + let mut rows = ResultRows::default(); + let mut section = SectionResults::default(); + section.add_results(&[user("@alice:x.org", false)], &mut rows); + let key = section.items[0].row_key(); + rows.populated.insert(key); + section.add_results(&[user("@alice:x.org", false)], &mut rows); + assert!(rows.populated.contains(&key)); + section.add_results(&[user("@alice:x.org", true)], &mut rows); + assert!(!rows.populated.contains(&key)); + } + + /// Returns a section that has finished searching and found the given users. + fn searched(users: &[UserSearchResult]) -> SectionResults { + let mut section = SectionResults::default(); + section.add_results(users, &mut ResultRows::default()); + section.has_exact_matches = true; + section.has_directory_matches = true; + section + } + + #[test] + fn finds_the_only_match_of_a_partial_query() { + let alice = || user("@alice:x.org", false); + let alicia = || user("@alicia:x.org", false); + let key = alice().row_key(); + assert_eq!(get_only_match("alice", &[&searched(&[alice()])]), Some(Some(key))); + assert_eq!(get_only_match("alice", &[&searched(&[alice()]), &searched(&[])]), Some(Some(key))); + assert_eq!(get_only_match("alice", &[&searched(&[alice(), alicia()])]), Some(None)); + assert_eq!(get_only_match("alice", &[&searched(&[alice()]), &searched(&[alicia()])]), Some(None)); + // There may be more matches that we haven't gotten. + let mut section = searched(&[alice()]); + section.more = Some(MoreResults::Limit(20)); + assert_eq!(get_only_match("alice", &[§ion]), Some(None)); + let mut section = searched(&[alice()]); + section.error = Some(String::from("failed")); + assert_eq!(get_only_match("alice", &[§ion]), Some(None)); + // We wait until we've looked up all of its matches. + let mut section = searched(&[alice()]); + section.has_directory_matches = false; + assert_eq!(get_only_match("alice", &[§ion]), None); + } + + #[test] + fn finds_the_exact_match_of_a_fully_qualified_query() { + let mut section = searched(&[user("@alicia:x.org", false), user("@alice:x.org", true)]); + let key = section.items[1].row_key(); + assert_eq!(get_only_match("@alice:x.org", &[§ion]), Some(Some(key))); + // The directory's other matches don't matter, so we needn't wait for them. + section.has_directory_matches = false; + assert_eq!(get_only_match("@alice:x.org", &[§ion]), Some(Some(key))); + section.has_exact_matches = false; + assert_eq!(get_only_match("@alice:x.org", &[§ion]), None); + assert_eq!(get_only_match("@bob:x.org", &[&searched(&[user("@bob:x.org", false)])]), Some(None)); + } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index c8f8f6177..b7d8cf431 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -28,7 +28,7 @@ use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, e use matrix_sdk_ui::sync_service::State; use crate::{ app::{AppStateAction, ConfirmDeleteAction, SelectedRoom}, event_preview::{plaintext_body_of_timeline_item, text_preview_of_thread_reply, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, invite_modal::InviteModalAction, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::LoadingPaneWidgetExt, navigation_tab_bar::NavigationBarAction, room_image_viewer::{fetch_full_image_for_viewer, get_image_file_details}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails}, media_cache::{get_image_cache_key, MediaCache, MediaCacheEntry}, profile::{ - user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneAction, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, + user_profile::{RoomMembershipInfo, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneAction, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, room::{reply_preview::{CollapsiblePreviewRef, CollapsiblePreviewWidgetRefExt}, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt}, @@ -1404,17 +1404,20 @@ impl Widget for RoomScreen { } // Handle the action that requests to show the user profile sliding pane. - if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { + if let ShowUserProfileAction::ShowUserProfile(UserProfileAndRoomId { user_profile, room_id }) = action.as_widget_action().cast() { self.show_user_profile( cx, &user_profile_sliding_pane, UserProfilePaneInfo { - profile_and_room_id, - room_name: self.room_name_id.as_ref().map_or_else( - || UNNAMED_ROOM.to_string(), - |r| r.to_string(), - ), - room_member: None, + user_profile, + room: Some(RoomMembershipInfo { + room_id, + room_name: self.room_name_id.as_ref().map_or_else( + || UNNAMED_ROOM.to_string(), + |r| r.to_string(), + ), + room_member: None, + }), }, ); } @@ -2381,16 +2384,16 @@ impl RoomScreen { cx, pane, UserProfilePaneInfo { - profile_and_room_id: UserProfileAndRoomId { - user_profile: UserProfile { - user_id, - username: None, - avatar_state: AvatarState::Unknown, - }, - room_id: room_name_id.room_id().clone(), + user_profile: UserProfile { + user_id, + username: None, + avatar_state: AvatarState::Unknown, }, - room_name: room_name_id.to_string(), - room_member: None, + room: Some(RoomMembershipInfo { + room_id: room_name_id.room_id().clone(), + room_name: room_name_id.to_string(), + room_member: None, + }), }, ); return true; diff --git a/src/lib.rs b/src/lib.rs index 9759840bc..86b041eec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,6 +76,7 @@ pub mod tsp_dummy; pub mod sliding_sync; pub mod space_service_sync; pub mod threads_list_sync; +pub mod directory_search; pub mod avatar_cache; pub mod room_preview_cache; pub mod media_cache; diff --git a/src/profile/user_profile.rs b/src/profile/user_profile.rs index a2b8b8c68..f9ae7411b 100644 --- a/src/profile/user_profile.rs +++ b/src/profile/user_profile.rs @@ -163,7 +163,7 @@ script_mod! { } } - LineH { padding: 15 } + membership_divider := LineH { padding: 15 } action_buttons := View { width: Fill, height: Fit @@ -305,22 +305,30 @@ pub enum UserProfilePaneAction { /// Information needed to populate/display the user profile sliding pane. #[derive(Clone, Debug)] pub struct UserProfilePaneInfo { - pub profile_and_room_id: UserProfileAndRoomId, - pub room_name: String, - pub room_member: Option, + pub user_profile: UserProfile, + /// This is `None` if the user was found outside of a room, e.g., by a search. + pub room: Option, } impl Deref for UserProfilePaneInfo { - type Target = UserProfileAndRoomId; + type Target = UserProfile; fn deref(&self) -> &Self::Target { - &self.profile_and_room_id + &self.user_profile } } impl DerefMut for UserProfilePaneInfo { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.profile_and_room_id + &mut self.user_profile } } -impl UserProfilePaneInfo { + +/// The room that the user profile sliding pane shows a user's membership in. +#[derive(Clone, Debug)] +pub struct RoomMembershipInfo { + pub room_id: OwnedRoomId, + pub room_name: String, + pub room_member: Option, +} +impl RoomMembershipInfo { fn membership_title(&self) -> String { if self.room_name.is_empty() { format!("Membership in Room {}", self.room_id.as_str()) @@ -385,6 +393,7 @@ pub struct UserProfileSlidingPane { #[live] overlay_bg_color: Vec4f, #[rust] info: Option, + #[rust] is_showing_current_account: bool, #[rust] is_animating_out: bool, /// Held while this pane is shown such that `Escape` closes this pane. /// This scope is dropped when the pane starts animating out or is reset. @@ -474,20 +483,25 @@ impl Widget for UserProfileSlidingPane { && let Some((new_profile, room_member)) = user_profile_cache::with_user_profile( cx, our_info.user_id.clone(), - Some(&our_info.room_id), + our_info.room.as_ref().map(|room| &room.room_id), false, - |profile, rooms| (profile.clone(), rooms.get(&our_info.room_id).and_then(|entry| entry.loaded()).cloned()) + |profile, rooms| ( + profile.clone(), + our_info.room.as_ref().and_then(|room| rooms.get(&room.room_id)).and_then(|entry| entry.loaded()).cloned(), + ) ) { our_info.user_profile = new_profile; - our_info.room_member = room_member; // Use the avatar URI from the `room_member`, as it will be the most up-to-date // and specific to the room that this user profile sliding pane is currently being shown for. - if let Some(avatar_uri) = our_info.room_member.as_ref() + if let Some(avatar_uri) = room_member.as_ref() .and_then(|rm| rm.avatar_url().map(|u| u.to_owned())) { our_info.avatar_state = AvatarState::Known(Some(avatar_uri)); } + if let Some(room) = our_info.room.as_mut() { + room.room_member = room_member; + } // If we know the avatar URI, try to get/fetch the actual avatar image data. our_info.avatar_state.update_from_cache(cx); self.redraw(cx); @@ -591,26 +605,29 @@ impl Widget for UserProfileSlidingPane { .unwrap_or_else(|| avatar_ref.show_text(cx, None, None, info.displayable_name())); // Set the membership status and role in the room. - self.label(cx, ids!(membership_title_label)).set_text(cx, &info.membership_title()); - self.label(cx, ids!(membership_status_label)).set_text(cx, info.membership_status()); - self.label(cx, ids!(role_info_label)).set_text(cx, info.role_in_room().as_ref()); + self.view(cx, ids!(membership)).set_visible(cx, info.room.is_some()); + self.view(cx, ids!(membership_divider)).set_visible(cx, info.room.is_some()); + if let Some(room) = info.room.as_ref() { + self.label(cx, ids!(membership_title_label)).set_text(cx, &room.membership_title()); + self.label(cx, ids!(membership_status_label)).set_text(cx, room.membership_status()); + self.label(cx, ids!(role_info_label)).set_text(cx, room.role_in_room().as_ref()); + } // Draw and show/hide the buttons according to user and room membership info: // * `direct_message_button` is hidden if the user is the same as the account user, // since you cannot direct message yourself. // * `copy_link_to_user_button` is always enabled with the same text. - // * `jump_to_read_receipt_button` is always shown with the same text. + // * `jump_to_read_receipt_button` is hidden if there's no room, since there's no timeline to jump in. // * `block_user_button` is hidden if the user is the same as the account user, // since you cannot block yourself. // * The button text changes to "Unblock" if the user is already blocked. - let is_pane_showing_current_account = info.room_member.as_ref() - .map(|rm| rm.is_account_user()) - .unwrap_or_else(|| current_user_id().is_some_and(|uid| uid == info.user_id)); - - self.button(cx, ids!(direct_message_button)).set_visible(cx, !is_pane_showing_current_account); + self.button(cx, ids!(direct_message_button)).set_visible(cx, !self.is_showing_current_account); + if info.room.is_none() { + self.button(cx, ids!(jump_to_read_receipt_button)).set_visible(cx, false); + } let block_user_button = self.button(cx, ids!(block_user_button)); - block_user_button.set_visible(cx, !is_pane_showing_current_account); + block_user_button.set_visible(cx, !self.is_showing_current_account); // Unfortunately the Matrix SDK's RoomMember type does not properly track // the blocked state of a user, so we have to maintain it separately. let is_blocked = is_user_blocked(&info.user_id); @@ -632,18 +649,19 @@ impl UserProfileSlidingPane { /// Sets the info to be displayed in this user profile sliding pane. /// - /// If the `room_member` field is `None`, this function will attempt to - /// retrieve the user's room membership info from the local cache, + /// If there's no `room_member` (or no `room` at all), this function will attempt to + /// retrieve the user's profile and room membership info from the local cache, /// and then submit a request to asynchronously fetch it from the server /// if it's not found in the cache. pub fn set_info(&mut self, cx: &mut Cx, mut info: UserProfilePaneInfo) { - if info.room_member.is_none() { + if info.room.as_ref().is_none_or(|room| room.room_member.is_none()) { + let room_id = info.room.as_ref().map(|room| &room.room_id); match user_profile_cache::with_user_profile( cx, info.user_id.clone(), - Some(&info.room_id), + room_id, true, - |profile, rooms| (profile.clone(), rooms.get(&info.room_id).and_then(|entry| entry.loaded()).cloned()) + |profile, rooms| (profile.clone(), room_id.and_then(|id| rooms.get(id)).and_then(|entry| entry.loaded()).cloned()) ) { Some((new_profile, Some(room_member))) => { log!("Found user {} room member info in cache", info.user_id); @@ -665,7 +683,9 @@ impl UserProfileSlidingPane { .map(|dn| dn.to_owned()) .or_else(|| new_profile.username.clone()); } - info.room_member = Some(room_member); + if let Some(room) = info.room.as_mut() { + room.room_member = Some(room_member); + } } // We don't have their room member info, so fill in what we can from their profile. Some((new_profile, None)) => { @@ -688,6 +708,7 @@ impl UserProfileSlidingPane { .show(cx, info.user_id.clone()); } + self.is_showing_current_account = current_user_id().is_some_and(|uid| uid == info.user_id); self.info = Some(info); } diff --git a/src/profile/user_profile_cache.rs b/src/profile/user_profile_cache.rs index bb2794436..3cd27e1f8 100644 --- a/src/profile/user_profile_cache.rs +++ b/src/profile/user_profile_cache.rs @@ -49,6 +49,21 @@ impl RoomMemberEntry { } } +/// Inserts the given user profile into the cache, unless the cache already has an entry for that user. +/// +/// This lets us show a profile that we already got elsewhere, e.g., from a search, without fetching it again. +pub fn insert_user_profile_if_missing(cx: &mut Cx, user_profile: UserProfile) { + let user_id = user_profile.user_id.clone(); + let was_missing = USER_PROFILE_CACHE.with_borrow_mut(|cache| { + let Entry::Vacant(entry) = cache.entry(user_id.clone()) else { return false }; + entry.insert(UserProfileCacheEntry::Loaded { user_profile, rooms: BTreeMap::new() }); + true + }); + if was_missing { + cx.action(UserProfilesUpdated { user_ids: BTreeSet::from([user_id]) }); + } +} + /// Inserts already-known room member info for the given room into the cache, /// emitting [`UserProfilesUpdated`] for that user. /// diff --git a/src/room/mod.rs b/src/room/mod.rs index 79e3f0ac8..a434b8c98 100644 --- a/src/room/mod.rs +++ b/src/room/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use makepad_widgets::ScriptVm; use matrix_sdk::{RoomDisplayName, RoomHeroWithProfile, RoomState, room_preview::RoomPreview}; -use ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, room::{JoinRuleSummary, RoomType}}; +use ruma::{OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, room::{JoinRuleSummary, RoomType}}; use crate::shared::avatar::AvatarImage; use crate::utils::RoomNameId; @@ -42,6 +42,7 @@ pub fn script_mod(vm: &mut ScriptVm) { /// but haven't yet fetched the full room preview, /// such as a room to navigate to, or a room to join. #[derive(Clone, Debug)] +#[allow(clippy::large_enum_variant)] pub enum BasicRoomDetails { /// We only know the room's ID, so we'll need to fetch the room preview /// to get more info about the room. @@ -155,6 +156,8 @@ pub struct FetchedRoomPreview { pub canonical_alias: Option, /// The room's topic, if set. pub topic: Option, + /// The room's avatar URL, if set. + pub avatar_url: Option, /// The number of joined members. pub num_joined_members: u64, /// The number of active members, if known (joined + invited). @@ -193,6 +196,7 @@ impl FetchedRoomPreview { alias, canonical_alias: room_preview.canonical_alias, topic: room_preview.topic, + avatar_url: room_preview.avatar_url, num_joined_members: room_preview.num_joined_members, num_active_members: room_preview.num_active_members, room_type: room_preview.room_type, diff --git a/src/room/room_members_list.rs b/src/room/room_members_list.rs index 5e2bf3e15..aba70f819 100644 --- a/src/room/room_members_list.rs +++ b/src/room/room_members_list.rs @@ -11,7 +11,7 @@ use matrix_sdk::{room::{RoomMember, RoomMemberRole}, ruma::{OwnedRoomId, events: use crate::{ profile::{ - user_profile::{UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, member_display_name, role_name}, + user_profile::{RoomMembershipInfo, UserProfile, UserProfilePaneInfo, UserProfileSlidingPaneRef, member_display_name, role_name}, user_profile_cache, }, room::pane_dock::FRAME_PADDING, @@ -203,7 +203,8 @@ impl Widget for RoomMembersList { self.redraw(cx); } - let (clicked, hover_changed) = handle_row_actions(&self.members_list(), actions, &mut self.hovered_index); + let list = self.members_list(); + let (clicked, hover_changed) = handle_row_actions(list.items_with_actions(actions), list.was_scrolling(), actions, &mut self.hovered_index); if hover_changed { self.redraw(cx); } @@ -401,12 +402,12 @@ pub fn show_member_profile( // Cache the member info first, otherwise the pane would drop it upon its next refresh. user_profile_cache::insert_room_member(cx, room_name_id.room_id().clone(), member.clone()); pane.set_info(cx, UserProfilePaneInfo { - profile_and_room_id: UserProfileAndRoomId { - user_profile: UserProfile::from(&member), + user_profile: UserProfile::from(&member), + room: Some(RoomMembershipInfo { room_id: room_name_id.room_id().clone(), - }, - room_name: room_name_id.to_string(), - room_member: Some(member), + room_name: room_name_id.to_string(), + room_member: Some(member), + }), }); pane.show(cx); } diff --git a/src/shared/avatar.rs b/src/shared/avatar.rs index 99d1dd2ae..4ec10d4cd 100644 --- a/src/shared/avatar.rs +++ b/src/shared/avatar.rs @@ -441,6 +441,13 @@ impl Avatar { /// This avatar isn't clickable, so clicks on it go to its parent, e.g., a list row. /// Returns `false` if the image is still being fetched, in which case call this again later. pub fn show_user(&mut self, cx: &mut Cx, avatar_url: Option<&OwnedMxcUri>, name: &str) -> bool { + self.show_image_or_text(cx, avatar_url, name, TextAvatarBgColor::Default) + } + + /// Shows the image at the given avatar URL once it's fetched, and otherwise the first letter of `text`. + /// + /// Returns `false` if the image is still being fetched, in which case call this again later. + fn show_image_or_text(&mut self, cx: &mut Cx, avatar_url: Option<&OwnedMxcUri>, text: &str, bg_color: TextAvatarBgColor) -> bool { // A reused avatar may have had another background color, e.g., for a room. match avatar_url.map(|mxc| (mxc, avatar_cache::get_or_fetch_avatar(cx, mxc))) { Some((mxc, AvatarCacheEntry::Loaded(data))) => { @@ -449,20 +456,20 @@ impl Avatar { // While the image is being decoded, show the text avatar as the placeholder. // (Toggling visibility on every call would redraw the whole window.) if self.display_state == AvatarDisplayState::ImageLoading { - self.set_text_label(cx, name); - self.set_text_bg_color(cx, TextAvatarBgColor::Default); + self.set_text_label(cx, text); + self.set_text_bg_color(cx, bg_color); } } else { - self.show_text(cx, Some(TextAvatarBgColor::Default), None, name); + self.show_text(cx, Some(bg_color), None, text); } true } Some((_, AvatarCacheEntry::Requested)) => { - self.show_text(cx, Some(TextAvatarBgColor::Default), None, name); + self.show_text(cx, Some(bg_color), None, text); false } Some((_, AvatarCacheEntry::Failed)) | None => { - self.show_text(cx, Some(TextAvatarBgColor::Default), None, name); + self.show_text(cx, Some(bg_color), None, text); true } } @@ -475,6 +482,20 @@ impl AvatarRef { self.borrow_mut().is_none_or(|mut inner| inner.show_user(cx, avatar_url, name)) } + /// Shows a room's avatar from the rooms list or the avatar cache, else the first letter of its name. + /// + /// Returns `false` if the image is still being fetched, in which case call this again later. + pub fn show_room(&self, cx: &mut Cx, room_id: &OwnedRoomId, avatar_url: Option<&OwnedMxcUri>, name_for_avatar: Option<&str>) -> bool { + if cx.has_global::() + && let Some(FetchedRoomAvatar::Image(image)) = cx.get_global::().get_room_avatar(room_id) + && self.show_image(cx, None, |cx, img| utils::load_avatar_image(&img, cx, &image)).is_ok() + { + return true; + } + let FetchedRoomAvatar::Text(text) = utils::avatar_from_room_name(name_for_avatar) else { return true }; + self.borrow_mut().is_none_or(|mut inner| inner.show_image_or_text(cx, avatar_url, &text, TextAvatarBgColor::UnknownRoom)) + } + /// Shows the given avatar of a room/space, or the first letter of its name like the rooms list does. pub fn show_room_avatar(&self, cx: &mut Cx, room_avatar: Option<&FetchedRoomAvatar>, room_name: &RoomNameId) { let name_avatar = utils::avatar_from_room_name(room_name.name_for_avatar()); diff --git a/src/shared/list_rows.rs b/src/shared/list_rows.rs index d22d0f607..aae097d6c 100644 --- a/src/shared/list_rows.rs +++ b/src/shared/list_rows.rs @@ -58,27 +58,32 @@ script_mod! { } } -/// Handles taps and hovers on the rows of the given list. +/// Handles taps and hovers on the given rows of a list, e.g., from its `items_with_actions()`. /// -/// Updates `hovered` to the index of the hovered row, and returns the index of the tapped row, if any, +/// Updates `hovered` to the key of the hovered row, and returns the key of the tapped row, if any, /// and whether `hovered` changed. -pub fn handle_row_actions(list: &PortalListRef, actions: &Actions, hovered: &mut Option) -> (Option, bool) { +pub fn handle_row_actions( + items: impl IntoIterator, + is_scrolling: bool, + actions: &Actions, + hovered: &mut Option, +) -> (Option, bool) { let mut tapped = None; let mut hover_changed = false; - for (index, item) in list.items_with_actions(actions) { + for (key, item) in items { let row = item.as_view(); // Don't treat a touch that drags (a scroll motion) as a regular tap/click. - if !list.was_scrolling() + if !is_scrolling && let Some(fe) = row.finger_up(actions) && fe.is_over && fe.is_primary_hit() && fe.was_tap() { - tapped = Some(index); + tapped = Some(key); } if row.finger_hover_in(actions).is_some() { - *hovered = Some(index); + *hovered = Some(key); hover_changed = true; } - if row.finger_hover_out(actions).is_some() && *hovered == Some(index) { + if row.finger_hover_out(actions).is_some() && *hovered == Some(key) { *hovered = None; hover_changed = true; } @@ -89,12 +94,26 @@ pub fn handle_row_actions(list: &PortalListRef, actions: &Actions, hovered: &mut /// Returns a `ListLoadingRow` or `ListEmptyRow` (from the list's `loading_row` or `empty_row` template) /// at the given index of the given list, showing the given text. pub fn status_row(cx: &mut Cx, list: &mut PortalList, index: usize, is_loading: bool, text: &str) -> WidgetRef { - let (template, label) = if is_loading { - (id!(loading_row), ids!(loading_label)) - } else { - (id!(empty_row), ids!(empty_label)) - }; + let (template, label) = status_row_template(is_loading); let row = list.item(cx, index, template); row.child_by_path(label).set_text(cx, text); row } + +/// Draws a `ListLoadingRow` or `ListEmptyRow` (from a `FlatList`'s `loading_row` or `empty_row` template) +/// with the given ID, showing the given text. +pub fn draw_flat_list_status_row(cx: &mut Cx2d, scope: &mut Scope, list: &mut FlatList, id: LiveId, is_loading: bool, text: &str) { + let (template, label) = status_row_template(is_loading); + let Some(row) = list.item(cx, id, template) else { return }; + row.child_by_path(label).set_text(cx, text); + row.draw_all(cx, scope); +} + +/// Returns the template and label of a `ListLoadingRow` if `is_loading`, otherwise of a `ListEmptyRow`. +fn status_row_template(is_loading: bool) -> (LiveId, &'static [LiveId]) { + if is_loading { + (id!(loading_row), ids!(loading_label)) + } else { + (id!(empty_row), ids!(empty_label)) + } +} diff --git a/src/shared/mention_popup.rs b/src/shared/mention_popup.rs index b6e2ac8df..fa64ce4e1 100644 --- a/src/shared/mention_popup.rs +++ b/src/shared/mention_popup.rs @@ -12,13 +12,10 @@ use std::sync::Arc; use makepad_widgets::*; use crate::{ - avatar_cache::{get_or_fetch_avatar, AvatarCacheEntry}, - home::rooms_list::RoomsListRef, - room::FetchedRoomAvatar, - shared::{avatar::{AvatarImage, TextAvatarBgColor, AvatarWidgetRefExt}, list_rows::{LIST_ROW_HEIGHT, handle_row_actions, status_row}, slash_commands::SlashCommand, styles::set_view_bg_color}, - utils::{self, RoomNameId}, + shared::{avatar::{TextAvatarBgColor, AvatarWidgetRefExt}, list_rows::{LIST_ROW_HEIGHT, handle_row_actions, status_row}, slash_commands::SlashCommand, styles::set_view_bg_color}, + utils::RoomNameId, }; -use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId}; +use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomAliasId, OwnedUserId}; // Note: most of the layout dimensions of the popup have to be calc'd in Rust code, // so they're defined here up front for clarity. @@ -204,7 +201,7 @@ impl Widget for MentionablePopup { self.redraw(cx); } - let (clicked_item_idx, should_redraw) = handle_row_actions(&list, actions, &mut self.pointer_hover_index); + let (clicked_item_idx, should_redraw) = handle_row_actions(list.items_with_actions(actions), list.was_scrolling(), actions, &mut self.pointer_hover_index); // Hovering over an item also focuses it for keyboard navigation. if should_redraw && let Some(index) = self.pointer_hover_index { self.keyboard_focus_index = Some(index); @@ -574,7 +571,7 @@ fn build_row(cx: &mut Cx, list: &mut PortalList, index: usize, item: &MentionIte new_widget.label(cx, ids!(info.title)).set_text(cx, &name); let alias = candidate.alias.as_ref().map(|a| a.as_str()).unwrap_or(""); new_widget.label(cx, ids!(info.subtitle)).set_text(cx, alias); - set_room_avatar(cx, &new_widget, candidate.room_name_id.room_id(), candidate.avatar_url.as_ref(), candidate.room_name_id.name_for_avatar()); + new_widget.avatar(cx, ids!(avatar)).show_room(cx, candidate.room_name_id.room_id(), candidate.avatar_url.as_ref(), candidate.room_name_id.name_for_avatar()); new_widget } MentionItem::Command(cmd) => { @@ -585,24 +582,3 @@ fn build_row(cx: &mut Cx, list: &mut PortalList, index: usize, item: &MentionIte } } } - -/// Resolves a room's avatar using the rooms list's metadata or the avatar cache. -fn set_room_avatar(cx: &mut Cx, row: &WidgetRef, room_id: &OwnedRoomId, avatar_url: Option<&OwnedMxcUri>, name_for_avatar: Option<&str>) { - let avatar = row.avatar(cx, ids!(avatar)); - if cx.has_global::() { - if let Some(FetchedRoomAvatar::Image(image)) = cx.get_global::().get_room_avatar(room_id) { - let _ = avatar.show_image(cx, None, |cx, img| utils::load_avatar_image(&img, cx, &image)); - return; - } - } - if let Some(mxc) = avatar_url - && let AvatarCacheEntry::Loaded(data) = get_or_fetch_avatar(cx, mxc) - { - let image = AvatarImage::from((mxc.clone(), data)); - let _ = avatar.show_image(cx, None, |cx, img| utils::load_avatar_image(&img, cx, &image)); - return; - } - if let FetchedRoomAvatar::Text(fallback) = utils::avatar_from_room_name(name_for_avatar) { - avatar.show_text(cx, Some(TextAvatarBgColor::UnknownRoom), None, &fallback); - } -} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 1d2fde0b4..fdf367e0f 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -944,6 +944,34 @@ pub enum MatrixRequest { request_id: u64, owner: WidgetUid, }, + /// Request to look up the rooms and spaces that the `query` text could exactly be, e.g., a partial alias. + /// + /// The results are posted as a [`DirectorySearchAction::ExactRooms`](crate::directory_search::DirectorySearchAction::ExactRooms). + ResolveRoomSearchQuery { + query: String, + }, + /// Request to look up the users that the `query` text could exactly be, e.g., a partial user ID. + /// + /// The results are posted as a [`DirectorySearchAction::ExactUsers`](crate::directory_search::DirectorySearchAction::ExactUsers). + ResolveUserSearchQuery { + query: String, + }, + /// Request to search our homeserver's room directory for rooms or spaces matching the `query` text. + /// + /// If `next_batch` is `Some`, this gets that next page of matches. + /// The results are posted as a [`DirectorySearchAction::DirectoryRooms`](crate::directory_search::DirectorySearchAction::DirectoryRooms). + SearchRoomDirectory { + query: String, + kind: crate::directory_search::RoomKind, + next_batch: Option, + }, + /// Request to search our homeserver's user directory for the `query` text, getting up to `limit` users. + /// + /// The results are posted as a [`DirectorySearchAction::DirectoryUsers`](crate::directory_search::DirectorySearchAction::DirectoryUsers). + SearchUserDirectory { + query: String, + limit: u32, + }, } /// Submits a request to the worker thread to be executed asynchronously. @@ -1295,18 +1323,35 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _knock_room_task = Handle::current().spawn(async move { log!("Sending request to knock on room {room_or_alias_id}..."); + // Show the success/failure popup here in case the UI screen that requested the knock + // has been hidden or navigated away from since then. match client.knock(room_or_alias_id.clone(), reason, server_names).await { Ok(room) => { - let _ = room.display_name().await; // populate this room's display name cache + let room_name_id = RoomNameId::from_room(&room).await; + enqueue_popup_notification( + format!( + "Successfully knocked on {} \"{room_name_id}\".", + if room.is_space() { "space" } else { "room" }, + ), + PopupKind::Success, + Some(4.0), + ); Cx::post_action(KnockResultAction::Knocked { room_or_alias_id, room, }); } - Err(error) => Cx::post_action(KnockResultAction::Failed { - room_or_alias_id, - error, - }), + Err(error) => { + enqueue_popup_notification( + format!("Failed to knock on room.\n\nError: {error}."), + PopupKind::Error, + None, + ); + Cx::post_action(KnockResultAction::Failed { + room_or_alias_id, + error, + }); + } } }); } @@ -1655,6 +1700,26 @@ async fn matrix_worker_task( }); } + MatrixRequest::ResolveRoomSearchQuery { query } => { + let Some(client) = get_client() else { continue }; + let _resolve_task = Handle::current().spawn(crate::directory_search::resolve_room_search_query(client, query)); + } + + MatrixRequest::ResolveUserSearchQuery { query } => { + let Some(client) = get_client() else { continue }; + let _resolve_task = Handle::current().spawn(crate::directory_search::resolve_user_search_query(client, query)); + } + + MatrixRequest::SearchRoomDirectory { query, kind, next_batch } => { + let Some(client) = get_client() else { continue }; + let _search_task = Handle::current().spawn(crate::directory_search::search_room_directory(client, query, kind, next_batch)); + } + + MatrixRequest::SearchUserDirectory { query, limit } => { + let Some(client) = get_client() else { continue }; + let _search_task = Handle::current().spawn(crate::directory_search::search_user_directory(client, query, limit)); + } + MatrixRequest::GetNumberUnreadMessages { timeline_kind } => { // The SDK only tracks unread counts per room, not per thread. let TimelineKind::MainRoom { .. } = &timeline_kind else { continue }; diff --git a/src/utils.rs b/src/utils.rs index 9869ade98..7324e7d9b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -32,7 +32,17 @@ pub fn open_url(url: &str) { /// Parses a `https://matrix.to/#/...` or `matrix:` link into its Matrix ID and `via` servers. pub fn parse_matrix_link(url: &str) -> Option<(MatrixId, Vec)> { - MatrixToUri::parse(url) + const MATRIX_TO_PREFIX: &str = "https://matrix.to/#/"; + // ruma panics on an empty segment in a matrix.to link's IDs, e.g., `!abc:x.org//`, + // so we drop those first. + let matrix_to_url = url.strip_prefix(MATRIX_TO_PREFIX) + .map(|rest| rest.split_at(rest.find('?').unwrap_or(rest.len()))) + .filter(|(ids, _)| ids.split('/').any(str::is_empty)) + .map_or(Cow::Borrowed(url), |(ids, query)| { + let ids = ids.split('/').filter(|segment| !segment.is_empty()).collect::>().join("/"); + Cow::Owned(format!("{MATRIX_TO_PREFIX}{ids}{query}")) + }); + MatrixToUri::parse(&matrix_to_url) .map(|uri| (uri.id().clone(), uri.via().to_owned())) .or_else(|_| MatrixUri::parse(url).map(|uri| (uri.id().clone(), uri.via().to_owned()))) .ok() @@ -1897,3 +1907,53 @@ mod tests_ends_with_href { assert!(!ends_with_href(" hrf= ")); } } + +#[cfg(test)] +mod tests_parse_matrix_link { + use super::*; + + #[test] + fn empty_segments_dont_panic() { + assert!(matches!( + parse_matrix_link("https://matrix.to/#/!abc:matrix.org///"), + Some((MatrixId::Room(id), _)) if id.as_str() == "!abc:matrix.org" + )); + assert!(parse_matrix_link("https://matrix.to/#///x").is_none()); + let Some((MatrixId::Room(id), via)) = parse_matrix_link("https://matrix.to/#/!abc:matrix.org//?via=matrix.org") else { + panic!("expected a room link"); + }; + assert_eq!(id.as_str(), "!abc:matrix.org"); + assert_eq!(via.iter().map(|s| s.as_str()).collect::>(), ["matrix.org"]); + // An event link without a room isn't valid. + assert!(parse_matrix_link("https://matrix.to/#/$ev:matrix.org///").is_none()); + } + + #[test] + fn parses_matrix_to_links() { + assert!(matches!( + parse_matrix_link("https://matrix.to/#/#robrix:matrix.org"), + Some((MatrixId::RoomAlias(alias), _)) if alias.as_str() == "#robrix:matrix.org" + )); + assert!(matches!( + parse_matrix_link("https://matrix.to/#/@alice:matrix.org"), + Some((MatrixId::User(id), _)) if id.as_str() == "@alice:matrix.org" + )); + let Some((MatrixId::Room(id), via)) = parse_matrix_link("https://matrix.to/#/!abc:matrix.org?via=matrix.org&via=example.org") else { + panic!("expected a room link"); + }; + assert_eq!(id.as_str(), "!abc:matrix.org"); + assert_eq!(via.iter().map(|s| s.as_str()).collect::>(), ["matrix.org", "example.org"]); + assert!(matches!( + parse_matrix_link("https://matrix.to/#/!abc:matrix.org/$ev:matrix.org"), + Some((MatrixId::Event(room, event), _)) if room.as_str() == "!abc:matrix.org" && event.as_str() == "$ev:matrix.org" + )); + } + + #[test] + fn parses_matrix_uris() { + assert!(matches!( + parse_matrix_link("matrix:r/robrix:matrix.org"), + Some((MatrixId::RoomAlias(alias), _)) if alias.as_str() == "#robrix:matrix.org" + )); + } +}