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:
+2
-1
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -36,6 +36,7 @@ const testNpubToHex = <String, String>{
|
||||
testNpubD: testPubkeyD,
|
||||
};
|
||||
const testGroupId = 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234';
|
||||
const otherTestGroupId = 'dbcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234';
|
||||
const testNostrGroupId = 'dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321';
|
||||
|
||||
void setUpTestView(WidgetTester tester) {
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user