Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
- Fixed modal dialogs (message actions, delete/flag confirmation) rendering over a white scrim in light theme; they now use the design system's scrim token.
- Fixed the message metadata in the long-press actions modal keeping its muted in-list colors against the dark scrim. The previewed message now renders its annotations ("Saved for later", "Pinned by", "Replied to a thread" + "View", "Reminder set"), username, sending status and read receipts, timestamp, "Edited" label, and thread-reply count in white. Added `StreamSendingIndicator.color` to override the indicator's icon color.

🔄 Changed

- Improved message text selection experience on desktop and web.

## 10.2.0

⚠️ Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,6 +31,7 @@ class StreamMessageText extends StatelessWidget {
this.onLinkTap,
this.onMentionTap,
this.onAnyMentionTap,
this.onSelectionChanged,
});

/// The message whose text to display.
Expand Down Expand Up @@ -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<SelectedContent?>? onSelectionChanged;

@override
Widget build(BuildContext context) {
final streamChat = StreamChat.of(context);
Expand All @@ -71,13 +76,19 @@ 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(onSelectionChanged: onSelectionChanged, child: streamMessageText);
}

return streamMessageText;
},
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import 'dart:async';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.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);
});

tearDown(() {
CurrentPlatform.debugCurrentPlatformOverride = null;
});

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);
});

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);
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<OwnUser?>.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();
});
}
Loading