diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 526c7edfb1..14b415a674 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -314,7 +314,11 @@ class _MentionAutocompleteItem extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - labelWidget, + Row(children: [ + Flexible(child: labelWidget), + if (option case UserMentionAutocompleteResult(:var userId)) + UserStatusEmoji(userId: userId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0))]), if (sublabelWidget != null) sublabelWidget, ])), ])); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index eb0b972337..56d2323d48 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1943,6 +1943,8 @@ class SenderRow extends StatelessWidget { : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), + UserStatusEmoji(userId: message.senderId, size: 18, + padding: const EdgeInsetsDirectional.only(start: 5.0)), if (sender?.isBot ?? false) ...[ const SizedBox(width: 5), Icon( diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 56f098790f..a845e4a043 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -317,6 +317,8 @@ class _SelectedUserChip extends StatelessWidget { fontSize: 16, height: 16 / 16, color: designVariables.labelMenuButton)))), + UserStatusEmoji(userId: userId, size: 16, + padding: EdgeInsetsDirectional.only(end: 4)), ]))); } } @@ -415,7 +417,12 @@ class _NewDmUserListItem extends StatelessWidget { Avatar(userId: userId, size: 32, borderRadius: 3), SizedBox(width: 8), Expanded( - child: Text(store.userDisplayName(userId), + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: store.userDisplayName(userId)), + UserStatusEmoji.asWidgetSpan(userId: userId, fontSize: 17, + textScaler: MediaQuery.textScalerOf(context))]), style: TextStyle( fontSize: 17, height: 19 / 17, diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index b4c610b71f..7a084f89c8 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -16,6 +16,7 @@ import 'page.dart'; import 'remote_settings.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; class _TextStyles { static const primaryFieldText = TextStyle(fontSize: 20); @@ -47,6 +48,7 @@ class ProfilePage extends StatelessWidget { if (user == null) { return const _ProfileErrorPage(); } + final userStatus = store.getUserStatus(userId); final nameStyle = _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700)); @@ -73,9 +75,21 @@ class ProfilePage extends StatelessWidget { ), // TODO write a test where the user is muted; check this and avatar TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: 20, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), ]), textAlign: TextAlign.center, style: nameStyle), + if (userStatus.text != null) + Text(userStatus.text!, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, height: 22 / 18, + color: DesignVariables.of(context).userStatusText)), + if (displayEmail != null) Text(displayEmail, textAlign: TextAlign.center, @@ -83,7 +97,6 @@ class ProfilePage extends StatelessWidget { Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), - // TODO(#197) render user status // TODO(#196) render active status // TODO(#292) render user local time diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 5526557589..2c37ba0ba7 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -148,7 +148,23 @@ class RecentDmConversationsItem extends StatelessWidget { const SizedBox(width: 8), Expanded(child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: title), + ...(switch (narrow.otherRecipientIds) { + // self-DM + [] => [UserStatusEmoji.asWidgetSpan(userId: store.selfUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context))], + // 1:1-DM + [final otherUserId] => + [UserStatusEmoji.asWidgetSpan(userId: otherUserId, + fontSize: 17, textScaler: MediaQuery.textScalerOf(context))], + // group-DM - show nothing + [...] => [], + }), + ] + ), style: TextStyle( fontSize: 17, height: (20 / 17), @@ -156,8 +172,7 @@ class RecentDmConversationsItem extends StatelessWidget { color: designVariables.labelMenuButton, ), maxLines: 2, - overflow: TextOverflow.ellipsis, - title))), + overflow: TextOverflow.ellipsis))), const SizedBox(width: 12), unreadCount > 0 ? Padding(padding: const EdgeInsetsDirectional.only(end: 16), diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..8e70f28e3c 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -213,6 +213,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( @@ -309,6 +310,8 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ @@ -388,6 +391,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -480,6 +484,7 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ @@ -559,6 +564,7 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, @@ -637,6 +643,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -722,6 +729,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 573921b663..f3e8bded3b 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,6 +7,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; @@ -15,6 +16,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/message_list.dart'; import '../api/fake_api.dart'; @@ -23,6 +25,7 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_images.dart'; +import 'message_list_test.dart'; import 'test_app.dart'; /// Simulates loading a [MessageListPage] and tapping to focus the compose input. @@ -36,6 +39,7 @@ import 'test_app.dart'; /// before the end of the test. Future setupToComposeInput(WidgetTester tester, { List users = const [], + List<(int userId, UserStatusChange change)>? userStatuses, Narrow? narrow, }) async { assert(narrow is ChannelNarrow? || narrow is SendableNarrow?); @@ -47,6 +51,7 @@ Future setupToComposeInput(WidgetTester tester, { final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); + await store.changeUserStatuses(userStatuses ?? []); final connection = store.connection as FakeApiConnection; narrow ??= DmNarrow( @@ -152,9 +157,24 @@ void main() { Finder findAvatarImage(int userId) => find.byWidgetPredicate((widget) => widget is AvatarImage && widget.userId == userId); - void checkUserShown(User user, {required bool expected}) { - check(find.text(user.fullName)).findsExactly(expected ? 1 : 0); - check(findAvatarImage(user.userId)).findsExactly(expected ? 1 : 0); + void checkUserShown(User user, {required bool expected, bool withStatusEmoji = false}) { + assert(expected || !withStatusEmoji); + + final nameFinder = find.text(user.fullName); + check(nameFinder).findsExactly(expected ? 1 : 0); + + final avatarFinder = findAvatarImage(user.userId); + check(avatarFinder).findsExactly(expected ? 1 : 0); + + final statusEmojiFinder = findStatusEmoji(UnicodeEmojiWidget); + if (withStatusEmoji) { + checkUserStatusEmoji(statusEmojiFinder, isAnimated: false); + } + final rowFinder = find.ancestor(of: nameFinder, + matching: find.ancestor(of: avatarFinder, + matching: find.ancestor(of: statusEmojiFinder, + matching: find.byType(Row)))); + check(rowFinder).findsExactly(expected && withStatusEmoji ? 1 : 0); } testWidgets('user options appear, disappear, and change correctly', (tester) async { @@ -202,6 +222,31 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + testWidgets('status emoji is set -> emoji is displayed', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User One', avatarUrl: 'user1.png'); + final user2 = eg.user(userId: 2, fullName: 'User Two', avatarUrl: 'user2.png'); + final composeInputFinder = await setupToComposeInput(tester, + users: [user1, user2], userStatuses: [ + ( + user1.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ]); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkUserShown(user1, expected: true, withStatusEmoji: true); + checkUserShown(user2, expected: true, withStatusEmoji: false); + + debugNetworkImageHttpClientProvider = null; + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index d068e12ed5..37376c6e4e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -14,6 +14,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/message.dart'; @@ -26,6 +27,7 @@ import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; @@ -50,6 +52,22 @@ import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; +/// Finder for [UserStatusEmoji] widget. +/// +/// Use [type] to specify the exact emoji child widget. It can be either +/// [UnicodeEmojiWidget] or [ImageEmojiWidget]. +Finder findStatusEmoji(Type type) { + assert(type == UnicodeEmojiWidget || type == ImageEmojiWidget); + return find.ancestor( + of: find.byType(type), + matching: find.byType(UserStatusEmoji)); +} + +void checkUserStatusEmoji(Finder emojiFinder, {required bool isAnimated}) { + check((emojiFinder + .evaluate().first.widget as UserStatusEmoji).neverAnimate).equals(!isAnimated); +} + void main() { TestZulipBinding.ensureInitialized(); MessageListPage.debugEnableMarkReadOnScroll = false; @@ -66,6 +84,7 @@ void main() { List? streams, List? users, List? mutedUserIds, + List<(int userId, UserStatusChange change)>? userStatuses, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, @@ -91,6 +110,7 @@ void main() { if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); } + await store.changeUserStatuses(userStatuses ?? []); if (fetchResult != null) { assert(foundOldest && messageCount == null && messages == null); } else { @@ -1755,6 +1775,64 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status emoji', () { + void checkStatusEmoji(User user, Type type, {required bool isPresent}) { + final nameFinder = find.text(user.fullName); + final statusEmojiFinder = findStatusEmoji(type); + + check(nameFinder).findsOne(); + check(statusEmojiFinder).findsExactly(isPresent ? 1 : 0); + if (isPresent) { + checkUserStatusEmoji(statusEmojiFinder, isAnimated: false); + } + + final senderRowFinder = find.ancestor(of: nameFinder, + matching: find.ancestor(of: statusEmojiFinder, + matching: find.byType(Row))); + isPresent + ? check(senderRowFinder).findsAny() + : check(senderRowFinder).findsNothing(); + } + + final user1 = eg.user(userId: 1, + fullName: 'User with a very very very long name to check if emoji is still visible'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + + testWidgets('status emoji is set -> emoji is displayed', (tester) async { + await setupMessageListPage(tester, + users: [user1, user2], + messages: [eg.streamMessage(sender: user1), eg.streamMessage(sender: user2)], + userStatuses: [ + ( + user1.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ( + user2.userId, + UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji))) + ), + ], + ); + checkStatusEmoji(user1, UnicodeEmojiWidget, isPresent: true); + checkStatusEmoji(user2, ImageEmojiWidget, isPresent: true); + }); + + testWidgets('status emoji is not set -> emoji is not displayed', (tester) async { + await setupMessageListPage(tester, + users: [user1], + messages: [eg.streamMessage(sender: user1)], + userStatuses: [], + ); + checkStatusEmoji(user1, UnicodeEmojiWidget, isPresent: false); + }); + }); + group('Muted sender', () { void checkMessage(Message message, {required bool expectIsMuted}) { final mutedLabel = 'Muted user'; diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index fc9567d78d..28c9987893 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/new_dm_sheet.dart'; @@ -17,11 +19,13 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; +import 'message_list_test.dart'; import 'test_app.dart'; Future setupSheet(WidgetTester tester, { required List users, List? mutedUserIds, + List<(int userId, UserStatusChange change)>? userStatuses, }) async { addTearDown(testBinding.reset); @@ -35,6 +39,7 @@ Future setupSheet(WidgetTester tester, { if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); } + await store.changeUserStatuses(userStatuses ?? []); await tester.pumpWidget(TestZulipApp( navigatorObservers: [testNavObserver], @@ -65,7 +70,8 @@ void main() { } Finder findUserTile(User user) => - find.widgetWithText(InkWell, user.fullName).first; + find.ancestor(of: find.textContaining(user.fullName), + matching: find.byType(InkWell)).first; Finder findUserChip(User user) { final findAvatar = find.byWidgetPredicate((widget) => @@ -120,23 +126,23 @@ void main() { testWidgets('shows all non-muted users initially', (tester) async { await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsOne(); - check(find.text('Charlie Carter')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Bob Brown')).findsOne(); + check(find.textContaining('Charlie Carter')).findsOne(); check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(find.text('Someone Muted')).findsNothing(); - check(find.text('Muted user')).findsNothing(); + check(find.textContaining('Someone Muted')).findsNothing(); + check(find.textContaining('Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); }); // TODO test sorting by recent-DMs @@ -146,11 +152,11 @@ void main() { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); await tester.enterText(find.byType(TextField), 'ALICE'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(find.textContaining('Alice Anderson')).findsOne(); }); testWidgets('partial name and last name search handling', (tester) async { @@ -158,31 +164,31 @@ void main() { await tester.enterText(find.byType(TextField), 'Ali'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Bob Brown')).findsNothing(); + check(find.textContaining('Charlie Carter')).findsNothing(); await tester.enterText(find.byType(TextField), 'Anderson'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); await tester.enterText(find.byType(TextField), 'son'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(find.textContaining('Alice Anderson')).findsOne(); + check(find.textContaining('Charlie Carter')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); }); testWidgets('shows empty state when no users match', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Zebra'); await tester.pump(); - check(find.text('No users found')).findsOne(); - check(find.text('Alice Anderson')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(find.textContaining('No users found')).findsOne(); + check(find.textContaining('Alice Anderson')).findsNothing(); + check(find.textContaining('Bob Brown')).findsNothing(); + check(find.textContaining('Charlie Carter')).findsNothing(); }); testWidgets('search text clears when user is selected', (tester) async { @@ -252,7 +258,7 @@ void main() { await tester.tap(findUserTile(eg.selfUser)); await tester.pump(); checkUserSelected(tester, eg.selfUser, true); - check(find.text(eg.selfUser.fullName)).findsExactly(2); + check(find.textContaining(eg.selfUser.fullName)).findsExactly(2); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -264,7 +270,7 @@ void main() { final otherUser = eg.user(fullName: 'Other User'); await setupSheet(tester, users: [eg.selfUser, otherUser]); - check(find.text(eg.selfUser.fullName)).findsOne(); + check(find.textContaining(eg.selfUser.fullName)).findsOne(); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -285,6 +291,56 @@ void main() { }); }); + testWidgets('status emoji is set -> emoji is displayed', (tester) async { + void checkTileStatusEmoji(User user, {required bool isPresent}) { + final statusEmojiFinder = findStatusEmoji(UnicodeEmojiWidget); + final tileStatusFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + if (isPresent) { + checkUserStatusEmoji(tileStatusFinder, isAnimated: false); + } + check(tileStatusFinder).findsExactly(isPresent ? 1 : 0); + } + + void checkChipStatusEmoji(User user, {required bool isPresent}) { + final statusEmojiFinder = findStatusEmoji(UnicodeEmojiWidget); + final chipStatusFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + if (isPresent) { + checkUserStatusEmoji(chipStatusFinder, isAnimated: false); + } + check(chipStatusFinder).findsExactly(isPresent ? 1 : 0); + } + + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await setupSheet(tester, users: [user1, user2], userStatuses: [ + ( + user1.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ]); + + checkTileStatusEmoji(user1, isPresent: true); + checkTileStatusEmoji(user2, isPresent: false); + check(findUserChip(user1)).findsNothing(); + check(findUserChip(user2)).findsNothing(); + + await tester.tap(findUserTile(user1)); + await tester.tap(findUserTile(user2)); + await tester.pump(); + + checkTileStatusEmoji(user1, isPresent: true); + checkTileStatusEmoji(user2, isPresent: false); + check(findUserChip(user1)).findsOne(); + check(findUserChip(user2)).findsOne(); + checkChipStatusEmoji(user1, isPresent: true); + checkChipStatusEmoji(user2, isPresent: false); + }); + group('navigation to DM Narrow', () { Future runAndCheck(WidgetTester tester, { required List users, diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 61a85cb63e..7cf69f593a 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -9,10 +9,12 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; @@ -27,6 +29,7 @@ import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import 'message_list_checks.dart'; +import 'message_list_test.dart'; import 'page_checks.dart'; import 'profile_page_checks.dart'; import 'test_app.dart'; @@ -38,6 +41,7 @@ Future setupPage(WidgetTester tester, { required int pageUserId, List? users, List? mutedUserIds, + List<(int userId, UserStatusChange change)>? userStatuses, List? customProfileFields, Map? realmDefaultExternalAccounts, bool realmPresenceDisabled = false, @@ -60,6 +64,7 @@ Future setupPage(WidgetTester tester, { if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); } + await store.changeUserStatuses(userStatuses ?? []); await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, @@ -95,12 +100,25 @@ void main() { final user = eg.user(userId: 1, fullName: 'test user', deliveryEmail: 'testuser@example.com'); - await setupPage(tester, users: [user], pageUserId: user.userId); - - check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); - check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); - check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); - }); + await setupPage(tester, users: [user], pageUserId: user.userId, + userStatuses: [ + ( + user.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ]); + + check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); + check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); + final statusEmojiFinder = findStatusEmoji(UnicodeEmojiWidget); + checkUserStatusEmoji(statusEmojiFinder, isAnimated: true); + check(because: 'find user status emoji', statusEmojiFinder).findsOne(); + check(because: 'find user status text', find.text('Busy')).findsOne(); + }); testWidgets('page builds; error page shows up if data is missing', (tester) async { await setupPage(tester, pageUserId: eg.selfUser.userId + 1989); diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 3eb49f2ca8..4ce50ca40a 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -5,8 +5,10 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -21,6 +23,7 @@ import '../model/test_store.dart'; import '../test_navigation.dart'; import 'content_checks.dart'; import 'message_list_checks.dart'; +import 'message_list_test.dart'; import 'page_checks.dart'; import 'test_app.dart'; @@ -28,6 +31,7 @@ Future setupPage(WidgetTester tester, { required List dmMessages, required List users, List? mutedUserIds, + List<(int userId, UserStatusChange change)>? userStatuses, NavigatorObserver? navigatorObserver, String? newNameForSelfUser, }) async { @@ -44,6 +48,8 @@ Future setupPage(WidgetTester tester, { await store.setMutedUsers(mutedUserIds); } + await store.changeUserStatuses(userStatuses ?? []); + await store.addMessages(dmMessages); if (newNameForSelfUser != null) { @@ -176,7 +182,7 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), + matching: find.textContaining(expectedText), )); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); @@ -186,6 +192,17 @@ void main() { } } + void checkStatusEmoji({required bool isPresent}) { + final statusEmojiFinder = findStatusEmoji(UnicodeEmojiWidget); + if (isPresent) { + checkUserStatusEmoji(statusEmojiFinder, isAnimated: false); + } + final itemFinder = find.descendant( + of: find.byType(RecentDmConversationsItem), + matching: statusEmojiFinder); + check(itemFinder).findsExactly(isPresent ? 1 : 0); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.handleEvent(UpdateMessageFlagsAddEvent( @@ -231,6 +248,28 @@ void main() { checkTitle(tester, name, 2); }); + testWidgets('status emoji is set -> emoji is displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: [], + userStatuses: [ + ( + eg.selfUser.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ]); + checkStatusEmoji(isPresent: true); + }); + + testWidgets('status emoji is not set -> emoji is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: [], + userStatuses: []); + checkStatusEmoji(isPresent: false); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -291,6 +330,30 @@ void main() { checkTitle(tester, user.fullName, 2); }); + testWidgets('status emoji is set -> emoji is displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message], + userStatuses: [ + ( + user.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ]); + checkStatusEmoji(isPresent: true); + }); + + testWidgets('status emoji is not set -> emoji is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message], + userStatuses: []); + checkStatusEmoji(isPresent: false); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -379,6 +442,29 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emojis are set -> emoji are not displayed', (tester) async { + final users = usersList(3); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message], + userStatuses: [ + ( + users.first.userId, + UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))) + ), + ( + users.last.userId, + UserStatusChange( + text: OptionSome('In a meeting'), + emoji: OptionSome(StatusEmoji(emojiName: 'calendar', + emojiCode: '1f4c5', reactionType: ReactionType.unicodeEmoji))) + ), + ]); + checkStatusEmoji(isPresent: false); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]);