diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index d38427fc5e..b4cca7838c 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 0f762fc1cd..fad1fdfc79 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -140,6 +140,37 @@
"@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)."
+ },
+ "seeWhoReactedSheetHeaderLabel": "Emoji reactions ({num} total)",
+ "@seeWhoReactedSheetHeaderLabel": {
+ "description": "In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)",
+ "placeholders": {
+ "num": {"type": "int", "example": "2"}
+ }
+ },
+ "seeWhoReactedSheetEmojiNameWithVoteCount": "{emojiName}: {num, plural, =1{1 vote} other{{num} votes}}",
+ "@seeWhoReactedSheetEmojiNameWithVoteCount": {
+ "description": "In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)",
+ "placeholders": {
+ "emojiName": {"type": "String", "example": "working_on_it"},
+ "num": {"type": "int", "example": "2"}
+ }
+ },
+ "seeWhoReactedSheetUserListLabel": "Votes for {emojiName} ({num})",
+ "@seeWhoReactedSheetUserListLabel": {
+ "description": "In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)",
+ "placeholders": {
+ "emojiName": {"type": "String", "example": "working_on_it"},
+ "num": {"type": "int", "example": "2"}
+ }
+ },
"actionSheetOptionCopyMessageText": "Copy message text",
"@actionSheetOptionCopyMessageText": {
"description": "Label for copy message text button on action sheet."
@@ -973,6 +1004,25 @@
"@reactedEmojiSelfUser": {
"description": "Display name for the user themself, to show on an emoji reaction added by the user."
},
+ "reactionChipsLabel": "Reactions",
+ "@reactionChipsLabel": {
+ "description": "Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)"
+ },
+ "reactionChipLabel": "{emojiName}: {votes}",
+ "@reactionChipLabel": {
+ "description": "Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)",
+ "placeholders": {
+ "emojiName": {"type": "String", "example": "working_on_it"},
+ "votes": {"type": "String", "example": "You, Chris, Greg"}
+ }
+ },
+ "reactionChipVotesYouAndOthers": "{otherUsersCount, plural, =1{You and 1 other} other{You and {otherUsersCount} others}}",
+ "@reactionChipVotesYouAndOthers": {
+ "description": "The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)",
+ "placeholders": {
+ "otherUsersCount": {"type": "int", "example": "4"}
+ }
+ },
"onePersonTyping": "{typist} is typing…",
"@onePersonTyping": {
"description": "Text to display when there is one user typing.",
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 0432baa8f7..6e3d6d8be2 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -341,6 +341,36 @@ 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;
+
+ /// In the 'See who reacted' sheet, a label for the list of emoji reactions at the top, with the total number of reactions. (An accessibility label for assistive technology.)
+ ///
+ /// In en, this message translates to:
+ /// **'Emoji reactions ({num} total)'**
+ String seeWhoReactedSheetHeaderLabel(int num);
+
+ /// In the 'See who reacted' sheet, an emoji reaction's name and how many votes it has. (An accessibility label for assistive technology.)
+ ///
+ /// In en, this message translates to:
+ /// **'{emojiName}: {num, plural, =1{1 vote} other{{num} votes}}'**
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num);
+
+ /// In the 'See who reacted' sheet, a label for the list of users who chose an emoji reaction, with the emoji's name and how many votes it has. (An accessibility label for assistive technology.)
+ ///
+ /// In en, this message translates to:
+ /// **'Votes for {emojiName} ({num})'**
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num);
+
/// Label for copy message text button on action sheet.
///
/// In en, this message translates to:
@@ -1469,6 +1499,24 @@ abstract class ZulipLocalizations {
/// **'You'**
String get reactedEmojiSelfUser;
+ /// Text identifying the container of reaction chips on a message. (An accessibility label for assistive technology.)
+ ///
+ /// In en, this message translates to:
+ /// **'Reactions'**
+ String get reactionChipsLabel;
+
+ /// Text describing a reaction chip, with the emoji name and a list or number of votes. (An accessibility label for assistive technology.)
+ ///
+ /// In en, this message translates to:
+ /// **'{emojiName}: {votes}'**
+ String reactionChipLabel(String emojiName, String votes);
+
+ /// The number of votes on a reaction chip, where the self-user and at least one other user has voted. (An accessibility label for assistive technology.)
+ ///
+ /// In en, this message translates to:
+ /// **'{otherUsersCount, plural, =1{You and 1 other} other{You and {otherUsersCount} others}}'**
+ String reactionChipVotesYouAndOthers(int otherUsersCount);
+
/// Text to display when there is one user typing.
///
/// 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 00a08887a2..cca05f41e2 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -121,6 +121,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Copy message text';
@@ -796,6 +823,25 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist is typing…';
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index a769a6e862..a5ffc40f1d 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -124,6 +124,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren';
@@ -816,6 +843,25 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'Du';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist tippt…';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index f85fff71dc..6191c23018 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -121,6 +121,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Copy message text';
@@ -796,6 +823,25 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist is typing…';
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 46c3aa2885..5a922da313 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -121,6 +121,33 @@ class ZulipLocalizationsFr 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Copy message text';
@@ -796,6 +823,25 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist is typing…';
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index dc0db573a4..f330fd6f59 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -123,6 +123,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio';
@@ -811,6 +838,25 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'Tu';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist sta scrivendo…';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 4fe6d68cd4..0a37c8a874 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -119,6 +119,33 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get errorUnresolveTopicFailedTitle => 'トピックを未解決にできませんでした';
+ @override
+ String get actionSheetOptionSeeWhoReacted => 'See who reacted';
+
+ @override
+ String get seeWhoReactedSheetNoReactions => 'This message has no reactions.';
+
+ @override
+ String seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'メッセージ本文をコピー';
@@ -793,6 +820,25 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist is typing…';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 2ec302486c..66e62b13dd 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -121,6 +121,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Copy message text';
@@ -796,6 +823,25 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist is typing…';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index a084d59ae3..c675e00354 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -124,6 +124,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości';
@@ -806,6 +833,25 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'Ty';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist coś pisze…';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 198d1b594f..655a2a07db 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -124,6 +124,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения';
@@ -810,6 +837,25 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'Вы';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist набирает сообщение…';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index d7b1580c21..205bab746b 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -121,6 +121,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Skopírovať text správy';
@@ -798,6 +825,25 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist píše…';
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 4102e374fe..6cc6780e46 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -122,6 +122,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila';
@@ -821,6 +848,25 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'Vi';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist tipka…';
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 8b682b7a92..9e25589959 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -125,6 +125,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення';
@@ -809,6 +836,25 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'Ви';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist друкує…';
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 3a3ce62aab..db563a0434 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -121,6 +121,33 @@ 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 seeWhoReactedSheetHeaderLabel(int num) {
+ return 'Emoji reactions ($num total)';
+ }
+
+ @override
+ String seeWhoReactedSheetEmojiNameWithVoteCount(String emojiName, int num) {
+ String _temp0 = intl.Intl.pluralLogic(
+ num,
+ locale: localeName,
+ other: '$num votes',
+ one: '1 vote',
+ );
+ return '$emojiName: $_temp0';
+ }
+
+ @override
+ String seeWhoReactedSheetUserListLabel(String emojiName, int num) {
+ return 'Votes for $emojiName ($num)';
+ }
+
@override
String get actionSheetOptionCopyMessageText => 'Copy message text';
@@ -796,6 +823,25 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
@override
String get reactedEmojiSelfUser => 'You';
+ @override
+ String get reactionChipsLabel => 'Reactions';
+
+ @override
+ String reactionChipLabel(String emojiName, String votes) {
+ return '$emojiName: $votes';
+ }
+
+ @override
+ String reactionChipVotesYouAndOthers(int otherUsersCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ otherUsersCount,
+ locale: localeName,
+ other: 'You and $otherUsersCount others',
+ one: 'You and 1 other',
+ );
+ return '$_temp0';
+ }
+
@override
String onePersonTyping(String typist) {
return '$typist is typing…';
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 73676b598e..92ac06acd9 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -92,12 +92,40 @@ 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 +188,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 +218,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.
@@ -647,6 +693,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes
final popularEmojiLoaded = store.popularEmojiCandidates().isNotEmpty;
+ final reactions = message.reactions;
+ final hasReactions = reactions != null && reactions.total > 0;
+
// The UI that's conditioned on this won't live-update during this appearance
// of the action sheet (we avoid calling composeBoxControllerOf in a build
// method; see its doc).
@@ -664,6 +713,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
final optionButtons = [
if (popularEmojiLoaded)
ReactionButtons(message: message, pageContext: pageContext),
+ if (hasReactions)
+ ViewReactionsButton(message: message, pageContext: pageContext),
StarButton(message: message, pageContext: pageContext),
if (isComposeBoxOffered)
QuoteAndReplyButton(message: message, pageContext: pageContext),
@@ -898,6 +949,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 cca31ccc07..1f7d9c1872 100644
--- a/lib/widgets/emoji_reaction.dart
+++ b/lib/widgets/emoji_reaction.dart
@@ -1,3 +1,6 @@
+import 'dart:ui';
+
+import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import '../api/exception.dart';
@@ -6,13 +9,18 @@ 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 'dialog.dart';
import 'emoji.dart';
import 'inset_shadow.dart';
+import 'page.dart';
+import 'profile.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';
+import 'user.dart';
/// Emoji-reaction styles that differ between light and dark themes.
class EmojiReactionTheme extends ThemeExtension {
@@ -120,15 +128,22 @@ class ReactionChipsList extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
final store = PerAccountStoreWidget.of(context);
final displayEmojiReactionUsers = store.userSettings.displayEmojiReactionUsers ?? false;
final showNames = displayEmojiReactionUsers && reactions.total <= 3;
- return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,
+ Widget result = Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,
children: reactions.aggregated.map((reactionVotes) => ReactionChip(
showName: showNames,
messageId: messageId, reactionWithVotes: reactionVotes),
).toList());
+
+ return Semantics(
+ label: zulipLocalizations.reactionChipsLabel,
+ container: true,
+ explicitChildNodes: true,
+ child: result);
}
}
@@ -144,6 +159,23 @@ class ReactionChip extends StatelessWidget {
required this.reactionWithVotes,
});
+ // Linear in the number of voters (of course);
+ // best to avoid calling this unless we know there are few voters.
+ String _voterNames(PerAccountStore store, ZulipLocalizations zulipLocalizations) {
+ final selfUserId = store.selfUserId;
+ final userIds = reactionWithVotes.userIds;
+ final result = [];
+ if (userIds.contains(selfUserId)) {
+ // Putting "You" first is helpful when this is used in the semantics label.
+ result.add(zulipLocalizations.reactedEmojiSelfUser);
+ }
+ result.addAll(userIds.whereNot((userId) => userId == selfUserId).map(store.userDisplayName));
+ // TODO(i18n): List formatting, like you can do in JavaScript:
+ // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu'])
+ // // 'Chris、Greg、Alya、Shu'
+ return result.join(', ');
+ }
+
@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
@@ -155,16 +187,23 @@ class ReactionChip extends StatelessWidget {
final userIds = reactionWithVotes.userIds;
final selfVoted = userIds.contains(store.selfUserId);
- final label = showName
- // TODO(i18n): List formatting, like you can do in JavaScript:
- // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu'])
- // // 'Chris、Greg、Alya、Shu'
- ? userIds.map((id) {
- return id == store.selfUserId
+ final String label;
+ final String semanticsLabel;
+ if (showName) {
+ final names = _voterNames(store, zulipLocalizations);
+ label = names;
+ semanticsLabel = zulipLocalizations.reactionChipLabel(emojiName, names);
+ } else {
+ final count = userIds.length;
+ final countStr = count.toString(); // TODO(i18n) number formatting?
+ label = countStr;
+ semanticsLabel = zulipLocalizations.reactionChipLabel(emojiName,
+ selfVoted
+ ? count == 1
? zulipLocalizations.reactedEmojiSelfUser
- : store.userDisplayName(id);
- }).join(', ')
- : userIds.length.toString();
+ : zulipLocalizations.reactionChipVotesYouAndOthers(count - 1)
+ : countStr);
+ }
final reactionTheme = EmojiReactionTheme.of(context);
final borderColor = selfVoted ? reactionTheme.borderSelected : reactionTheme.borderUnselected;
@@ -194,74 +233,81 @@ class ReactionChip extends StatelessWidget {
emojiDisplay: emojiDisplay, selected: selfVoted),
};
- return Tooltip(
- // TODO(#434): Semantics with eg "Reaction: ; you and N others: "
- excludeFromSemantics: true,
- message: emojiName,
- child: Material(
- color: backgroundColor,
- shape: shape,
- child: InkWell(
- customBorder: shape,
- splashColor: splashColor,
- highlightColor: highlightColor,
- onTap: () {
- (selfVoted ? removeReaction : addReaction).call(store.connection,
- messageId: messageId,
- reactionType: reactionType,
- emojiCode: emojiCode,
- emojiName: emojiName,
- );
- },
- child: Padding(
- // 1px of this padding accounts for the border, which Flutter
- // just paints without changing size.
- padding: const EdgeInsetsDirectional.fromSTEB(4, 3, 5, 3),
- child: LayoutBuilder(
- builder: (context, constraints) {
- final maxRowWidth = constraints.maxWidth;
- // To give text emojis some room so they need fewer line breaks
- // when the label is long.
- // TODO(#433) This is a bit overzealous. The shorter width
- // won't be necessary when the text emoji is very short, or
- // in the near-universal case of small, square emoji (i.e.
- // Unicode and image emoji). But it's not simple to recognize
- // those cases here: we don't know at this point whether we'll
- // be showing a text emoji, because we use that for various
- // error conditions (including when an image fails to load,
- // which we learn about especially late).
- final maxLabelWidth = (maxRowWidth - 6) * 0.75; // 6 is padding
-
- final labelScaler = _labelTextScalerClamped(context);
- return Row(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- // So text-emoji chips are at least as tall as square-emoji
- // ones (probably a good thing).
- SizedBox(height: _squareEmojiScalerClamped(context).scale(_squareEmojiSize)),
- Flexible( // [Flexible] to let text emojis expand if they can
- child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3),
- child: emoji)),
- Padding(padding: const EdgeInsets.symmetric(horizontal: 3),
- child: Container(
- constraints: BoxConstraints(maxWidth: maxLabelWidth),
- child: Text(
- textWidthBasis: TextWidthBasis.longestLine,
- textScaler: labelScaler,
- style: TextStyle(
- fontSize: (14 * 0.90),
- letterSpacing: proportionalLetterSpacing(context,
- kButtonTextLetterSpacingProportion,
- baseFontSize: (14 * 0.90),
- textScaler: labelScaler),
- height: 13 / (14 * 0.90),
- color: labelColor,
- ).merge(weightVariableTextStyle(context,
- wght: selfVoted ? 600 : null)),
- label))),
- ]);
- })))));
+ Widget result = Material(
+ color: backgroundColor,
+ shape: shape,
+ child: InkWell(
+ 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,
+ reactionType: reactionType,
+ emojiCode: emojiCode,
+ emojiName: emojiName,
+ );
+ },
+ child: Padding(
+ // 1px of this padding accounts for the border, which Flutter
+ // just paints without changing size.
+ padding: const EdgeInsetsDirectional.fromSTEB(4, 3, 5, 3),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final maxRowWidth = constraints.maxWidth;
+ // To give text emojis some room so they need fewer line breaks
+ // when the label is long.
+ // TODO(#433) This is a bit overzealous. The shorter width
+ // won't be necessary when the text emoji is very short, or
+ // in the near-universal case of small, square emoji (i.e.
+ // Unicode and image emoji). But it's not simple to recognize
+ // those cases here: we don't know at this point whether we'll
+ // be showing a text emoji, because we use that for various
+ // error conditions (including when an image fails to load,
+ // which we learn about especially late).
+ final maxLabelWidth = (maxRowWidth - 6) * 0.75; // 6 is padding
+
+ final labelScaler = _labelTextScalerClamped(context);
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // So text-emoji chips are at least as tall as square-emoji
+ // ones (probably a good thing).
+ SizedBox(height: _squareEmojiScalerClamped(context).scale(_squareEmojiSize)),
+ Flexible( // [Flexible] to let text emojis expand if they can
+ child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3),
+ child: emoji)),
+ Padding(padding: const EdgeInsets.symmetric(horizontal: 3),
+ child: Container(
+ constraints: BoxConstraints(maxWidth: maxLabelWidth),
+ child: Text(
+ textWidthBasis: TextWidthBasis.longestLine,
+ textScaler: labelScaler,
+ style: TextStyle(
+ fontSize: (14 * 0.90),
+ letterSpacing: proportionalLetterSpacing(context,
+ kButtonTextLetterSpacingProportion,
+ baseFontSize: (14 * 0.90),
+ textScaler: labelScaler),
+ height: 13 / (14 * 0.90),
+ color: labelColor,
+ ).merge(weightVariableTextStyle(context,
+ wght: selfVoted ? 600 : null)),
+ label))),
+ ]);
+ }))));
+
+ return Semantics(
+ label: semanticsLabel,
+ container: true,
+ child: ExcludeSemantics(child: result));
}
}
@@ -512,7 +558,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(
@@ -595,3 +641,447 @@ 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(
+ messageId: messageId,
+ initialEmojiCode: initialEmojiCode,
+ initialReactionType: initialReactionType)));
+ });
+}
+
+class ViewReactions extends StatefulWidget {
+ const ViewReactions({
+ super.key,
+ required this.messageId,
+ this.initialReactionType,
+ this.initialEmojiCode,
+ });
+
+ final int messageId;
+ final ReactionType? initialReactionType;
+ final String? initialEmojiCode;
+
+ @override
+ State createState() => _ViewReactionsState();
+}
+
+class _ViewReactionsState extends State with PerAccountStoreAwareStateMixin {
+ ReactionType? reactionType;
+ String? emojiCode;
+ String? emojiName;
+
+ PerAccountStore? store;
+
+ void _setSelection(ReactionWithVotes? selection) {
+ setState(() {
+ reactionType = selection?.reactionType;
+ emojiCode = selection?.emojiCode;
+ emojiName = selection?.emojiName;
+ });
+ }
+
+ 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() {
+ // TODO scroll into view
+ _setSelection(_findMatchingReaction());
+ }
+
+ ReactionWithVotes? _findMatchingReaction() {
+ final message = PerAccountStoreWidget.of(context).messages[widget.messageId];
+
+ final reactions = message?.reactions?.aggregated;
+
+ if (reactions == null || reactions.isEmpty) {
+ return null;
+ }
+
+ return reactions
+ .firstWhereOrNull((x) =>
+ x.reactionType == reactionType && x.emojiCode == emojiCode)
+ // first item will exist; early-return above on reactions.isEmpty
+ ?? reactions.first;
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.initialReactionType != null) {
+ assert(widget.initialEmojiCode != null);
+ reactionType = widget.initialReactionType!;
+ emojiCode = widget.initialEmojiCode!;
+ }
+ }
+
+ @override
+ void onNewStore() {
+ // TODO(#1747) listen for changes in the message's reactions
+ store?.removeListener(_storeChanged);
+ store = PerAccountStoreWidget.of(context);
+ store!.addListener(_storeChanged);
+ _reconcile();
+ }
+
+ @override
+ void dispose() {
+ store?.removeListener(_storeChanged);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // TODO could pull out this layout/appearance code,
+ // focusing this widget only on state management
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ ViewReactionsHeader(
+ messageId: widget.messageId,
+ reactionType: reactionType,
+ emojiCode: emojiCode,
+ onRequestSelect: _setSelection,
+ ),
+ // 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(
+ messageId: widget.messageId,
+ reactionType: reactionType!,
+ emojiCode: emojiCode!,
+ emojiName: emojiName!)),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close))
+ ]);
+ }
+}
+
+class ViewReactionsHeader extends StatelessWidget {
+ const ViewReactionsHeader({
+ super.key,
+ required this.messageId,
+ required this.reactionType,
+ required this.emojiCode,
+ required this.onRequestSelect,
+ });
+
+ final int messageId;
+ final ReactionType? reactionType;
+ final String? emojiCode;
+ final void Function(ReactionWithVotes) onRequestSelect;
+
+ /// A [double] between 0.0 and 1.0 for an emoji's position in the list.
+ ///
+ /// When auto-scrolling an emoji into view,
+ /// this is where the scroll position will land
+ /// (the min- and max- scroll extent lerped at this value).
+ double _emojiItemPosition(int index, int aggregatedLength) {
+ if (aggregatedLength == 1) {
+ assert(index == 0);
+ return 0.5;
+ }
+ return index / (aggregatedLength - 1);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final message = PerAccountStoreWidget.of(context).messages[messageId];
+
+ final reactions = message?.reactions;
+
+ if (reactions == null || reactions.aggregated.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(
+ // TODO(upstream) we want to pass excludeFromSemantics: true
+ // to the underlying Scrollable to remove an unwanted node
+ // in accessibility focus traversal when there are many items.
+ scrollDirection: Axis.horizontal,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: Semantics(
+ role: SemanticsRole.tabBar,
+ container: true,
+ explicitChildNodes: true,
+ label: zulipLocalizations.seeWhoReactedSheetHeaderLabel(reactions.total),
+ child: Row(
+ children: reactions.aggregated.mapIndexed((i, r) =>
+ _ViewReactionsEmojiItem(
+ reactionWithVotes: r,
+ position: _emojiItemPosition(i, reactions.aggregated.length),
+ selected: r.reactionType == reactionType && r.emojiCode == emojiCode,
+ onRequestSelect: onRequestSelect),
+ ).toList()))))));
+ }
+}
+
+class _ViewReactionsEmojiItem extends StatelessWidget {
+ const _ViewReactionsEmojiItem({
+ required this.reactionWithVotes,
+ required this.position,
+ required this.selected,
+ required this.onRequestSelect,
+ });
+
+ final ReactionWithVotes reactionWithVotes;
+ final double position;
+ final bool selected;
+ final void Function(ReactionWithVotes) onRequestSelect;
+
+ static const double emojiSize = 24;
+
+ /// Animates the list's scroll position for this item.
+ ///
+ /// This serves two purposes when the list is longer than the viewport width:
+ /// - Ensures the item is in view
+ /// - By animating, draws attention to the fact that this is a scrollable list
+ /// and there may be more items in view. (In particular, does this when
+ /// any item is tapped, because each item has a different [position].)
+ void _scrollIntoView(BuildContext context) {
+ final scrollPosition = Scrollable.of(context, axis: Axis.horizontal).position;
+ final destination = lerpDouble(
+ scrollPosition.minScrollExtent,
+ scrollPosition.maxScrollExtent,
+ position)!;
+
+ scrollPosition.animateTo(destination,
+ duration: Duration(milliseconds: 200),
+ curve: Curves.ease);
+ }
+
+ void _handleTap(BuildContext context) {
+ _scrollIntoView(context);
+ onRequestSelect(reactionWithVotes);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final designVariables = DesignVariables.of(context);
+ final store = PerAccountStoreWidget.of(context);
+ final count = reactionWithVotes.userIds.length;
+
+ final emojiName = reactionWithVotes.emojiName;
+ final emojiDisplay = store.emojiDisplayFor(
+ emojiType: reactionWithVotes.reactionType,
+ emojiCode: reactionWithVotes.emojiCode,
+ emojiName: emojiName);
+
+ // Don't use a :text_emoji:-style display here.
+ final placeholder = SizedBox.square(dimension: 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,
+ };
+
+ Widget result = Tooltip(
+ message: emojiName,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => _handleTap(context),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ border: selected
+ ? Border.all(color: designVariables.borderBar)
+ : null,
+ borderRadius: BorderRadius.circular(10),
+ color: selected ? designVariables.background : null,
+ ),
+ child: Padding(
+ 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?
+ ])),
+ ))));
+
+ return Semantics(
+ role: SemanticsRole.tab,
+ onDidGainAccessibilityFocus: () => _scrollIntoView(context),
+
+ // I *think* we're following the doc with this but it's hard to tell;
+ // I've only tested on iOS and I didn't notice a behavior change.
+ controlsNodes: {ViewReactionsUserList.semanticsIdentifier},
+
+ selected: selected,
+ label: zulipLocalizations.seeWhoReactedSheetEmojiNameWithVoteCount(emojiName, count),
+ onTap: () => _handleTap(context),
+ child: ExcludeSemantics(
+ child: result));
+ }
+}
+
+
+@visibleForTesting
+class ViewReactionsUserList extends StatelessWidget {
+ const ViewReactionsUserList({
+ super.key,
+ required this.messageId,
+ required this.reactionType,
+ required this.emojiCode,
+ required this.emojiName,
+ });
+
+ final int messageId;
+ final ReactionType reactionType;
+ final String emojiCode;
+ final String emojiName;
+
+ static const semanticsIdentifier = 'view-reactions-user-list';
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(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.
+ // Muted users will be shown as muted.)
+
+ if (userIds == null) {
+ // This reaction lost all its votes, or the message was deleted.
+ return SizedBox.shrink();
+ }
+
+ Widget result = SizedBox(
+ height: 400, // TODO(design) tune
+ child: InsetShadowBox(
+ top: 8,
+ bottom: 8,
+ color: designVariables.bgContextMenu,
+ // TODO(upstream) we want to pass excludeFromSemantics: true
+ // to the underlying Scrollable to remove an unwanted node
+ // in accessibility focus traversal when there are many items.
+ child: ListView.builder(
+ padding: EdgeInsets.only(
+ // The Figma excludes the 8px top padding, which is unusual with the
+ // shadow effect (our InsetShadowBox). We include it so that the
+ // first item's touch feedback is shadow-free in the item's initial/
+ // scrolled-to-top position.
+ top: 8,
+ bottom: 8,
+ ),
+ itemCount: userIds.length,
+ itemBuilder: (_, index) =>
+ ViewReactionsUserItem(userId: userIds[index]))));
+
+ return Semantics(
+ identifier: semanticsIdentifier, // See note on `controlsNodes` on the tab.
+ label: zulipLocalizations.seeWhoReactedSheetUserListLabel(emojiName, userIds.length),
+ role: SemanticsRole.tabPanel,
+ container: true,
+ child: result);
+ }
+}
+
+@visibleForTesting
+class ViewReactionsUserItem extends StatelessWidget {
+ const ViewReactionsUserItem({
+ super.key,
+ required this.userId,
+ });
+
+ final int userId;
+
+ void _onPressed(BuildContext context) {
+ // Dismiss the action sheet.
+ Navigator.pop(context);
+
+ Navigator.push(context,
+ ProfilePage.buildRoute(context: context, userId: userId));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final store = PerAccountStoreWidget.of(context);
+ final designVariables = DesignVariables.of(context);
+
+ return InkWell(
+ onTap: () => _onPressed(context),
+ splashFactory: NoSplash.splashFactory,
+ overlayColor: WidgetStateColor.fromMap({
+ WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20),
+ }),
+ 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 a1dea0dff8..62c09c0857 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 1ba94dc389..a8180cd9ad 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -147,41 +147,44 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "search".
static const IconData search = IconData(0xf129, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "see_who_reacted".
+ static const IconData see_who_reacted = IconData(0xf12a, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "send".
- static const IconData send = IconData(0xf12a, fontFamily: "Zulip Icons");
+ static const IconData send = IconData(0xf12b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "settings".
- static const IconData settings = IconData(0xf12b, fontFamily: "Zulip Icons");
+ static const IconData settings = IconData(0xf12c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf12c, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf12d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf12d, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf12e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf12e, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf12f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf12f, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf130, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf130, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf131, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf131, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf132, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf132, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf133, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topics".
- static const IconData topics = IconData(0xf133, fontFamily: "Zulip Icons");
+ static const IconData topics = IconData(0xf134, fontFamily: "Zulip Icons");
/// The Zulip custom icon "two_person".
- static const IconData two_person = IconData(0xf134, fontFamily: "Zulip Icons");
+ static const IconData two_person = IconData(0xf135, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf135, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf136, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/inset_shadow.dart b/lib/widgets/inset_shadow.dart
index a4133ac7de..3c2533f1d8 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;
@@ -50,10 +62,14 @@ class InsetShadowBox extends StatelessWidget {
fit: StackFit.passthrough,
children: [
child,
- Positioned(top: 0, height: top, left: 0, right: 0,
+ if (top != 0) Positioned(top: 0, height: top, left: 0, right: 0,
child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))),
- Positioned(bottom: 0, height: bottom, left: 0, right: 0,
+ if (bottom != 0) Positioned(bottom: 0, height: bottom, left: 0, right: 0,
child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))),
+ if (start != 0) PositionedDirectional(start: 0, width: start, top: 0, bottom: 0,
+ child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerStart))),
+ if (end != 0) 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 8e70f28e3c..e66a9fc535 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),
@@ -260,6 +261,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),
@@ -358,6 +360,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,
@@ -447,6 +450,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;
@@ -531,6 +535,7 @@ class DesignVariables extends ThemeExtension {
Color? labelEdited,
Color? labelMenuButton,
Color? labelSearchPrompt,
+ Color? labelTime,
Color? listMenuItemBg,
Color? listMenuItemIcon,
Color? listMenuItemText,
@@ -610,6 +615,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,
@@ -696,6 +702,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/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 561c136473..d65ac20507 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -26,6 +26,7 @@ import 'package:zulip/widgets/app_bar.dart';
import 'package:zulip/widgets/button.dart';
import 'package:zulip/widgets/compose_box.dart';
import 'package:zulip/widgets/content.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';
@@ -50,6 +51,7 @@ import 'test_app.dart';
late PerAccountStore store;
late FakeApiConnection connection;
+late TransitionDurationObserver transitionDurationObserver;
/// Simulates loading a [MessageListPage] and long-pressing on [message].
Future setupToMessageActionSheet(WidgetTester tester, {
@@ -98,9 +100,13 @@ Future setupToMessageActionSheet(WidgetTester tester, {
: eg.serverEmojiDataPopular);
}
+ transitionDurationObserver = TransitionDurationObserver();
+
connection.prepare(json: eg.newestGetMessagesResult(
foundOldest: true, messages: [message]).toJson());
- await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id,
+ await tester.pumpWidget(TestZulipApp(
+ accountId: selfAccount.id,
+ navigatorObservers: [transitionDurationObserver],
child: MessageListPage(initNarrow: narrow)));
// global store, per-account store, and message list get loaded
@@ -1108,6 +1114,47 @@ void main() {
}
});
+ group('ViewReactionsButton', () {
+ final findButtonInSheet = find.descendant(
+ of: find.byType(BottomSheet),
+ matching: find.byIcon(ZulipIcons.see_who_reacted));
+
+ testWidgets('not visible if message has no reactions', (tester) async {
+ final message = eg.streamMessage(reactions: []);
+ await setupToMessageActionSheet(tester,
+ message: message, narrow: CombinedFeedNarrow());
+
+ check(findButtonInSheet).findsNothing();
+ });
+
+ Future tapButton(WidgetTester tester) async {
+ await tester.ensureVisible(findButtonInSheet);
+ await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+ await tester.tap(findButtonInSheet);
+ }
+
+ testWidgets('smoke', (tester) async {
+ final message = eg.streamMessage(reactions: [eg.unicodeEmojiReaction]);
+ await setupToMessageActionSheet(tester,
+ message: message, narrow: CombinedFeedNarrow());
+
+ await tapButton(tester);
+
+ // The message action sheet exits and the view-reactions sheet enters.
+ //
+ // This just pumps through twice the duration of the latest transition.
+ // Ideally we'd check that the two expected transitions were triggered
+ // and that they started at the same time, and pump through the
+ // longer of the two durations.
+ // TODO(upstream) support this in TransitionDurationObserver
+ await transitionDurationObserver.pumpPastTransition(tester);
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ check(findButtonInSheet).findsNothing(); // the message action sheet exited
+ check(find.byType(ViewReactions)).findsOne();
+ });
+ });
+
group('StarButton', () {
Future tapButton(WidgetTester tester, {bool starred = false}) async {
// Starred messages include the same icon so we need to
diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart
index 5948e6828c..027caf998b 100644
--- a/test/widgets/emoji_reaction_test.dart
+++ b/test/widgets/emoji_reaction_test.dart
@@ -1,9 +1,11 @@
import 'dart:io' as io;
import 'dart:io';
+import 'dart:ui';
import 'package:checks/checks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_checks/flutter_checks.dart';
@@ -40,6 +42,7 @@ void main() {
late PerAccountStore store;
late FakeApiConnection connection;
+ late TransitionDurationObserver transitionDurationObserver;
Future prepare() async {
addTearDown(testBinding.reset);
@@ -54,13 +57,36 @@ void main() {
await fontLoader.load();
}
+ // Base JSON for various unicode emoji reactions. Just missing user_id.
+ final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'};
+ final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'};
+ final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'};
+ final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'};
+ final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'};
+
+ // Base JSON for various realm-emoji reactions. Just missing user_id.
+ final i1 = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji'};
+ final i2 = {'emoji_name': 'threecents', 'emoji_code': '182', 'reaction_type': 'realm_emoji'};
+
+ // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id.
+ final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'};
+
+ String nameOf(Map jsonEmoji) => jsonEmoji['emoji_name']!;
+
Future setupChipsInBox(WidgetTester tester, {
required List reactions,
double width = 245.0, // (seen in context on an iPhone 13 Pro)
}) async {
final message = eg.streamMessage(reactions: reactions);
+ await store.addMessage(message);
+
+ tester.platformDispatcher.accessibilityFeaturesTestValue =
+ FakeAccessibilityFeatures(accessibleNavigation: true);
+ addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
+ transitionDurationObserver = TransitionDurationObserver();
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+ navigatorObservers: [transitionDurationObserver],
child: Center(
child: ColoredBox(
color: Colors.white,
@@ -76,6 +102,44 @@ void main() {
check(reactionChipsList).size.isNotNull().width.equals(width);
}
+ final findViewReactionsTabBar = find.semantics.byPredicate((node) =>
+ node.role == SemanticsRole.tabBar
+ && node.label.contains('Emoji reactions'));
+
+ FinderBase findViewReactionsEmojiItem(String emojiName) =>
+ find.semantics.descendant(
+ of: findViewReactionsTabBar,
+ matching: find.semantics.byPredicate(
+ (node) => node.role == SemanticsRole.tab && node.label.contains(emojiName)));
+
+ /// Checks that a given emoji item is present or absent in [ViewReactions].
+ ///
+ /// If the `expectFoo` fields are null, checks that the item is absent,
+ /// otherwise checks that it is present with the given details.
+ void checkViewReactionsEmojiItem(WidgetTester tester, {
+ required String emojiName,
+ required int? expectCount,
+ required bool? expectSelected,
+ }) {
+ assert((expectCount == null) == (expectSelected == null));
+ check(findViewReactionsTabBar).findsOne();
+
+ final nodes = findViewReactionsEmojiItem(emojiName).evaluate();
+ check(nodes).length.isLessThan(2);
+
+ if (expectCount == null) {
+ check(nodes).isEmpty();
+ } else {
+ final expectedLabel = switch (expectCount) {
+ 1 => '$emojiName: 1 vote',
+ _ => '$emojiName: $expectCount votes',
+ };
+ check(nodes).single.containsSemantics(
+ label: expectedLabel,
+ isSelected: expectSelected!);
+ }
+ }
+
group('ReactionChipsList', () {
// Smoke tests under various conditions.
for (final displayEmojiReactionUsers in [true, false]) {
@@ -159,20 +223,6 @@ void main() {
skip: io.Platform.isMacOS);
}
- // Base JSON for various unicode emoji reactions. Just missing user_id.
- final u1 = {'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji'};
- final u2 = {'emoji_name': 'family_man_man_girl_boy', 'emoji_code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'reaction_type': 'unicode_emoji'};
- final u3 = {'emoji_name': 'slight_smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'};
- final u4 = {'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'};
- final u5 = {'emoji_name': 'exploding_head', 'emoji_code': '1f92f', 'reaction_type': 'unicode_emoji'};
-
- // Base JSON for various realm-emoji reactions. Just missing user_id.
- final i1 = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji'};
- final i2 = {'emoji_name': 'threecents', 'emoji_code': '182', 'reaction_type': 'realm_emoji'};
-
- // Base JSON for the one "Zulip extra emoji" reaction. Just missing user_id.
- final z1 = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji'};
-
final user1 = eg.user(fullName: 'abc');
final user2 = eg.user(fullName: 'Long Name With Many Words In It');
final user3 = eg.user(fullName: 'longnamelongnamelongnamelongname');
@@ -249,6 +299,24 @@ void main() {
matching: find.text('Muted user, User 2')
)).findsOne();
});
+
+ testWidgets('show view-reactions sheet on long-press', (tester) async {
+ await prepare();
+ await store.addUser(eg.otherUser);
+
+ await setupChipsInBox(tester,
+ reactions: [
+ Reaction.fromJson({'user_id': eg.selfUser.userId, ...u1}),
+ Reaction.fromJson({'user_id': eg.otherUser.userId, ...u2}),
+ ]);
+
+ await tester.longPress(find.byType(ReactionChip).last);
+ await tester.pump();
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ checkViewReactionsEmojiItem(tester,
+ emojiName: nameOf(u2), expectCount: 1, expectSelected: true);
+ });
});
testWidgets('Smoke test for light/dark/lerped', (tester) async {
@@ -269,7 +337,8 @@ void main() {
Color? backgroundColor(String emojiName) {
final material = tester.widget(find.descendant(
- of: find.byTooltip(emojiName), matching: find.byType(Material)));
+ of: find.bySemanticsLabel(RegExp(r'^' + RegExp.escape(emojiName) + r':\ ')),
+ matching: find.byType(Material)));
return material.color;
}
@@ -581,4 +650,118 @@ void main() {
});
});
});
+
+ group('showViewReactionsSheet', () {
+ Future setupViewReactionsSheet(WidgetTester tester, {
+ required StreamMessage message,
+ List usersExcludingSelf = const [],
+ }) async {
+ assert(message.reactions != null && message.reactions!.total > 0);
+ addTearDown(testBinding.reset);
+
+ final httpClient = FakeImageHttpClient();
+ debugNetworkImageHttpClientProvider = () => httpClient;
+ httpClient.request.response
+ ..statusCode = HttpStatus.ok
+ ..content = kSolidBlueAvatar;
+
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ await store.addUsers([
+ eg.selfUser,
+ ...usersExcludingSelf,
+ ]);
+ final stream = eg.stream(streamId: message.streamId);
+ await store.addStream(stream);
+ await store.addSubscription(eg.subscription(stream));
+
+ transitionDurationObserver = TransitionDurationObserver();
+
+ connection = store.connection as FakeApiConnection;
+ connection.prepare(json: eg.newestGetMessagesResult(
+ foundOldest: true, messages: [message]).toJson());
+ await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+ navigatorObservers: [transitionDurationObserver],
+ child: MessageListPage(initNarrow: CombinedFeedNarrow())));
+
+ store.setServerEmojiData(eg.serverEmojiDataPopularPlus(
+ ServerEmojiData(codeToNames: {
+ '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data)
+ })));
+
+ // global store, per-account store, and message list get loaded
+ await tester.pumpAndSettle();
+
+ await tester.longPress(find.byType(MessageContent));
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: {
+ '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'),
+ }));
+
+ await tester.tap(find.byIcon(ZulipIcons.see_who_reacted));
+ await tester.pumpAndSettle();
+ await tester.ensureVisible(find.byType(ViewReactions));
+ }
+
+ void checkUserList(WidgetTester tester, String emojiName, List expectUsers) {
+ final findPanel = find.semantics.byPredicate((node) =>
+ node.role == SemanticsRole.tabPanel
+ && node.label.contains('Votes for $emojiName'));
+
+ final panel = findPanel.evaluate().single;
+ check(panel).containsSemantics(label: 'Votes for $emojiName (${expectUsers.length})');
+
+ for (final user in expectUsers) {
+ check(find.semantics.descendant(
+ of: findPanel,
+ matching: find.semantics.byLabel(user.fullName)),
+ because: 'expect ${user.fullName}').findsOne();
+ }
+ }
+
+ testWidgets('smoke', (tester) async {
+ final reactions = [
+ Reaction.fromJson({'user_id': eg.selfUser.userId, ...i1}),
+ Reaction.fromJson({'user_id': eg.selfUser.userId, ...z1}),
+ Reaction.fromJson({'user_id': eg.selfUser.userId, ...u1}),
+ Reaction.fromJson({'user_id': eg.selfUser.userId, ...u2}),
+
+ Reaction.fromJson({'user_id': eg.otherUser.userId, ...i1}),
+ Reaction.fromJson({'user_id': eg.otherUser.userId, ...z1}),
+ Reaction.fromJson({'user_id': eg.otherUser.userId, ...u2}),
+ Reaction.fromJson({'user_id': eg.otherUser.userId, ...u3}),
+ ];
+
+ final message = eg.streamMessage(reactions: reactions);
+ await setupViewReactionsSheet(tester, message: message, usersExcludingSelf: [eg.otherUser]);
+
+ checkViewReactionsEmojiItem(tester, emojiName: nameOf(i1), expectCount: 2, expectSelected: true);
+ checkViewReactionsEmojiItem(tester, emojiName: nameOf(z1), expectCount: 2, expectSelected: false);
+ checkViewReactionsEmojiItem(tester, emojiName: nameOf(u1), expectCount: 1, expectSelected: false);
+ checkViewReactionsEmojiItem(tester, emojiName: nameOf(u2), expectCount: 2, expectSelected: false);
+ checkViewReactionsEmojiItem(tester, emojiName: nameOf(u3), expectCount: 1, expectSelected: false);
+
+ checkUserList(tester, nameOf(i1), [eg.selfUser, eg.otherUser]);
+ tester.semantics.tap(findViewReactionsEmojiItem(nameOf(z1)));
+ await tester.pump();
+ checkUserList(tester, nameOf(z1), [eg.selfUser, eg.otherUser]);
+ tester.semantics.tap(findViewReactionsEmojiItem(nameOf(u1)));
+ await tester.pump();
+ checkUserList(tester, nameOf(u1), [eg.selfUser]);
+ tester.semantics.tap(findViewReactionsEmojiItem(nameOf(u3)));
+ await tester.pump();
+ checkUserList(tester, nameOf(u3), [eg.otherUser]);
+
+ // TODO(upstream) Do this in an addTearDown once we can:
+ // https://github.com/flutter/flutter/issues/123189
+ debugNetworkImageHttpClientProvider = null;
+ });
+
+ // TODO test last-vote-removed on selected emoji
+ // TODO test message deleted
+ // TODO test that tapping a user opens their profile
+ // TODO test emoji list's scroll-into-view logic
+ // TODO test expired event queue/refresh
+ });
}
diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart
index bf207155b7..5a8d3cca33 100644
--- a/test/widgets/home_test.dart
+++ b/test/widgets/home_test.dart
@@ -230,7 +230,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();
@@ -264,10 +264,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 {
diff --git a/test/widgets/inset_shadow_test.dart b/test/widgets/inset_shadow_test.dart
index a8e3d5f498..6051be47d2 100644
--- a/test/widgets/inset_shadow_test.dart
+++ b/test/widgets/inset_shadow_test.dart
@@ -16,7 +16,7 @@ void main() {
// to ease the check on [Rect] later.
alignment: Alignment.topLeft,
child: SizedBox(width: 20, height: 20,
- child: InsetShadowBox(top: 7, bottom: 3,
+ child: InsetShadowBox(top: 7, bottom: 3, start: 5, end: 6,
color: Colors.red,
child: SizedBox.shrink())))));
@@ -29,20 +29,20 @@ void main() {
check(childRect).equals(parentRect);
});
- testWidgets('render shadow correctly', (tester) async {
- PaintPatternPredicate paintGradient({required Rect rect}) {
- // This is inspired by
- // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475
- return (Symbol methodName, List arguments) {
- check(methodName).equals(#drawRect);
- check(arguments[0]).isA().equals(rect);
- // We can't further check [ui.Gradient] because it is opaque:
- // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487
- check(arguments[1]).isA().shader.isA();
- return true;
- };
- }
+ PaintPatternPredicate paintGradient({required Rect rect}) {
+ // This is inspired by
+ // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475
+ return (Symbol methodName, List arguments) {
+ check(methodName).equals(#drawRect);
+ check(arguments[0]).isA().equals(rect);
+ // We can't further check [ui.Gradient] because it is opaque:
+ // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487
+ check(arguments[1]).isA().shader.isA();
+ return true;
+ };
+ }
+ testWidgets('render shadow correctly: top/bottom', (tester) async {
await tester.pumpWidget(const Directionality(
textDirection: TextDirection.ltr,
child: Center(
@@ -61,4 +61,33 @@ void main() {
..something(paintGradient(rect: const Rect.fromLTRB(0, 100-7, 100, 100)))
) as Matcher);
});
+
+ final textDirectionVariant =
+ ValueVariant({TextDirection.ltr, TextDirection.rtl});
+
+ testWidgets('render shadow correctly: start/end', (tester) async {
+ final textDirection = textDirectionVariant.currentValue!;
+ await tester.pumpWidget(Directionality(
+ textDirection: textDirection,
+ child: Center(
+ // This would be forced to fill up the screen
+ // if not wrapped in a widget like [Center].
+ child: SizedBox(width: 100, height: 100,
+ child: InsetShadowBox(start: 3, end: 7,
+ color: Colors.red,
+ child: SizedBox(width: 30, height: 30))))));
+
+ final box = tester.renderObject(find.byType(InsetShadowBox));
+ check(box).legacyMatcher(
+ // The coordinate system of these [Rect]'s is relative to the parent
+ // of the [Gradient] from [InsetShadowBox], not the entire [FlutterView].
+ switch (textDirection) {
+ TextDirection.ltr => paints
+ ..something(paintGradient(rect: Rect.fromLTRB(0, 0, 0+3, 100)))
+ ..something(paintGradient(rect: Rect.fromLTRB(100-7, 0, 100, 100))),
+ TextDirection.rtl => paints
+ ..something(paintGradient(rect: Rect.fromLTRB(100-3, 0, 100, 100)))
+ ..something(paintGradient(rect: Rect.fromLTRB(0, 0, 0+7, 100))),
+ } as Matcher);
+ }, variant: textDirectionVariant);
}