@@ -26,7 +26,7 @@ use matrix_sdk_ui::timeline::{
2626use ruma:: { OwnedUserId , api:: client:: receipt:: create_receipt:: v3:: ReceiptType , events:: { AnySyncMessageLikeEvent , AnySyncTimelineEvent , SyncMessageLikeEvent } , owned_room_id} ;
2727
2828use crate :: {
29- app:: { AppStateAction , ConfirmDeleteAction , SelectedRoom } , avatar_cache, event_preview:: { plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_room_membership_change, text_preview_of_timeline_item} , home:: { edited_indicator:: EditedIndicatorWidgetRefExt , link_preview:: { LinkPreviewCache , LinkPreviewRef , LinkPreviewWidgetRefExt } , loading_pane:: { LoadingPaneState , LoadingPaneWidgetExt } , room_image_viewer:: { get_image_name_and_filesize, populate_matrix_image_modal} , rooms_list:: { RoomsListAction , RoomsListRef } , tombstone_footer:: SuccessorRoomDetails } , media_cache:: { MediaCache , MediaCacheEntry } , profile:: {
29+ app:: { AppStateAction , ConfirmDeleteAction , SelectedRoom } , avatar_cache, event_preview:: { plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_room_membership_change, text_preview_of_timeline_item} , home:: { edited_indicator:: EditedIndicatorWidgetRefExt , event_group :: extract_small_state_events , link_preview:: { LinkPreviewCache , LinkPreviewRef , LinkPreviewWidgetRefExt } , loading_pane:: { LoadingPaneState , LoadingPaneWidgetExt } , room_image_viewer:: { get_image_name_and_filesize, populate_matrix_image_modal} , rooms_list:: { RoomsListAction , RoomsListRef } , tombstone_footer:: SuccessorRoomDetails } , media_cache:: { MediaCache , MediaCacheEntry } , profile:: {
3030 user_profile:: { ShowUserProfileAction , UserProfile , UserProfileAndRoomId , UserProfilePaneInfo , UserProfileSlidingPaneRef , UserProfileSlidingPaneWidgetExt } ,
3131 user_profile_cache,
3232 } ,
@@ -535,9 +535,84 @@ script_mod! {
535535 ImageMessage : = mod . widgets. ImageMessage { }
536536 CondensedImageMessage : = mod . widgets. CondensedImageMessage { }
537537 SmallStateEvent : = mod . widgets. SmallStateEvent { }
538+ SmallStateGroupHeader : = mod . prelude. widgets. FoldHeader {
539+ header: View {
540+ width: Fill ,
541+ height: Fit ,
542+ flow: Right ,
543+ align: Align { x: 0.0 , y: 0.5 } ,
544+ margin: Inset { top: 5.0 , bottom: 5.0 }
545+ spacing: 8.0 ,
546+ padding: Inset {
547+ left: 7.0 ,
548+ top: 2.0 ,
549+ bottom: 2.0 ,
550+ right: 10.0
551+ }
552+ fold_button : = FoldButton {
553+ width: 25 , height: 25
554+ draw_bg +: {
555+ color: uniform( #555 )
556+ color_hover: uniform( #333 )
557+ color_active: uniform( #444 )
558+ // Make triangle larger
559+ pixel: fn ( ) {
560+ let sz = 5.0
561+ let c = vec2( self . rect_size. x * 0.5 , self . rect_size. y * 0.5 )
562+ let sdf = Sdf2d . viewport( self . pos * self . rect_size)
563+ sdf. clear( vec4( 0. ) )
564+ sdf. rotate( self . active * 0.5 * PI + 0.5 * PI , c. x, c. y)
565+ sdf. move_to( c. x - sz, c. y + sz)
566+ sdf. line_to( c. x, c. y - sz)
567+ sdf. line_to( c. x + sz, c. y + sz)
568+ sdf. close_path( )
569+ sdf. fill(
570+ mix(
571+ mix( self . color, self . color_hover, self . hover)
572+ mix( self . color_active, self . color_hover, self . hover)
573+ self . active
574+ )
575+ )
576+ return sdf. result * self . fade
577+ }
578+ }
579+ }
580+ user_event_avatar_row : = mod . widgets. AvatarRow {
581+ margin: Inset {
582+ left: 5.0 ,
583+ top: 0.0
584+ }
585+ }
586+ summary_text : = Label {
587+ width: Fill , height: Fit
588+ flow: Flow . Right { wrap: true } ,
589+ draw_text+: {
590+ wrap: Line ,
591+ color: ( SMALL_STATE_TEXT_COLOR ) ,
592+ text_style: SMALL_STATE_TEXT_COLOR { font_size: 11 } ,
593+ }
594+ }
595+ }
596+ body: View {
597+ width: Fill ,
598+ height: Fit
599+ flow: Down
600+ padding: Inset { left: 30.0 }
601+ PortalList {
602+ height: Fit , width: Fill
603+ SmallStateEvent : = mod . widgets. SmallStateEvent { }
604+ CondensedMessage : = mod . widgets. CondensedMessage { }
605+ Message : = mod . widgets. Message { }
606+ }
607+ }
608+ }
538609 Empty : = mod . widgets. Empty { }
539610 DateDivider : = mod . widgets. DateDivider { }
540611 ReadMarker : = mod . widgets. ReadMarker { }
612+ BottomSpace : = View {
613+ height: 100.0
614+ width: Fill
615+ }
541616 }
542617
543618 // A jump to bottom button (with an unread message badge) that is shown
@@ -1074,6 +1149,46 @@ impl Widget for RoomScreen {
10741149 while let Some ( item_id) = list. next_visible_item ( cx) {
10751150 let item = {
10761151 let tl_idx = item_id;
1152+ if let Some ( group_range) = tl_state. small_state_group_manager . check_group_range ( item_id) {
1153+ if group_range. start == item_id {
1154+ // This is the first item of a group - render FoldHeader
1155+ if let ( Some ( group) , Some ( timeline_kind) ) = ( tl_state. small_state_group_manager . get_group_at_item_id ( item_id) , & self . timeline_kind ) {
1156+ populate_small_state_group_header (
1157+ cx,
1158+ scope,
1159+ walk,
1160+ list,
1161+ item_id,
1162+ timeline_kind,
1163+ & group_range,
1164+ group,
1165+ tl_items,
1166+ & mut tl_state. content_drawn_since_last_update ,
1167+ & mut tl_state. profile_drawn_since_last_update ,
1168+ & mut tl_state. media_cache ,
1169+ & mut tl_state. link_preview_cache ,
1170+ & tl_state. fetched_thread_summaries ,
1171+ & mut tl_state. pending_thread_summary_fetches ,
1172+ & tl_state. user_power ,
1173+ & self . pinned_events ,
1174+ room_screen_widget_uid,
1175+ ) ;
1176+ } else {
1177+ let item = list. item ( cx, item_id, id ! ( Empty ) ) ;
1178+ item. draw_all ( cx, scope) ;
1179+ }
1180+ continue ;
1181+ } else if group_range. contains ( & item_id) {
1182+ let item = list. item ( cx, item_id, id ! ( Empty ) ) ;
1183+ item. draw_all ( cx, scope) ;
1184+ continue ;
1185+ }
1186+ }
1187+ if item_id > tl_items. len ( ) {
1188+ let item = list. item ( cx, item_id, id ! ( BottomSpace ) ) ;
1189+ item. draw_all ( cx, scope) ;
1190+ continue ;
1191+ }
10771192 let Some ( timeline_item) = tl_items. get ( tl_idx) else {
10781193 // This shouldn't happen (unless the timeline gets corrupted or some other weird error),
10791194 // but we can always safely fill the item with an empty widget that takes up no space.
@@ -1263,6 +1378,8 @@ impl RoomScreen {
12631378
12641379 tl. items = initial_items;
12651380 done_loading = true ;
1381+ let small_state_events = extract_small_state_events ( tl. items . iter ( ) . cloned ( ) ) ;
1382+ tl. small_state_group_manager . compute_group_state ( small_state_events) ;
12661383 }
12671384 TimelineUpdate :: NewItems { new_items, changed_indices, is_append, clear_cache } => {
12681385 if new_items. is_empty ( ) {
@@ -1380,6 +1497,8 @@ impl RoomScreen {
13801497 // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update);
13811498 }
13821499 tl. items = new_items;
1500+ let small_state_events = extract_small_state_events ( tl. items . iter ( ) . cloned ( ) ) ;
1501+ tl. small_state_group_manager . compute_group_state ( small_state_events) ;
13831502 done_loading = true ;
13841503 }
13851504 TimelineUpdate :: NewUnreadMessagesCount ( unread_messages_count) => {
@@ -2254,6 +2373,7 @@ impl RoomScreen {
22542373 scrolled_past_read_marker : false ,
22552374 latest_own_user_receipt : None ,
22562375 tombstone_info,
2376+ small_state_group_manager : crate :: home:: event_group:: SmallStateGroupManager :: default ( ) ,
22572377 } ;
22582378 ( tl_state, true )
22592379 } ;
@@ -2846,6 +2966,8 @@ struct TimelineUiState {
28462966 /// If `Some`, this room has been tombstoned and the details of its successor room
28472967 /// are contained within. If `None`, the room has not been tombstoned.
28482968 tombstone_info : Option < SuccessorRoomDetails > ,
2969+ /// Manager for small state event groups that can be collapsed/expanded.
2970+ small_state_group_manager : crate :: home:: event_group:: SmallStateGroupManager ,
28492971}
28502972
28512973#[ derive( Default , Debug ) ]
@@ -4172,6 +4294,93 @@ pub fn populate_preview_of_timeline_item(
41724294 widget_out. show_html ( cx, html) ;
41734295}
41744296
4297+ /// Creates and populates a SmallStateGroupHeader (FoldHeader) for a group of small state events.
4298+ ///
4299+ /// This follows the portal_list_auto_grouping pattern:
4300+ /// - The header shows a summary and fold button
4301+ /// - The body contains a ViewList with all grouped items (excluding the first one)
4302+ ///
4303+ /// # Arguments
4304+ /// * `group_range` - The range of timeline indices covered by this group
4305+ /// * `group` - The SmallStateGroup metadata containing cached summary and user IDs
4306+ /// * `tl_items` - The full list of timeline items
4307+ fn populate_small_state_group_header (
4308+ cx : & mut Cx2d ,
4309+ scope : & mut Scope ,
4310+ walk : Walk ,
4311+ list : & mut PortalList ,
4312+ item_id : usize ,
4313+ timeline_kind : & TimelineKind ,
4314+ group_range : & std:: ops:: Range < usize > ,
4315+ group : & crate :: home:: event_group:: SmallStateGroup ,
4316+ tl_items : & imbl:: Vector < Arc < TimelineItem > > ,
4317+ content_drawn_since_last_update : & mut RangeSet < usize > ,
4318+ profile_drawn_since_last_update : & mut RangeSet < usize > ,
4319+ media_cache : & mut MediaCache ,
4320+ link_preview_cache : & mut LinkPreviewCache ,
4321+ fetched_thread_summaries : & HashMap < OwnedEventId , FetchedThreadSummary > ,
4322+ pending_thread_summary_fetches : & mut HashSet < OwnedEventId > ,
4323+ user_power_levels : & UserPowerLevels ,
4324+ pinned_events : & [ OwnedEventId ] ,
4325+ room_screen_widget_uid : WidgetUid ,
4326+ ) {
4327+ let ( fold_item, _existed) = list. item_with_existed ( cx, item_id, id ! ( SmallStateGroupHeader ) ) ;
4328+ // Set the header summary text
4329+ if let Some ( summary) = & group. cached_summary {
4330+ fold_item. label ( cx, ids ! ( summary_text) ) . set_text ( cx, summary) ;
4331+ }
4332+
4333+ // Set the avatars in the header
4334+ if let Some ( user_ids) = & group. cached_avatar_user_ids {
4335+ crate :: home:: event_group:: populate_avatar_row_from_user_ids (
4336+ cx,
4337+ & fold_item,
4338+ timeline_kind,
4339+ user_ids,
4340+ ) ;
4341+ }
4342+ let mut walk = walk;
4343+ walk. height = Size :: fit ( ) ;
4344+ while let Some ( item) = fold_item. draw_walk ( cx, scope, walk) . step ( ) {
4345+ if let Some ( mut list_ref) = item. as_portal_list ( ) . borrow_mut ( ) {
4346+ let list = list_ref. deref_mut ( ) ;
4347+ for tl_idx in ( group_range. start ) ..group_range. end {
4348+ if let Some ( timeline_item) = tl_items. get ( tl_idx) {
4349+ let item_drawn_status = ItemDrawnStatus {
4350+ content_drawn : content_drawn_since_last_update. contains ( & tl_idx) ,
4351+ profile_drawn : profile_drawn_since_last_update. contains ( & tl_idx) ,
4352+ } ;
4353+ if let TimelineItemKind :: Event ( event_tl_item) = timeline_item. kind ( ) {
4354+ // Create a new SmallStateEvent view from the template
4355+ let ( item, item_drawn_status) = match event_tl_item. content ( ) {
4356+ TimelineItemContent :: MsgLike ( msg_like_content) => match & msg_like_content. kind {
4357+ MsgLikeKind :: Redacted => {
4358+ let prev_event = tl_idx. checked_sub ( 1 ) . and_then ( |i| tl_items. get ( i) ) ;
4359+ populate_message_view ( cx, list, tl_idx, timeline_kind, event_tl_item, msg_like_content, prev_event, media_cache, link_preview_cache, fetched_thread_summaries, pending_thread_summary_fetches, user_power_levels, pinned_events, item_drawn_status, room_screen_widget_uid)
4360+ }
4361+ MsgLikeKind :: Poll ( poll_state) => populate_small_state_event ( cx, list, tl_idx, timeline_kind, event_tl_item, poll_state, item_drawn_status) ,
4362+ MsgLikeKind :: UnableToDecrypt ( utd) => populate_small_state_event ( cx, list, tl_idx, timeline_kind, event_tl_item, utd, item_drawn_status) ,
4363+ MsgLikeKind :: Other ( other) => populate_small_state_event ( cx, list, tl_idx, timeline_kind, event_tl_item, other, item_drawn_status) ,
4364+ _ => ( list. item_with_existed ( cx, tl_idx, id ! ( Empty ) ) . 0 , item_drawn_status)
4365+ } ,
4366+ TimelineItemContent :: MembershipChange ( membership_change) => populate_small_state_event ( cx, list, tl_idx, timeline_kind, event_tl_item, membership_change, item_drawn_status) ,
4367+ TimelineItemContent :: ProfileChange ( profile_change) => populate_small_state_event ( cx, list, tl_idx, timeline_kind, event_tl_item, profile_change, item_drawn_status) ,
4368+ TimelineItemContent :: OtherState ( other_state) => populate_small_state_event ( cx, list, tl_idx, timeline_kind, event_tl_item, other_state, item_drawn_status) ,
4369+ _=> ( list. item_with_existed ( cx, tl_idx, id ! ( Empty ) ) . 0 , item_drawn_status)
4370+ } ;
4371+ if item_drawn_status. content_drawn {
4372+ content_drawn_since_last_update. insert ( tl_idx..tl_idx + 1 ) ;
4373+ }
4374+ if item_drawn_status. profile_drawn {
4375+ profile_drawn_since_last_update. insert ( tl_idx..tl_idx + 1 ) ;
4376+ }
4377+ item. draw_all ( cx, scope) ;
4378+ }
4379+ }
4380+ }
4381+ }
4382+ }
4383+ }
41754384
41764385/// A trait for abstracting over the different types of timeline events
41774386/// that can be displayed in a `SmallStateEvent` widget.
0 commit comments