diff --git a/Cargo.lock b/Cargo.lock index 2d4e5a13..4bc957d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ab_glyph_rasterizer" @@ -3363,6 +3363,7 @@ name = "robrix" version = "0.0.1-pre-alpha" dependencies = [ "anyhow", + "bitflags 2.6.0", "bytesize", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index b0fe90e0..101be9ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ unicode-segmentation = "1.11.0" url = "2.5.0" emojis = "0.6.1" bytesize = "1.3.0" - +bitflags = "2.6.0" ################################################################################################### #### Note: we now enable the usage of `rustls-tls` and the `bundled-sqlite` features in the 2 #### diff --git a/resources/img/favorites.png b/resources/img/favorites.png deleted file mode 100644 index 4ca91584..00000000 Binary files a/resources/img/favorites.png and /dev/null differ diff --git a/resources/img/file_transfer_avatar.png b/resources/img/file_transfer_avatar.png deleted file mode 100644 index 754329a6..00000000 Binary files a/resources/img/file_transfer_avatar.png and /dev/null differ diff --git a/resources/img/friend_radar.png b/resources/img/friend_radar.png deleted file mode 100644 index f53a9171..00000000 Binary files a/resources/img/friend_radar.png and /dev/null differ diff --git a/resources/img/group_chats.png b/resources/img/group_chats.png deleted file mode 100644 index a86634fd..00000000 Binary files a/resources/img/group_chats.png and /dev/null differ diff --git a/resources/img/hero.jpg b/resources/img/hero.jpg deleted file mode 100644 index cf9c1ec4..00000000 Binary files a/resources/img/hero.jpg and /dev/null differ diff --git a/resources/img/invite_friends.png b/resources/img/invite_friends.png deleted file mode 100644 index 217f61f1..00000000 Binary files a/resources/img/invite_friends.png and /dev/null differ diff --git a/resources/img/keyboard_icon.png b/resources/img/keyboard_icon.png deleted file mode 100644 index 8db15e20..00000000 Binary files a/resources/img/keyboard_icon.png and /dev/null differ diff --git a/resources/img/mini_programs.png b/resources/img/mini_programs.png deleted file mode 100644 index c77124eb..00000000 Binary files a/resources/img/mini_programs.png and /dev/null differ diff --git a/resources/img/mobile_contacts.png b/resources/img/mobile_contacts.png deleted file mode 100644 index b4a94c61..00000000 Binary files a/resources/img/mobile_contacts.png and /dev/null differ diff --git a/resources/img/moments.png b/resources/img/moments.png deleted file mode 100644 index 84174695..00000000 Binary files a/resources/img/moments.png and /dev/null differ diff --git a/resources/img/my-posts.png b/resources/img/my-posts.png deleted file mode 100644 index e92862df..00000000 Binary files a/resources/img/my-posts.png and /dev/null differ diff --git a/resources/img/new_friends.png b/resources/img/new_friends.png deleted file mode 100644 index ee3ecc53..00000000 Binary files a/resources/img/new_friends.png and /dev/null differ diff --git a/resources/img/official_accounts.png b/resources/img/official_accounts.png deleted file mode 100644 index d85e7ada..00000000 Binary files a/resources/img/official_accounts.png and /dev/null differ diff --git a/resources/img/people_nearby.png b/resources/img/people_nearby.png deleted file mode 100644 index 01e88b43..00000000 Binary files a/resources/img/people_nearby.png and /dev/null differ diff --git a/resources/img/plus.png b/resources/img/plus.png deleted file mode 100644 index 056c9adf..00000000 Binary files a/resources/img/plus.png and /dev/null differ diff --git a/resources/img/post1.jpg b/resources/img/post1.jpg deleted file mode 100644 index d282aee5..00000000 Binary files a/resources/img/post1.jpg and /dev/null differ diff --git a/resources/img/post2.jpg b/resources/img/post2.jpg deleted file mode 100644 index c9b83446..00000000 Binary files a/resources/img/post2.jpg and /dev/null differ diff --git a/resources/img/profile_1.jpg b/resources/img/profile_1.jpg deleted file mode 100644 index 829babb7..00000000 Binary files a/resources/img/profile_1.jpg and /dev/null differ diff --git a/resources/img/qr_green.png b/resources/img/qr_green.png deleted file mode 100644 index 87c8b27b..00000000 Binary files a/resources/img/qr_green.png and /dev/null differ diff --git a/resources/img/scan.png b/resources/img/scan.png deleted file mode 100644 index bacc10d7..00000000 Binary files a/resources/img/scan.png and /dev/null differ diff --git a/resources/img/scan_qr.png b/resources/img/scan_qr.png deleted file mode 100644 index 2dabef9e..00000000 Binary files a/resources/img/scan_qr.png and /dev/null differ diff --git a/resources/img/search.png b/resources/img/search.png deleted file mode 100644 index 9cf638eb..00000000 Binary files a/resources/img/search.png and /dev/null differ diff --git a/resources/img/settings.png b/resources/img/settings.png deleted file mode 100644 index 82214478..00000000 Binary files a/resources/img/settings.png and /dev/null differ diff --git a/resources/img/shake.png b/resources/img/shake.png deleted file mode 100644 index 38df09ce..00000000 Binary files a/resources/img/shake.png and /dev/null differ diff --git a/resources/img/smiley_face_bw.png b/resources/img/smiley_face_bw.png deleted file mode 100644 index 9ef6dc9c..00000000 Binary files a/resources/img/smiley_face_bw.png and /dev/null differ diff --git a/resources/img/sticker-gallery.png b/resources/img/sticker-gallery.png deleted file mode 100644 index acf2d0c1..00000000 Binary files a/resources/img/sticker-gallery.png and /dev/null differ diff --git a/resources/img/tags.png b/resources/img/tags.png deleted file mode 100644 index ba2ae1ae..00000000 Binary files a/resources/img/tags.png and /dev/null differ diff --git a/resources/img/wechat_avatar.png b/resources/img/wechat_avatar.png deleted file mode 100644 index a7c0013d..00000000 Binary files a/resources/img/wechat_avatar.png and /dev/null differ diff --git a/resources/img/wecom_contacts.png b/resources/img/wecom_contacts.png deleted file mode 100644 index 12600e95..00000000 Binary files a/resources/img/wecom_contacts.png and /dev/null differ diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 9f58f02d..32738574 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,14 +1,14 @@ //! A room screen is the UI page that displays a single Room's timeline of events/messages //! along with a message input bar at the bottom. -use std::{borrow::Cow, collections::{BTreeMap, HashMap}, ops::{DerefMut, Range}, sync::{Arc, Mutex}, time::{Instant, SystemTime}}; +use std::{borrow::Cow, collections::BTreeMap, ops::{DerefMut, Range}, sync::{Arc, Mutex}, time::SystemTime}; use bytesize::ByteSize; use imbl::Vector; use makepad_widgets::*; use matrix_sdk::{ ruma::{ - events::{room::{ + events::{receipt::Receipt, room::{ message::{ AudioMessageEventContent, CustomEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, RoomMessageEventContent, ServerNoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent }, ImageInfo, MediaSource @@ -23,9 +23,10 @@ use robius_location::Coordinates; use crate::{ avatar_cache::{self, AvatarCacheEntry}, event_preview::{text_preview_of_member_profile_change, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::loading_modal::LoadingModalWidgetExt, location::{get_latest_location, init_location_subscriber, request_location_update, LocationAction, LocationRequest, LocationUpdate}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ - user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache} - , shared::{ - avatar::{AvatarRef, AvatarWidgetRefExt}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt}, jump_to_bottom_button::JumpToBottomButtonWidgetExt, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, typing_animation::TypingAnimationWidgetExt + user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, + user_profile_cache, + }, shared::{ + avatar::{AvatarRef, AvatarWidgetRefExt}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, typing_animation::TypingAnimationWidgetExt }, sliding_sync::{self, get_client, submit_async_request, take_timeline_endpoints, BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineRequestSender}, utils::{self, unix_time_millis_to_datetime, ImageFormat, MediaFormatConst} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; @@ -50,6 +51,7 @@ live_design! { use crate::shared::avatar::Avatar; use crate::shared::text_or_image::TextOrImage; use crate::shared::html_or_plaintext::*; + use crate::shared::icon_button::*; use crate::profile::user_profile::UserProfileSlidingPane; use crate::shared::typing_animation::TypingAnimation; use crate::shared::icon_button::*; @@ -682,11 +684,6 @@ live_design! { } } - - IMG_SMILEY_FACE_BW = dep("crate://self/resources/img/smiley_face_bw.png") - IMG_PLUS = dep("crate://self/resources/img/plus.png") - IMG_KEYBOARD_ICON = dep("crate://self/resources/img/keyboard_icon.png") - pub RoomScreen = {{RoomScreen}} { width: Fill, height: Fill, cursor: Default, @@ -1034,8 +1031,6 @@ pub struct RoomScreen { #[rust] room_name: String, /// The persistent UI-relevant states for the room that this widget is currently displaying. #[rust] tl_state: Option, - /// 5 secs timer when scroll ends - #[rust] fully_read_timer: Timer, } impl Drop for RoomScreen { @@ -1323,19 +1318,6 @@ impl Widget for RoomScreen { } } - // Mark events as fully read after they have been displayed on screen for 5 seconds. - if self.fully_read_timer.is_event(event).is_some() { - if let (Some(ref mut tl_state), Some(ref _room_id)) = (&mut self.tl_state, &self.room_id) { - for (k, (room, event, start, ref mut moved_to_queue)) in &mut tl_state.read_event_hashmap { - if start.elapsed() > std::time::Duration::new(5, 0) && !*moved_to_queue{ - tl_state.marked_fully_read_queue.insert(k.clone(), (room.clone(), event.clone())); - *moved_to_queue = true; - } - } - } - cx.stop_timer(self.fully_read_timer); - } - if self.animator_handle_event(cx, event).must_redraw() { self.redraw(cx); } @@ -1597,7 +1579,8 @@ impl RoomScreen { log!("Timeline::handle_event(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); tl.prev_first_index = Some(new_item_idx); - cx.stop_timer(self.fully_read_timer); + // Set scrolled_past_read_marker false when we jump to a new event + tl.scrolled_past_read_marker = false; } } // @@ -1609,10 +1592,13 @@ impl RoomScreen { // If new items were appended to the end of the timeline, show an unread messages badge on the jump to bottom button. if is_append && !portal_list.is_at_end() { - // log!("is_append was true, showing unread message badge on the jump to bottom button visible"); - jump_to_bottom.show_unread_message_badge(1); + if let Some(room_id) = &self.room_id { + // Immediately show the unread badge with no count while we fetch the actual count in the background. + jump_to_bottom.show_unread_message_badge(cx, UnreadMessageCount::Unknown); + submit_async_request(MatrixRequest::GetNumberUnreadMessages{ room_id: room_id.clone() }); + } } - + if clear_cache { tl.content_drawn_since_last_update.clear(); tl.profile_drawn_since_last_update.clear(); @@ -1646,6 +1632,9 @@ impl RoomScreen { tl.items = new_items; done_loading = true; } + TimelineUpdate::NewUnreadMessagesCount(unread_messages_count) => { + jump_to_bottom.show_unread_message_badge(cx, unread_messages_count); + } TimelineUpdate::TargetEventFound { target_event_id, index } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.room_id); tl.request_sender.send_if_modified(|requests| { @@ -1757,6 +1746,9 @@ impl RoomScreen { input_bar.set_visible(can_user_send_message); can_not_send_message_notice.set_visible(!can_user_send_message); } + TimelineUpdate::OwnUserReadReceipt(receipt) => { + tl.latest_own_user_receipt = Some(receipt); + } } } @@ -2000,8 +1992,8 @@ impl RoomScreen { message_highlight_animation_state: MessageHighlightAnimationState::default(), last_scrolled_index: usize::MAX, prev_first_index: None, - read_event_hashmap: HashMap::new(), - marked_fully_read_queue: HashMap::new(), + scrolled_past_read_marker: false, + latest_own_user_receipt: None, }; (new_tl_state, true) }; @@ -2015,6 +2007,7 @@ impl RoomScreen { } ); + submit_async_request(MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { room_id: room_id.clone(), subscribe: true }); // Kick off a back pagination request for this room. This is "urgent", // because we want to show the user some messages as soon as possible // when they first open the room, and there might not be any messages yet. @@ -2148,7 +2141,7 @@ impl RoomScreen { /// Sends read receipts based on the current scroll position of the timeline. fn send_user_read_receipts_based_on_scroll_pos( &mut self, - cx: &mut Cx, + _cx: &mut Cx, actions: &ActionsBuf, portal_list: &PortalListRef, ) { @@ -2157,52 +2150,55 @@ impl RoomScreen { return; } let first_index = portal_list.first_id(); - let Some(tl_state) = self.tl_state.as_mut() else { return }; - let Some(room_id) = self.room_id.as_ref() else { return }; + if let Some(ref mut index) = tl_state.prev_first_index { // to detect change of scroll when scroll ends if *index != first_index { - // scroll changed - self.fully_read_timer = cx.start_interval(5.0); - let time_now = std::time::Instant::now(); - if first_index > *index { - // Store visible event messages with current time into a hashmap - let mut read_receipt_event = None; - for r in first_index .. (first_index + portal_list.visible_items() + 1) { - if let Some(v) = tl_state.items.get(r) { - if let Some(e) = v.as_event().and_then(|f| f.event_id()) { - read_receipt_event = Some(e.to_owned()); - tl_state.read_event_hashmap - .entry(e.to_string()) - .or_insert_with(|| (room_id.clone(), e.to_owned(), time_now, false)); - } - } - } - if let Some(event_id) = read_receipt_event { - submit_async_request(MatrixRequest::ReadReceipt { room_id: room_id.clone(), event_id }); - } - let mut fully_read_receipt_event = None; - // Implements sending fully read receipts when message is scrolled out of first row - for r in *index..first_index { - if let Some(v) = tl_state.items.get(r) { - if let Some(e) = v.as_event().and_then(|f| f.event_id()) { - let mut to_remove = vec![]; - for (event_id_string, (_, event_id)) in &tl_state.marked_fully_read_queue { - if e == event_id { - fully_read_receipt_event = Some(event_id.clone()); - to_remove.push(event_id_string.clone()); - } - } - for r in to_remove { - tl_state.marked_fully_read_queue.remove(&r); - } + if first_index >= *index { + // Get event_id and timestamp for the last visible event + let Some((last_event_id, last_timestamp)) = tl_state + .items + .get(first_index + portal_list.visible_items()) + .and_then(|f| f.as_event()) + .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) + else { + *index = first_index; + return; + }; + submit_async_request(MatrixRequest::ReadReceipt { + room_id: tl_state.room_id.clone(), + event_id: last_event_id.to_owned(), + }); + if tl_state.scrolled_past_read_marker { + submit_async_request(MatrixRequest::FullyReadReceipt { + room_id: tl_state.room_id.clone(), + event_id: last_event_id.to_owned(), + }); + } else { + if let Some(own_user_receipt_timestamp) = &tl_state.latest_own_user_receipt.clone() + .and_then(|receipt| receipt.ts) { + let Some((_first_event_id, first_timestamp)) = tl_state + .items + .get(first_index) + .and_then(|f| f.as_event()) + .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) + else { + *index = first_index; + return; + }; + if own_user_receipt_timestamp >= &first_timestamp + && own_user_receipt_timestamp <= &last_timestamp + { + tl_state.scrolled_past_read_marker = true; + submit_async_request(MatrixRequest::FullyReadReceipt { + room_id: tl_state.room_id.clone(), + event_id: last_event_id.to_owned(), + }); } + } } - if let Some(event_id) = fully_read_receipt_event { - submit_async_request(MatrixRequest::FullyReadReceipt { room_id: room_id.clone(), event_id: event_id.clone()}); - } } *index = first_index; } @@ -2288,6 +2284,8 @@ pub enum TimelineUpdate { /// This supersedes `index_of_first_change` and is used when the entire timeline is being redrawn. clear_cache: bool, }, + /// The updated number of unread messages in the room. + NewUnreadMessagesCount(UnreadMessageCount), /// The target event ID was found at the given `index` in the timeline items vector. /// /// This means that the RoomScreen widget can scroll the timeline up to this event, @@ -2330,9 +2328,10 @@ pub enum TimelineUpdate { /// The list of users (their displayable name) who are currently typing in this room. users: Vec, }, - /// A notice that the permission of user's ability to send messages in this room, - /// this condition is simple so that we only use `bool` - CanUserSendMessage (bool) + /// An update containing whether the user is permitted to send messages in this room. + CanUserSendMessage(bool), + /// An update to the currently logged-in user's own read receipt for this room. + OwnUserReadReceipt(Receipt), } /// The global set of all timeline states, one entry per room. @@ -2414,9 +2413,24 @@ struct TimelineUiState { /// at which point we submit a backwards pagination request to fetch more events. last_scrolled_index: usize, + /// The index of the first item shown in the timeline's PortalList from *before* the last "jump". + /// + /// This index is saved before the timeline undergoes any jumps, e.g., + /// receiving new items, major scroll changes, or other timeline view jumps. prev_first_index: Option, - read_event_hashmap: HashMap, - marked_fully_read_queue: HashMap, + + /// Whether the user has scrolled past their latest read marker. + /// + /// This is used to determine whether we should send a fully-read receipt + /// after the user scrolls past their "read marker", i.e., their latest fully-read receipt. + /// Its value is determined by comparing the fully-read event's timestamp with the + /// first and last timestamp of displayed events in the timeline. + /// When scrolling down, if the value is true, we send a fully-read receipt + /// for the last visible event in the timeline. + /// + /// When new message come in, this value is reset to `false`. + scrolled_past_read_marker: bool, + latest_own_user_receipt: Option, } #[derive(Default, Debug)] diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 2869e19d..2b596d57 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1,11 +1,12 @@ -use std::{collections::HashMap, ops::Deref}; +use std::{cmp::Ordering, collections::HashMap, ops::Deref}; use crossbeam_queue::SegQueue; +use imbl::HashSet; use makepad_widgets::*; -use matrix_sdk::ruma::{events::tag::Tags, MilliSecondsSinceUnixEpoch, OwnedRoomId}; - +use matrix_sdk::ruma::{events::tag::{TagName, Tags}, MilliSecondsSinceUnixEpoch, OwnedRoomAliasId, OwnedRoomId}; +use bitflags::bitflags; use crate::{app::AppState, sliding_sync::{submit_async_request, MatrixRequest, PaginationDirection}}; -use super::room_preview::RoomPreviewAction; +use super::{room_preview::RoomPreviewAction, rooms_sidebar::RoomsViewAction}; /// Whether to pre-paginate visible rooms at least once in order to /// be able to display the latest message in the room preview, @@ -141,6 +142,10 @@ pub struct RoomsListEntry { pub room_id: OwnedRoomId, /// The displayable name of this room, if known. pub room_name: Option, + /// The canonical alias for this room, if any. + pub canonical_alias: Option, + /// The alternative aliases for this room, if any. + pub alt_aliases: Vec, /// The tags associated with this room, if any. /// This includes things like is_favourite, is_low_priority, /// whether the room is a server notice room, etc. @@ -200,6 +205,188 @@ impl Deref for RoomDisplayFilter { } } +bitflags! { + /// The criteria that can be used to filter rooms in the `RoomDisplayFilter`. + #[derive(Copy, Clone, PartialEq, Eq)] + pub struct RoomFilterCriteria: u8 { + const RoomId = 0b0000_0001; + const RoomName = 0b0000_0010; + const RoomAlias = 0b0000_0100; + const RoomTags = 0b0000_1000; + const All = Self::RoomId.bits() | Self::RoomName.bits() | Self::RoomAlias.bits() | Self::RoomTags.bits(); + } +} + +impl Default for RoomFilterCriteria { + fn default() -> Self { RoomFilterCriteria::All } +} + +type SortFn = dyn Fn(&RoomsListEntry, &RoomsListEntry) -> Ordering; + +/// A builder for creating a `RoomDisplayFilter` with a specific set of filter types and a sorting function. +pub struct RoomDisplayFilterBuilder { + keywords: String, + filter_criteria: RoomFilterCriteria, + sort_fn: Option>, +} +/// ## Example +/// You can create any combination of filters and sorting functions using the `RoomDisplayFilterBuilder`. +/// ```rust,norun +/// let (filter, sort_fn) = RoomDisplayFilterBuilder::new() +/// .set_keywords(keywords) +/// .by_room_id() +/// .by_room_name() +/// .sort_by(|a, b| { +/// let name_a = a.room_name.as_ref().map_or("", |n| n.as_str()); +/// let name_b = b.room_name.as_ref().map_or("", |n| n.as_str()); +/// name_a.cmp(name_b) +/// }) +/// .build(); +/// ``` +impl RoomDisplayFilterBuilder { + pub fn new() -> Self { + Self { + keywords: String::new(), + filter_criteria: RoomFilterCriteria::default(), + sort_fn: None, + } + } + + pub fn set_keywords(mut self, keywords: String) -> Self { + self.keywords = keywords; + self + } + + fn set_filter_criteria(mut self, filter_criteria: RoomFilterCriteria) -> Self { + self.filter_criteria = filter_criteria; + self + } + + pub fn sort_by(mut self, sort_fn: F) -> Self + where + F: Fn(&RoomsListEntry, &RoomsListEntry) -> Ordering + 'static + { + self.sort_fn = Some(Box::new(sort_fn)); + self + } + + fn matches_room_id(room: &RoomsListEntry, keywords: &str) -> bool { + room.room_id.to_string().eq_ignore_ascii_case(keywords) + } + + fn matches_room_name(room: &RoomsListEntry, keywords: &str) -> bool { + room.room_name + .as_ref() + .map_or(false, |name| name.to_lowercase().contains(keywords)) + } + + fn matches_room_alias(room: &RoomsListEntry, keywords: &str) -> bool { + room.canonical_alias + .as_ref() + .map_or(false, |alias| alias.as_str().eq_ignore_ascii_case(keywords)) + || room.alt_aliases + .iter() + .any(|alias| alias.as_str().eq_ignore_ascii_case(keywords)) + } + + fn matches_room_tags(room: &RoomsListEntry, keywords: &str) -> bool { + let search_tags: HashSet<&str> = keywords + .split_whitespace() + .map(|tag| tag.trim_start_matches(':')) + .collect(); + + fn is_tag_match(search_tag: &str, tag_name: &TagName) -> bool { + match tag_name { + TagName::Favorite => ["favourite", "favorite"].contains(&search_tag), + TagName::LowPriority => ["low_priority", "low-priority", "lowpriority", "lowPriority"].contains(&search_tag), + TagName::ServerNotice => ["server_notice", "server-notice", "servernotice", "serverNotice"].contains(&search_tag), + TagName::User(user_tag) => user_tag.as_ref().eq_ignore_ascii_case(search_tag), + _ => false, + } + } + + room.tags.as_ref().map_or(false, |room_tags| { + search_tags.iter().all(|search_tag| { + room_tags.iter().any(|(tag_name, _)| is_tag_match(search_tag, tag_name)) + }) + }) + } + + // Check if the keywords have a special prefix that indicates a pre-match filter check. + fn pre_match_filter_check(keywords: &str) -> (RoomFilterCriteria, &str) { + match keywords.chars().next() { + Some('!') => (RoomFilterCriteria::RoomId, keywords), + Some('#') => (RoomFilterCriteria::RoomAlias, keywords), + Some(':') => (RoomFilterCriteria::RoomTags, keywords), + _ => (RoomFilterCriteria::All, keywords), + } + } + + fn matches_filter(room: &RoomsListEntry, keywords: &str, filter_criteria: RoomFilterCriteria) -> bool { + if filter_criteria.is_empty() { + return false; + } + + let (specific_type, cleaned_keywords) = Self::pre_match_filter_check(keywords); + + if specific_type != RoomFilterCriteria::All { + // When using a special prefix, only check that specific type + match specific_type { + RoomFilterCriteria::RoomId if filter_criteria.contains(RoomFilterCriteria::RoomId) => { + Self::matches_room_id(room, cleaned_keywords) + } + RoomFilterCriteria::RoomAlias if filter_criteria.contains(RoomFilterCriteria::RoomAlias) => { + Self::matches_room_alias(room, cleaned_keywords) + } + RoomFilterCriteria::RoomTags if filter_criteria.contains(RoomFilterCriteria::RoomTags) => { + Self::matches_room_tags(room, cleaned_keywords) + } + _ => false + } + } else { + // No special prefix, check all enabled filter types + let mut matches = false; + + if filter_criteria.contains(RoomFilterCriteria::RoomId) { + matches |= Self::matches_room_id(room, cleaned_keywords); + } + if filter_criteria.contains(RoomFilterCriteria::RoomName) { + matches |= Self::matches_room_name(room, cleaned_keywords); + } + if filter_criteria.contains(RoomFilterCriteria::RoomAlias) { + matches |= Self::matches_room_alias(room, cleaned_keywords); + } + if filter_criteria.contains(RoomFilterCriteria::RoomTags) { + matches |= Self::matches_room_tags(room, cleaned_keywords); + } + + matches + } + } + + pub fn build(self) -> (RoomDisplayFilter, Option>) { + let keywords = self.keywords; + let filter_criteria = self.filter_criteria; + + let filter = RoomDisplayFilter(Box::new(move |room| { + if keywords.is_empty() || filter_criteria.is_empty() { + return true; + } + let keywords = keywords.trim().to_lowercase(); + Self::matches_filter(room, &keywords, self.filter_criteria) + })); + + (filter, self.sort_fn) + } + +} + +impl Default for RoomDisplayFilterBuilder { + fn default() -> Self { + Self::new() + } +} + #[derive(Live, LiveHook, Widget)] pub struct RoomsList { #[deref] view: View, @@ -233,6 +420,7 @@ pub struct RoomsList { } impl RoomsList { + /// Updates the status message to show how many rooms have been loaded. fn update_status_rooms_count(&mut self) { self.status = if let Some(max_rooms) = self.max_known_rooms { format!("Loaded {} of {} total rooms.", self.all_rooms.len(), max_rooms) @@ -240,6 +428,16 @@ impl RoomsList { format!("Loaded {} rooms.", self.all_rooms.len()) }; } + + /// Updates the status message to show how many rooms are currently displayed + /// that match the current search filter. + fn update_status_matching_rooms(&mut self) { + self.status = match self.displayed_rooms.len() { + 0 => "No matching rooms found.".to_string(), + 1 => "Found 1 matching room.".to_string(), + n => format!("Found {} matching rooms.", n), + } + } } impl Widget for RoomsList { @@ -388,6 +586,7 @@ impl Widget for RoomsList { self.redraw(cx); } } + self.widget_match_event(cx, event, scope); } @@ -415,6 +614,7 @@ impl Widget for RoomsList { list.set_item_range(cx, 0, count + 1); while let Some(item_id) = list.next_visible_item(cx) { + let mut scope = Scope::empty(); // Draw the room preview for each room in the `displayed_rooms` list. @@ -462,3 +662,53 @@ impl Widget for RoomsList { } } + +impl WidgetMatchEvent for RoomsList { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + for action in actions { + if let RoomsViewAction::Search(keywords) = action.as_widget_action().cast() { + + if keywords.is_empty() { + // Reset the displayed rooms list to show all rooms. + self.display_filter = RoomDisplayFilter::default(); + self.displayed_rooms = self.all_rooms.keys().cloned().collect(); + self.update_status_rooms_count(); + self.redraw(cx); + return; + } + + let (filter, sort_fn) = RoomDisplayFilterBuilder::new() + .set_keywords(keywords.clone()) + .set_filter_criteria(RoomFilterCriteria::All) + .build(); + self.display_filter = filter; + + let displayed_rooms = if let Some(sort_fn) = sort_fn { + let mut filtered_rooms: Vec<_> = self.all_rooms + .iter() + .filter(|(_, room)| (self.display_filter)(room)) + .collect(); + + filtered_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(room_a, room_b)); + + filtered_rooms + .into_iter() + .map(|(room_id, _)| room_id.clone()) + .collect() + } else { + self.all_rooms + .iter() + .filter(|(_, room)| (self.display_filter)(room)) + .map(|(room_id, _)| room_id.clone()) + .collect() + }; + + // Update the displayed rooms list. + self.displayed_rooms = displayed_rooms; + self.update_status_matching_rooms(); + // Redraw the rooms list. + self.redraw(cx); + } + } + } +} \ No newline at end of file diff --git a/src/home/rooms_sidebar.rs b/src/home/rooms_sidebar.rs index 5a287466..1548bd6d 100644 --- a/src/home/rooms_sidebar.rs +++ b/src/home/rooms_sidebar.rs @@ -1,5 +1,7 @@ use makepad_widgets::*; +use crate::shared::search_bar::SearchBarAction; + live_design! { use link::theme::*; use link::shaders::*; @@ -8,6 +10,7 @@ live_design! { use crate::shared::styles::*; use crate::shared::helpers::*; use crate::shared::adaptive_view::AdaptiveView; + use crate::shared::search_bar::SearchBar; use crate::home::rooms_list::RoomsList; @@ -34,6 +37,11 @@ live_design! { text_style: {} } } + search_bar = { + input = { + empty_message: "Search rooms..." + } + } { rooms_list = {} } @@ -53,6 +61,13 @@ live_design! { } } +#[derive(Clone, Debug, DefaultNone)] +pub enum RoomsViewAction { + /// Search for rooms + Search(String), + None, +} + #[derive(Widget, Live, LiveHook)] pub struct RoomsView { #[deref] @@ -62,9 +77,27 @@ pub struct RoomsView { impl Widget for RoomsView { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { self.view.draw_walk(cx, scope, walk) } } + +impl WidgetMatchEvent for RoomsView { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) { + let widget_uid = self.widget_uid(); + for action in actions { + match action.as_widget_action().cast() { + SearchBarAction::Search(keywords) => { + cx.widget_action(widget_uid, &scope.path, RoomsViewAction::Search(keywords.clone())); + } + SearchBarAction::ResetSearch => { + cx.widget_action(widget_uid, &scope.path, RoomsViewAction::Search("".to_string())); + } + _ => {} + } + } + } +} \ No newline at end of file diff --git a/src/shared/jump_to_bottom_button.rs b/src/shared/jump_to_bottom_button.rs index 7f25b4bf..ee4ae71f 100644 --- a/src/shared/jump_to_bottom_button.rs +++ b/src/shared/jump_to_bottom_button.rs @@ -19,55 +19,73 @@ live_design! { flow: Overlay, align: {x: 1.0, y: 1.0}, visible: false, - - jump_to_bottom_button = { - margin: {right: 15.0, bottom: 15.0}, - width: 50, height: 50, - draw_icon: {svg_file: (ICO_JUMP_TO_BOTTOM)}, - icon_walk: {width: 20, height: 20, margin: {top: 10, right: 4.5} } - // draw a circular background for the button - draw_bg: { - instance background_color: #edededce, - fn pixel(self) -> vec4 { - let sdf = Sdf2d::viewport(self.pos * self.rect_size); - let c = self.rect_size * 0.5; - sdf.circle(c.x, c.x, c.x); - sdf.fill_keep(self.background_color); - return sdf.result + { + width: 65, height: 75, + align: {x: 0.5, y: 1.0}, + flow: Overlay, + jump_to_bottom_button = { + width: 50, height: 50, + margin: {bottom: 8}, + draw_icon: {svg_file: (ICO_JUMP_TO_BOTTOM)}, + icon_walk: {width: 20, height: 20, margin: {top: 10, right: 4.5} } + // draw a circular background for the button + draw_bg: { + instance background_color: #edededce, + fn pixel(self) -> vec4 { + let sdf = Sdf2d::viewport(self.pos * self.rect_size); + let c = self.rect_size * 0.5; + sdf.circle(c.x, c.x, c.x); + sdf.fill_keep(self.background_color); + return sdf.result + } } } - } - // A badge overlay on the jump to bottom button showing unread messages - unread_message_badge = { - width: 12, height: 12, - margin: {right: 33.0, bottom: 11.0}, - visible: false, - - show_bg: true, - draw_bg: { - color: (COLOR_UNREAD_MESSAGE_BADGE) - fn pixel(self) -> vec4 { - let sdf = Sdf2d::viewport(self.pos * self.rect_size); - let c = self.rect_size * 0.5; - sdf.circle(c.x, c.x, c.x); - sdf.fill_keep(self.color); - return sdf.result; + // A badge overlay on the jump to bottom button showing unread messages + unread_message_badge = { + width: 25, height: 20, + align: { + x: 0.5, + y: 0.5 + } + visible: false, + flow: Overlay, + green_rounded_label = { + width: Fill, + height: Fill, + show_bg: true, + draw_bg: { + color: (COLOR_UNREAD_MESSAGE_BADGE) + instance radius: 4.0 + // Adjust this border_width to larger value to make oval smaller + instance border_width: 2.0 + fn pixel(self) -> vec4 { + let sdf = Sdf2d::viewport(self.pos * self.rect_size) + sdf.box( + self.border_width, + self.border_width, + self.rect_size.x - (self.border_width * 2.0), + self.rect_size.y - (self.border_width * 2.0), + max(1.0, self.radius) + ) + sdf.fill_keep(self.color) + return sdf.result; + } + } + } + // Label that displays the unread message count + unread_messages_count =