Files
org.parres.whitenoise/test/screens/chat_screen_test.dart
T

2385 lines
81 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/providers/debug_view_provider.dart';
import 'package:whitenoise/providers/offline_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/screens/chat_info_screen.dart';
import 'package:whitenoise/screens/chat_list_screen.dart';
import 'package:whitenoise/screens/chat_raw_debug_screen.dart';
import 'package:whitenoise/screens/group_info_screen.dart';
import 'package:whitenoise/screens/message_actions_screen.dart';
import 'package:whitenoise/src/rust/api/chat_list.dart';
import 'package:whitenoise/src/rust/api/drafts.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/api/messages.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/widgets/chat_media_upload_preview.dart';
import 'package:whitenoise/widgets/chat_message_quote.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_button.dart';
import 'package:whitenoise/widgets/wn_chat_message_input.dart';
import 'package:whitenoise/widgets/wn_message_bubble.dart';
import 'package:whitenoise/widgets/wn_slate.dart';
import 'package:whitenoise/widgets/wn_system_notice.dart';
import '../mocks/mock_wn_api.dart';
import '../test_helpers.dart';
const _testPubkey = testPubkeyA;
const _testGroupId = testGroupId;
class _MockImagePickerPlatform extends ImagePickerPlatform with MockPlatformInterfaceMixin {
List<XFile> filesToReturn = [];
@override
Future<List<XFile>> getMultiImageWithOptions({
MultiImagePickerOptions options = const MultiImagePickerOptions(),
}) async {
return filesToReturn;
}
@override
Future<List<XFile>> getMedia({required MediaOptions options}) async {
return filesToReturn;
}
}
class _MockTag implements Tag {
final List<String> vec;
_MockTag(this.vec);
@override
void dispose() {}
@override
bool get isDisposed => false;
}
ChatMessage _message(
String id,
DateTime createdAt, {
String pubkey = testPubkeyB,
bool isDeleted = false,
bool isReply = false,
String? replyToId,
ReactionSummary reactions = const ReactionSummary(byEmoji: [], userReactions: []),
DeliveryStatus? deliveryStatus,
}) => ChatMessage(
id: id,
pubkey: pubkey,
content: 'Message $id',
createdAt: createdAt,
tags: const [],
isReply: isReply,
replyToId: replyToId,
isDeleted: isDeleted,
contentTokens: const [],
reactions: reactions,
mediaAttachments: const [],
kind: 9,
deliveryStatus: deliveryStatus,
);
class _MockApi extends MockWnApi {
StreamController<MessageStreamItem>? controller;
List<ChatMessage> initialMessages = [];
String groupName = 'Test Group';
final List<String> sentMessages = [];
final List<({String groupId, int kind, List<Tag>? tags})> deletionCalls = [];
final List<({String groupId, String message, int kind, List<Tag>? tags})> reactionCalls = [];
final List<List<List<String>>> sentTextMessageTagVecs = [];
final List<({String pubkey, String groupId, String eventId})> retryAttempts = [];
Exception? sendError;
Exception? deleteError;
Exception? reactionError;
Exception? retryError;
int _sendCallCount = 0;
bool isDm = false;
List<String> groupMembers = [];
Completer<MediaFile>? uploadCompleter;
Map<String, FlutterMetadata>? metadataByPubkey;
Completer<List<ChatMessage>>? fetchOlderCompleter;
DateTime? removedAt;
bool blockedUser = false;
bool shouldFailUnblockUser = false;
List<SearchResult> Function(String query)? searchOverride;
@override
void reset() {
super.reset();
controller?.close();
controller = null;
initialMessages = [];
groupName = 'Test Group';
sentMessages.clear();
deletionCalls.clear();
reactionCalls.clear();
retryAttempts.clear();
sentTextMessageTagVecs.clear();
sendError = null;
deleteError = null;
reactionError = null;
retryError = null;
_sendCallCount = 0;
isDm = false;
groupMembers = [];
uploadCompleter = null;
metadataByPubkey = null;
fetchOlderCompleter = null;
removedAt = null;
blockedUser = false;
shouldFailUnblockUser = false;
searchOverride = null;
}
@override
Future<List<ChatMessage>> crateApiMessagesFetchAggregatedMessagesForGroup({
required String pubkey,
required String groupId,
DateTime? before,
String? beforeMessageId,
int? limit,
}) {
if (fetchOlderCompleter != null) return fetchOlderCompleter!.future;
if (before != null && beforeMessageId != null && initialMessages.isNotEmpty) {
final sorted = [...initialMessages]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final idx = sorted.indexWhere((m) => m.id == beforeMessageId);
if (idx > 0) {
final start = (idx - (limit ?? 2)).clamp(0, idx);
return Future.value(sorted.sublist(start, idx));
}
}
return Future.value([]);
}
@override
Stream<ChatListStreamItem> crateApiChatListSubscribeToChatList({
required String accountPubkey,
}) {
final summary = ChatSummary(
mlsGroupId: _testGroupId,
groupType: GroupType.group,
createdAt: DateTime(2024),
pendingConfirmation: false,
unreadCount: BigInt.zero,
removedAt: removedAt,
);
return Stream.value(ChatListStreamItem.initialSnapshot(items: [summary]));
}
@override
Future<void> crateApiMessagesRetryMessagePublish({
required String pubkey,
required String groupId,
required String eventId,
}) async {
retryAttempts.add((pubkey: pubkey, groupId: groupId, eventId: eventId));
if (retryError != null) throw retryError!;
}
@override
Future<Tag> crateApiUtilsTagFromVec({required List<String> vec}) async {
return _MockTag(vec);
}
@override
String crateApiUtilsEventIdToNeventUri({
required String eventIdHex,
required String pubkeyHex,
}) {
return 'nostr:nevent1mock$eventIdHex';
}
void emitMessage(ChatMessage message) {
controller?.add(
MessageStreamItem.update(
update: MessageUpdate(trigger: UpdateTrigger.newMessage, message: message),
),
);
}
@override
Future<MessageWithTokens> crateApiMessagesSendMessageToGroup({
required String pubkey,
required String groupId,
required String message,
required int kind,
List<Tag>? tags,
}) async {
_sendCallCount++;
if (kind == 5) {
if (deleteError != null) throw deleteError!;
deletionCalls.add((groupId: groupId, kind: kind, tags: tags));
} else if (kind == 7) {
if (reactionError != null) throw reactionError!;
reactionCalls.add((groupId: groupId, message: message, kind: kind, tags: tags));
} else {
if (sendError != null) throw sendError!;
sentMessages.add(message);
if (tags != null) {
sentTextMessageTagVecs.add(tags.cast<_MockTag>().map((t) => t.vec).toList());
}
}
return MessageWithTokens(
id: 'mock_$_sendCallCount',
pubkey: pubkey,
kind: kind,
createdAt: DateTime.now(),
content: message,
tokens: const [],
);
}
@override
Stream<MessageStreamItem> crateApiMessagesSubscribeToGroupMessages({
String? pubkey,
required String groupId,
}) {
controller?.close();
controller = StreamController<MessageStreamItem>.broadcast();
Future.microtask(() {
controller?.add(
MessageStreamItem.initialSnapshot(messages: initialMessages),
);
});
return controller!.stream;
}
@override
Future<FlutterMetadata> crateApiUsersUserMetadata({
required bool blockingDataSync,
required String pubkey,
}) async {
if (metadataByPubkey != null && metadataByPubkey!.containsKey(pubkey)) {
return metadataByPubkey![pubkey]!;
}
return super.crateApiUsersUserMetadata(
blockingDataSync: blockingDataSync,
pubkey: pubkey,
);
}
@override
Future<Group> crateApiGroupsGetGroup({
required String accountPubkey,
required String groupId,
}) {
return Future.value(
Group(
mlsGroupId: groupId,
nostrGroupId: 'nostr_$groupId',
name: groupName,
description: '',
adminPubkeys: const [],
epoch: BigInt.zero,
state: GroupState.active,
),
);
}
@override
Future<String?> crateApiGroupsGetGroupImagePath({
required String accountPubkey,
required String groupId,
}) {
return Future.value('https://example.com/group.jpg');
}
@override
Future<bool> crateApiGroupsGroupIsDirectMessageType({
required Group that,
required String accountPubkey,
}) {
return Future.value(isDm);
}
@override
Future<List<String>> crateApiGroupsGroupMembers({
required String pubkey,
required String groupId,
}) {
return Future.value(groupMembers);
}
@override
Future<List<String>> crateApiGroupsGroupAdmins({
required String pubkey,
required String groupId,
}) {
return Future.value([]);
}
@override
Future<List<SearchResult>> crateApiMessagesSearchMessagesInGroup({
required String pubkey,
required String groupId,
required String query,
int? limit,
}) async {
if (searchOverride != null) return searchOverride!(query);
final matches = initialMessages
.where((m) => m.content.toLowerCase().contains(query.toLowerCase()))
.toList();
return matches
.asMap()
.entries
.map(
(entry) => SearchResult(
message: entry.value,
mlsGroupId: groupId,
highlightSpans: [
HighlightSpan(
start: entry.value.content.toLowerCase().indexOf(query.toLowerCase()),
end: entry.value.content.toLowerCase().indexOf(query.toLowerCase()) + query.length,
),
],
position: BigInt.from(entry.key),
),
)
.toList();
}
@override
Future<bool> crateApiMuteListIsUserBlocked({
required String accountPubkey,
required String targetPubkey,
}) async => blockedUser;
@override
Future<void> crateApiMuteListUnblockUser({
required String accountPubkey,
required String targetPubkey,
}) async {
if (shouldFailUnblockUser) throw Exception('Unblock failed');
blockedUser = false;
}
@override
Future<MediaFile> crateApiMediaFilesUploadChatMedia({
required String accountPubkey,
required String groupId,
required String filePath,
}) async {
if (uploadCompleter != null) {
return uploadCompleter!.future;
}
return MediaFile(
id: 'media_${filePath.hashCode}',
mlsGroupId: groupId,
accountPubkey: accountPubkey,
filePath: filePath,
encryptedFileHash: 'encrypted_hash',
mimeType: 'image/jpeg',
mediaType: 'image',
blossomUrl: 'https://example.com/media',
nostrKey: 'nostr_key',
createdAt: DateTime(2024),
);
}
}
class _MockAuthNotifier extends AuthNotifier {
@override
Future<String?> build() async => _testPubkey;
}
class _MockDebugViewNotifier extends DebugViewNotifier {
@override
Future<bool> build() async => true;
}
final _api = _MockApi();
void main() {
setUpAll(() {
mockPathProvider();
RustLib.initMock(api: _api);
});
setUp(() => _api.reset());
Future<void> pumpChatScreen(
WidgetTester tester, {
List overrides = const [],
bool isOffline = false,
}) async {
await mountTestApp(
tester,
overrides: [
authProvider.overrideWith(() => _MockAuthNotifier()),
offlineProvider.overrideWith((ref) => Stream.value(isOffline)),
...overrides,
],
);
await tester.pumpAndSettle();
Routes.goToChat(
tester.element(find.byType(Scaffold)),
_testGroupId,
);
await tester.pumpAndSettle();
}
group('ChatScreen', () {
testWidgets('displays group name in header', (tester) async {
_api.groupName = 'My Chat Group';
await pumpChatScreen(tester);
expect(find.text('My Chat Group'), findsOneWidget);
});
testWidgets('displays Unknown group when group name is empty', (tester) async {
_api.groupName = '';
await pumpChatScreen(tester);
expect(find.text('Unknown group'), findsOneWidget);
});
testWidgets('displays disabled chat input', (tester) async {
await pumpChatScreen(tester);
expect(find.text('Message'), findsOneWidget);
});
testWidgets('displays back button', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('back_button')), findsOneWidget);
});
testWidgets('displays avatar', (tester) async {
await pumpChatScreen(tester);
expect(find.byType(WnAvatar), findsOneWidget);
});
group('avatar color', () {
group('when group', () {
testWidgets('uses group ID color', (tester) async {
await pumpChatScreen(tester);
final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
expect(avatar.color, AvatarColor.violet);
});
});
group('when DM', () {
setUp(() {
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyC];
});
testWidgets('uses other member pubkey color', (tester) async {
await pumpChatScreen(tester);
final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
expect(avatar.color, AvatarColor.blue);
});
testWidgets('displays Unknown user when display name is null', (tester) async {
_api.metadataByPubkey = {
testPubkeyC: const FlutterMetadata(custom: {}),
};
await pumpChatScreen(tester);
expect(find.text('Unknown user'), findsOneWidget);
});
});
});
group('with no messages', () {
testWidgets('shows empty state', (tester) async {
await pumpChatScreen(tester);
expect(find.text('No messages yet'), findsOneWidget);
});
});
group('loading older messages indicator', () {
testWidgets('hidden when not loading older messages', (tester) async {
_api.initialMessages = [_message('m1', DateTime(2024))];
await pumpChatScreen(tester);
expect(find.byKey(const Key('loading_older_messages_indicator')), findsNothing);
});
testWidgets('visible while older messages are being fetched', (tester) async {
// Use enough messages to create a scrollable list with sufficient height so
// the top-threshold scroll listener fires when scrolled to the oldest end.
_api.initialMessages = List.generate(
20,
(i) => _message('m$i', DateTime(2024, 1, i + 1)),
);
_api.lastReadMessageId = 'm19';
final completer = Completer<List<ChatMessage>>();
_api.fetchOlderCompleter = completer;
await pumpChatScreen(tester);
await tester.pumpAndSettle();
// The list is reversed (newest at pixels=0). Scroll to maxScrollExtent
// (oldest end) to cross the top-threshold and trigger loadOlderMessages.
final position = Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
position.jumpTo(position.maxScrollExtent);
// Notify listeners manually since jumpTo in tests does not always fire them.
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('loading_older_messages_indicator')), findsOneWidget);
completer.complete([]);
await tester.pumpAndSettle();
expect(find.byKey(const Key('loading_older_messages_indicator')), findsNothing);
});
});
group('with messages', () {
testWidgets('displays messages', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024, 1, 2)),
_message('m2', DateTime(2024, 1, 3)),
];
await pumpChatScreen(tester);
expect(find.byType(WnMessageBubble), findsNWidgets(2));
});
testWidgets('displays message content', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024, 1, 4)),
];
await pumpChatScreen(tester);
expect(find.textContaining('Message m1'), findsOneWidget);
});
testWidgets('does not display deleted message text', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024, 1, 2)),
_message('m2', DateTime(2024, 1, 3), isDeleted: true),
];
await pumpChatScreen(tester);
expect(find.textContaining('Message m2'), findsNothing);
});
});
group('bubble grouping', () {
Finder avatarsInBubbles() => find.descendant(
of: find.byType(WnMessageBubble),
matching: find.byType(WnAvatar),
);
testWidgets('same sender <5 min apart: only older message shows avatar', (tester) async {
final base = DateTime(2024, 1, 1, 12);
_api.initialMessages = [
_message('m1', base),
_message('m2', base.add(const Duration(minutes: 2))),
];
await pumpChatScreen(tester);
expect(avatarsInBubbles(), findsOneWidget);
});
testWidgets('same sender >=5 min apart: both messages show avatar', (tester) async {
final base = DateTime(2024, 1, 1, 12);
_api.initialMessages = [
_message('m1', base),
_message('m2', base.add(const Duration(minutes: 5))),
];
await pumpChatScreen(tester);
expect(avatarsInBubbles(), findsNWidgets(2));
});
testWidgets('different senders: both messages show avatar', (tester) async {
final base = DateTime(2024, 1, 1, 12);
_api.initialMessages = [
_message('m1', base),
_message('m2', base.add(const Duration(minutes: 1)), pubkey: testPubkeyC),
];
await pumpChatScreen(tester);
expect(avatarsInBubbles(), findsNWidgets(2));
});
testWidgets('own messages never show avatar', (tester) async {
final base = DateTime(2024, 1, 1, 12);
_api.initialMessages = [
_message('m1', base, pubkey: _testPubkey),
_message('m2', base.add(const Duration(minutes: 10)), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
expect(avatarsInBubbles(), findsNothing);
});
testWidgets('single incoming message shows avatar', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
expect(avatarsInBubbles(), findsOneWidget);
});
testWidgets('same sender <5 min apart: only older message has tail', (tester) async {
final base = DateTime(2024, 1, 1, 12);
_api.initialMessages = [
_message('m1', base),
_message('m2', base.add(const Duration(minutes: 2))),
];
await pumpChatScreen(tester);
final tails = find.descendant(
of: find.byType(WnMessageBubble),
matching: find.byType(CustomPaint),
);
expect(tails, findsOneWidget);
});
testWidgets('same sender >=5 min apart: both messages have tail', (tester) async {
final base = DateTime(2024, 1, 1, 12);
_api.initialMessages = [
_message('m1', base),
_message('m2', base.add(const Duration(minutes: 5))),
];
await pumpChatScreen(tester);
final tails = find.descendant(
of: find.byType(WnMessageBubble),
matching: find.byType(CustomPaint),
);
expect(tails, findsNWidgets(2));
});
});
group('navigation', () {
testWidgets('back button navigates to chat list', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('back_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatListScreen), findsOneWidget);
});
testWidgets('OS back navigates to chat list', (tester) async {
await pumpChatScreen(tester);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(ChatListScreen), findsOneWidget);
});
testWidgets('avatar navigates to group info screen for group chat', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
await tester.pumpAndSettle();
expect(find.byType(GroupInfoScreen), findsOneWidget);
});
testWidgets('avatar navigates to chat info screen for DM', (tester) async {
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyC];
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
await tester.pumpAndSettle();
expect(find.byType(ChatInfoScreen), findsOneWidget);
});
testWidgets('name navigates to chat info screen for DM', (tester) async {
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyC];
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('header_name_tap_area')));
await tester.pumpAndSettle();
expect(find.byType(ChatInfoScreen), findsOneWidget);
});
});
group('message sending', () {
testWidgets('send button appears when text is entered', (tester) async {
await pumpChatScreen(tester);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
expect(find.byKey(const Key('send_button')), findsOneWidget);
});
testWidgets('input is cleared after sending', (tester) async {
await pumpChatScreen(tester);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('send_button')));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller!.text, isEmpty);
});
testWidgets('send button disappears after sending', (tester) async {
await pumpChatScreen(tester);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('send_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('send_button')), findsNothing);
});
group('when sending fails', () {
Future<void> attemptSend(WidgetTester tester) async {
_api.sendError = Exception('Network error');
await pumpChatScreen(tester);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('send_button')));
await tester.pumpAndSettle();
}
testWidgets('input is not cleared', (tester) async {
await attemptSend(tester);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller!.text, 'Hello');
});
testWidgets('shows system notice', (tester) async {
await attemptSend(tester);
expect(find.byType(WnSystemNotice), findsOneWidget);
expect(find.text('Failed to send message. Please try again.'), findsOneWidget);
});
testWidgets('dismisses notice after auto-hide duration', (tester) async {
await attemptSend(tester);
expect(find.byType(WnSystemNotice), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.byType(WnSystemNotice), findsNothing);
});
testWidgets('system notice is rendered inside WnSlate', (tester) async {
await attemptSend(tester);
final noticeFinder = find.byType(WnSystemNotice);
expect(noticeFinder, findsOneWidget);
expect(
find.ancestor(
of: noticeFinder,
matching: find.byType(WnSlate),
),
findsOneWidget,
);
});
});
});
group('message reception', () {
testWidgets('message bubble appears when stream emits update', (tester) async {
await pumpChatScreen(tester);
_api.emitMessage(_message('new_msg', DateTime.now()));
await tester.pumpAndSettle();
expect(find.textContaining('Message new_msg'), findsOneWidget);
});
});
group('retry failed message', () {
setUp(() {
_api.initialMessages = [
_message(
'failed1',
DateTime(2024),
pubkey: _testPubkey,
deliveryStatus: const DeliveryStatus.failed(reason: 'timeout'),
),
];
});
testWidgets('calls retryMessagePublish on tap', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('status_tap_area')));
await tester.pumpAndSettle();
expect(_api.retryAttempts.length, 1);
expect(_api.retryAttempts.first.eventId, 'failed1');
});
testWidgets('shows system notice when retry fails', (tester) async {
_api.retryError = Exception('retry failed');
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('status_tap_area')));
await tester.pumpAndSettle();
expect(find.byType(WnSystemNotice), findsOneWidget);
expect(find.text('Failed to send message. Please try again.'), findsOneWidget);
});
testWidgets('does not show retry for non-own messages', (tester) async {
_api.initialMessages = [
_message(
'failed2',
DateTime(2024),
deliveryStatus: const DeliveryStatus.failed(reason: 'timeout'),
),
];
await pumpChatScreen(tester);
expect(find.byKey(const Key('status_tap_area')), findsNothing);
});
});
group('focus management', () {
testWidgets('tapping outside unfocuses input', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
await tester.tap(find.text('No messages yet'));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode!.hasFocus, isFalse);
});
});
group('auto-scroll', () {
setUp(() {
_api.initialMessages = List.generate(
20,
(i) => _message('m$i', DateTime(2024, 1, i + 1)),
);
_api.lastReadMessageId = 'm19';
});
ScrollPosition getScrollPosition(WidgetTester tester) {
return Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
}
testWidgets('scrolls to bottom on initial load', (tester) async {
await pumpChatScreen(tester);
await tester.pumpAndSettle();
final position = getScrollPosition(tester);
expect(position.pixels, 0);
});
testWidgets('scrolls to bottom when own message arrives', (tester) async {
await pumpChatScreen(tester);
_api.emitMessage(_message('own', DateTime.now(), pubkey: _testPubkey));
await tester.pumpAndSettle();
final position = getScrollPosition(tester);
expect(position.pixels, 0);
});
testWidgets('scrolls when at bottom and other message arrives', (tester) async {
await pumpChatScreen(tester);
await tester.pumpAndSettle();
final position = getScrollPosition(tester);
expect(position.pixels, 0);
_api.emitMessage(_message('other', DateTime.now()));
await tester.pumpAndSettle();
expect(position.pixels, 0);
});
testWidgets('does not scroll when not at bottom and other message arrives', (tester) async {
await pumpChatScreen(tester);
await tester.pumpAndSettle();
final position = getScrollPosition(tester);
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
final positionBeforeMessage = position.pixels;
_api.emitMessage(_message('other', DateTime.now()));
await tester.pumpAndSettle();
expect(position.pixels, positionBeforeMessage);
});
testWidgets('scrolls to bottom when input is focused', (tester) async {
await pumpChatScreen(tester);
await tester.pumpAndSettle();
final position = getScrollPosition(tester);
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
await tester.tap(find.byType(TextField));
final animationDelay = const Duration(milliseconds: 400);
await tester.pump(animationDelay);
await tester.pumpAndSettle();
expect(position.pixels, 0);
});
});
group('message actions', () {
Future<void> longPressMessage(WidgetTester tester, String messageId) async {
final messageFinder = find.textContaining('Message $messageId');
await tester.longPress(messageFinder);
await tester.pumpAndSettle();
}
testWidgets('opens on long press', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
expect(find.byType(MessageActionsScreen), findsOneWidget);
});
testWidgets('shows Delete button for own message', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
expect(find.text('Delete'), findsOneWidget);
});
testWidgets('hides Delete button for other user message', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: 'other_user'),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
expect(find.text('Delete'), findsNothing);
});
testWidgets('closes when tapping outside', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.byType(MessageActionsScreen), findsNothing);
});
testWidgets('unfocuses text field when opening', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode!.hasFocus, isTrue);
await longPressMessage(tester, 'm1');
expect(textField.focusNode!.hasFocus, isFalse);
});
testWidgets('unfocuses text field when closing message actions', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode!.hasFocus, isTrue);
await longPressMessage(tester, 'm1');
expect(textField.focusNode!.hasFocus, isFalse);
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.byType(MessageActionsScreen), findsNothing);
expect(textField.focusNode!.hasFocus, isFalse);
});
testWidgets('unfocuses text field after selecting reaction', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode!.hasFocus, isTrue);
await longPressMessage(tester, 'm1');
expect(textField.focusNode!.hasFocus, isFalse);
await tester.tap(find.text('❤'));
await tester.pumpAndSettle();
expect(find.byType(MessageActionsScreen), findsNothing);
expect(textField.focusNode!.hasFocus, isFalse);
});
group('message deletion', () {
testWidgets('calls API when Delete is tapped', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(_api.deletionCalls.length, 1);
});
testWidgets('close messages actions after deletion', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.byType(MessageActionsScreen), findsNothing);
});
testWidgets('sends deletion to correct group', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(_api.deletionCalls.first.groupId, _testGroupId);
});
testWidgets('deletes expected message id', (tester) async {
_api.initialMessages = [
_message('msg_to_delete', DateTime(2024), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'msg_to_delete');
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
final tags = _api.deletionCalls.first.tags!.cast<_MockTag>();
expect(tags[0].vec, ['e', 'msg_to_delete']);
});
testWidgets('shows system notice when deletion fails', (tester) async {
_api.deleteError = Exception('Network error');
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: _testPubkey),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(_api.deletionCalls.length, 0);
expect(find.byType(WnSystemNotice), findsOneWidget);
expect(find.text('Failed to delete message. Please try again.'), findsOneWidget);
expect(find.byType(MessageActionsScreen), findsOneWidget);
});
});
group('message reactions', () {
testWidgets('calls API when reaction is tapped', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('❤'));
await tester.pumpAndSettle();
expect(_api.reactionCalls.length, 1);
});
testWidgets('sends correct emoji as reaction', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('🤣'));
await tester.pumpAndSettle();
expect(_api.reactionCalls.first.message, '🤣');
});
testWidgets('sends reaction to correct group', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('❤'));
await tester.pumpAndSettle();
expect(_api.reactionCalls.first.groupId, _testGroupId);
});
testWidgets('includes message reference in reaction tags', (tester) async {
_api.initialMessages = [
_message('msg_to_react', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'msg_to_react');
await tester.tap(find.text('❤'));
await tester.pumpAndSettle();
final tags = _api.reactionCalls.first.tags!.cast<_MockTag>();
expect(tags[0].vec, ['e', 'msg_to_react']);
});
testWidgets('closes message actions after sending reaction', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('❤'));
await tester.pumpAndSettle();
expect(find.byType(MessageActionsScreen), findsNothing);
});
testWidgets('shows system notice when reaction fails', (tester) async {
_api.reactionError = Exception('Network error');
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.text('❤'));
await tester.pumpAndSettle();
expect(_api.reactionCalls.length, 0);
expect(find.byType(WnSystemNotice), findsOneWidget);
expect(find.text('Failed to send reaction. Please try again.'), findsOneWidget);
expect(find.byType(MessageActionsScreen), findsOneWidget);
});
});
group('reaction deletion from message actions', () {
ReactionSummary ownReaction(String emoji, String reactionId) => ReactionSummary(
byEmoji: [
EmojiReaction(emoji: emoji, count: BigInt.one, users: const [_testPubkey]),
],
userReactions: [
UserReaction(
reactionId: reactionId,
emoji: emoji,
user: _testPubkey,
createdAt: DateTime(2024),
),
],
);
testWidgets('calls delete API when tapping selected emoji', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), reactions: ownReaction('❤', 'reaction_1')),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.byKey(const Key('reaction_❤')));
await tester.pumpAndSettle();
expect(_api.deletionCalls.length, 1);
});
testWidgets('sends correct reaction ID in deletion tags', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), reactions: ownReaction('❤', 'reaction_to_remove')),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.byKey(const Key('reaction_❤')));
await tester.pumpAndSettle();
final tags = _api.deletionCalls.first.tags!.cast<_MockTag>();
expect(tags[0].vec, ['e', 'reaction_to_remove']);
});
testWidgets('closes message actions after removing reaction', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), reactions: ownReaction('❤', 'reaction_1')),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.byKey(const Key('reaction_❤')));
await tester.pumpAndSettle();
expect(find.byType(MessageActionsScreen), findsNothing);
});
testWidgets('shows system notice when reaction removal fails', (tester) async {
_api.deleteError = Exception('Network error');
_api.initialMessages = [
_message('m1', DateTime(2024), reactions: ownReaction('❤', 'reaction_1')),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
await tester.tap(find.byKey(const Key('reaction_❤')));
await tester.pumpAndSettle();
expect(_api.deletionCalls.length, 0);
expect(find.byType(WnSystemNotice), findsOneWidget);
expect(find.text('Failed to remove reaction. Please try again.'), findsOneWidget);
expect(find.byType(MessageActionsScreen), findsOneWidget);
});
});
testWidgets('shows reply preview for reply message', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 1, 2), isReply: true, replyToId: 'm1'),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm2');
expect(
find.descendant(
of: find.byType(MessageActionsScreen),
matching: find.byType(ChatMessageQuote),
),
findsOneWidget,
);
});
testWidgets('shows sender picture in avatar', (tester) async {
_api.metadataByPubkey = {
testPubkeyB: const FlutterMetadata(
name: 'Sender',
displayName: 'Sender',
picture: 'https://example.com/avatar.jpg',
custom: {},
),
};
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await longPressMessage(tester, 'm1');
final avatar = tester.widget<WnAvatar>(
find.descendant(
of: find.byType(MessageActionsScreen),
matching: find.byType(WnAvatar),
),
);
expect(avatar.pictureUrl, 'https://example.com/avatar.jpg');
});
});
group('replies', () {
testWidgets('displays reply preview in bubble when message is a reply', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 1, 2), isReply: true, replyToId: 'm1', pubkey: _testPubkey),
];
await pumpChatScreen(tester);
expect(find.textContaining('Message m1'), findsWidgets);
expect(find.textContaining('Message m2'), findsOneWidget);
expect(find.byType(ChatMessageQuote), findsOneWidget);
});
testWidgets('swiping message bubble shows reply preview in input', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
expect(find.byType(ChatMessageQuote), findsNothing);
await tester.fling(find.textContaining('Message m1'), const Offset(500, 0), 1000);
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsOneWidget);
expect(find.textContaining('Message m1'), findsWidgets);
});
testWidgets('tapping Reply in message actions shows reply preview in input', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
expect(find.byType(ChatMessageQuote), findsNothing);
await tester.longPress(find.textContaining('Message m1'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('reply_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsOneWidget);
expect(find.textContaining('Message m1'), findsWidgets);
});
testWidgets('sending while replying includes reply reference in send', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await tester.longPress(find.textContaining('Message m1'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('reply_button')));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'My reply');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('send_button')));
await tester.pumpAndSettle();
expect(_api.sentMessages.last, contains('My reply'));
expect(_api.sentMessages.last, startsWith('nostr:nevent1'));
expect(_api.sentTextMessageTagVecs, isNotEmpty);
final tagVecs = _api.sentTextMessageTagVecs.last;
expect(tagVecs.any((t) => t.isNotEmpty && t[0] == 'q'), isTrue);
});
testWidgets('cancel reply hides reply preview in input', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await tester.longPress(find.textContaining('Message m1'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('reply_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsOneWidget);
await tester.tap(find.byKey(const Key('cancel_quote_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsNothing);
});
testWidgets('hides reply preview when replied message is deleted', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
];
await pumpChatScreen(tester);
await tester.longPress(find.textContaining('Message m1'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('reply_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsOneWidget);
_api.emitMessage(_message('m1', DateTime(2024), isDeleted: true));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsNothing);
});
testWidgets('tapping reply preview scrolls to original message', (tester) async {
_api.initialMessages = [
...List.generate(
20,
(i) => _message('m$i', DateTime(2024, 1, i + 1)),
),
_message(
'reply_msg',
DateTime(2024, 1, 22),
isReply: true,
replyToId: 'm0',
pubkey: _testPubkey,
),
];
// Mark newest message as read so chat starts at bottom
_api.lastReadMessageId = 'reply_msg';
await pumpChatScreen(tester);
await tester.pumpAndSettle();
final position = Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
expect(position.pixels, 0);
await tester.tap(find.byKey(const Key('message_quote_tap_area')));
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
});
});
group('draft restoration', () {
Future<void> pumpChatScreenWithDraftSettle(WidgetTester tester) async {
await pumpChatScreen(tester);
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100)));
await tester.pumpAndSettle();
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100)));
await tester.pumpAndSettle();
}
group('with draft content', () {
setUp(() {
_api.loadDraftResult = Draft(
accountPubkey: _testPubkey,
mlsGroupId: _testGroupId,
content: 'unsent message',
mediaAttachments: const [],
createdAt: DateTime(2024),
updatedAt: DateTime(2024),
);
});
testWidgets('restores content into input field', (tester) async {
await pumpChatScreenWithDraftSettle(tester);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller!.text, 'unsent message');
});
testWidgets('shows send button after restoring content', (tester) async {
await pumpChatScreenWithDraftSettle(tester);
expect(find.byKey(const Key('send_button')), findsOneWidget);
});
});
group('when draft reply message not in list', () {
setUp(() {
_api.initialMessages = [];
_api.loadDraftResult = Draft(
accountPubkey: _testPubkey,
mlsGroupId: _testGroupId,
content: 'draft',
replyToId: 'missing-id',
mediaAttachments: const [],
createdAt: DateTime(2024),
updatedAt: DateTime(2024),
);
});
testWidgets('does not show reply preview', (tester) async {
await pumpChatScreenWithDraftSettle(tester);
expect(find.byType(ChatMessageQuote), findsNothing);
});
});
});
group('reaction pills', () {
testWidgets('appears when reaction event arrives', (tester) async {
_api.initialMessages = [_message('m1', DateTime(2024))];
await pumpChatScreen(tester);
expect(find.text('🎉'), findsNothing);
final reactions = ReactionSummary(
byEmoji: [
EmojiReaction(emoji: '🎉', count: BigInt.one, users: const ['other']),
],
userReactions: const [],
);
_api.emitMessage(_message('m1', DateTime(2024), reactions: reactions));
await tester.pumpAndSettle();
expect(find.text('🎉'), findsWidgets);
});
group('when user has no reaction to the message', () {
ReactionSummary reactionFromOther(String emoji) => ReactionSummary(
byEmoji: [
EmojiReaction(emoji: emoji, count: BigInt.one, users: const ['other']),
],
userReactions: const [],
);
testWidgets('calls API when reaction pill is tapped', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), reactions: reactionFromOther('👍')),
];
await pumpChatScreen(tester);
await tester.tap(find.text('👍'));
await tester.pumpAndSettle();
expect(_api.reactionCalls.length, 1);
});
testWidgets('sends correct emoji from tapped pill', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024), reactions: reactionFromOther('🔥')),
];
await pumpChatScreen(tester);
await tester.tap(find.text('🔥'));
await tester.pumpAndSettle();
expect(_api.reactionCalls.first.message, '🔥');
});
testWidgets('includes message reference in reaction tags', (tester) async {
_api.initialMessages = [
_message('msg_with_reaction', DateTime(2024), reactions: reactionFromOther('👍')),
];
await pumpChatScreen(tester);
await tester.tap(find.text('👍'));
await tester.pumpAndSettle();
final tags = _api.reactionCalls.first.tags!.cast<_MockTag>();
expect(tags[0].vec, ['e', 'msg_with_reaction']);
});
});
group('when user has a reaction to the message', () {
testWidgets('calls delete API when tapping own reaction', (tester) async {
final ownReaction = ReactionSummary(
byEmoji: [
EmojiReaction(emoji: '👍', count: BigInt.one, users: const [_testPubkey]),
],
userReactions: [
UserReaction(
reactionId: 'reaction_to_delete',
emoji: '👍',
user: _testPubkey,
createdAt: DateTime(2024),
),
],
);
_api.initialMessages = [_message('m1', DateTime(2024), reactions: ownReaction)];
await pumpChatScreen(tester);
await tester.tap(find.text('👍'));
await tester.pumpAndSettle();
expect(_api.deletionCalls.length, 1);
final tags = _api.deletionCalls.first.tags!.cast<_MockTag>();
expect(tags[0].vec, ['e', 'reaction_to_delete']);
});
});
});
group('unread indicator', () {
setUp(() {
_api.initialMessages = List.generate(
20,
(i) => _message('m$i', DateTime(2024, 1, i + 1)),
);
_api.lastReadMessageId = 'm19';
});
Future<ScrollPosition> scrollUpAndReceiveMessage(WidgetTester tester) async {
await pumpChatScreen(tester);
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
final position = Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
_api.emitMessage(_message('m_new', DateTime(2024, 2)));
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
return position;
}
testWidgets('hidden when no messages', (tester) async {
_api.initialMessages = [];
_api.lastReadMessageId = null;
await pumpChatScreen(tester);
await tester.pumpAndSettle();
expect(find.byKey(const Key('scroll_down_button')), findsNothing);
});
testWidgets('shows when scrolled up with unread messages', (tester) async {
await scrollUpAndReceiveMessage(tester);
expect(find.byKey(const Key('scroll_down_button')), findsOneWidget);
});
testWidgets('hidden when scrolled up with all messages already read', (tester) async {
await pumpChatScreen(tester);
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
final position = Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
expect(find.byKey(const Key('scroll_down_button')), findsNothing);
});
testWidgets('tapping scrolls to bottom', (tester) async {
final position = await scrollUpAndReceiveMessage(tester);
expect(position.pixels, greaterThan(0));
await tester.tap(find.byKey(const Key('scroll_down_button')));
await tester.pumpAndSettle();
expect(position.pixels, 0);
});
});
group('mark as read', () {
setUp(() {
_api.initialMessages = List.generate(
20,
(i) => _message('m$i', DateTime(2024, 1, i + 1)),
);
_api.lastReadMessageId = 'm19';
});
testWidgets('marks incoming message as read when at bottom', (tester) async {
await pumpChatScreen(tester);
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
_api.emitMessage(_message('m_new', DateTime(2024, 2)));
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
expect(_api.markedAsReadMessages, contains('m_new'));
});
testWidgets('does not mark message as read when scrolled up', (tester) async {
await pumpChatScreen(tester);
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
final position = Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
_api.emitMessage(_message('m_new', DateTime(2024, 2)));
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
expect(_api.markedAsReadMessages, isEmpty);
});
testWidgets('marks own message as read', (tester) async {
await pumpChatScreen(tester);
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
final position = Scrollable.of(tester.element(find.byType(WnMessageBubble).first)).position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
_api.emitMessage(_message('m_own', DateTime(2024, 2), pubkey: _testPubkey));
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
expect(_api.markedAsReadMessages, contains('m_own'));
});
});
group('media attachment', () {
testWidgets('displays attach button always when no media attached', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('add_button')), findsOneWidget);
});
testWidgets('unfocuses input when attach button is tapped', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode!.hasFocus, isTrue);
await tester.tap(find.byKey(const Key('add_button')));
await tester.pumpAndSettle();
expect(textField.focusNode!.hasFocus, isFalse);
});
});
group('message search', () {
Future<void> openSearch(WidgetTester tester) async {
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyC];
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('search_button')));
await tester.pumpAndSettle();
}
Future<void> openGroupSearch(WidgetTester tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('group_search_button')));
await tester.pumpAndSettle();
}
group('opened from group info', () {
testWidgets('search bar appears after tapping search in group info', (tester) async {
await openGroupSearch(tester);
expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);
expect(find.byKey(const Key('chat_search_field')), findsOneWidget);
});
testWidgets('search bar is not shown before tapping search', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('chat_search_bar')), findsNothing);
});
testWidgets('navigation bar appears when query is entered', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openGroupSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pumpAndSettle();
expect(find.byKey(const Key('chat_search_navigation')), findsOneWidget);
});
testWidgets('closing search hides search bar', (tester) async {
await openGroupSearch(tester);
expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);
await tester.tap(find.byKey(const Key('back_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('chat_search_bar')), findsNothing);
});
});
testWidgets('search bar is hidden by default', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('chat_search_bar')), findsNothing);
expect(find.byKey(const Key('chat_search_field')), findsNothing);
});
testWidgets('search bar appears after tapping search in chat info', (tester) async {
await openSearch(tester);
expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);
expect(find.byKey(const Key('chat_search_field')), findsOneWidget);
});
testWidgets('navigation bar is hidden when search query is empty', (tester) async {
await openSearch(tester);
expect(find.byKey(const Key('chat_search_navigation')), findsNothing);
});
testWidgets('navigation bar appears when search query is not empty', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pumpAndSettle();
expect(find.byKey(const Key('chat_search_navigation')), findsOneWidget);
});
testWidgets('shows no results when query has no matches', (tester) async {
_api.initialMessages = [_message('m1', DateTime(2024))];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'zzznomatch');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('chat_search_match_count')), findsOneWidget);
expect(find.text('No results'), findsOneWidget);
});
testWidgets('shows singular match count for one result', (tester) async {
_api.initialMessages = [_message('m1', DateTime(2024))];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message m1');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('chat_search_match_count')), findsOneWidget);
expect(find.text('1 of 1 match'), findsOneWidget);
});
testWidgets('shows plural match count for multiple results', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('chat_search_match_count')), findsOneWidget);
expect(find.text('1 of 2 matches'), findsOneWidget);
});
testWidgets('shows prev and next navigation buttons', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('chat_search_prev_button')), findsOneWidget);
expect(find.byKey(const Key('chat_search_next_button')), findsOneWidget);
});
testWidgets('tapping next button advances match index', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.text('1 of 2 matches'), findsOneWidget);
await tester.tap(find.byKey(const Key('chat_search_next_button')));
await tester.pumpAndSettle();
expect(find.text('2 of 2 matches'), findsOneWidget);
});
testWidgets('tapping prev button goes to previous match', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
await tester.tap(find.byKey(const Key('chat_search_next_button')));
await tester.pumpAndSettle();
expect(find.text('2 of 2 matches'), findsOneWidget);
await tester.tap(find.byKey(const Key('chat_search_prev_button')));
await tester.pumpAndSettle();
expect(find.text('1 of 2 matches'), findsOneWidget);
});
testWidgets('does not show no messages text when search is active with no results', (
tester,
) async {
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'zzznomatch');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.text('No messages yet'), findsNothing);
});
testWidgets('closing search hides search bar', (tester) async {
await openSearch(tester);
expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);
await tester.tap(find.byKey(const Key('back_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('chat_search_bar')), findsNothing);
});
testWidgets('match index resets when query changes', (tester) async {
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
await tester.tap(find.byKey(const Key('chat_search_next_button')));
await tester.pumpAndSettle();
expect(find.text('2 of 2 matches'), findsOneWidget);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message m1');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
expect(find.text('1 of 1 match'), findsOneWidget);
});
testWidgets('hides message input when search is active', (tester) async {
await openSearch(tester);
expect(find.byType(WnChatMessageInput), findsNothing);
});
testWidgets('shows message input after closing search', (tester) async {
await openSearch(tester);
await tester.tap(find.byKey(const Key('back_button')));
await tester.pumpAndSettle();
expect(find.byType(WnChatMessageInput), findsOneWidget);
});
testWidgets('renders separator between distant match groups', (tester) async {
// m1 matches "alpha", gap of 5 non-matching messages, m7 matches "alpha".
// Context windows: [m1] and [m5, m6, m7] — no overlap, so a separator is expected.
_api.initialMessages = [
_message('m1', DateTime(2024)),
_message('m2', DateTime(2024, 2)),
_message('m3', DateTime(2024, 3)),
_message('m4', DateTime(2024, 4)),
_message('m5', DateTime(2024, 5)),
_message('m6', DateTime(2024, 6)),
_message('m7', DateTime(2024, 7)),
];
// Override search to return only m1 and m7
_api.searchOverride = (query) => [
SearchResult(
message: _api.initialMessages[0],
mlsGroupId: _testGroupId,
highlightSpans: [const HighlightSpan(start: 0, end: 5)],
position: BigInt.zero,
),
SearchResult(
message: _api.initialMessages[6],
mlsGroupId: _testGroupId,
highlightSpans: [const HighlightSpan(start: 0, end: 5)],
position: BigInt.from(6),
),
];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'alpha');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
await tester.pump();
expect(find.textContaining('Message m1'), findsOneWidget);
expect(find.textContaining('Message m7'), findsOneWidget);
expect(
find.byKey(const Key('search_separator_0')).evaluate().isNotEmpty ||
find.byKey(const Key('search_separator_1')).evaluate().isNotEmpty ||
find.byKey(const Key('search_separator_2')).evaluate().isNotEmpty ||
find.byKey(const Key('search_separator_3')).evaluate().isNotEmpty,
isTrue,
);
});
testWidgets('tapping search result exits search mode', (tester) async {
_api.initialMessages = [_message('m1', DateTime(2024))];
await openSearch(tester);
await tester.enterText(find.byKey(const Key('chat_search_field')), 'Message m1');
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.tap(find.byKey(const Key('search_match_0')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('chat_search_bar')), findsNothing);
expect(find.byType(WnChatMessageInput), findsOneWidget);
});
});
group('attachment area with reply and media', () {
late _MockImagePickerPlatform mockImagePicker;
setUp(() {
mockImagePicker = _MockImagePickerPlatform();
ImagePickerPlatform.instance = mockImagePicker;
});
testWidgets('shows media upload preview when media is picked', (tester) async {
mockImagePicker.filesToReturn = [XFile('/tmp/test_image.jpg')];
_api.initialMessages = [_message('m1', DateTime(2024))];
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('add_button')));
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100)));
await tester.pumpAndSettle();
expect(find.byType(ChatMediaUploadPreview), findsOneWidget);
});
testWidgets('shows both reply preview and media preview with spacer', (tester) async {
mockImagePicker.filesToReturn = [XFile('/tmp/test_image.jpg')];
_api.initialMessages = [_message('m1', DateTime(2024))];
await pumpChatScreen(tester);
await tester.longPress(find.textContaining('Message m1'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('reply_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsOneWidget);
await tester.tap(find.byKey(const Key('add_button')));
await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100)));
await tester.pumpAndSettle();
expect(find.byType(ChatMessageQuote), findsOneWidget);
expect(find.byType(ChatMediaUploadPreview), findsOneWidget);
});
});
group('debug view', () {
Future<void> pumpChatScreenWithDebug(WidgetTester tester) async {
await mountTestApp(
tester,
overrides: [
authProvider.overrideWith(() => _MockAuthNotifier()),
debugViewProvider.overrideWith(() => _MockDebugViewNotifier()),
],
);
await tester.pumpAndSettle();
Routes.goToChat(
tester.element(find.byType(Scaffold)),
_testGroupId,
);
await tester.pumpAndSettle();
}
testWidgets('shows debug button when debug view is enabled', (tester) async {
await pumpChatScreenWithDebug(tester);
expect(find.byKey(const Key('chat_raw_debug_button')), findsOneWidget);
});
testWidgets('debug button navigates to raw debug screen', (tester) async {
await pumpChatScreenWithDebug(tester);
await tester.tap(find.byKey(const Key('chat_raw_debug_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatRawDebugScreen), findsOneWidget);
});
testWidgets('back button in raw debug screen returns to chat', (tester) async {
await pumpChatScreenWithDebug(tester);
await tester.tap(find.byKey(const Key('chat_raw_debug_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('slate_back_button')));
await tester.pumpAndSettle();
expect(find.byType(ChatRawDebugScreen), findsNothing);
});
});
group('removed from group', () {
setUp(() => _api.removedAt = DateTime(2024, 6));
testWidgets('shows WnSystemNotice with removed title', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('removed_from_group_notice')), findsOneWidget);
expect(find.text('You were removed from this group'), findsOneWidget);
});
testWidgets('shows description text', (tester) async {
await pumpChatScreen(tester);
expect(find.textContaining('You can still view saved messages'), findsOneWidget);
});
testWidgets('notice is expanded by default', (tester) async {
await pumpChatScreen(tester);
final notice = tester.widget<WnSystemNotice>(
find.byKey(const Key('removed_from_group_notice')),
);
expect(notice.variant, WnSystemNoticeVariant.expanded);
});
testWidgets('notice collapses when chevron is tapped', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
await tester.pumpAndSettle();
final notice = tester.widget<WnSystemNotice>(
find.byKey(const Key('removed_from_group_notice')),
);
expect(notice.variant, WnSystemNoticeVariant.collapsed);
});
testWidgets('notice expands again after second chevron tap', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
await tester.pumpAndSettle();
final notice = tester.widget<WnSystemNotice>(
find.byKey(const Key('removed_from_group_notice')),
);
expect(notice.variant, WnSystemNoticeVariant.expanded);
});
testWidgets('hides the message input', (tester) async {
await pumpChatScreen(tester);
expect(find.byType(TextField), findsNothing);
});
testWidgets('still shows messages', (tester) async {
_api.initialMessages = [_message('m1', DateTime(2024))];
await pumpChatScreen(tester);
expect(find.byType(WnMessageBubble), findsOneWidget);
});
});
group('not removed from group', () {
testWidgets('does not show removed notice', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('removed_from_group_notice')), findsNothing);
});
testWidgets('shows the message input', (tester) async {
await pumpChatScreen(tester);
expect(find.byType(TextField), findsOneWidget);
});
});
group('blocked user', () {
setUp(() {
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyC];
_api.blockedUser = true;
});
testWidgets('shows blocked notice when peer is blocked', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('user_blocked_notice')), findsOneWidget);
expect(find.text('You blocked this user'), findsOneWidget);
});
testWidgets('blocked notice is expanded by default', (tester) async {
await pumpChatScreen(tester);
final notice = tester.widget<WnSystemNotice>(
find.byKey(const Key('user_blocked_notice')),
);
expect(notice.variant, WnSystemNoticeVariant.expanded);
});
testWidgets('blocked notice uses neutral type', (tester) async {
await pumpChatScreen(tester);
final notice = tester.widget<WnSystemNotice>(
find.byKey(const Key('user_blocked_notice')),
);
expect(notice.type, WnSystemNoticeType.neutral);
});
testWidgets('shows blocked notice description', (tester) async {
await pumpChatScreen(tester);
expect(
find.textContaining('You won\'t receive new messages'),
findsOneWidget,
);
});
testWidgets('blocked notice collapses when chevron is tapped', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
await tester.pumpAndSettle();
final notice = tester.widget<WnSystemNotice>(
find.byKey(const Key('user_blocked_notice')),
);
expect(notice.variant, WnSystemNoticeVariant.collapsed);
});
testWidgets('shows error notice when unblock action fails', (tester) async {
_api.shouldFailUnblockUser = true;
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('blocked_notice_unblock_button')));
await tester.pumpAndSettle();
expect(find.text('Failed to unblock user. Please try again.'), findsOneWidget);
});
testWidgets('archive button shows Archive when not archived', (tester) async {
await pumpChatScreen(tester);
final button = tester.widget<WnButton>(
find.byKey(const Key('blocked_notice_archive_button')),
);
expect(button.text, 'Archive');
});
testWidgets('archive button updates to Unarchive after archiving', (tester) async {
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('blocked_notice_archive_button')));
await tester.pumpAndSettle();
final button = tester.widget<WnButton>(
find.byKey(const Key('blocked_notice_archive_button')),
);
expect(button.text, 'Unarchive');
});
testWidgets('shows error notice when archive action fails from blocked notice', (
tester,
) async {
_api.shouldFailArchiveChat = true;
await pumpChatScreen(tester);
await tester.tap(find.byKey(const Key('blocked_notice_archive_button')));
await tester.pumpAndSettle();
expect(find.text('Failed to archive chat. Please try again.'), findsOneWidget);
});
});
group('not blocked user', () {
testWidgets('does not show blocked notice', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('user_blocked_notice')), findsNothing);
});
});
group('removed from group takes priority over blocked', () {
setUp(() {
_api.removedAt = DateTime(2024, 6);
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyC];
_api.blockedUser = true;
});
testWidgets('shows removed notice and not blocked notice', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('removed_from_group_notice')), findsOneWidget);
expect(find.byKey(const Key('user_blocked_notice')), findsNothing);
});
});
group('offline state', () {
testWidgets('shows offline_notice when offline', (tester) async {
await pumpChatScreen(tester, isOffline: true);
expect(find.byKey(const Key('offline_notice')), findsOneWidget);
});
testWidgets('text field remains enabled when offline', (tester) async {
await pumpChatScreen(tester, isOffline: true);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, isNot(false));
});
testWidgets('send button is not present when offline', (
tester,
) async {
await pumpChatScreen(tester, isOffline: true);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
expect(find.byKey(const Key('send_button')), findsNothing);
});
testWidgets('Online does not show offline_notice and enables send button', (tester) async {
await pumpChatScreen(tester);
expect(find.byKey(const Key('offline_notice')), findsNothing);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
final sendButton = find.byKey(const Key('send_button'));
expect(sendButton, findsOneWidget);
await tester.tap(sendButton);
await tester.pumpAndSettle();
expect(_api.sentMessages, isNotEmpty);
});
group('message actions when offline', () {
setUp(() {
_api.initialMessages = [
_message('m1', DateTime(2024), pubkey: _testPubkey),
];
});
Future<void> openMessageMenuOffline(WidgetTester tester) async {
await pumpChatScreen(tester, isOffline: true);
await tester.longPress(find.textContaining('Message m1'));
await tester.pumpAndSettle();
}
testWidgets('hides delete button for own message', (tester) async {
await openMessageMenuOffline(tester);
expect(find.byType(MessageActionsScreen), findsOneWidget);
expect(find.byKey(const Key('delete_button')), findsNothing);
});
testWidgets('hides reactions row', (tester) async {
await openMessageMenuOffline(tester);
expect(find.byKey(const Key('reaction_❤')), findsNothing);
expect(find.byKey(const Key('emoji_picker_button')), findsNothing);
});
});
});
});
}