Invite screen improvements: show replies, use chat stream and use fixed avatar color (#232)

* refactor: rename use chat avatar to use chat profile and move color logic inside

* refactor: receive avatar color in wn avatar header

* refactor: use renamed hook in chat screen

* feat: show replies, use chat stream and use fixed avatar color in chat invite screen

* docs: updat change log
This commit is contained in:
Pepi
2026-02-13 10:10:41 -03:00
committed by GitHub
parent be896a0d86
commit db83f18ff6
9 changed files with 298 additions and 143 deletions
+2 -1
View File
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Loading indicator in search field during name search [PR #234](https://github.com/marmot-protocol/whitenoise/pull/234)
- Pass metadata to start chat screen for instant display [PR #234](https://github.com/marmot-protocol/whitenoise/pull/234)
- Invite callout [PR #230](https://github.com/marmot-protocol/whitenoise/pull/230)
- Show replies in invite screen [PR #232](https://github.com/marmot-protocol/whitenoise/pull/232)
### Changed
@@ -69,7 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixes logout not working after app reinstall [PR #31](https://github.com/marmot-protocol/sloth/pull/31)
- Fixes sign out exception and adds dedicated sign out screen with private key backup [PR #45](https://github.com/marmot-protocol/sloth/pull/45)
- QR code color now uses theme-aware color [PR #183](https://github.com/marmot-protocol/sloth/pull/183)
- DM avatar color inconsistency [PR #199](https://github.com/marmot-protocol/sloth/pull/199)
- DM avatar color inconsistency [PR #199](https://github.com/marmot-protocol/sloth/pull/199), [PR #232](https://github.com/marmot-protocol/whitenoise/pull/232)
- Ignore duplicate newMessage for accounts on same device [PR #244](https://github.com/marmot-protocol/whitenoise/pull/244)
- Sanitize malformed UTF-16 in user metadata to prevent rendering crashes [PR #234](https://github.com/marmot-protocol/whitenoise/pull/234)
- Lock app orientation to portrait mode [PR #235](https://github.com/marmot-protocol/whitenoise/pull/235)
@@ -3,17 +3,20 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:logging/logging.dart';
import 'package:whitenoise/src/rust/api/groups.dart' as groups_api;
import 'package:whitenoise/src/rust/api/users.dart' as users_api;
import 'package:whitenoise/utils/avatar_color.dart';
import 'package:whitenoise/utils/metadata.dart';
final _logger = Logger('useChatAvatar');
final _logger = Logger('useChatProfile');
class ChatAvatarData {
class ChatProfile {
final String displayName;
final String? pictureUrl;
final String? otherMemberPubkey;
final AvatarColor color;
const ChatAvatarData({
const ChatProfile({
required this.displayName,
required this.color,
this.pictureUrl,
this.otherMemberPubkey,
});
@@ -21,26 +24,27 @@ class ChatAvatarData {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChatAvatarData &&
other is ChatProfile &&
runtimeType == other.runtimeType &&
displayName == other.displayName &&
pictureUrl == other.pictureUrl &&
otherMemberPubkey == other.otherMemberPubkey;
otherMemberPubkey == other.otherMemberPubkey &&
color == other.color;
@override
int get hashCode => Object.hash(displayName, pictureUrl, otherMemberPubkey);
int get hashCode => Object.hash(displayName, pictureUrl, otherMemberPubkey, color);
}
AsyncSnapshot<ChatAvatarData> useChatAvatar(String pubkey, String groupId) {
AsyncSnapshot<ChatProfile> useChatProfile(String pubkey, String groupId) {
final future = useMemoized(
() => _fetchGroupAvatar(pubkey, groupId),
() => _fetchChatProfile(pubkey, groupId),
[pubkey, groupId],
);
return useFuture(future);
}
Future<ChatAvatarData> _fetchGroupAvatar(String pubkey, String groupId) async {
_logger.fine('Fetching group avatar for groupId: $groupId');
Future<ChatProfile> _fetchChatProfile(String pubkey, String groupId) async {
_logger.fine('Fetching chat profile for groupId: $groupId');
final group = await groups_api.getGroup(
accountPubkey: pubkey,
@@ -50,28 +54,29 @@ Future<ChatAvatarData> _fetchGroupAvatar(String pubkey, String groupId) async {
final isDm = await group.isDirectMessageType(accountPubkey: pubkey);
if (isDm) {
_logger.info('Fetching DM avatar data');
return await _fetchDmAvatarData(group, pubkey);
_logger.info('Fetching DM profile');
return await _fetchDmProfile(group, pubkey);
} else {
_logger.info('Fetching group avatar data');
return await _fetchGroupAvatarData(group, pubkey);
_logger.info('Fetching group profile');
return await _fetchGroupProfile(group, pubkey);
}
}
Future<ChatAvatarData> _fetchGroupAvatarData(groups_api.Group group, String pubkey) async {
Future<ChatProfile> _fetchGroupProfile(groups_api.Group group, String pubkey) async {
_logger.info('Fetching group image path');
final imagePath = await groups_api.getGroupImagePath(
accountPubkey: pubkey,
groupId: group.mlsGroupId,
);
_logger.fine('Group image path fetched');
return ChatAvatarData(
return ChatProfile(
displayName: group.name.isEmpty ? 'Unknown group' : group.name,
pictureUrl: imagePath,
color: AvatarColor.fromPubkey(group.mlsGroupId),
);
}
Future<ChatAvatarData> _fetchDmAvatarData(
Future<ChatProfile> _fetchDmProfile(
groups_api.Group group,
String pubkey,
) async {
@@ -86,7 +91,7 @@ Future<ChatAvatarData> _fetchDmAvatarData(
if (otherMemberPubkey == null) {
_logger.warning('No other member found in DM group');
return const ChatAvatarData(displayName: 'Unknown User');
return ChatProfile(displayName: 'Unknown User', color: AvatarColor.fromPubkey(groupId));
}
final metadata = await users_api.userMetadata(
@@ -94,9 +99,10 @@ Future<ChatAvatarData> _fetchDmAvatarData(
blockingDataSync: false,
);
return ChatAvatarData(
return ChatProfile(
displayName: presentName(metadata) ?? 'Unknown User',
pictureUrl: metadata.picture,
otherMemberPubkey: otherMemberPubkey,
color: AvatarColor.fromPubkey(otherMemberPubkey),
);
}
+20 -26
View File
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:whitenoise/hooks/use_chat_avatar.dart';
import 'package:whitenoise/hooks/use_chat_messages.dart';
import 'package:whitenoise/hooks/use_chat_profile.dart';
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/src/rust/api/account_groups.dart' as account_groups_api;
import 'package:whitenoise/src/rust/api/messages.dart' as messages_api;
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_button.dart';
@@ -40,19 +40,8 @@ class ChatInviteScreen extends HookConsumerWidget {
noticeMessage.value = null;
}
final groupAvatarSnapshot = useChatAvatar(pubkey, mlsGroupId);
final messagesFuture = useMemoized(
() => messages_api.fetchAggregatedMessagesForGroup(
pubkey: pubkey,
groupId: mlsGroupId,
),
[pubkey, mlsGroupId],
);
final messagesSnapshot = useFuture(messagesFuture);
final messages = messagesSnapshot.data ?? [];
final isLoading = messagesSnapshot.connectionState == ConnectionState.waiting;
final chatProfile = useChatProfile(pubkey, mlsGroupId);
final chatMessages = useChatMessages(mlsGroupId);
Future<void> handleAccept() async {
isAccepting.value = true;
@@ -107,22 +96,22 @@ class ChatInviteScreen extends HookConsumerWidget {
Column(
children: [
WnChatHeader(
mlsGroupId: mlsGroupId,
displayName: groupAvatarSnapshot.data?.displayName ?? '',
pictureUrl: groupAvatarSnapshot.data?.pictureUrl,
displayName: chatProfile.data?.displayName ?? '',
avatarColor: chatProfile.data?.color ?? AvatarColor.neutral,
pictureUrl: chatProfile.data?.pictureUrl,
onBack: () => Routes.goToChatList(context),
onMenuTap: () => Routes.pushToWip(context),
),
SizedBox(height: 48.h),
WnAvatar(
pictureUrl: groupAvatarSnapshot.data?.pictureUrl,
displayName: groupAvatarSnapshot.data?.displayName,
pictureUrl: chatProfile.data?.pictureUrl,
displayName: chatProfile.data?.displayName,
size: WnAvatarSize.large,
color: AvatarColor.fromPubkey(mlsGroupId),
color: chatProfile.data?.color ?? AvatarColor.neutral,
),
SizedBox(height: 16.h),
Text(
groupAvatarSnapshot.data?.displayName ?? '',
chatProfile.data?.displayName ?? '',
style: typography.semiBold18.copyWith(
color: colors.backgroundContentPrimary,
),
@@ -132,13 +121,13 @@ class ChatInviteScreen extends HookConsumerWidget {
],
),
Expanded(
child: isLoading
child: chatMessages.isLoading
? Center(
child: CircularProgressIndicator(
color: colors.backgroundContentPrimary,
),
)
: messages.isEmpty
: chatMessages.messageCount == 0
? Center(
child: Text(
context.l10n.invitedToSecureChat,
@@ -148,15 +137,20 @@ class ChatInviteScreen extends HookConsumerWidget {
),
)
: ListView.builder(
reverse: true,
padding: EdgeInsets.symmetric(vertical: 8.h),
itemCount: messages.length,
itemCount: chatMessages.messageCount,
itemBuilder: (context, index) {
final message = messages[index];
final message = chatMessages.getMessage(index);
final isOwnMessage = message.pubkey == pubkey;
final replyPreview = message.isReply
? chatMessages.getReplyPreview(message.replyToId)
: null;
return WnMessageBubble(
message: message,
isOwnMessage: isOwnMessage,
currentUserPubkey: pubkey,
replyPreview: replyPreview,
);
},
),
+7 -6
View File
@@ -4,9 +4,9 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:whitenoise/hooks/use_chat_avatar.dart';
import 'package:whitenoise/hooks/use_chat_input.dart';
import 'package:whitenoise/hooks/use_chat_messages.dart';
import 'package:whitenoise/hooks/use_chat_profile.dart';
import 'package:whitenoise/hooks/use_chat_scroll.dart';
import 'package:whitenoise/hooks/use_scroll_to_message.dart';
import 'package:whitenoise/l10n/l10n.dart';
@@ -17,6 +17,7 @@ import 'package:whitenoise/screens/message_actions_screen.dart';
import 'package:whitenoise/services/message_service.dart';
import 'package:whitenoise/src/rust/api/messages.dart' show ChatMessage;
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/avatar_color.dart';
import 'package:whitenoise/widgets/wn_chat_header.dart';
import 'package:whitenoise/widgets/wn_icon.dart';
import 'package:whitenoise/widgets/wn_message_bubble.dart';
@@ -50,7 +51,7 @@ class ChatScreen extends HookConsumerWidget {
) = useChatMessages(
groupId,
);
final groupAvatarSnapshot = useChatAvatar(pubkey, groupId);
final chatProfile = useChatProfile(pubkey, groupId);
final scrollToMessageResult = useScrollToMessage(
getReversedMessageIndex: getReversedMessageIndex,
);
@@ -203,12 +204,12 @@ class ChatScreen extends HookConsumerWidget {
child: WnSlate(
padding: EdgeInsets.symmetric(vertical: 14.h),
header: WnChatHeader(
mlsGroupId: groupId,
displayName: groupAvatarSnapshot.data?.displayName ?? '',
pictureUrl: groupAvatarSnapshot.data?.pictureUrl,
displayName: chatProfile.data?.displayName ?? '',
avatarColor: chatProfile.data?.color ?? AvatarColor.neutral,
pictureUrl: chatProfile.data?.pictureUrl,
onBack: () => Routes.goToChatList(context),
onMenuTap: () {
final otherPubkey = groupAvatarSnapshot.data?.otherMemberPubkey;
final otherPubkey = chatProfile.data?.otherMemberPubkey;
if (otherPubkey != null) {
Routes.pushToChatInfo(context, otherPubkey);
} else {
+3 -5
View File
@@ -7,18 +7,16 @@ import 'package:whitenoise/widgets/wn_icon.dart';
class WnChatHeader extends StatelessWidget {
const WnChatHeader({
super.key,
required this.mlsGroupId,
required this.displayName,
required this.avatarColor,
this.pictureUrl,
this.peerPubkey,
required this.onBack,
required this.onMenuTap,
});
final String mlsGroupId;
final String displayName;
final AvatarColor avatarColor;
final String? pictureUrl;
final String? peerPubkey;
final VoidCallback onBack;
final VoidCallback onMenuTap;
@@ -42,7 +40,7 @@ class WnChatHeader extends StatelessWidget {
WnAvatar(
pictureUrl: pictureUrl,
displayName: displayName,
color: AvatarColor.fromPubkey(peerPubkey ?? mlsGroupId),
color: avatarColor,
),
SizedBox(width: 12.w),
Expanded(
@@ -1,14 +1,19 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:whitenoise/hooks/use_chat_avatar.dart';
import 'package:whitenoise/hooks/use_chat_profile.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/utils/avatar_color.dart';
import '../mocks/mock_wn_api.dart';
import '../test_helpers.dart';
const _pubkey = 'my_pubkey';
const _groupId = 'group_123';
const _otherPubkey = 'other_pubkey';
const _pubkey = testPubkeyA;
const _groupId = otherTestGroupId;
const _otherPubkey = testPubkeyB;
const _pubkeyColor = AvatarColor.violet;
const _otherPubkeyColor = AvatarColor.amber;
const _groupIdColor = AvatarColor.cyan;
Group _group({required String name}) => Group(
mlsGroupId: _groupId,
@@ -27,7 +32,7 @@ const _metadata = FlutterMetadata(
custom: {},
);
class _MockApi implements RustLibApi {
class _MockApi extends MockWnApi {
bool isDm = false;
String groupName = 'Test Group';
List<String> members = [_pubkey, _otherPubkey];
@@ -66,17 +71,14 @@ class _MockApi implements RustLibApi {
required String accountPubkey,
required String groupId,
}) => Future.value('https://example.com/group.jpg');
@override
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
}
final _api = _MockApi();
late AsyncSnapshot<ChatAvatarData> Function() getResult;
late AsyncSnapshot<ChatProfile> Function() getResult;
Future<void> _mountHook(WidgetTester tester) async {
getResult = await mountHook(tester, () => useChatAvatar(_pubkey, _groupId));
getResult = await mountHook(tester, () => useChatProfile(_pubkey, _groupId));
await tester.pump();
}
@@ -91,18 +93,19 @@ void main() {
_api.shouldError = false;
});
group('useChatAvatar', () {
group('useChatProfile', () {
group('when is DM', () {
setUp(() => _api.isDm = true);
group('when other member has metadata', () {
testWidgets('returns other member avatar data', (tester) async {
testWidgets('returns other member profile', (tester) async {
await _mountHook(tester);
expect(
getResult().data,
const ChatAvatarData(
const ChatProfile(
displayName: 'Alice',
color: _otherPubkeyColor,
pictureUrl: 'https://example.com/alice.jpg',
otherMemberPubkey: _otherPubkey,
),
@@ -115,20 +118,25 @@ void main() {
expect(
getResult().data,
const ChatAvatarData(displayName: 'bob', otherMemberPubkey: _otherPubkey),
const ChatProfile(
displayName: 'bob',
color: _otherPubkeyColor,
otherMemberPubkey: _otherPubkey,
),
);
});
});
group('when other member has no metadata', () {
testWidgets('returns Unknown User avatar data', (tester) async {
testWidgets('returns Unknown User profile', (tester) async {
_api.metadata = const FlutterMetadata(custom: {});
await _mountHook(tester);
expect(
getResult().data,
const ChatAvatarData(
const ChatProfile(
displayName: 'Unknown User',
color: _otherPubkeyColor,
otherMemberPubkey: _otherPubkey,
),
);
@@ -138,12 +146,15 @@ void main() {
group('when there is no other member', () {
setUp(() => _api.members = [_pubkey]);
testWidgets('returns Unknown User avatar data', (tester) async {
testWidgets('returns Unknown User profile', (tester) async {
await _mountHook(tester);
expect(
getResult().data,
const ChatAvatarData(displayName: 'Unknown User'),
const ChatProfile(
displayName: 'Unknown User',
color: _groupIdColor,
),
);
});
});
@@ -152,14 +163,15 @@ void main() {
group('when is not DM', () {
setUp(() => _api.isDm = false);
testWidgets('returns group avatar data', (tester) async {
testWidgets('returns group profile', (tester) async {
_api.groupName = 'Cool Group';
await _mountHook(tester);
expect(
getResult().data,
const ChatAvatarData(
const ChatProfile(
displayName: 'Cool Group',
color: _groupIdColor,
pictureUrl: 'https://example.com/group.jpg',
),
);
@@ -171,8 +183,9 @@ void main() {
expect(
getResult().data,
const ChatAvatarData(
const ChatProfile(
displayName: 'Unknown group',
color: _groupIdColor,
pictureUrl: 'https://example.com/group.jpg',
),
);
@@ -192,65 +205,79 @@ void main() {
});
});
group('ChatAvatarData equality and hashCode', () {
group('ChatProfile equality and hashCode', () {
test('equal objects have equal hash codes', () {
const avatar1 = ChatAvatarData(
const profile1 = ChatProfile(
displayName: 'Alice',
color: _pubkeyColor,
pictureUrl: 'https://example.com/alice.jpg',
otherMemberPubkey: 'pubkey1',
);
const avatar2 = ChatAvatarData(
const profile2 = ChatProfile(
displayName: 'Alice',
color: _pubkeyColor,
pictureUrl: 'https://example.com/alice.jpg',
otherMemberPubkey: 'pubkey1',
);
expect(avatar1, avatar2);
expect(avatar1.hashCode, avatar2.hashCode);
expect(profile1, profile2);
expect(profile1.hashCode, profile2.hashCode);
});
test('equal objects with null pictureUrl have equal hash codes', () {
const avatar1 = ChatAvatarData(displayName: 'Bob');
const avatar2 = ChatAvatarData(displayName: 'Bob');
const profile1 = ChatProfile(displayName: 'Bob', color: AvatarColor.blue);
const profile2 = ChatProfile(displayName: 'Bob', color: AvatarColor.blue);
expect(avatar1, avatar2);
expect(avatar1.hashCode, avatar2.hashCode);
expect(profile1, profile2);
expect(profile1.hashCode, profile2.hashCode);
});
test('different displayNames produce different hash codes', () {
const avatar1 = ChatAvatarData(displayName: 'Alice');
const avatar2 = ChatAvatarData(displayName: 'Bob');
const profile1 = ChatProfile(displayName: 'Alice', color: AvatarColor.blue);
const profile2 = ChatProfile(displayName: 'Bob', color: AvatarColor.blue);
expect(avatar1, isNot(avatar2));
expect(avatar1.hashCode, isNot(avatar2.hashCode));
expect(profile1, isNot(profile2));
expect(profile1.hashCode, isNot(profile2.hashCode));
});
test('different pictureUrls produce different hash codes', () {
const avatar1 = ChatAvatarData(
const profile1 = ChatProfile(
displayName: 'Alice',
color: AvatarColor.blue,
pictureUrl: 'https://example.com/pic1.jpg',
);
const avatar2 = ChatAvatarData(
const profile2 = ChatProfile(
displayName: 'Alice',
color: AvatarColor.blue,
pictureUrl: 'https://example.com/pic2.jpg',
);
expect(avatar1, isNot(avatar2));
expect(avatar1.hashCode, isNot(avatar2.hashCode));
expect(profile1, isNot(profile2));
expect(profile1.hashCode, isNot(profile2.hashCode));
});
test('different otherMemberPubkeys produce different hash codes', () {
const avatar1 = ChatAvatarData(
const profile1 = ChatProfile(
displayName: 'Alice',
color: AvatarColor.blue,
otherMemberPubkey: 'pubkey1',
);
const avatar2 = ChatAvatarData(
const profile2 = ChatProfile(
displayName: 'Alice',
color: AvatarColor.blue,
otherMemberPubkey: 'pubkey2',
);
expect(avatar1, isNot(avatar2));
expect(avatar1.hashCode, isNot(avatar2.hashCode));
expect(profile1, isNot(profile2));
expect(profile1.hashCode, isNot(profile2.hashCode));
});
test('different colors produce different hash codes', () {
const profile1 = ChatProfile(displayName: 'Alice', color: AvatarColor.blue);
const profile2 = ChatProfile(displayName: 'Alice', color: AvatarColor.amber);
expect(profile1, isNot(profile2));
expect(profile1.hashCode, isNot(profile2.hashCode));
});
});
}
+166 -18
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -9,9 +11,11 @@ import 'package:whitenoise/screens/wip_screen.dart';
import 'package:whitenoise/src/rust/api/account_groups.dart';
import 'package:whitenoise/src/rust/api/groups.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/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_message_bubble.dart';
import 'package:whitenoise/widgets/wn_reply_preview.dart';
import 'package:whitenoise/widgets/wn_system_notice.dart';
import '../mocks/mock_wn_api.dart';
@@ -20,13 +24,21 @@ import '../test_helpers.dart';
const _testPubkey = testPubkeyA;
const _testGroupId = testGroupId;
ChatMessage _message(String id, {bool isDeleted = false}) => ChatMessage(
ChatMessage _message(
String id, {
bool isDeleted = false,
bool isReply = false,
String? replyToId,
String pubkey = testPubkeyB,
String content = '',
}) => ChatMessage(
id: id,
pubkey: testPubkeyB,
content: 'Message $id',
pubkey: pubkey,
content: content.isEmpty ? 'Message $id' : content,
createdAt: DateTime(2024),
tags: const [],
isReply: false,
isReply: isReply,
replyToId: replyToId,
isDeleted: isDeleted,
contentTokens: const [],
reactions: const ReactionSummary(byEmoji: [], userReactions: []),
@@ -42,27 +54,58 @@ AccountGroup _accountGroup() => AccountGroup(
);
class _MockApi extends MockWnApi {
List<ChatMessage> messages = [];
StreamController<MessageStreamItem>? controller;
List<ChatMessage> initialMessages = [];
String groupName = 'Test Group';
bool acceptCalled = false;
bool declineCalled = false;
Exception? errorToThrow;
FlutterMetadata? userMetadataResponse;
bool isDm = false;
List<String> groupMembers = [];
@override
void reset() {
messages = [];
controller?.close();
controller = null;
initialMessages = [];
groupName = 'Test Group';
acceptCalled = false;
declineCalled = false;
errorToThrow = null;
userMetadataResponse = null;
isDm = false;
groupMembers = [];
}
void emitMessage(ChatMessage message) {
controller?.add(
MessageStreamItem.update(
update: MessageUpdate(trigger: UpdateTrigger.newMessage, message: message),
),
);
}
@override
Future<List<ChatMessage>> crateApiMessagesFetchAggregatedMessagesForGroup({
Future<FlutterMetadata> crateApiUsersUserMetadata({
required String pubkey,
required String groupId,
required bool blockingDataSync,
}) async {
return messages;
return userMetadataResponse ?? const FlutterMetadata(displayName: 'Author', custom: {});
}
@override
Stream<MessageStreamItem> crateApiMessagesSubscribeToGroupMessages({
required String groupId,
}) {
controller?.close();
controller = StreamController<MessageStreamItem>.broadcast();
Future.microtask(() {
controller?.add(
MessageStreamItem.initialSnapshot(messages: initialMessages),
);
});
return controller!.stream;
}
@override
@@ -81,6 +124,18 @@ class _MockApi extends MockWnApi {
);
}
@override
Future<bool> crateApiGroupsGroupIsDirectMessageType({
required Group that,
required String accountPubkey,
}) async => isDm;
@override
Future<List<String>> crateApiGroupsGroupMembers({
required String pubkey,
required String groupId,
}) async => groupMembers;
@override
Future<AccountGroup> crateApiAccountGroupsAcceptAccountGroup({
required String accountPubkey,
@@ -155,14 +210,28 @@ void main() {
expect(find.text('Decline'), findsOneWidget);
});
testWidgets('displays avatars with color derived from mlsGroupId', (tester) async {
await pumpInviteScreen(tester);
group('avatar color', () {
testWidgets('uses group ID for non-DM', (tester) async {
await pumpInviteScreen(tester);
final avatars = tester.widgetList<WnAvatar>(find.byType(WnAvatar)).toList();
expect(avatars.length, 2);
for (final avatar in avatars) {
expect(avatar.color, AvatarColor.fromPubkey(_testGroupId));
}
final avatars = tester.widgetList<WnAvatar>(find.byType(WnAvatar)).toList();
expect(avatars.length, 2);
for (final avatar in avatars) {
expect(avatar.color, AvatarColor.fromPubkey(_testGroupId));
}
});
testWidgets('uses other member pubkey for DM', (tester) async {
_api.isDm = true;
_api.groupMembers = [_testPubkey, testPubkeyB];
await pumpInviteScreen(tester);
final avatars = tester.widgetList<WnAvatar>(find.byType(WnAvatar)).toList();
expect(avatars.length, 2);
for (final avatar in avatars) {
expect(avatar.color, AvatarColor.fromPubkey(testPubkeyB));
}
});
});
group('with no messages', () {
@@ -174,7 +243,9 @@ void main() {
});
group('with messages', () {
setUp(() => _api.messages = [_message('m1'), _message('m2')]);
setUp(() {
_api.initialMessages = [_message('m1'), _message('m2')];
});
testWidgets('displays messages', (tester) async {
await pumpInviteScreen(tester);
@@ -189,13 +260,90 @@ void main() {
});
testWidgets('does not display deleted message text', (tester) async {
_api.messages = [_message('m1'), _message('m2', isDeleted: true)];
_api.initialMessages = [_message('m1'), _message('m2', isDeleted: true)];
await pumpInviteScreen(tester);
expect(find.text('Message m2'), findsNothing);
});
});
group('message reception', () {
testWidgets('message appears when stream emits update', (tester) async {
await pumpInviteScreen(tester);
_api.emitMessage(_message('new_msg'));
await tester.pumpAndSettle();
expect(find.text('Message new_msg'), findsOneWidget);
});
});
group('reply previews', () {
testWidgets('displays reply preview when message is a reply', (tester) async {
_api.initialMessages = [
_message('m1', content: 'Original message'),
_message('m2', isReply: true, replyToId: 'm1', content: 'Reply message'),
];
await pumpInviteScreen(tester);
expect(find.byType(WnReplyPreview), findsOneWidget);
});
testWidgets('does not display reply preview for non-reply messages', (tester) async {
_api.initialMessages = [_message('m1'), _message('m2')];
await pumpInviteScreen(tester);
expect(find.byType(WnReplyPreview), findsNothing);
});
testWidgets('displays author name in reply preview', (tester) async {
_api.userMetadataResponse = const FlutterMetadata(
displayName: 'Reply Author',
custom: {},
);
_api.initialMessages = [
_message('m1', content: 'Original', pubkey: testPubkeyC),
_message('m2', isReply: true, replyToId: 'm1'),
];
await pumpInviteScreen(tester);
expect(find.text('Reply Author'), findsOneWidget);
});
testWidgets('displays original message content in reply preview', (tester) async {
_api.initialMessages = [
_message('m1', content: 'Original message content'),
_message('m2', isReply: true, replyToId: 'm1', content: 'Reply text'),
];
await pumpInviteScreen(tester);
final replyPreview = find.byType(WnReplyPreview);
expect(replyPreview, findsOneWidget);
expect(
find.descendant(of: replyPreview, matching: find.text('Original message content')),
findsOneWidget,
);
});
testWidgets('displays "Message not found" when reply target is missing', (tester) async {
_api.initialMessages = [_message('m2', isReply: true, replyToId: 'nonexistent')];
await pumpInviteScreen(tester);
expect(find.byType(WnReplyPreview), findsOneWidget);
expect(find.text('Message not found'), findsOneWidget);
});
testWidgets('displays "Message not found" when reply target is deleted', (tester) async {
_api.initialMessages = [
_message('m1', isDeleted: true),
_message('m2', isReply: true, replyToId: 'm1'),
];
await pumpInviteScreen(tester);
expect(find.byType(WnReplyPreview), findsOneWidget);
expect(find.text('Message not found'), findsOneWidget);
});
});
group('accept action', () {
testWidgets('calls acceptAccountGroup', (tester) async {
await pumpInviteScreen(tester);
+1
View File
@@ -36,6 +36,7 @@ const testNpubToHex = <String, String>{
testNpubD: testPubkeyD,
};
const testGroupId = 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234';
const otherTestGroupId = 'dbcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234';
const testNostrGroupId = 'dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321';
void setUpTestView(WidgetTester tester) {
+5 -26
View File
@@ -17,17 +17,15 @@ void main() {
Future<void> pumpHeader(
WidgetTester tester, {
String mlsGroupId = testGroupId,
String displayName = 'Test User',
AvatarColor avatarColor = AvatarColor.violet,
String? pictureUrl,
String? peerPubkey,
}) async {
await mountWidget(
WnChatHeader(
mlsGroupId: mlsGroupId,
displayName: displayName,
avatarColor: avatarColor,
pictureUrl: pictureUrl,
peerPubkey: peerPubkey,
onBack: () => backPressed = true,
onMenuTap: () => menuPressed = true,
),
@@ -73,30 +71,11 @@ void main() {
expect(avatar.displayName, 'Bob');
});
testWidgets('uses mlsGroupId for color when peerPubkey is null', (tester) async {
await pumpHeader(tester);
testWidgets('passes color to avatar', (tester) async {
await pumpHeader(tester, avatarColor: AvatarColor.amber);
final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
expect(avatar.color, AvatarColor.fromPubkey(testGroupId));
});
testWidgets('uses peerPubkey for color when provided', (tester) async {
await pumpHeader(tester, peerPubkey: testPubkeyB);
final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
expect(avatar.color, AvatarColor.fromPubkey(testPubkeyB));
});
testWidgets('different mlsGroupId produces different color when no peerPubkey', (tester) async {
await pumpHeader(tester);
final avatar1 = tester.widget<WnAvatar>(find.byType(WnAvatar));
final color1 = avatar1.color;
await pumpHeader(tester, mlsGroupId: testNostrGroupId);
final avatar2 = tester.widget<WnAvatar>(find.byType(WnAvatar));
final color2 = avatar2.color;
expect(color1, isNot(equals(color2)));
expect(avatar.color, AvatarColor.amber);
});
group('back button', () {