diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 205ef69ff..c68099b23 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -27,6 +27,7 @@ import 'package:fluffychat/pages/chat/chat_report_message_additional_reason_dial import 'package:fluffychat/pages/chat/chat_view.dart'; import 'package:fluffychat/pages/chat/context_item_chat_action.dart'; import 'package:fluffychat/pages/chat/dialog_reject_invite_widget.dart'; +import 'package:fluffychat/presentation/mixins/grouped_events_mixin.dart'; import 'package:fluffychat/pages/chat/events/message_content_mixin.dart'; import 'package:fluffychat/pages/chat/input_bar/focus_suggestion_controller.dart'; import 'package:fluffychat/presentation/enum/chat/right_column_type_enum.dart'; @@ -141,7 +142,8 @@ class ChatController extends State LeaveChatMixin, DeleteEventMixin, UnblockUserMixin, - AudioMixin { + AudioMixin, + EventGrouperMixin { final NetworkConnectionService networkConnectionService = getIt.get(); @@ -1211,17 +1213,22 @@ class ChatController extends State return eventIndex + addedHeadItemsInChat; } + List get groupedEvents => groupEvents(timeline?.events ?? []); + int _getEventIndex(String eventId) { - final foundEvent = - timeline!.events.firstWhereOrNull((event) => event.eventId == eventId); + // Find the group that contains the eventId (in main or additional events) + final foundGroup = groupedEvents.firstWhereOrNull( + (group) => group.allEvents.any((event) => event.eventId == eventId), + ); - final eventIndex = foundEvent == null - ? -1 - : timeline!.events.indexWhere( - (event) => event.eventId == foundEvent.eventId, - ); + if (foundGroup == null) { + return -1; + } - return eventIndex; + // Return the index of the main event in the original timeline + return timeline!.events.indexWhere( + (event) => event.eventId == foundGroup.mainEvent.eventId, + ); } Future scrollToEventId(String eventId, {bool highlight = true}) async { diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 0cabadbd7..9fd896def 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -29,12 +29,20 @@ class ChatEventList extends StatelessWidget { Widget build(BuildContext context) { final horizontalPadding = TwakeThemes.isColumnMode(context) ? 16.0 : 0.0; - final events = controller.timeline!.events; - // create a map of eventId --> index to greatly improve performance of + // Group consecutive image events + final groupedEvents = controller.groupedEvents; + + // create a map of eventId --> grouped index to greatly improve performance of // ListView's findChildIndexCallback + // This maps all event IDs (including those in groups) to their group's index final thisEventsKeyMap = {}; - for (var i = 0; i < events.length; i++) { - thisEventsKeyMap[events[i].eventId] = i; + for (var i = 0; i < groupedEvents.length; i++) { + final group = groupedEvents[i]; + // Map the main event and all additional events to the same group index + for (final event in group.allEvents) { + // Add 1 to account for the first item (footer) + thisEventsKeyMap[event.eventId] = i + 1; + } } if (controller.hasNoMessageEvents) { @@ -117,7 +125,7 @@ class ChatEventList extends StatelessWidget { return const SizedBox.shrink(); } // Request history button or progress indicator: - if (index == events.length + 1) { + if (index == groupedEvents.length + 1) { if (controller.timeline!.isRequestingHistory) { return const Center( child: CupertinoActivityIndicator(), @@ -133,14 +141,18 @@ class ChatEventList extends StatelessWidget { } return const SizedBox.shrink(); } - final currentEventIndex = index - 1; - final event = events[currentEventIndex]; - final previousEvent = currentEventIndex > 0 - ? events[currentEventIndex - 1] - : null; - final nextEvent = index < events.length - ? events[currentEventIndex + 1] + + final currentGroupIndex = index - 1; + final group = groupedEvents[currentGroupIndex]; + final event = group.mainEvent; + + final previousEvent = currentGroupIndex > 0 + ? groupedEvents[currentGroupIndex - 1].mainEvent : null; + final nextEvent = + currentGroupIndex < groupedEvents.length - 1 + ? groupedEvents[currentGroupIndex + 1].mainEvent + : null; return event.isVisibleInGui ? AutoScrollTag( key: ValueKey(event.eventId), @@ -237,11 +249,12 @@ class ChatEventList extends StatelessWidget { .getRecentReactionsInteractor .execute(), onReport: controller.reportEventAction, + groupedEvents: group.isGrouped ? group : null, ), ) : const SizedBox.shrink(); }, - childCount: events.length + 2, + childCount: groupedEvents.length + 2, findChildIndexCallback: (key) => controller .findChildIndexCallback(key, thisEventsKeyMap), ), diff --git a/lib/pages/chat/events/images_builder/message_content_image_builder.dart b/lib/pages/chat/events/images_builder/message_content_image_builder.dart index 62444b92f..88193e3ab 100644 --- a/lib/pages/chat/events/images_builder/message_content_image_builder.dart +++ b/lib/pages/chat/events/images_builder/message_content_image_builder.dart @@ -17,12 +17,15 @@ class MessageImageBuilder extends StatelessWidget { final double? maxWidth; + final bool rounded; + const MessageImageBuilder({ super.key, required this.event, this.onTapPreview, this.onTapSelectMode, this.maxWidth, + this.rounded = true, }); @override @@ -44,6 +47,7 @@ class MessageImageBuilder extends StatelessWidget { event: event, onTapPreview: onTapPreview, displayImageInfo: displayImageInfo, + rounded: rounded, ); } displayImageInfo ??= DisplayImageInfo( @@ -65,6 +69,7 @@ class MessageImageBuilder extends StatelessWidget { event: event, onTapPreview: onTapPreview, displayImageInfo: displayImageInfo, + rounded: rounded, ); } return ImageBubble( @@ -76,6 +81,8 @@ class MessageImageBuilder extends StatelessWidget { onTapPreview: onTapPreview, animated: true, thumbnailOnly: true, + thumbnailCacheKey: event.eventId, + rounded: rounded, ); } } diff --git a/lib/pages/chat/events/images_builder/sending_image_info_widget.dart b/lib/pages/chat/events/images_builder/sending_image_info_widget.dart index bffb3986d..74167e40a 100644 --- a/lib/pages/chat/events/images_builder/sending_image_info_widget.dart +++ b/lib/pages/chat/events/images_builder/sending_image_info_widget.dart @@ -22,6 +22,7 @@ class SendingImageInfoWidget extends StatefulWidget { required this.event, required this.displayImageInfo, this.onTapPreview, + this.rounded = true, }); final MatrixImageFile matrixFile; @@ -32,6 +33,8 @@ class SendingImageInfoWidget extends StatefulWidget { final DisplayImageInfo displayImageInfo; + final bool rounded; + @override State createState() => _SendingImageInfoWidgetState(); } @@ -44,7 +47,6 @@ class _SendingImageInfoWidgetState extends State @override void dispose() { sendingFileProgressNotifier.dispose(); - uploadFileStateNotifier.dispose(); super.dispose(); } @@ -112,11 +114,14 @@ class _SendingImageInfoWidgetState extends State ); }, child: Material( - borderRadius: MessageContentStyle.borderRadiusBubble, + borderRadius: + widget.rounded ? MessageContentStyle.borderRadiusBubble : null, child: InkWell( onTap: () => _onTap(context), child: ClipRRect( - borderRadius: MessageContentStyle.borderRadiusBubble, + borderRadius: widget.rounded + ? MessageContentStyle.borderRadiusBubble + : BorderRadius.zero, child: Stack( alignment: Alignment.center, children: [ diff --git a/lib/pages/chat/events/message/grouped_image_message_widget.dart b/lib/pages/chat/events/message/grouped_image_message_widget.dart new file mode 100644 index 000000000..9128be262 --- /dev/null +++ b/lib/pages/chat/events/message/grouped_image_message_widget.dart @@ -0,0 +1,263 @@ +import 'package:fluffychat/presentation/mixins/grouped_events_mixin.dart'; +import 'package:fluffychat/pages/chat/events/images_builder/message_content_image_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class GroupedImageMessageWidget extends StatelessWidget { + final GroupedEvents groupedEvents; + final void Function(Event)? onTapPreview; + final void Function()? onTapSelectMode; + + static const double _imageSpacing = 2.0; + + const GroupedImageMessageWidget({ + super.key, + required this.groupedEvents, + this.onTapPreview, + this.onTapSelectMode, + }); + + @override + Widget build(BuildContext context) { + final images = groupedEvents.allEvents; + + return ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: switch (images.length) { + 1 => _buildSingleImage(images.first), + 2 => _buildTwoImages(images), + 3 => _buildThreeImages(images), + 4 => _buildFourImages(images), + _ => _buildFiveOrMoreImages(images), + }, + ); + } + + Widget _buildSingleImage(Event image) { + return MessageImageBuilder( + event: image, + onTapPreview: () => onTapPreview?.call(image), + onTapSelectMode: onTapSelectMode, + rounded: false, + ); + } + + Widget _buildTwoImages(List images) { + return Row( + children: [ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[0], + onTapPreview: () => onTapPreview?.call(images[0]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + const SizedBox(width: _imageSpacing), + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[1], + onTapPreview: () => onTapPreview?.call(images[1]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + ], + ); + } + + Widget _buildThreeImages(List images) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[0], + onTapPreview: () => onTapPreview?.call(images[0]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + const SizedBox(width: _imageSpacing), + Expanded( + child: Column( + children: [ + AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[1], + onTapPreview: () => onTapPreview?.call(images[1]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + const SizedBox(height: _imageSpacing), + AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[2], + onTapPreview: () => onTapPreview?.call(images[2]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildFourImages(List images) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[0], + onTapPreview: () => onTapPreview?.call(images[0]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + const SizedBox(width: _imageSpacing), + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[1], + onTapPreview: () => onTapPreview?.call(images[1]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + ], + ), + const SizedBox(height: _imageSpacing), + Row( + children: [ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[2], + onTapPreview: () => onTapPreview?.call(images[2]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + const SizedBox(width: _imageSpacing), + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[3], + onTapPreview: () => onTapPreview?.call(images[3]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildFiveOrMoreImages(List images) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[0], + onTapPreview: () => onTapPreview?.call(images[0]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + const SizedBox(width: _imageSpacing), + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[1], + onTapPreview: () => onTapPreview?.call(images[1]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + ], + ), + const SizedBox(height: _imageSpacing), + Row( + children: [ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: MessageImageBuilder( + event: images[2], + onTapPreview: () => onTapPreview?.call(images[2]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + ), + ), + const SizedBox(width: _imageSpacing), + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + fit: StackFit.expand, + children: [ + MessageImageBuilder( + event: images[3], + onTapPreview: () => onTapPreview?.call(images[3]), + onTapSelectMode: onTapSelectMode, + rounded: false, + ), + if (images.length > 4) + Container( + color: Colors.black.withOpacity(0.6), + child: Center( + child: Text( + '+${images.length - 4}', + style: const TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pages/chat/events/message/message.dart b/lib/pages/chat/events/message/message.dart index 87aef5ee8..8a06580a1 100644 --- a/lib/pages/chat/events/message/message.dart +++ b/lib/pages/chat/events/message/message.dart @@ -3,6 +3,7 @@ import 'package:fluffychat/di/global/get_it_initializer.dart'; import 'package:fluffychat/pages/chat/chat_horizontal_action_menu.dart'; import 'package:fluffychat/pages/chat/chat_view_body_style.dart'; import 'package:fluffychat/pages/chat/context_item_chat_action.dart'; +import 'package:fluffychat/presentation/mixins/grouped_events_mixin.dart'; import 'package:fluffychat/pages/chat/events/message/message_content_with_timestamp_builder.dart'; import 'package:fluffychat/pages/chat/events/message/message_style.dart'; import 'package:fluffychat/pages/chat/events/message/multi_platform_message_container.dart'; @@ -99,6 +100,7 @@ class Message extends StatefulWidget { final void Function(BuildContext context, Event, TapDownDetails)? onTapMoreButton; final Future? recentEmojiFuture; + final GroupedEvents? groupedEvents; const Message( this.event, { @@ -140,6 +142,7 @@ class Message extends StatefulWidget { this.onSaveToGallery, this.onTapMoreButton, this.recentEmojiFuture, + this.groupedEvents, }); /// Indicates wheither the user may use a mouse instead @@ -289,6 +292,7 @@ class _MessageState extends State with MessageAvatarMixin { saveToGallery: widget.onSaveToGallery, onTapMoreButton: widget.onTapMoreButton, recentEmojiFuture: widget.recentEmojiFuture, + groupedEvents: widget.groupedEvents, ), ), ]; diff --git a/lib/pages/chat/events/message/message_content_builder.dart b/lib/pages/chat/events/message/message_content_builder.dart index 1cbd249a9..0211043af 100644 --- a/lib/pages/chat/events/message/message_content_builder.dart +++ b/lib/pages/chat/events/message/message_content_builder.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/presentation/mixins/grouped_events_mixin.dart'; import 'package:fluffychat/pages/chat/events/message/message_content_builder_mixin.dart'; import 'package:fluffychat/pages/chat/events/message/message_style.dart'; import 'package:fluffychat/pages/chat/events/message_time.dart'; @@ -20,6 +21,7 @@ class MessageContentBuilder extends StatelessWidget final void Function(Event)? onSelect; final Event? nextEvent; final bool selectMode; + final GroupedEvents? groupedEvents; const MessageContentBuilder({ super.key, @@ -29,6 +31,7 @@ class MessageContentBuilder extends StatelessWidget this.nextEvent, this.scrollToEventId, this.selectMode = true, + this.groupedEvents, }); @override @@ -104,6 +107,7 @@ class MessageContentBuilder extends StatelessWidget onTapPreview: !selectMode ? () {} : null, ownMessage: event.isOwnMessage, timeline: timeline, + groupedEvents: groupedEvents, ), PositionedDirectional( end: 8, diff --git a/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart b/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart index 55ee87aae..db5503aa3 100644 --- a/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart +++ b/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/di/global/get_it_initializer.dart'; import 'package:fluffychat/domain/model/room/room_extension.dart'; +import 'package:fluffychat/presentation/mixins/grouped_events_mixin.dart'; import 'package:fluffychat/pages/chat/events/message/display_name_widget.dart'; import 'package:fluffychat/pages/chat/events/message/message.dart'; import 'package:fluffychat/pages/chat/events/message/message_content_builder.dart'; @@ -69,6 +70,7 @@ class MessageContentWithTimestampBuilder extends StatefulWidget { final void Function(BuildContext context, Event, TapDownDetails)? onTapMoreButton; final Future? recentEmojiFuture; + final GroupedEvents? groupedEvents; const MessageContentWithTimestampBuilder({ super.key, @@ -103,6 +105,7 @@ class MessageContentWithTimestampBuilder extends StatefulWidget { this.saveToGallery, this.onTapMoreButton, this.recentEmojiFuture, + this.groupedEvents, }); @override @@ -330,6 +333,8 @@ class _MessageContentWithTimestampBuilderState displayTime: displayTime, paddingBubble: EdgeInsets.zero, enableBorder: false, + groupedEvents: + widget.groupedEvents, ), ), ), @@ -449,6 +454,7 @@ class _MessageContentWithTimestampBuilderState timelineText: timelineText, noBubble: noBubble, displayTime: displayTime, + groupedEvents: widget.groupedEvents, ), ), ), @@ -597,6 +603,7 @@ class _MessageContentWithTimestampBuilderState EdgeInsets? paddingBubble, bool enableBorder = true, MainAxisSize mainAxisSize = MainAxisSize.max, + GroupedEvents? groupedEvents, }) { final hasReactionEvent = widget.event.hasReactionEvent( timeline: widget.timeline, @@ -664,6 +671,7 @@ class _MessageContentWithTimestampBuilderState nextEvent: widget.nextEvent, scrollToEventId: widget.scrollToEventId, selectMode: widget.selectMode, + groupedEvents: groupedEvents, ), Positioned( child: OptionalSelectionContainerDisabled( diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index d113974e0..931913961 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -2,8 +2,10 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/events/call_invite_content.dart'; import 'package:fluffychat/pages/chat/events/encrypted_content.dart'; import 'package:fluffychat/pages/chat/events/formatted_text_widget.dart'; +import 'package:fluffychat/presentation/mixins/grouped_events_mixin.dart'; import 'package:fluffychat/pages/chat/events/images_builder/message_content_image_builder.dart'; import 'package:fluffychat/pages/chat/events/message/message_style.dart'; +import 'package:fluffychat/pages/chat/events/message/grouped_image_message_widget.dart'; import 'package:fluffychat/pages/chat/events/message_content_style.dart'; import 'package:fluffychat/pages/chat/events/message_download_content_web.dart'; import 'package:fluffychat/pages/chat/events/message_upload_content.dart'; @@ -44,6 +46,7 @@ class MessageContent extends StatelessWidget final void Function()? onTapSelectMode; final bool ownMessage; final Timeline timeline; + final GroupedEvents? groupedEvents; const MessageContent( this.event, { @@ -54,6 +57,7 @@ class MessageContent extends StatelessWidget this.onTapSelectMode, required this.ownMessage, required this.timeline, + this.groupedEvents, }); @override @@ -66,6 +70,16 @@ class MessageContent extends StatelessWidget case EventTypes.Sticker: switch (event.messageType) { case MessageTypes.Image: + if (groupedEvents?.isGrouped == true) { + return OptionalSelectionContainerDisabled( + isEnabled: PlatformInfos.isWeb, + child: GroupedImageMessageWidget( + groupedEvents: groupedEvents!, + onTapPreview: (event) => onTapPreview?.call(), + onTapSelectMode: onTapSelectMode, + ), + ); + } if (event.isImageWithCaption()) { return OptionalSelectionContainerDisabled( isEnabled: PlatformInfos.isWeb, diff --git a/lib/presentation/extensions/send_file_extension.dart b/lib/presentation/extensions/send_file_extension.dart index 612080b95..f12f4df35 100644 --- a/lib/presentation/extensions/send_file_extension.dart +++ b/lib/presentation/extensions/send_file_extension.dart @@ -634,6 +634,7 @@ extension SendFileExtension on Room { sendPlaceholdersForImagePickerFiles({ required List entities, String? captionInfo, + Map? extraContent, }) async { final txIdMapToImageFile = {}; for (final entity in entities) { @@ -651,6 +652,7 @@ extension SendFileExtension on Room { txid: txid, messageType: entity.messageType, captionInfo: captionInfo, + extraContent: extraContent, ); txIdMapToImageFile[txid] = FakeSendingFileInfo( fileInfo: fileInfo, diff --git a/lib/presentation/mixins/grouped_events_mixin.dart b/lib/presentation/mixins/grouped_events_mixin.dart new file mode 100644 index 000000000..84bb6b03a --- /dev/null +++ b/lib/presentation/mixins/grouped_events_mixin.dart @@ -0,0 +1,117 @@ +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:matrix/matrix.dart'; + +/// Represents a group of consecutive events that should be displayed together +class GroupedEvents { + /// The main event (first event in the group) + final Event mainEvent; + + /// List of additional events in the group (excluding the main event) + final List additionalEvents; + + /// Whether this is a grouped event (has additional events) + bool get isGrouped => additionalEvents.isNotEmpty; + + /// All events in the group (main event + additional events) + List get allEvents => [mainEvent, ...additionalEvents]; + + GroupedEvents({ + required this.mainEvent, + this.additionalEvents = const [], + }); + + /// Creates a single event group (not grouped) + factory GroupedEvents.single(Event event) { + return GroupedEvents(mainEvent: event); + } + + /// Creates a grouped event with multiple events + factory GroupedEvents.multiple(List events) { + if (events.isEmpty) { + throw ArgumentError('Events list cannot be empty'); + } + return GroupedEvents( + mainEvent: events.first, + additionalEvents: events.sublist(1), + ); + } +} + +/// Helper class to group consecutive image events +mixin EventGrouperMixin { + /// Groups consecutive image events with matching image_bubble_id + /// Only groups images from the same sender that have the same non-null groupId + List groupEvents(List events) { + final List groupedEvents = []; + int i = 0; + + while (i < events.length) { + final event = events[i]; + + // Check if this is an image event without caption + if (_isGroupableImage(event)) { + final List imageGroup = [event]; + final sender = event.senderId; + final groupId = event.imageBubbleId(); + int j = i + 1; + + // Collect consecutive images with matching groupId + while (j < events.length) { + final nextEvent = events[j]; + final nextGroupId = nextEvent.imageBubbleId(); + + // Only group if both events have the same non-null groupId + final shouldGroup = _isGroupableImage(nextEvent) && + nextEvent.senderId == sender && + groupId != null && + groupId == nextGroupId; + + if (shouldGroup) { + imageGroup.add(nextEvent); + j++; + } else { + break; + } + } + + // Create grouped event + if (imageGroup.length > 1) { + groupedEvents.add(GroupedEvents.multiple(imageGroup)); + i = j; + } else { + groupedEvents.add(GroupedEvents.single(event)); + i++; + } + } else { + groupedEvents.add(GroupedEvents.single(event)); + i++; + } + } + + return groupedEvents; + } + + /// Checks if an event is a groupable image (image without caption) + bool _isGroupableImage(Event event) { + if (event.messageType != MessageTypes.Image) { + return false; + } + + // Don't group images with captions + final body = event.content.tryGet('body') ?? ''; + final filename = event.content + .tryGetMap('info') + ?.tryGet('filename'); + + // If body is different from filename, it has a caption + if (filename != null && body != filename) { + return false; + } + + if (event.imageBubbleId() == null) { + return false; + } + + return true; + } +} diff --git a/lib/utils/manager/upload_manager/upload_manager.dart b/lib/utils/manager/upload_manager/upload_manager.dart index d1d351790..8e3cdfad3 100644 --- a/lib/utils/manager/upload_manager/upload_manager.dart +++ b/lib/utils/manager/upload_manager/upload_manager.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/utils/manager/upload_manager/upload_state.dart'; import 'package:fluffychat/utils/manager/upload_manager/upload_worker_queue.dart'; import 'package:fluffychat/utils/task_queue/task.dart'; import 'package:matrix/matrix.dart'; +import 'package:uuid/uuid.dart'; class UploadManager { UploadManager._(); @@ -83,14 +84,30 @@ class UploadManager { return _eventIdMapUploadFileInfo[txid]?.uploadStream; } + /// Generates a unique group ID for grouping multiple images together + /// This ID will be used to identify which images belong to the same group + String generateGroupId() { + return const Uuid().v4(); + } + Future uploadMediaMobile({ required Room room, required List entities, String? caption, }) async { + // Generate a unique group ID for this batch of images + final groupId = entities.length > 1 ? generateGroupId() : null; + + Logs().d( + 'UploadManager::uploadMediaMobile(): Uploading ${entities.length} files with groupId: $groupId', + ); + final txids = await room.sendPlaceholdersForImagePickerFiles( entities: entities, captionInfo: caption, + extraContent: { + if (groupId != null) 'image_bubble_id': groupId, + }, ); for (final txid in txids.entries) { @@ -116,6 +133,9 @@ class UploadManager { messageType: fakeSendingFileInfo.messageType, sentDate: sentDate, captionInfo: _eventIdMapUploadFileInfo[txidKey]?.captionInfo?.caption, + extraContent: { + if (groupId != null) 'image_bubble_id': groupId, + }, ); final streamController = @@ -155,6 +175,9 @@ class UploadManager { sentDate: sentDate, shrinkImageMaxDimension: _shrinkImageMaxDimension, captionInfo: _eventIdMapUploadFileInfo[txidKey]?.captionInfo?.caption, + extraContent: { + if (groupId != null) 'image_bubble_id': groupId, + }, ); } } @@ -165,6 +188,13 @@ class UploadManager { Map? thumbnails, String? caption, }) async { + // Generate a unique group ID for this batch of images + final groupId = files.length > 1 ? generateGroupId() : null; + + print( + 'UploadManager::uploadMediaMobile(): Uploading ${files.length} files with groupId: $groupId', + ); + for (final matrixFile in files.asMap().entries) { final txid = room.client.generateUniqueTransactionId(); final fileIndex = matrixFile.key; @@ -181,6 +211,9 @@ class UploadManager { fileInfo, txid: txid, captionInfo: _eventIdMapUploadFileInfo[txid]?.captionInfo?.caption, + extraContent: { + if (groupId != null) 'image_bubble_id': groupId, + }, ); final streamController = @@ -223,6 +256,9 @@ class UploadManager { thumbnail: thumbnails?[fileInfo], sentDate: sentDate, captionInfo: _eventIdMapUploadFileInfo[txid]?.captionInfo?.caption, + extraContent: { + if (groupId != null) 'image_bubble_id': groupId, + }, ), ]); } @@ -309,6 +345,7 @@ class UploadManager { DateTime? sentDate, int? shrinkImageMaxDimension, String? captionInfo, + Map? extraContent, }) { uploadWorkerQueue.addTask( Task( @@ -325,6 +362,7 @@ class UploadManager { cancelToken: cancelToken, sentDate: sentDate, captionInfo: captionInfo, + extraContent: extraContent, ); } catch (e) { streamController.add( @@ -349,6 +387,7 @@ class UploadManager { MatrixImageFile? thumbnail, DateTime? sentDate, String? captionInfo, + Map? extraContent, }) { return uploadWorkerQueue.addTask( Task( @@ -364,6 +403,7 @@ class UploadManager { cancelToken: cancelToken, sentDate: sentDate, captionInfo: captionInfo, + extraContent: extraContent, ); } catch (e) { streamController.add( diff --git a/lib/utils/matrix_sdk_extensions/event_extension.dart b/lib/utils/matrix_sdk_extensions/event_extension.dart index a2e00904a..5358b6493 100644 --- a/lib/utils/matrix_sdk_extensions/event_extension.dart +++ b/lib/utils/matrix_sdk_extensions/event_extension.dart @@ -422,4 +422,17 @@ extension LocalizedBody on Event { text.isNotEmpty && filename != text; } + + String? imageBubbleId() { + // First try to get from main content (for actual sent events) + final groupId = content.tryGet('image_bubble_id'); + if (groupId != null) { + return groupId; + } + + // Fallback to unsigned.extra_content (for fake/pending events) + return unsigned + ?.tryGetMap('extra_content') + ?.tryGet('image_bubble_id'); + } } diff --git a/pubspec.lock b/pubspec.lock index bbe44bcc3..a72d124f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -3085,7 +3085,7 @@ packages: source: hosted version: "3.1.4" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index 95e2e3886..fb1548205 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -217,6 +217,7 @@ dependencies: pretty_qr_code: 3.3.0 byte_converter: 2.0.0 flutter_vodozemac: ^0.3.0 + uuid: 4.5.1 dev_dependencies: build_runner: 2.4.12