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 {