From 45ae06f0340419d75716f7aaa721a8955b68f7e4 Mon Sep 17 00:00:00 2001 From: Chamber Date: Wed, 5 Aug 2026 17:39:22 +0800 Subject: [PATCH 1/6] feat(ui): improve message text selection on desktop/web * Use `SelectionArea` to handle text selection on desktop and web platforms instead of passing the `selectable` property to `core.StreamMessageText`. --- .../message_widget/components/stream_message_text.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart index de532bb054..858bda2537 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart @@ -71,13 +71,17 @@ class StreamMessageText extends StatelessWidget { if (messageText == null || messageText.trim().isEmpty) return const Empty(); - return core.StreamMessageText( + final streamMessageText = core.StreamMessageText( messageText, - selectable: isDesktopDeviceOrWeb, + selectable: false, onTapLink: onLinkTap, onTapMention: onMentionTap, onTapAnyMention: onAnyMentionTap, ); + + if (isDesktopDeviceOrWeb) return SelectionArea(child: streamMessageText); + + return streamMessageText; }, ); } From 8e59737cb3df10c7c6573e057b7e1b855f47c53d Mon Sep 17 00:00:00 2001 From: Chamber Date: Wed, 5 Aug 2026 17:45:26 +0800 Subject: [PATCH 2/6] Update CHANGELOG.md --- packages/stream_chat_flutter/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 2220c44b44..0b25d2f766 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -25,6 +25,10 @@ - Fixed the `StreamBackButton` unread badge including the currently open channel in its total count. - Fixed `StreamMessageListView` jumping several screens when selecting text in a message on desktop or web. The `ScrollablePositionedList` viewports now account for their `anchor` in `getOffsetToReveal`, so implicit reveals (`Scrollable.ensureVisible`, `RenderObject.showOnScreen`) no longer overshoot. [#2862](https://github.com/GetStream/stream-chat-flutter/issues/2862) +🔄 Changed + +- Improved message text selection experience on desktop and web. + ## 10.2.0 ⚠️ Changed From 5c80bc11cd3cd76975bb4613edbca8ffdfdf1d38 Mon Sep 17 00:00:00 2001 From: Chamber Date: Thu, 6 Aug 2026 01:54:05 +0800 Subject: [PATCH 3/6] test: add tests for StreamMessageText --- .../stream_message_text_test.dart | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart new file mode 100644 index 0000000000..409073ead4 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide StreamMessageText; +import 'package:stream_core_flutter/chat.dart' as core; + +import '../mocks.dart'; + +void main() { + late MockClient client; + late MockClientState clientState; + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + }); + + Widget wrap(Widget child) { + return MaterialApp( + home: StreamChat( + client: client, + child: Scaffold(body: child), + ), + ); + } + + testWidgets('StreamMessageText renders message text', (tester) async { + final user = OwnUser(id: 'test-user', language: 'en'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + + final message = Message(text: 'Hello world'); + + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + + expect(find.text('Hello world'), findsOneWidget); + expect(find.byType(core.StreamMessageText), findsOneWidget); + expect(find.byType(SelectionArea), isDesktopDeviceOrWeb ? findsOneWidget : findsNothing); + }); + + testWidgets('StreamMessageText renders translated message text', (tester) async { + final user = OwnUser(id: 'test-user', language: 'fr'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + + final message = Message( + text: 'Hello world', + i18n: const { + 'fr_text': 'Bonjour le monde', + }, + ); + + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + + expect(find.text('Bonjour le monde'), findsOneWidget); + }); + + testWidgets('StreamMessageText renders empty when text is null or empty', (tester) async { + final user = OwnUser(id: 'test-user', language: 'en'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + + await tester.pumpWidget(wrap(StreamMessageText(message: Message()))); + await tester.pump(); + + expect(find.byType(core.StreamMessageText), findsNothing); + }); + + testWidgets('StreamMessageText replaces mentions', (tester) async { + final user = OwnUser(id: 'test-user', language: 'en'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + + final mentionedUser = User(id: 'u1', name: 'Alice'); + final message = Message( + text: 'Hello @u1', + mentionedUsers: [mentionedUser], + ); + + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + + // replaceMentions() should change "@u1" to "[@Alice](mention:u1)" + // core.StreamMessageText renders this as markdown. + expect(find.textContaining('Alice'), findsOneWidget); + }); + + testWidgets('StreamMessageText rebuilds when user language changes', (tester) async { + final userEn = OwnUser(id: 'test-user', language: 'en'); + final userFr = OwnUser(id: 'test-user', language: 'fr'); + + final userStreamController = StreamController.broadcast()..add(userEn); + + when(() => clientState.currentUser).thenReturn(userEn); + when(() => clientState.currentUserStream).thenAnswer((_) => userStreamController.stream); + + final message = Message( + text: 'Hello world', + i18n: const { + 'fr_text': 'Bonjour le monde', + }, + ); + + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + + expect(find.text('Hello world'), findsOneWidget); + + when(() => clientState.currentUser).thenReturn(userFr); + userStreamController.add(userFr); + + // BetterStreamBuilder needs to process the stream event and trigger a rebuild. + // pumpAndSettle ensures all scheduled frames and microtasks are handled. + await tester.pumpAndSettle(); + + expect(find.text('Bonjour le monde'), findsOneWidget); + + await userStreamController.close(); + }); +} From 0ca7b9b59abbb2f938ce5d1aa654bdb4fe76f8d0 Mon Sep 17 00:00:00 2001 From: Chamber Date: Fri, 7 Aug 2026 09:54:44 +0800 Subject: [PATCH 4/6] test(ui): add `onSelectionChanged` to `StreamMessageText` and expand tests * feat(ui): expose `onSelectionChanged` callback in `StreamMessageText` to handle text selection updates. * test(ui): add comprehensive platform-specific tests for `SelectionArea` visibility on desktop, web, and mobile. * test(ui): add functional test for verifying text selection content in `StreamMessageText`. --- .../components/stream_message_text.dart | 9 +- .../stream_message_text_test.dart | 96 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart index 858bda2537..507b362c02 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/stream_chat.dart'; @@ -30,6 +31,7 @@ class StreamMessageText extends StatelessWidget { this.onLinkTap, this.onMentionTap, this.onAnyMentionTap, + this.onSelectionChanged, }); /// The message whose text to display. @@ -59,6 +61,9 @@ class StreamMessageText extends StatelessWidget { /// If null, falls back to [onMentionTap] for user mentions only. final core.MarkdownTapAnyMentionCallback? onAnyMentionTap; + /// Called when the selected content changes. + final ValueChanged? onSelectionChanged; + @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); @@ -79,7 +84,9 @@ class StreamMessageText extends StatelessWidget { onTapAnyMention: onAnyMentionTap, ); - if (isDesktopDeviceOrWeb) return SelectionArea(child: streamMessageText); + if (isDesktopDeviceOrWeb) { + return SelectionArea(onSelectionChanged: onSelectionChanged, child: streamMessageText); + } return streamMessageText; }, diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart index 409073ead4..bd188cd93e 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; @@ -19,6 +22,10 @@ void main() { when(() => client.state).thenReturn(clientState); }); + tearDown(() { + CurrentPlatform.debugCurrentPlatformOverride = null; + }); + Widget wrap(Widget child) { return MaterialApp( home: StreamChat( @@ -40,9 +47,96 @@ void main() { expect(find.text('Hello world'), findsOneWidget); expect(find.byType(core.StreamMessageText), findsOneWidget); - expect(find.byType(SelectionArea), isDesktopDeviceOrWeb ? findsOneWidget : findsNothing); }); + testWidgets('StreamMessageText is selectable on desktop and web', (tester) async { + final user = OwnUser(id: 'test-user', language: 'en'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + + final message = Message(text: 'Hello world'); + + // Desktop + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.macOS; + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + expect(find.byType(SelectionArea), findsOneWidget); + + // Web + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.web; + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + expect(find.byType(SelectionArea), findsOneWidget); + }); + + testWidgets('StreamMessageText is not selectable on mobile', (tester) async { + final user = OwnUser(id: 'test-user', language: 'en'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + + final message = Message(text: 'Hello world'); + + // iOS + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.ios; + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + expect(find.byType(SelectionArea), findsNothing); + + // Android + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.android; + await tester.pumpWidget(wrap(StreamMessageText(message: message))); + await tester.pump(); + expect(find.byType(SelectionArea), findsNothing); + }); + + testWidgets( + 'StreamMessageText allows selecting text on desktop', + (tester) async { + addTearDown(() { + CurrentPlatform.debugCurrentPlatformOverride = null; + }); + + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.macOS; + + SelectedContent? content; + + final user = OwnUser(id: 'test-user', language: 'en'); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream) + .thenAnswer((_) => Stream.value(user)); + + const text = '- Item 1\n- Item 2\n- Item 3'; + + await tester.pumpWidget( + wrap( + StreamMessageText( + message: Message(text: text), + onSelectionChanged: (selectedContent) { + content = selectedContent; + }, + ), + ), + ); + + final target = find.byType(StreamMessageText); + expect(target, findsOneWidget); + + final gesture = await tester.startGesture( + tester.getTopLeft(target), + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(tester.getBottomRight(target)); + await gesture.up(); + await tester.pump(); + + expect(content, isNotNull); + expect(content!.plainText, '•Item 1•Item 2•Item 3'); + }, + ); + testWidgets('StreamMessageText renders translated message text', (tester) async { final user = OwnUser(id: 'test-user', language: 'fr'); when(() => clientState.currentUser).thenReturn(user); From 329cef53a758aed9817f9a4450c5df0057999f25 Mon Sep 17 00:00:00 2001 From: Chamber Date: Fri, 7 Aug 2026 09:55:25 +0800 Subject: [PATCH 5/6] Update stream_message_text_test.dart --- .../test/src/message_widget/stream_message_text_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart index bd188cd93e..bfebb9732b 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; From 6ac9b013c4dfb5550fe48d0e3f8be9c61b6d0e09 Mon Sep 17 00:00:00 2001 From: Chamber Date: Thu, 13 Aug 2026 15:14:43 +0800 Subject: [PATCH 6/6] test(ui): fix formatting in `stream_message_text_test.dart` --- .../test/src/message_widget/stream_message_text_test.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart index bfebb9732b..4ebcdfb9ca 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_text_test.dart @@ -90,7 +90,7 @@ void main() { testWidgets( 'StreamMessageText allows selecting text on desktop', - (tester) async { + (tester) async { addTearDown(() { CurrentPlatform.debugCurrentPlatformOverride = null; }); @@ -101,8 +101,7 @@ void main() { final user = OwnUser(id: 'test-user', language: 'en'); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); const text = '- Item 1\n- Item 2\n- Item 3';