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); }