diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a..355d1e8dca 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/see_who_reacted.svg b/assets/icons/see_who_reacted.svg new file mode 100644 index 0000000000..78c2a48063 --- /dev/null +++ b/assets/icons/see_who_reacted.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c24f23dce9..549e31ec69 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -136,6 +136,14 @@ "@errorUnresolveTopicFailedTitle": { "description": "Error title when marking a topic as unresolved failed." }, + "actionSheetOptionSeeWhoReacted": "See who reacted", + "@actionSheetOptionSeeWhoReacted": { + "description": "Label for the 'See who reacted' button in the message action sheet." + }, + "seeWhoReactedSheetNoReactions": "This message has no reactions.", + "@seeWhoReactedSheetNoReactions": { + "description": "Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened)." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 99b52aced1..4f6766b486 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -333,6 +333,18 @@ abstract class ZulipLocalizations { /// **'Failed to mark topic as unresolved'** String get errorUnresolveTopicFailedTitle; + /// Label for the 'See who reacted' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'See who reacted'** + String get actionSheetOptionSeeWhoReacted; + + /// Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened). + /// + /// In en, this message translates to: + /// **'This message has no reactions.'** + String get seeWhoReactedSheetNoReactions; + /// Label for copy message text button on action sheet. /// /// 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 110b0dbe24..846b3fc7df 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index f3b1bdad67..f6101e712e 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -121,6 +121,12 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Thema konnte nicht als ungelöst markiert werden'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f99c386087..ba3579857c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2d7d35e23e..b3b5f5acb2 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -120,6 +120,12 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Impossibile contrassegnare l\'argomento come irrisolto'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index ff27eaee8b..da8f0c7eaf 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0568bc0ae7..38793e3633 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e9cf379b6..73324c039b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -121,6 +121,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Nie udało się oznaczyć brak rozwiązania'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 1349d79baa..4093a57f9a 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -121,6 +121,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Не удалось отметить тему как нерешенную'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 33b4465eb6..113e73e7b7 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8d587b9085..ae43ea7853 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -119,6 +119,12 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Neuspela označitev teme kot nerazrešene'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..2c130cfc85 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -122,6 +122,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Не вдалося позначити тему як невирішену'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b7eaba478f..7d93ad7c11 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -118,6 +118,12 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6b280df6ee..1a490451f7 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -92,12 +92,41 @@ void _showActionSheet( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 8), child: MenuButtonsShape(buttons: optionButtons)))), - const ActionSheetCancelButton(), + const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel), ]))), ])))); }); } +/// A header for a bottom sheet with a multiline UI string. +/// +/// Assumes 8px padding below the top of the bottom sheet. +/// +/// Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev +class BottomSheetHeaderPlainText extends StatelessWidget { + const BottomSheetHeaderPlainText({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 4), + child: SizedBox( + width: double.infinity, + child: Text( + style: TextStyle( + color: designVariables.labelTime, + fontSize: 17, + height: 22 / 17), + text))); + } +} + + /// A button in an action sheet. /// /// When built from server data, the action sheet ignores changes in that data; @@ -160,12 +189,22 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { } } -class ActionSheetCancelButton extends StatelessWidget { - const ActionSheetCancelButton({super.key}); +/// A stretched gray "Cancel" / "Close" button for the bottom of a bottom sheet. +class BottomSheetDismissButton extends StatelessWidget { + const BottomSheetDismissButton({super.key, required this.style}); + + final BottomSheetDismissButtonStyle style; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final label = switch (style) { + BottomSheetDismissButtonStyle.cancel => zulipLocalizations.dialogCancel, + BottomSheetDismissButtonStyle.close => zulipLocalizations.dialogClose, + }; + return TextButton( style: TextButton.styleFrom( minimumSize: const Size.fromHeight(44), @@ -180,12 +219,20 @@ class ActionSheetCancelButton extends StatelessWidget { onPressed: () { Navigator.pop(context); }, - child: Text(ZulipLocalizations.of(context).dialogCancel, + child: Text(label, style: const TextStyle(fontSize: 20, height: 24 / 20) .merge(weightVariableTextStyle(context, wght: 600)))); } } +enum BottomSheetDismissButtonStyle { + /// The "Cancel" label, for action sheets. + cancel, + + /// The "Close" label, for bottom sheets that are read-only or for navigation. + close, +} + /// Show a sheet of actions you can take on a channel. /// /// Needs a [PageRoot] ancestor. @@ -613,6 +660,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), + ViewReactionsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -839,6 +887,21 @@ class ReactionButtons extends StatelessWidget { } } +class ViewReactionsButton extends MessageActionSheetMenuItemButton { + ViewReactionsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.see_who_reacted; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionSeeWhoReacted; + } + + @override void onPressed() { + showViewReactionsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3c26361d3a..b735936a68 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../api/exception.dart'; @@ -6,10 +7,15 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/autocomplete.dart'; import '../model/emoji.dart'; +import '../model/store.dart'; +import 'action_sheet.dart'; import 'color.dart'; +import 'content.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'inset_shadow.dart'; +import 'page.dart'; +import 'profile.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -205,6 +211,12 @@ class ReactionChip extends StatelessWidget { customBorder: shape, splashColor: splashColor, highlightColor: highlightColor, + onLongPress: () { + showViewReactionsSheet(PageRoot.contextOf(context), + messageId: messageId, + initialReactionType: reactionType, + initialEmojiCode: emojiCode); + }, onTap: () { (selfVoted ? removeReaction : addReaction).call(store.connection, messageId: messageId, @@ -518,7 +530,7 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat states.contains(WidgetState.pressed) ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) : Colors.transparent)), - child: Text(zulipLocalizations.dialogClose, + child: Text(zulipLocalizations.dialogCancel, style: const TextStyle(fontSize: 20, height: 30 / 20))), ])), Expanded(child: InsetShadowBox( @@ -614,3 +626,355 @@ class EmojiPickerListEntry extends StatelessWidget { )); } } + +/// Opens a bottom sheet showing who reacted to the message. +void showViewReactionsSheet(BuildContext pageContext, { + required int messageId, + ReactionType? initialReactionType, + String? initialEmojiCode, +}) { + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + + showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ViewReactions(pageContext, + messageId: messageId, + initialEmojiCode: initialEmojiCode, + initialReactionType: initialReactionType))); + }); +} + +class ViewReactions extends StatefulWidget { + const ViewReactions(this.pageContext, { + super.key, + required this.messageId, + this.initialReactionType, + this.initialEmojiCode, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType? initialReactionType; + final String? initialEmojiCode; + + @override + State createState() => _ViewReactionsState(); +} + +class _ViewReactionsState extends State with PerAccountStoreAwareStateMixin { + ReactionType? reactionType; + String? emojiCode; + + PerAccountStore? store; + + void _setSelection(ReactionWithVotes? selection) { + setState(() { + reactionType = selection?.reactionType; + emojiCode = selection?.emojiCode; + }); + } + + void _storeChanged() { + _reconcile(); + } + + /// Check that the given reaction still has votes; + /// if not, select a different one if possible or clear the selection. + void _reconcile() { + final message = PerAccountStoreWidget.of(context).messages[widget.messageId]; + + final reactions = message?.reactions?.aggregated; + + if (reactions == null || reactions.isEmpty) { + _setSelection(null); + return; + } + + final selectedReaction = reactions.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode); + + // TODO scroll into view + _setSelection(selectedReaction + // first item will exist; early-return above on reactions.isEmpty + ?? reactions.first); + } + + @override + void onNewStore() { + store?.removeListener(_storeChanged); + store = PerAccountStoreWidget.of(context); + store!.addListener(_storeChanged); + if (reactionType == null && widget.initialReactionType != null) { + assert(emojiCode == null); + assert(widget.initialEmojiCode != null); + reactionType = widget.initialReactionType!; + emojiCode = widget.initialEmojiCode!; + } + _reconcile(); + } + + @override + Widget build(BuildContext context) { + // TODO could pull out this layout/appearance code, + // focusing this widget only on state management + return SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ViewReactionsHeader(widget.pageContext, + messageId: widget.messageId, + reactionType: reactionType, + emojiCode: emojiCode, + onRequestSelect: (r) => _setSelection(r), + ), + // TODO if all reactions (or whole message) disappeared, + // we show a message saying there are no reactions, + // but the layout shifts (the sheet's height changes dramatically); + // we should avoid this. + if (reactionType != null && emojiCode != null) Flexible( + child: ViewReactionsUserList(widget.pageContext, + messageId: widget.messageId, + reactionType: reactionType!, + emojiCode: emojiCode!)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ])); + } +} + +class ViewReactionsHeader extends StatelessWidget { + const ViewReactionsHeader( + this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + required this.onRequestSelect, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType? reactionType; + final String? emojiCode; + final void Function(ReactionWithVotes) onRequestSelect; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final message = PerAccountStoreWidget.of(context).messages[messageId]; + + final reactionsWithVotes = message?.reactions?.aggregated; + + if (reactionsWithVotes == null || reactionsWithVotes.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: BottomSheetHeaderPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: InsetShadowBox(start: 8, end: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: reactionsWithVotes.map((r) => + _ViewReactionsEmojiItem( + reactionWithVotes: r, + selected: r.reactionType == reactionType && r.emojiCode == emojiCode, + onRequestSelect: () => onRequestSelect(r)), + ).toList()))))); + } +} + +class _ViewReactionsEmojiItem extends StatelessWidget { + const _ViewReactionsEmojiItem({ + required this.reactionWithVotes, + required this.selected, + required this.onRequestSelect, + }); + + final ReactionWithVotes reactionWithVotes; + final bool selected; + final void Function() onRequestSelect; + + static const double emojiSize = 24; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final count = reactionWithVotes.userIds.length; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: reactionWithVotes.reactionType, + emojiCode: reactionWithVotes.emojiCode, + emojiName: reactionWithVotes.emojiName); + + // Don't use a :text_emoji:-style display here. + final placeholder = SizedBox.fromSize(size: Size.square(emojiSize)); + + // TODO make a helper widget for this + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => UnicodeEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay), + ImageEmojiDisplay() => ImageEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder), + TextEmojiDisplay() => placeholder, + }; + + return Tooltip( + message: reactionWithVotes.emojiName, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onRequestSelect, + child: Container( + decoration: BoxDecoration( + border: Border.all( + // This border seems to affect the layout + // (I thought it was normally paint-only?), + // so always include a border, so positions don't shift. + color: selected + ? designVariables.borderBar + : Colors.transparent), + borderRadius: BorderRadius.circular(10), + color: selected ? designVariables.background : null, + ), + padding: EdgeInsets.fromLTRB(14, 4.5, 14, 4.5), + child: Center( + child: Column( + spacing: 3, + mainAxisSize: MainAxisSize.min, + children: [ + emoji, + Text( + style: TextStyle( + color: designVariables.title, + fontSize: 14, + height: 14 / 14), + count.toString()) // TODO(i18n) number formatting? + ]))))); + } +} + + +@visibleForTesting +class ViewReactionsUserList extends StatelessWidget { + const ViewReactionsUserList(this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType reactionType; + final String emojiCode; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final message = store.messages[messageId]; + + final userIds = message?.reactions?.aggregated.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode + )?.userIds.toList(); + + // (No filtering of muted or deactivated users.) + + if (userIds == null) { + // This reaction lost all its votes, or the message was deleted. + return SizedBox.shrink(); + } + + // TODO sort userIds? + + return InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SizedBox( + height: 400, // TODO(design) tune + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: userIds.length, + itemBuilder: (context, index) => + ViewReactionsUserItem(context, userId: userIds[index])))); + } +} + +@visibleForTesting +class ViewReactionsUserItem extends StatelessWidget { + const ViewReactionsUserItem(this.pageContext, { + super.key, + required this.userId, + }); + + final BuildContext pageContext; + final int userId; + + void _onPressed() { + // Dismiss the action sheet. + Navigator.pop(pageContext); + + Navigator.push(pageContext, + ProfilePage.buildRoute(context: pageContext, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: _onPressed, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index dd269e03f7..c57fca7b22 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -324,7 +324,8 @@ void _showMainMenu(BuildContext context, { child: AnimatedScaleOnTap( scaleEnd: 0.95, duration: Duration(milliseconds: 100), - child: ActionSheetCancelButton())), + child: BottomSheetDismissButton( + style: BottomSheetDismissButtonStyle.close))), ]))); }); } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b5c424b0b..1b8457a9f7 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -141,41 +141,44 @@ abstract final class ZulipIcons { /// The Zulip custom icon "search". static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "see_who_reacted". + static const IconData see_who_reacted = IconData(0xf128, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "send". - static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inset_shadow.dart b/lib/widgets/inset_shadow.dart index a4133ac7de..c5da27a934 100644 --- a/lib/widgets/inset_shadow.dart +++ b/lib/widgets/inset_shadow.dart @@ -17,6 +17,8 @@ class InsetShadowBox extends StatelessWidget { super.key, this.top = 0, this.bottom = 0, + this.start = 0, + this.end = 0, required this.color, required this.child, }); @@ -31,7 +33,17 @@ class InsetShadowBox extends StatelessWidget { /// This does not pad the child widget. final double bottom; - /// The shadow color to fade into transparency from the top and bottom borders. + /// The distance that the shadow from the child's start edge grows endwards. + /// + /// This does not pad the child widget. + final double start; + + /// The distance that the shadow from the child's end edge grows startwards. + /// + /// This does not pad the child widget. + final double end; + + /// The shadow color to fade into transparency from the edges, inward. final Color color; final Widget child; @@ -54,6 +66,10 @@ class InsetShadowBox extends StatelessWidget { child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))), Positioned(bottom: 0, height: bottom, left: 0, right: 0, child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))), + PositionedDirectional(start: 0, width: start, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerStart))), + PositionedDirectional(end: 0, width: end, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerEnd))), ]); } } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..5169a24a9b 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -171,6 +171,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), + labelTime: const Color(0x00000000).withValues(alpha: 0.49), listMenuItemBg: const Color(0xffcbcdd6), listMenuItemIcon: const Color(0xff9194a3), listMenuItemText: const Color(0xff2d303c), @@ -259,6 +260,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), + labelTime: const Color(0xffffffff).withValues(alpha: 0.50), listMenuItemBg: const Color(0xff2d303c), listMenuItemIcon: const Color(0xff767988), listMenuItemText: const Color(0xffcbcdd6), @@ -355,6 +357,7 @@ class DesignVariables extends ThemeExtension { required this.labelEdited, required this.labelMenuButton, required this.labelSearchPrompt, + required this.labelTime, required this.listMenuItemBg, required this.listMenuItemIcon, required this.listMenuItemText, @@ -443,6 +446,7 @@ class DesignVariables extends ThemeExtension { final Color labelEdited; final Color labelMenuButton; final Color labelSearchPrompt; + final Color labelTime; final Color listMenuItemBg; final Color listMenuItemIcon; final Color listMenuItemText; @@ -526,6 +530,7 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? labelSearchPrompt, + Color? labelTime, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -604,6 +609,7 @@ class DesignVariables extends ThemeExtension { labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, + labelTime: labelTime ?? this.labelTime, listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, listMenuItemText: listMenuItemText ?? this.listMenuItemText, @@ -689,6 +695,7 @@ class DesignVariables extends ThemeExtension { labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, + labelTime: Color.lerp(labelTime, other.labelTime, t)!, listMenuItemBg: Color.lerp(listMenuItemBg, other.listMenuItemBg, t)!, listMenuItemIcon: Color.lerp(listMenuItemIcon, other.listMenuItemIcon, t)!, listMenuItemText: Color.lerp(listMenuItemText, other.listMenuItemText, t)!, diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 1b5c8ad8b5..619cf984b3 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -231,7 +231,7 @@ void main () { await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); - await tapButtonAndAwaitTransition(tester, find.text('Cancel')); + await tapButtonAndAwaitTransition(tester, find.text('Close')); await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); @@ -265,10 +265,10 @@ void main () { await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); }); - testWidgets('cancel button dismisses the menu', (tester) async { + testWidgets('close button dismisses the menu', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - await tapButtonAndAwaitTransition(tester, find.text('Cancel')); + await tapButtonAndAwaitTransition(tester, find.text('Close')); }); testWidgets('menu buttons dismiss the menu', (tester) async {