diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 5c428fc67b..771e1ee307 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -33,6 +33,10 @@ class _MessageBubble extends ConsumerWidget { currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && profile?.ownerPubkey == currentPubkey?.toLowerCase()); + final hasImageGallery = hasTrailingImageGallery( + message.content, + message.tags, + ); // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); @@ -57,10 +61,10 @@ class _MessageBubble extends ConsumerWidget { child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's trailing - // gutter. InkWell still clips its ink to [borderRadius], while leaving - // overflowing message content visible. - clipBehavior: Clip.none, + // Only galleries paint through the trailing gutter. Keep normal rows + // clipped so recycled list children cannot leave stale text over the + // following message or thread summary. + clipBehavior: hasImageGallery ? Clip.none : Clip.antiAlias, child: InkWell( key: ValueKey('message-row-${message.id}'), borderRadius: BorderRadius.circular(Radii.md), diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 664594ade7..3dd9def49f 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -59,6 +59,12 @@ final openDownloadedFileProvider = Provider((ref) { }; }); +/// Whether [content] ends with the multi-image gallery rendered by +/// [MessageContent]. +bool hasTrailingImageGallery(String content, List> tags) { + return _extractTrailingImageGallery(content, parseImetaTags(tags)) != null; +} + String _safeDownloadedFilename(String filename) { final safe = filename .split(RegExp(r'[/\\]')) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index e8081f42fe..fa9e2b9750 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -486,6 +486,10 @@ class _ThreadMessage extends ConsumerWidget { currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && profile?.ownerPubkey == currentPubkey?.toLowerCase()); + final hasImageGallery = hasTrailingImageGallery( + message.content, + message.tags, + ); final userCache = ref.watch(userCacheProvider); final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId)); @@ -515,10 +519,10 @@ class _ThreadMessage extends ConsumerWidget { child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's - // trailing gutter. InkWell still clips its ink to [borderRadius], - // while leaving overflowing message content visible. - clipBehavior: Clip.none, + // Only galleries paint through the trailing gutter. Keep normal rows + // clipped so recycled list children cannot leave stale text over the + // following message or thread summary. + clipBehavior: hasImageGallery ? Clip.none : Clip.antiAlias, child: InkWell( key: ValueKey('thread-message-row-${message.id}'), borderRadius: BorderRadius.circular(Radii.md), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 127e6851e6..9a796b494c 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -696,6 +696,16 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(find.text('alice@example.com'), findsOneWidget); expect(find.text('Bob'), findsOneWidget); + final textMessageMaterial = find + .ancestor( + of: find.byKey(const ValueKey('message-row-msg1')), + matching: find.byType(Material), + ) + .first; + expect( + tester.widget(textMessageMaterial).clipBehavior, + Clip.antiAlias, + ); final messageAvatars = find.byType(CircleAvatar); expect(messageAvatars, findsNWidgets(2)); for (final avatar in messageAvatars.evaluate()) {