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 {