diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a..80c2fae746 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/check_check.svg b/assets/icons/check_check.svg new file mode 100644 index 0000000000..3d7b4a59d6 --- /dev/null +++ b/assets/icons/check_check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/assets/icons/see_who_reacted.svg b/assets/icons/see_who_reacted.svg new file mode 100644 index 0000000000..78c2a48063 --- /dev/null +++ b/assets/icons/see_who_reacted.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c24f23dce9..757097c67f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -136,6 +136,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)." + }, + "actionSheetOptionViewReadReceipts": "View read receipts", + "@actionSheetOptionViewReadReceipts": { + "description": "Label for the 'View read receipts' button in the message action sheet." + }, + "actionSheetReadReceipts": "Read receipts", + "@actionSheetReadReceipts": { + "description": "Title for the \"Read receipts\" bottom sheet." + }, + "actionSheetReadReceiptsReadCount": "This message has been read by {count} {count, plural, =1{person} other{people}}:", + "@actionSheetReadReceiptsReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when one or more people have read the message.", + "placeholders": { + "count": {"type": "int", "example": "1"} + } + }, + "actionSheetReadReceiptsZeroReadCount": "No one has read this message yet.", + "@actionSheetReadReceiptsZeroReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when no one has read the message." + }, + "actionSheetReadReceiptsErrorReadCount": "Failed to load read receipts.", + "@actionSheetReadReceiptsErrorReadCount": { + "description": "Label in the \"Read receipts\" bottom sheet when loading read receipts failed." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 99b52aced1..691267a036 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -333,6 +333,48 @@ abstract class ZulipLocalizations { /// **'Failed to mark topic as unresolved'** String get errorUnresolveTopicFailedTitle; + /// Label for the 'See who reacted' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'See who reacted'** + String get actionSheetOptionSeeWhoReacted; + + /// Explanation on the 'See who reacted' sheet when the message has no reactions (because they were removed after the sheet was opened). + /// + /// In en, this message translates to: + /// **'This message has no reactions.'** + String get seeWhoReactedSheetNoReactions; + + /// Label for the 'View read receipts' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'View read receipts'** + String get actionSheetOptionViewReadReceipts; + + /// Title for the "Read receipts" bottom sheet. + /// + /// In en, this message translates to: + /// **'Read receipts'** + String get actionSheetReadReceipts; + + /// Label in the "Read receipts" bottom sheet when one or more people have read the message. + /// + /// In en, this message translates to: + /// **'This message has been read by {count} {count, plural, =1{person} other{people}}:'** + String actionSheetReadReceiptsReadCount(int count); + + /// Label in the "Read receipts" bottom sheet when no one has read the message. + /// + /// In en, this message translates to: + /// **'No one has read this message yet.'** + String get actionSheetReadReceiptsZeroReadCount; + + /// Label in the "Read receipts" bottom sheet when loading read receipts failed. + /// + /// In en, this message translates to: + /// **'Failed to load read receipts.'** + String get actionSheetReadReceiptsErrorReadCount; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 110b0dbe24..f1324e00f0 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -118,6 +118,37 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index f3b1bdad67..8eb9fe4672 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -121,6 +121,37 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Thema konnte nicht als ungelöst markiert werden'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Nachrichtentext kopieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f99c386087..3d4de28f09 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -118,6 +118,37 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2d7d35e23e..c78b349c65 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -120,6 +120,37 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Impossibile contrassegnare l\'argomento come irrisolto'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copia il testo del messaggio'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index ff27eaee8b..153fb83446 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -118,6 +118,37 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0568bc0ae7..fff1d7ce4a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -118,6 +118,37 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e9cf379b6..fe4fea417b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -121,6 +121,37 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Nie udało się oznaczyć brak rozwiązania'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Skopiuj tekst wiadomości'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 1349d79baa..781e7f6fa9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -121,6 +121,37 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Не удалось отметить тему как нерешенную'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Скопировать текст сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 33b4465eb6..40792f7a3d 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -118,6 +118,37 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Skopírovať text správy'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8d587b9085..93f45509f0 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -119,6 +119,37 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Neuspela označitev teme kot nerazrešene'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Kopiraj besedilo sporočila'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..f15d2fd2d4 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -122,6 +122,37 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Не вдалося позначити тему як невирішену'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Копіювати текст повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index b7eaba478f..06b25c0212 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -118,6 +118,37 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get errorUnresolveTopicFailedTitle => 'Failed to mark topic as unresolved'; + @override + String get actionSheetOptionSeeWhoReacted => 'See who reacted'; + + @override + String get seeWhoReactedSheetNoReactions => 'This message has no reactions.'; + + @override + String get actionSheetOptionViewReadReceipts => 'View read receipts'; + + @override + String get actionSheetReadReceipts => 'Read receipts'; + + @override + String actionSheetReadReceiptsReadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'people', + one: 'person', + ); + return 'This message has been read by $count $_temp0:'; + } + + @override + String get actionSheetReadReceiptsZeroReadCount => + 'No one has read this message yet.'; + + @override + String get actionSheetReadReceiptsErrorReadCount => + 'Failed to load read receipts.'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6b280df6ee..2a5bf6a3aa 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -29,6 +29,7 @@ import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; import 'page.dart'; +import 'read_receipts.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -92,12 +93,43 @@ void _showActionSheet( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 8), child: MenuButtonsShape(buttons: optionButtons)))), - const ActionSheetCancelButton(), + const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel), ]))), ])))); }); } +/// A plain text widget for a bottom sheet with a multiline UI string. +/// +/// Comes with built-in 16px horizontal padding. +/// +/// Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev +class BottomSheetPlainText extends StatelessWidget { + const BottomSheetPlainText({super.key, required this.text, this.textAlign}); + + final String text; + final TextAlign? textAlign; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + child: Text( + textAlign: textAlign, + 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 +192,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 +222,20 @@ class ActionSheetCancelButton extends StatelessWidget { onPressed: () { Navigator.pop(context); }, - child: Text(ZulipLocalizations.of(context).dialogCancel, + child: Text(label, style: const TextStyle(fontSize: 20, height: 24 / 20) .merge(weightVariableTextStyle(context, wght: 600)))); } } +enum BottomSheetDismissButtonStyle { + /// The "Cancel" label, for action sheets. + cancel, + + /// The "Close" label, for bottom sheets that are read-only or for navigation. + close, +} + /// Show a sheet of actions you can take on a channel. /// /// Needs a [PageRoot] ancestor. @@ -613,6 +663,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), + ViewReactionsButton(message: message, pageContext: pageContext), + ViewReadReceiptsButton(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: pageContext), @@ -839,6 +891,36 @@ 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 ViewReadReceiptsButton extends MessageActionSheetMenuItemButton { + ViewReadReceiptsButton({super.key, required super.message, required super.pageContext}); + + @override IconData get icon => ZulipIcons.check_check; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionViewReadReceipts; + } + + @override void onPressed() { + showReadReceiptsSheet(pageContext, messageId: message.id); + } +} + class StarButton extends MessageActionSheetMenuItemButton { StarButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3c26361d3a..2e8125e76a 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../api/exception.dart'; @@ -6,10 +7,15 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/autocomplete.dart'; import '../model/emoji.dart'; +import '../model/store.dart'; +import 'action_sheet.dart'; import 'color.dart'; +import 'content.dart'; import 'dialog.dart'; import 'emoji.dart'; import 'inset_shadow.dart'; +import 'page.dart'; +import 'profile.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -205,6 +211,12 @@ class ReactionChip extends StatelessWidget { customBorder: shape, splashColor: splashColor, highlightColor: highlightColor, + onLongPress: () { + showViewReactionsSheet(PageRoot.contextOf(context), + messageId: messageId, + initialReactionType: reactionType, + initialEmojiCode: emojiCode); + }, onTap: () { (selfVoted ? removeReaction : addReaction).call(store.connection, messageId: messageId, @@ -518,7 +530,7 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat states.contains(WidgetState.pressed) ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) : Colors.transparent)), - child: Text(zulipLocalizations.dialogClose, + child: Text(zulipLocalizations.dialogCancel, style: const TextStyle(fontSize: 20, height: 30 / 20))), ])), Expanded(child: InsetShadowBox( @@ -614,3 +626,355 @@ class EmojiPickerListEntry extends StatelessWidget { )); } } + +/// Opens a bottom sheet showing who reacted to the message. +void showViewReactionsSheet(BuildContext pageContext, { + required int messageId, + ReactionType? initialReactionType, + String? initialEmojiCode, +}) { + final accountId = PerAccountStoreWidget.accountIdOf(pageContext); + + showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ViewReactions(pageContext, + messageId: messageId, + initialEmojiCode: initialEmojiCode, + initialReactionType: initialReactionType))); + }); +} + +class ViewReactions extends StatefulWidget { + const ViewReactions(this.pageContext, { + super.key, + required this.messageId, + this.initialReactionType, + this.initialEmojiCode, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType? initialReactionType; + final String? initialEmojiCode; + + @override + State createState() => _ViewReactionsState(); +} + +class _ViewReactionsState extends State with PerAccountStoreAwareStateMixin { + ReactionType? reactionType; + String? emojiCode; + + PerAccountStore? store; + + void _setSelection(ReactionWithVotes? selection) { + setState(() { + reactionType = selection?.reactionType; + emojiCode = selection?.emojiCode; + }); + } + + void _storeChanged() { + _reconcile(); + } + + /// Check that the given reaction still has votes; + /// if not, select a different one if possible or clear the selection. + void _reconcile() { + final message = PerAccountStoreWidget.of(context).messages[widget.messageId]; + + final reactions = message?.reactions?.aggregated; + + if (reactions == null || reactions.isEmpty) { + _setSelection(null); + return; + } + + final selectedReaction = reactions.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode); + + // TODO scroll into view + _setSelection(selectedReaction + // first item will exist; early-return above on reactions.isEmpty + ?? reactions.first); + } + + @override + void onNewStore() { + store?.removeListener(_storeChanged); + store = PerAccountStoreWidget.of(context); + store!.addListener(_storeChanged); + if (reactionType == null && widget.initialReactionType != null) { + assert(emojiCode == null); + assert(widget.initialEmojiCode != null); + reactionType = widget.initialReactionType!; + emojiCode = widget.initialEmojiCode!; + } + _reconcile(); + } + + @override + Widget build(BuildContext context) { + // TODO could pull out this layout/appearance code, + // focusing this widget only on state management + return SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ViewReactionsHeader(widget.pageContext, + messageId: widget.messageId, + reactionType: reactionType, + emojiCode: emojiCode, + onRequestSelect: (r) => _setSelection(r), + ), + // TODO if all reactions (or whole message) disappeared, + // we show a message saying there are no reactions, + // but the layout shifts (the sheet's height changes dramatically); + // we should avoid this. + if (reactionType != null && emojiCode != null) Flexible( + child: ViewReactionsUserList(widget.pageContext, + messageId: widget.messageId, + reactionType: reactionType!, + emojiCode: emojiCode!)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ])); + } +} + +class ViewReactionsHeader extends StatelessWidget { + const ViewReactionsHeader( + this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + required this.onRequestSelect, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType? reactionType; + final String? emojiCode; + final void Function(ReactionWithVotes) onRequestSelect; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final message = PerAccountStoreWidget.of(context).messages[messageId]; + + final reactionsWithVotes = message?.reactions?.aggregated; + + if (reactionsWithVotes == null || reactionsWithVotes.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: BottomSheetPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions), + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: InsetShadowBox(start: 8, end: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: reactionsWithVotes.map((r) => + _ViewReactionsEmojiItem( + reactionWithVotes: r, + selected: r.reactionType == reactionType && r.emojiCode == emojiCode, + onRequestSelect: () => onRequestSelect(r)), + ).toList()))))); + } +} + +class _ViewReactionsEmojiItem extends StatelessWidget { + const _ViewReactionsEmojiItem({ + required this.reactionWithVotes, + required this.selected, + required this.onRequestSelect, + }); + + final ReactionWithVotes reactionWithVotes; + final bool selected; + final void Function() onRequestSelect; + + static const double emojiSize = 24; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final count = reactionWithVotes.userIds.length; + + final emojiDisplay = store.emojiDisplayFor( + emojiType: reactionWithVotes.reactionType, + emojiCode: reactionWithVotes.emojiCode, + emojiName: reactionWithVotes.emojiName); + + // Don't use a :text_emoji:-style display here. + final placeholder = SizedBox.fromSize(size: Size.square(emojiSize)); + + // TODO make a helper widget for this + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => UnicodeEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay), + ImageEmojiDisplay() => ImageEmojiWidget( + size: emojiSize, + emojiDisplay: emojiDisplay, + // If image emoji fails to load, show nothing. + errorBuilder: (_, _, _) => placeholder), + TextEmojiDisplay() => placeholder, + }; + + return Tooltip( + message: reactionWithVotes.emojiName, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onRequestSelect, + child: Container( + decoration: BoxDecoration( + border: Border.all( + // This border seems to affect the layout + // (I thought it was normally paint-only?), + // so always include a border, so positions don't shift. + color: selected + ? designVariables.borderBar + : Colors.transparent), + borderRadius: BorderRadius.circular(10), + color: selected ? designVariables.background : null, + ), + padding: EdgeInsets.fromLTRB(14, 4.5, 14, 4.5), + child: Center( + child: Column( + spacing: 3, + mainAxisSize: MainAxisSize.min, + children: [ + emoji, + Text( + style: TextStyle( + color: designVariables.title, + fontSize: 14, + height: 14 / 14), + count.toString()) // TODO(i18n) number formatting? + ]))))); + } +} + + +@visibleForTesting +class ViewReactionsUserList extends StatelessWidget { + const ViewReactionsUserList(this.pageContext, { + super.key, + required this.messageId, + required this.reactionType, + required this.emojiCode, + }); + + final BuildContext pageContext; + final int messageId; + final ReactionType reactionType; + final String emojiCode; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final message = store.messages[messageId]; + + final userIds = message?.reactions?.aggregated.firstWhereOrNull( + (x) => x.reactionType == reactionType && x.emojiCode == emojiCode + )?.userIds.toList(); + + // (No filtering of muted or deactivated users.) + + if (userIds == null) { + // This reaction lost all its votes, or the message was deleted. + return SizedBox.shrink(); + } + + // TODO sort userIds? + + return InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: SizedBox( + height: 400, // TODO(design) tune + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: userIds.length, + itemBuilder: (context, index) => + ViewReactionsUserItem(context, userId: userIds[index])))); + } +} + +@visibleForTesting +class ViewReactionsUserItem extends StatelessWidget { + const ViewReactionsUserItem(this.pageContext, { + super.key, + required this.userId, + }); + + final BuildContext pageContext; + final int userId; + + void _onPressed() { + // Dismiss the action sheet. + Navigator.pop(pageContext); + + Navigator.push(pageContext, + ProfilePage.buildRoute(context: pageContext, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: _onPressed, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index dd269e03f7..c57fca7b22 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -324,7 +324,8 @@ void _showMainMenu(BuildContext context, { child: AnimatedScaleOnTap( scaleEnd: 0.95, duration: Duration(milliseconds: 100), - child: ActionSheetCancelButton())), + child: BottomSheetDismissButton( + style: BottomSheetDismissButtonStyle.close))), ]))); }); } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b5c424b0b..2c1b59148f 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -48,134 +48,140 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check". static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_check". + static const IconData check_check = IconData(0xf109, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "check_circle_checked". - static const IconData check_circle_checked = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData check_circle_checked = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "check_circle_unchecked". - static const IconData check_circle_unchecked = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData check_circle_unchecked = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "check_remove". - static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData check_remove = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf128, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "see_who_reacted". + static const IconData see_who_reacted = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf135, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inset_shadow.dart b/lib/widgets/inset_shadow.dart index a4133ac7de..c5da27a934 100644 --- a/lib/widgets/inset_shadow.dart +++ b/lib/widgets/inset_shadow.dart @@ -17,6 +17,8 @@ class InsetShadowBox extends StatelessWidget { super.key, this.top = 0, this.bottom = 0, + this.start = 0, + this.end = 0, required this.color, required this.child, }); @@ -31,7 +33,17 @@ class InsetShadowBox extends StatelessWidget { /// This does not pad the child widget. final double bottom; - /// The shadow color to fade into transparency from the top and bottom borders. + /// The distance that the shadow from the child's start edge grows endwards. + /// + /// This does not pad the child widget. + final double start; + + /// The distance that the shadow from the child's end edge grows startwards. + /// + /// This does not pad the child widget. + final double end; + + /// The shadow color to fade into transparency from the edges, inward. final Color color; final Widget child; @@ -54,6 +66,10 @@ class InsetShadowBox extends StatelessWidget { child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))), Positioned(bottom: 0, height: bottom, left: 0, right: 0, child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))), + PositionedDirectional(start: 0, width: start, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerStart))), + PositionedDirectional(end: 0, width: end, top: 0, bottom: 0, + child: DecoratedBox(decoration: _shadowFrom(AlignmentDirectional.centerEnd))), ]); } } diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart new file mode 100644 index 0000000000..f28bbca8e0 --- /dev/null +++ b/lib/widgets/read_receipts.dart @@ -0,0 +1,243 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:styled_text/styled_text.dart'; + +import '../api/core.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import 'action_sheet.dart'; +import 'actions.dart'; +import 'color.dart'; +import 'content.dart'; +import 'inset_shadow.dart'; +import 'profile.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +part 'read_receipts.g.dart'; + +/// Opens a bottom sheet showing who has read the message. +void showReadReceiptsSheet(BuildContext pageContext, {required int messageId}) { + 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: ReadReceipts(messageId: messageId))); + }); +} + +class ReadReceipts extends StatefulWidget { + const ReadReceipts({super.key, required this.messageId}); + + final int messageId; + + @override + State createState() => _ReadReceiptsState(); +} + +class _ReadReceiptsState extends State { + List userIds = []; + FetchStatus status = FetchStatus.loading; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + tryFetchReadReceipts(); + } + + Future tryFetchReadReceipts() async { + final store = PerAccountStoreWidget.of(context); + try { + final result = await getReadReceipts(store.connection, messageId: widget.messageId); + // TODO(i18n): add locale-aware sorting + userIds = result.userIds.sortedByCompare( + (id) => store.userDisplayName(id), + (nameA, nameB) => nameA.toLowerCase().compareTo(nameB.toLowerCase()), + ); + status = FetchStatus.success; + } catch (e) { + status = FetchStatus.error; + } finally { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ReadReceiptsHeader(receiptCount: userIds.length, status: status), + Expanded(child: _ReadReceiptsUserList(userIds: userIds, status: status)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close)) + ])); + } +} + +enum FetchStatus { loading, success, error } + +class _ReadReceiptsHeader extends StatelessWidget { + const _ReadReceiptsHeader({required this.receiptCount, required this.status}); + + final int receiptCount; + final FetchStatus status; + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Padding( + padding: EdgeInsetsDirectional.fromSTEB(18, 16, 18, 8), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(localizations.actionSheetReadReceipts, + style: TextStyle( + fontSize: 20, + height: 20 / 20, + color: designVariables.title, + ).merge(weightVariableTextStyle(context, wght: 600))), + if (status == FetchStatus.success && receiptCount > 0) + StyledText( + text: localizations.actionSheetReadReceiptsReadCount(receiptCount), + tags: { + 'link': StyledTextActionTag((_, attrs) { + PlatformActions.launchUrl(context, Uri.parse(attrs['href']!)); + }, + style: TextStyle( + decoration: TextDecoration.underline, + color: designVariables.link, + decorationColor: designVariables.link), + )}, + style: TextStyle(fontSize: 17, height: 22 / 17, + color: designVariables.textMessage)), + ])); + } +} + +class _ReadReceiptsUserList extends StatelessWidget { + const _ReadReceiptsUserList({required this.userIds, required this.status}); + + final List userIds; + final FetchStatus status; + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Center( + child: status == FetchStatus.loading + ? CircularProgressIndicator() + : status == FetchStatus.error + ? BottomSheetPlainText( + text: localizations.actionSheetReadReceiptsErrorReadCount, + textAlign: TextAlign.center) + : userIds.isEmpty + ? BottomSheetPlainText( + text: localizations.actionSheetReadReceiptsZeroReadCount, + textAlign: TextAlign.center) + : InsetShadowBox( + top: 8, + bottom: 8, + color: designVariables.bgContextMenu, + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: userIds.length, + itemBuilder: (context, index) => + ReadReceiptsUserItem(context, userId: userIds[index])))); + } +} + + +// TODO: deduplicate the code with [ViewReactionsUserItem] +@visibleForTesting +class ReadReceiptsUserItem extends StatelessWidget { + const ReadReceiptsUserItem(this.pageContext, { + super.key, + required this.userId, + }); + + final BuildContext pageContext; + final int userId; + + void _onPressed() { + // Dismiss the action sheet. + Navigator.pop(pageContext); + + Navigator.push(pageContext, + ProfilePage.buildRoute(context: pageContext, userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: _onPressed, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row(spacing: 8, children: [ + Avatar( + size: 32, + borderRadius: 3, + backgroundColor: designVariables.bgContextMenu, + userId: userId), + Flexible( + child: Text( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 17 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500)), + store.userDisplayName(userId))), + ]))); + } +} + +/// https://zulip.com/api/get-read-receipts +Future getReadReceipts(ApiConnection connection, { + required int messageId, +}) { + return connection.get('getReadReceipts', GetReadReceiptsResult.fromJson, + 'messages/$messageId/read_receipts', null); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GetReadReceiptsResult { + const GetReadReceiptsResult({required this.userIds}); + + final List userIds; + + factory GetReadReceiptsResult.fromJson(Map json) => + _$GetReadReceiptsResultFromJson(json); + + Map toJson() => _$GetReadReceiptsResultToJson(this); +} diff --git a/lib/widgets/read_receipts.g.dart b/lib/widgets/read_receipts.g.dart new file mode 100644 index 0000000000..254e23fd94 --- /dev/null +++ b/lib/widgets/read_receipts.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'read_receipts.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetReadReceiptsResult _$GetReadReceiptsResultFromJson( + Map json, +) => GetReadReceiptsResult( + userIds: (json['user_ids'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$GetReadReceiptsResultToJson( + GetReadReceiptsResult instance, +) => {'user_ids': instance.userIds}; diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 6039072116..0dc2998c60 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -171,6 +171,8 @@ 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), + link: const Color(0xff066bd0), listMenuItemBg: const Color(0xffcbcdd6), listMenuItemIcon: const Color(0xff9194a3), listMenuItemText: const Color(0xff2d303c), @@ -259,6 +261,8 @@ 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), + link: const Color(0xff066bd0), listMenuItemBg: const Color(0xff2d303c), listMenuItemIcon: const Color(0xff767988), listMenuItemText: const Color(0xffcbcdd6), @@ -355,6 +359,8 @@ class DesignVariables extends ThemeExtension { required this.labelEdited, required this.labelMenuButton, required this.labelSearchPrompt, + required this.labelTime, + required this.link, required this.listMenuItemBg, required this.listMenuItemIcon, required this.listMenuItemText, @@ -443,6 +449,8 @@ class DesignVariables extends ThemeExtension { final Color labelEdited; final Color labelMenuButton; final Color labelSearchPrompt; + final Color labelTime; + final Color link; final Color listMenuItemBg; final Color listMenuItemIcon; final Color listMenuItemText; @@ -526,6 +534,8 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? labelSearchPrompt, + Color? labelTime, + Color? link, Color? listMenuItemBg, Color? listMenuItemIcon, Color? listMenuItemText, @@ -604,6 +614,8 @@ class DesignVariables extends ThemeExtension { labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, + labelTime: labelTime ?? this.labelTime, + link: link ?? this.link, listMenuItemBg: listMenuItemBg ?? this.listMenuItemBg, listMenuItemIcon: listMenuItemIcon ?? this.listMenuItemIcon, listMenuItemText: listMenuItemText ?? this.listMenuItemText, @@ -689,6 +701,8 @@ 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)!, + link: Color.lerp(link, other.link, 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/pubspec.lock b/pubspec.lock index e693e77f23..070db06efc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1059,6 +1059,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + styled_text: + dependency: "direct main" + description: + name: styled_text + sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3 + url: "https://pub.dev" + source: hosted + version: "8.1.0" sync_http: dependency: transitive description: @@ -1339,6 +1347,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + xmlstream: + dependency: transitive + description: + name: xmlstream + sha256: cfc14e3f256997897df9481ae630d94c2d85ada5187ebeb868bb1aabc2c977b4 + url: "https://pub.dev" + source: hosted + version: "1.1.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a87dc4a4bd..82c68b9145 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: share_plus_platform_interface: ^6.0.0 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 + styled_text: ^8.1.0 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" video_player: ^2.10.0 diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 1b5c8ad8b5..619cf984b3 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -231,7 +231,7 @@ void main () { await tapOpenMenuAndAwait(tester); checkIconSelected(tester, inboxMenuIconFinder); checkIconNotSelected(tester, channelsMenuIconFinder); - await tapButtonAndAwaitTransition(tester, find.text('Cancel')); + await tapButtonAndAwaitTransition(tester, find.text('Close')); await tester.tap(find.byIcon(ZulipIcons.hash_italic)); await tester.pump(); @@ -265,10 +265,10 @@ void main () { await tapButtonAndAwaitTransition(tester, channelsMenuIconFinder); }); - testWidgets('cancel button dismisses the menu', (tester) async { + testWidgets('close button dismisses the menu', (tester) async { await prepare(tester); await tapOpenMenuAndAwait(tester); - await tapButtonAndAwaitTransition(tester, find.text('Cancel')); + await tapButtonAndAwaitTransition(tester, find.text('Close')); }); testWidgets('menu buttons dismiss the menu', (tester) async {