diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 15fce5b25a..8c1ec23db8 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/reactions.svg b/assets/icons/reactions.svg new file mode 100644 index 0000000000..78c2a48063 --- /dev/null +++ b/assets/icons/reactions.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1060027553..4e83b1fb3e 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -104,6 +104,10 @@ "@actionSheetOptionShare": { "description": "Label for share button on action sheet." }, + "actionSheetOptionViewReactions": "See who reacted", + "@actionSheetOptionViewReactions": { + "description": "Label for View Reactions button on action sheet." + }, "actionSheetOptionQuoteAndReply": "Quote and reply", "@actionSheetOptionQuoteAndReply": { "description": "Label for Quote and reply button on action sheet." @@ -116,6 +120,10 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "reactionSheetEmptyReactions": "No reactions yet", + "@reactionSheetEmptyReactions": { + "description": "Text to display when the reactions sheet is open, but there are no reactions to show." + }, "errorWebAuthOperationalErrorTitle": "Something went wrong", "@errorWebAuthOperationalErrorTitle": { "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 501eb577bf..9adcff63e5 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -261,6 +261,12 @@ abstract class ZulipLocalizations { /// **'Share'** String get actionSheetOptionShare; + /// Label for View Reactions button on action sheet. + /// + /// In en, this message translates to: + /// **'See who reacted'** + String get actionSheetOptionViewReactions; + /// Label for Quote and reply button on action sheet. /// /// In en, this message translates to: @@ -279,6 +285,12 @@ abstract class ZulipLocalizations { /// **'Unstar message'** String get actionSheetOptionUnstarMessage; + /// Text to display when the reactions sheet is open, but there are no reactions to show. + /// + /// In en, this message translates to: + /// **'No reactions yet'** + String get reactionSheetEmptyReactions; + /// Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials). /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 721b20ac02..d4ddbc7716 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Share'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Quote and reply'; @@ -97,6 +100,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 6936cfe736..f38037c9b9 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Share'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Quote and reply'; @@ -97,6 +100,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c431471645..aa77a37c82 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Share'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Quote and reply'; @@ -97,6 +100,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index fc530fccaa..9b0d078a03 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Share'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Quote and reply'; @@ -97,6 +100,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index f817d400a8..648b2cd1a6 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Udostępnij'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Odpowiedz cytując'; @@ -97,6 +100,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Coś poszło nie tak'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index f6d8f1e41c..8f2a454edb 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Поделиться'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Ответить с цитированием'; @@ -97,6 +100,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Что-то пошло не так'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index d6e04126d3..7057453786 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -88,6 +88,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionShare => 'Zdielať'; + @override + String get actionSheetOptionViewReactions => 'See who reacted'; + @override String get actionSheetOptionQuoteAndReply => 'Citovať a odpovedať'; @@ -97,6 +100,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odhviezdičkovať správu'; + @override + String get reactionSheetEmptyReactions => 'No reactions yet'; + @override String get errorWebAuthOperationalErrorTitle => 'Niečo sa pokazilo'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6a90b61e64..588882176c 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -392,6 +392,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ ReactionButtons(message: message, pageContext: context), + if((message.reactions?.total ?? 0) > 0) + ViewReactionsButton(message: message, pageContext: context), StarButton(message: message, pageContext: context), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: context), @@ -692,6 +694,25 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { } } +class ViewReactionsButton extends MessageActionSheetMenuItemButton { + ViewReactionsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.reactions; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionViewReactions; + } + + @override void onPressed() async { + showReactionListSheet( + pageContext, + messageId: message.id, + messageListView: MessageListPage.ancestorOf(pageContext).model, + ); + } +} + class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { MarkAsUnreadButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3d69e3d0d1..a25ea70725 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -6,10 +6,15 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/autocomplete.dart'; import '../model/emoji.dart'; +import '../model/message_list.dart'; +import '../model/store.dart'; import 'color.dart'; +import 'content.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'inset_shadow.dart'; +import 'message_list.dart'; +import 'profile.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -125,10 +130,14 @@ class ReactionChipsList extends StatelessWidget { final showNames = displayEmojiReactionUsers && reactions.total <= 3; return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, - children: reactions.aggregated.map((reactionVotes) => ReactionChip( - showName: showNames, - messageId: messageId, reactionWithVotes: reactionVotes), - ).toList()); + children: reactions.aggregated.map((reactionVotes) { + return ReactionChip( + showName: showNames, + messageId: messageId, + reactionWithVotes: reactionVotes, + ); + }).toList() + ); } } @@ -204,6 +213,14 @@ class ReactionChip extends StatelessWidget { customBorder: shape, splashColor: splashColor, highlightColor: highlightColor, + onLongPress: (){ + showReactionListSheet( + context, + messageId: messageId, + messageListView: MessageListPage.ancestorOf(context).model, + initialTab: reactionWithVotes, + ); + }, onTap: () { (selfVoted ? removeReaction : addReaction).call(store.connection, messageId: messageId, @@ -264,6 +281,269 @@ class ReactionChip extends StatelessWidget { } } +void showReactionListSheet( + BuildContext context, { + required int messageId, + required MessageListView? messageListView, + ReactionWithVotes? initialTab, +}) { + final store = PerAccountStoreWidget.of(context); + + showModalBottomSheet( + context: context, + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (BuildContext modalContext) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: InsetShadowBox( + top: 8, + bottom: 8, + color: DesignVariables.of(context).bgContextMenu, + child: PerAccountStoreWidget( + accountId: store.accountId, + child: ReactionListContent( + store: store, + messageId: messageId, + initialTab: initialTab, + messageListView: messageListView, + ), + ), + ), + ), + const ReactionSheetCloseButton(), + ], + ), + ), + ), + ); + }, + ); +} + +class ReactionListContent extends StatefulWidget { + final PerAccountStore store; + final int messageId; + final ReactionWithVotes? initialTab; + final MessageListView? messageListView; + + const ReactionListContent({ + super.key, + required this.store, + required this.messageListView, + required this.messageId, + this.initialTab, + }); + + @override + State createState() => _ReactionListContentState(); +} +class _ReactionListContentState extends State { + late MessageListView? model; + List reactionList = []; + bool isLoading = true; + + @override + void initState() { + super.initState(); + _initModel(); + } + + void _initModel() { + model = widget.messageListView; + model!.addListener(_onMessageListChanged); + _updateReactionList(); + } + + void _onMessageListChanged() { + _updateReactionList(); + } + + void _updateReactionList() { + setState(() { + reactionList = widget.store.messages[widget.messageId]?.reactions?.aggregated + .where((reaction) => reaction.userIds.isNotEmpty) + .toList() ?? + []; + isLoading = false; + }); + } + + @override + void dispose() { + if (model != null) { + model!.removeListener(_onMessageListChanged); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (reactionList.isEmpty) { + return Center( + child: Text( + zulipLocalizations.reactionSheetEmptyReactions, + style: TextStyle( + color: designVariables.foreground.withFadedAlpha(0.6), + fontSize: 16, + ).merge(weightVariableTextStyle(context, wght: 500)), + ), + ); + } + + final tabs = reactionList.map((reaction) { + final emojiDisplay = widget.store.emojiDisplayFor( + emojiType: reaction.reactionType, + emojiCode: reaction.emojiCode, + emojiName: reaction.emojiName, + ).resolve(widget.store.userSettings); + + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => _UnicodeEmoji(emojiDisplay: emojiDisplay), + ImageEmojiDisplay() => _ImageEmoji( + emojiDisplay: emojiDisplay, + emojiName: reaction.emojiName, + selected: reaction.userIds.contains(widget.store.selfUserId), + ), + TextEmojiDisplay() => _TextEmoji( + emojiDisplay: emojiDisplay, + selected: reaction.userIds.contains(widget.store.selfUserId), + ), + }; + + return Tab( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + emoji, + const SizedBox(height: 4), + Text( + '${reaction.userIds.length}', + style: const TextStyle() + .merge(weightVariableTextStyle(context, wght: 600)), + ), + ], + ), + ); + }).toList(); + + final tabViews = reactionList.map((reaction) { + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: reaction.userIds.length, + itemBuilder: (context, index) { + final userId = reaction.userIds.elementAt(index); + return ListTile( + leading: Avatar(userId: userId, size: 32.0, borderRadius: 3), + title: Text( + userId == widget.store.selfUserId + ? 'You' + : widget.store.users[userId]?.fullName ?? zulipLocalizations.unknownUserName, + style: TextStyle( + color: designVariables.foreground.withFadedAlpha(0.80), + fontSize: 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + ), + onTap: () { + Navigator.push( + context, + ProfilePage.buildRoute(context: context, userId: userId), + ); + }, + ); + }, + ); + }).toList(); + + return DefaultTabController( + length: tabs.length, + initialIndex: widget.initialTab != null + ? reactionList.indexOf(widget.initialTab as ReactionWithVotes) + : 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + dividerColor: Colors.transparent, + indicator: BoxDecoration( + color: designVariables.background, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: designVariables.foreground.withFadedAlpha(0.2), + width: 1, + ), + ), + splashFactory: NoSplash.splashFactory, + indicatorSize: TabBarIndicatorSize.tab, + labelColor: designVariables.foreground, + unselectedLabelColor: designVariables.foreground, + labelStyle: const TextStyle(fontSize: 14) + .merge(weightVariableTextStyle(context, wght: 400)), + unselectedLabelStyle: const TextStyle(fontSize: 14) + .merge(weightVariableTextStyle(context, wght: 400)), + tabs: tabs, + ), + ), + const SizedBox(height: 8), + Flexible( + child: TabBarView(children: tabViews), + ), + ], + ), + ); + } +} +class ReactionSheetCloseButton extends StatelessWidget { + const ReactionSheetCloseButton({super.key}); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return TextButton( + style: TextButton.styleFrom( + minimumSize: const Size.fromHeight(44), + padding: const EdgeInsets.all(10), + foregroundColor: designVariables.contextMenuCancelText, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + splashFactory: NoSplash.splashFactory, + ).copyWith(backgroundColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuCancelPressedBg, + ~WidgetState.pressed: designVariables.contextMenuCancelBg, + })), + onPressed: () { + Navigator.pop(context); + }, + child: Text(ZulipLocalizations.of(context).dialogClose, + style: const TextStyle(fontSize: 20, height: 24 / 20) + .merge(weightVariableTextStyle(context, wght: 600)))); + } +} /// The size of a square emoji (Unicode or image). /// /// Should be scaled by [_emojiTextScalerClamped]. diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 7d58305fb4..9b29e6c555 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -99,38 +99,41 @@ abstract final class ZulipIcons { /// The Zulip custom icon "mute". static const IconData mute = IconData(0xf119, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "reactions". + static const IconData reactions = IconData(0xf11a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf125, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/packages/zulip_plugin/pubspec.lock b/packages/zulip_plugin/pubspec.lock new file mode 100644 index 0000000000..e4de75859b --- /dev/null +++ b/packages/zulip_plugin/pubspec.lock @@ -0,0 +1,6 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: {} +sdks: + dart: ">=3.4.0-256.0.dev <4.0.0" + flutter: ">=3.3.0" diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index e6b48384b8..ee369660af 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -25,6 +25,7 @@ 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/emoji_reaction.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; @@ -526,18 +527,104 @@ void main() { } }); - group('StarButton', () { - Future tapButton(WidgetTester tester, {bool starred = false}) async { - // Starred messages include the same icon so we need to - // match only by descendants of [BottomSheet]. - await tester.ensureVisible(find.descendant( + group('ViewReaction', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future tapButton(WidgetTester tester) async { + await tester.ensureVisible(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.reactions, skipOffstage: false))); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.reactions))); + await tester.pump(); + } + + testWidgets('reaction option is absent when reactions list is empty', (tester) async { + final message = eg.streamMessage(reactions: []); + + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + connection.prepare(json: {}); + + expect( + find.descendant( of: find.byType(BottomSheet), - matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star, skipOffstage: false))); - await tester.tap(find.descendant( + matching: find.byIcon(ZulipIcons.reactions), + ), + findsNothing, + ); + }); + + testWidgets('reaction sheet displays reactions correctly', (tester) async { + + final message = eg.streamMessage( + reactions:[ eg.unicodeEmojiReaction , eg.realmEmojiReaction] + ); + + await setupToMessageActionSheet(tester, message:message, narrow: TopicNarrow.ofMessage(message)); + connection.prepare(json: {}); + + expect( + find.descendant( of: find.byType(BottomSheet), - matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star))); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } + matching: find.byIcon(ZulipIcons.reactions), + ), + findsOneWidget, + ); + + await tapButton(tester); + await tester.pumpAndSettle(); + + // Verify tabs and reaction content + expect(find.byType(ReactionListContent), findsOneWidget); + expect(find.byType(Tab), findsNWidgets(2)); + expect( + find.widgetWithText(ReactionListContent, '👍'), + findsOneWidget, + ); + + final reactionListFinder = find.byType(ReactionListContent); + await tester.drag(reactionListFinder, const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, 'You'), findsOneWidget); + }); + + testWidgets('close button dismisses reaction list sheet', (tester) async { + final message = eg.streamMessage( + reactions:[ eg.unicodeEmojiReaction , eg.realmEmojiReaction] + ); + await setupToMessageActionSheet(tester, message:message, narrow: TopicNarrow.ofMessage(message)); + connection.prepare(json: {}); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(ZulipIcons.reactions), + ), + findsOneWidget, + ); + // opening the reaction sheet + await tapButton(tester); + await tester.pumpAndSettle(); + + final findCloseButton = find.text(zulipLocalizations.dialogClose); + await tester.tap(findCloseButton); + await tester.pumpAndSettle(); + expect(find.byType(ReactionListContent), findsNothing); + }); + + }); + group('StarButton', () { + Future tapButton(WidgetTester tester, {bool starred = false}) async { + // Starred messages include the same icon so we need to + // match only by descendants of [BottomSheet]. + await tester.ensureVisible(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star, skipOffstage: false))); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star))); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } testWidgets('star success', (tester) async { final message = eg.streamMessage(flags: []);