diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 85221a7e12..c753a15308 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/l10n/app_en.arb b/assets/l10n/app_en.arb
index 77200c01eb..8dfc332b39 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -202,6 +202,29 @@
"num": {"type": "int", "example": "2"}
}
},
+ "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": "{count, plural, =1{This message has been read by {count} person:} other{This message has been read by {count} 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/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 342128b8b7..0f22f0b5f0 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -90,6 +90,8 @@ class InitialSnapshot {
final bool realmAllowMessageEditing;
final int? realmMessageContentEditLimitSeconds;
+ final bool realmEnableReadReceipts;
+
final bool realmPresenceDisabled;
final Map realmDefaultExternalAccounts;
@@ -158,6 +160,7 @@ class InitialSnapshot {
required this.realmWaitingPeriodThreshold,
required this.realmAllowMessageEditing,
required this.realmMessageContentEditLimitSeconds,
+ required this.realmEnableReadReceipts,
required this.realmPresenceDisabled,
required this.realmDefaultExternalAccounts,
required this.maxFileUploadSizeMib,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index ff05e50c20..e7a5923c89 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -91,6 +91,7 @@ InitialSnapshot _$InitialSnapshotFromJson(
realmAllowMessageEditing: json['realm_allow_message_editing'] as bool,
realmMessageContentEditLimitSeconds:
(json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(),
+ realmEnableReadReceipts: json['realm_enable_read_receipts'] as bool,
realmPresenceDisabled: json['realm_presence_disabled'] as bool,
realmDefaultExternalAccounts:
(json['realm_default_external_accounts'] as Map).map(
@@ -162,6 +163,7 @@ Map _$InitialSnapshotToJson(
'realm_allow_message_editing': instance.realmAllowMessageEditing,
'realm_message_content_edit_limit_seconds':
instance.realmMessageContentEditLimitSeconds,
+ 'realm_enable_read_receipts': instance.realmEnableReadReceipts,
'realm_presence_disabled': instance.realmPresenceDisabled,
'realm_default_external_accounts': instance.realmDefaultExternalAccounts,
'max_file_upload_size_mib': instance.maxFileUploadSizeMib,
diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart
index 44053bcc4a..428a7c9a95 100644
--- a/lib/api/route/messages.dart
+++ b/lib/api/route/messages.dart
@@ -436,3 +436,23 @@ class UpdateMessageFlagsForNarrowResult {
Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this);
}
+
+/// 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/api/route/messages.g.dart b/lib/api/route/messages.g.dart
index 306a58ca4d..4169634701 100644
--- a/lib/api/route/messages.g.dart
+++ b/lib/api/route/messages.g.dart
@@ -89,6 +89,18 @@ Map _$UpdateMessageFlagsForNarrowResultToJson(
'found_newest': instance.foundNewest,
};
+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};
+
const _$AnchorCodeEnumMap = {
AnchorCode.newest: 'newest',
AnchorCode.oldest: 'oldest',
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index d7e9c25f8f..fd25c06f0a 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -275,7 +275,7 @@ abstract class ZulipLocalizations {
/// **'To upload files, please grant Zulip additional permissions in Settings.'**
String get permissionsDeniedReadExternalStorage;
- /// Label in the channel context menu for subscribing to the channel.
+ /// Label in the channel action sheet for subscribing to the channel.
///
/// In en, this message translates to:
/// **'Subscribe'**
@@ -305,7 +305,7 @@ abstract class ZulipLocalizations {
/// **'List of topics'**
String get actionSheetOptionListOfTopics;
- /// Label in the channel context menu for unsubscribing from the channel.
+ /// Label in the channel action sheet for unsubscribing from the channel.
///
/// In en, this message translates to:
/// **'Unsubscribe'**
@@ -413,6 +413,36 @@ abstract class ZulipLocalizations {
/// **'Votes for {emojiName} ({num})'**
String seeWhoReactedSheetUserListLabel(String emojiName, int num);
+ /// 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:
+ /// **'{count, plural, =1{This message has been read by {count} person:} other{This message has been read by {count} 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 5ff9981001..6843aa1c42 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -172,6 +172,31 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 8d0826bac9..676822d83e 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -175,6 +175,31 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 bffdb9f9a9..3fde8abba3 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -172,6 +172,31 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 5001c79eed..192c83cc49 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -172,6 +172,31 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 25a5c4e999..7a5e331297 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -174,6 +174,31 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 e39ebe4377..69c693d520 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -170,6 +170,31 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index d08ca0eaf0..952265659c 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -172,6 +172,31 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 9b856a6aae..f06b7d03b3 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -175,6 +175,31 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 848b02eefb..307daa27b9 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -175,6 +175,31 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 96e9e0c542..570be3ac66 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -172,6 +172,31 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 bdb56cf44d..415fa97968 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -173,6 +173,31 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 0c2f49363e..049cde5c78 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -176,6 +176,31 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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 fdfd2966b5..b2cf26a2ae 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -172,6 +172,31 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
return 'Votes for $emojiName ($num)';
}
+ @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: 'This message has been read by $count people:',
+ one: 'This message has been read by $count person:',
+ );
+ return '$_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/model/realm.dart b/lib/model/realm.dart
index c214b554fa..d54e5a5467 100644
--- a/lib/model/realm.dart
+++ b/lib/model/realm.dart
@@ -42,6 +42,7 @@ mixin RealmStore on PerAccountStoreBase {
realmMessageContentEditLimitSeconds == null ? null
: Duration(seconds: realmMessageContentEditLimitSeconds!);
int? get realmMessageContentEditLimitSeconds;
+ bool get realmEnableReadReceipts;
bool get realmPresenceDisabled;
int get realmWaitingPeriodThreshold;
@@ -141,6 +142,8 @@ mixin ProxyRealmStore on RealmStore {
@override
int? get realmMessageContentEditLimitSeconds => realmStore.realmMessageContentEditLimitSeconds;
@override
+ bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts;
+ @override
bool get realmPresenceDisabled => realmStore.realmPresenceDisabled;
@override
int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold;
@@ -180,6 +183,7 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore {
realmMandatoryTopics = initialSnapshot.realmMandatoryTopics,
maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib,
realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds,
+ realmEnableReadReceipts = initialSnapshot.realmEnableReadReceipts,
realmPresenceDisabled = initialSnapshot.realmPresenceDisabled,
realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold,
realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy,
@@ -208,6 +212,8 @@ class RealmStoreImpl extends PerAccountStoreBase with RealmStore {
@override
final int? realmMessageContentEditLimitSeconds;
@override
+ final bool realmEnableReadReceipts;
+ @override
final bool realmPresenceDisabled;
@override
final int realmWaitingPeriodThreshold;
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 7adfb2ba24..b3e4b91f8b 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';
@@ -102,31 +103,128 @@ void _showActionSheet(
});
}
-/// A header for a bottom sheet with a multiline UI string.
+typedef WidgetBuilderFromTextStyle = Widget Function(TextStyle);
+
+/// A header for a bottom sheet with an optional title and multiline subtitle.
+///
+/// A title, subtitle, or both must be provided.
+///
+/// Provide a title by passing [title] or [buildTitle] (not both).
+/// Provide a subtitle by passing [subtitle] or [buildSubtitle] (not both).
+/// The "build" params support richer content, such as [TextWithLink],
+/// and the callback is passed a [TextStyle] which is the base style.
///
/// Assumes 8px padding below the top of the bottom sheet.
///
-/// Figma:
+/// Figma; just text no title:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev
-class BottomSheetHeaderPlainText extends StatelessWidget {
- const BottomSheetHeaderPlainText({super.key, required this.text});
-
- final String text;
+///
+/// Figma; title and text:
+/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6326-96125&m=dev
+/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20898&m=dev
+/// The latter example (read receipts) has more horizontal and bottom padding;
+/// that looks like an accident that we don't need to follow.
+/// It also colors the subtitle text more opaquely…that difference might be
+/// intentional, but Vlad's time is limited and I prefer consistency.
+class BottomSheetHeader extends StatelessWidget {
+ const BottomSheetHeader({
+ super.key,
+ this.title,
+ this.buildTitle,
+ this.subtitle,
+ this.buildSubtitle,
+ }) : assert(subtitle == null || buildSubtitle == null),
+ assert(title == null || buildTitle == null),
+ assert((subtitle != null || buildSubtitle != null)
+ || (title != null || buildTitle != null));
+
+ final String? title;
+ final Widget Function(TextStyle)? buildTitle;
+ final String? subtitle;
+ final Widget Function(TextStyle)? buildSubtitle;
@override
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
+ final baseTitleStyle = TextStyle(
+ fontSize: 20,
+ height: 20 / 20,
+ color: designVariables.title,
+ ).merge(weightVariableTextStyle(context, wght: 600));
+
+ final effectiveTitle = switch ((buildTitle, title)) {
+ (WidgetBuilderFromTextStyle build, null) => build(baseTitleStyle),
+ (null, String data) => Text(style: baseTitleStyle, data),
+ _ => null,
+ };
+
+ final baseSubtitleStyle = TextStyle(
+ color: designVariables.labelTime,
+ fontSize: 17,
+ height: 22 / 17);
+
+ final effectiveSubtitle = switch ((buildSubtitle, subtitle)) {
+ (WidgetBuilderFromTextStyle build, null) => build(baseSubtitleStyle),
+ (null, String data) => Text(style: baseSubtitleStyle, data),
+ _ => null,
+ };
+
+ // (should have been caught by `assert` in constructor)
+ if (effectiveTitle == null && effectiveSubtitle == null) throw ArgumentError();
+
return Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
- child: SizedBox(
- width: double.infinity,
- child: Text(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 8,
+ children: [?effectiveTitle, ?effectiveSubtitle]));
+ }
+}
+
+/// A placeholder for when a bottom sheet has no content to show.
+///
+/// Pass [message] for a "no-content-here" message,
+/// or pass true for [loading] if the content hasn't finished loading yet,
+/// but don't pass both.
+///
+/// Show this below a [BottomSheetHeader] if present.
+///
+/// See also:
+/// * [PageBodyEmptyContentPlaceholder], for a similar element to use in
+/// pages on the home screen.
+// TODO(design) we don't yet have a design for this;
+// it was ad-hoc and modeled on [PageBodyEmptyContentPlaceholder].
+class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
+ const BottomSheetEmptyContentPlaceholder({
+ super.key,
+ this.message,
+ this.loading = false,
+ }) : assert(message == null || !loading);
+
+ final String? message;
+ final bool loading;
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+
+ final child = loading
+ ? CircularProgressIndicator()
+ : Text(
+ textAlign: TextAlign.center,
style: TextStyle(
- color: designVariables.labelTime,
+ color: designVariables.labelSearchPrompt,
fontSize: 17,
- height: 22 / 17),
- text)));
+ height: 23 / 17,
+ ).merge(weightVariableTextStyle(context, wght: 500)),
+ message!);
+
+ return Padding(
+ padding: EdgeInsets.fromLTRB(24, 48, 24, 16),
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: child));
}
}
@@ -806,6 +904,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
final reactions = message.reactions;
final hasReactions = reactions != null && reactions.total > 0;
+ final readReceiptsEnabled = store.realmEnableReadReceipts;
+
// The UI that's conditioned on this won't live-update during this appearance
// of the action sheet (we avoid calling composeBoxControllerOf in a build
// method; see its doc).
@@ -825,6 +925,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
ReactionButtons(message: message, pageContext: pageContext),
if (hasReactions)
ViewReactionsButton(message: message, pageContext: pageContext),
+ if (readReceiptsEnabled)
+ ViewReadReceiptsButton(message: message, pageContext: pageContext),
StarButton(message: message, pageContext: pageContext),
if (isComposeBoxOffered)
QuoteAndReplyButton(message: message, pageContext: pageContext),
@@ -1074,6 +1176,21 @@ class ViewReactionsButton extends MessageActionSheetMenuItemButton {
}
}
+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 1f7d9c1872..aab7437086 100644
--- a/lib/widgets/emoji_reaction.dart
+++ b/lib/widgets/emoji_reaction.dart
@@ -822,7 +822,7 @@ class ViewReactionsHeader extends StatelessWidget {
if (reactions == null || reactions.aggregated.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
- child: BottomSheetHeaderPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions),
+ child: BottomSheetHeader(subtitle: zulipLocalizations.seeWhoReactedSheetNoReactions),
);
}
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index 4251973ef0..ed352feeba 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -48,146 +48,149 @@ 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_down".
- static const IconData chevron_down = IconData(0xf10c, fontFamily: "Zulip Icons");
+ static const IconData chevron_down = IconData(0xf10d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "chevron_right".
- static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons");
+ static const IconData chevron_right = IconData(0xf10e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "circle_x".
- static const IconData circle_x = IconData(0xf10e, fontFamily: "Zulip Icons");
+ static const IconData circle_x = IconData(0xf10f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "clock".
- static const IconData clock = IconData(0xf10f, fontFamily: "Zulip Icons");
+ static const IconData clock = IconData(0xf110, fontFamily: "Zulip Icons");
/// The Zulip custom icon "contacts".
- static const IconData contacts = IconData(0xf110, fontFamily: "Zulip Icons");
+ static const IconData contacts = IconData(0xf111, fontFamily: "Zulip Icons");
/// The Zulip custom icon "copy".
- static const IconData copy = IconData(0xf111, fontFamily: "Zulip Icons");
+ static const IconData copy = IconData(0xf112, fontFamily: "Zulip Icons");
/// The Zulip custom icon "edit".
- static const IconData edit = IconData(0xf112, fontFamily: "Zulip Icons");
+ static const IconData edit = IconData(0xf113, fontFamily: "Zulip Icons");
/// The Zulip custom icon "eye".
- static const IconData eye = IconData(0xf113, fontFamily: "Zulip Icons");
+ static const IconData eye = IconData(0xf114, fontFamily: "Zulip Icons");
/// The Zulip custom icon "eye_off".
- static const IconData eye_off = IconData(0xf114, fontFamily: "Zulip Icons");
+ static const IconData eye_off = IconData(0xf115, fontFamily: "Zulip Icons");
/// The Zulip custom icon "follow".
- static const IconData follow = IconData(0xf115, fontFamily: "Zulip Icons");
+ static const IconData follow = IconData(0xf116, fontFamily: "Zulip Icons");
/// The Zulip custom icon "format_quote".
- static const IconData format_quote = IconData(0xf116, fontFamily: "Zulip Icons");
+ static const IconData format_quote = IconData(0xf117, fontFamily: "Zulip Icons");
/// The Zulip custom icon "globe".
- static const IconData globe = IconData(0xf117, fontFamily: "Zulip Icons");
+ static const IconData globe = IconData(0xf118, fontFamily: "Zulip Icons");
/// The Zulip custom icon "group_dm".
- static const IconData group_dm = IconData(0xf118, fontFamily: "Zulip Icons");
+ static const IconData group_dm = IconData(0xf119, fontFamily: "Zulip Icons");
/// The Zulip custom icon "hash_italic".
- static const IconData hash_italic = IconData(0xf119, fontFamily: "Zulip Icons");
+ static const IconData hash_italic = IconData(0xf11a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "hash_sign".
- static const IconData hash_sign = IconData(0xf11a, fontFamily: "Zulip Icons");
+ static const IconData hash_sign = IconData(0xf11b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "image".
- static const IconData image = IconData(0xf11b, fontFamily: "Zulip Icons");
+ static const IconData image = IconData(0xf11c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "inbox".
- static const IconData inbox = IconData(0xf11c, fontFamily: "Zulip Icons");
+ static const IconData inbox = IconData(0xf11d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "info".
- static const IconData info = IconData(0xf11d, fontFamily: "Zulip Icons");
+ static const IconData info = IconData(0xf11e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "inherit".
- static const IconData inherit = IconData(0xf11e, fontFamily: "Zulip Icons");
+ static const IconData inherit = IconData(0xf11f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "language".
- static const IconData language = IconData(0xf11f, fontFamily: "Zulip Icons");
+ static const IconData language = IconData(0xf120, fontFamily: "Zulip Icons");
/// The Zulip custom icon "link".
- static const IconData link = IconData(0xf120, fontFamily: "Zulip Icons");
+ static const IconData link = IconData(0xf121, fontFamily: "Zulip Icons");
/// The Zulip custom icon "lock".
- static const IconData lock = IconData(0xf121, fontFamily: "Zulip Icons");
+ static const IconData lock = IconData(0xf122, fontFamily: "Zulip Icons");
/// The Zulip custom icon "menu".
- static const IconData menu = IconData(0xf122, fontFamily: "Zulip Icons");
+ static const IconData menu = IconData(0xf123, fontFamily: "Zulip Icons");
/// The Zulip custom icon "message_checked".
- static const IconData message_checked = IconData(0xf123, fontFamily: "Zulip Icons");
+ static const IconData message_checked = IconData(0xf124, fontFamily: "Zulip Icons");
/// The Zulip custom icon "message_feed".
- static const IconData message_feed = IconData(0xf124, fontFamily: "Zulip Icons");
+ static const IconData message_feed = IconData(0xf125, fontFamily: "Zulip Icons");
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf125, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf126, fontFamily: "Zulip Icons");
/// The Zulip custom icon "person".
- static const IconData person = IconData(0xf126, fontFamily: "Zulip Icons");
+ static const IconData person = IconData(0xf127, fontFamily: "Zulip Icons");
/// The Zulip custom icon "plus".
- static const IconData plus = IconData(0xf127, fontFamily: "Zulip Icons");
+ static const IconData plus = IconData(0xf128, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf128, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf129, fontFamily: "Zulip Icons");
/// The Zulip custom icon "remove".
- static const IconData remove = IconData(0xf129, fontFamily: "Zulip Icons");
+ static const IconData remove = IconData(0xf12a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "search".
- static const IconData search = IconData(0xf12a, fontFamily: "Zulip Icons");
+ static const IconData search = IconData(0xf12b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "see_who_reacted".
- static const IconData see_who_reacted = IconData(0xf12b, fontFamily: "Zulip Icons");
+ static const IconData see_who_reacted = IconData(0xf12c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "send".
- static const IconData send = IconData(0xf12c, fontFamily: "Zulip Icons");
+ static const IconData send = IconData(0xf12d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "settings".
- static const IconData settings = IconData(0xf12d, fontFamily: "Zulip Icons");
+ static const IconData settings = IconData(0xf12e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf12e, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf12f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf12f, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf130, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf130, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf131, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf131, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf132, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf132, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf133, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf133, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf134, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf134, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf135, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topics".
- static const IconData topics = IconData(0xf135, fontFamily: "Zulip Icons");
+ static const IconData topics = IconData(0xf136, fontFamily: "Zulip Icons");
/// The Zulip custom icon "two_person".
- static const IconData two_person = IconData(0xf136, fontFamily: "Zulip Icons");
+ static const IconData two_person = IconData(0xf137, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf137, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf138, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart
index 35bdf34923..9aebca9dfd 100644
--- a/lib/widgets/page.dart
+++ b/lib/widgets/page.dart
@@ -220,6 +220,10 @@ class LoadingPlaceholderPage extends StatelessWidget {
/// This handles the horizontal device insets
/// and the bottom inset when needed (in a message list with no compose box).
/// The top inset is handled externally by the app bar.
+///
+/// See also:
+/// * [BottomSheetEmptyContentPlaceholder], for a similar element to use in
+/// a bottom sheet.
// TODO(#311) If the message list gets a bottom nav, the bottom inset will
// always be handled externally too; simplify implementation and dartdoc.
class PageBodyEmptyContentPlaceholder extends StatelessWidget {
diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart
new file mode 100644
index 0000000000..53a985e972
--- /dev/null
+++ b/lib/widgets/read_receipts.dart
@@ -0,0 +1,208 @@
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+
+import '../api/route/messages.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import 'action_sheet.dart';
+import 'actions.dart';
+import 'color.dart';
+import 'inset_shadow.dart';
+import 'profile.dart';
+import 'store.dart';
+import 'text.dart';
+import 'theme.dart';
+import 'user.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)));
+ });
+}
+
+/// The read receipts sheet.
+///
+/// Figma link:
+/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20647&t=lSnHudU6l7NWx0Fa-0
+class ReadReceipts extends StatefulWidget {
+ const ReadReceipts({super.key, required this.messageId});
+
+ final int messageId;
+
+ static const _helpCenterReference = '/help/read-receipts';
+
+ @override
+ State createState() => _ReadReceiptsState();
+}
+
+class _ReadReceiptsState extends State with PerAccountStoreAwareStateMixin {
+ List userIds = [];
+ FetchStatus status = FetchStatus.loading;
+
+ @override
+ void onNewStore() {
+ tryFetchReadReceipts(context);
+ }
+
+ Future tryFetchReadReceipts(BuildContext context) async {
+ final store = PerAccountStoreWidget.of(context);
+ try {
+ final result = await getReadReceipts(store.connection, messageId: widget.messageId);
+
+ if (!context.mounted) return;
+ final storeNow = PerAccountStoreWidget.of(context);
+ if (!identical(store, storeNow)) return;
+
+ // TODO(i18n): add locale-aware sorting
+ userIds = result.userIds.sortedByCompare(
+ (id) => storeNow.userDisplayName(id),
+ (nameA, nameB) => nameA.toLowerCase().compareTo(nameB.toLowerCase()),
+ );
+ status = FetchStatus.success;
+ } catch (e) {
+ status = FetchStatus.error;
+ } finally {
+ setState(() {});
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // TODO could pull out this layout/appearance code,
+ // focusing this widget only on state management
+
+ return SizedBox(
+ height: 500, // TODO(design) tune
+ child: Column(
+ 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 zulipLocalizations = ZulipLocalizations.of(context);
+
+ WidgetBuilderFromTextStyle? headerSubtitleBuilder;
+ if (status == FetchStatus.success && receiptCount > 0) {
+ headerSubtitleBuilder = (TextStyle style) => TextWithLink(
+ onTap: () {
+ PlatformActions.launchUrl(context, PerAccountStoreWidget.of(context)
+ .tryResolveUrl(ReadReceipts._helpCenterReference)!);
+ },
+ style: style,
+ markup: zulipLocalizations.actionSheetReadReceiptsReadCount(receiptCount));
+ }
+
+ return Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: BottomSheetHeader(
+ title: zulipLocalizations.actionSheetReadReceipts,
+ buildSubtitle: headerSubtitleBuilder));
+ }
+}
+
+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);
+
+ return switch(status) {
+ FetchStatus.loading => BottomSheetEmptyContentPlaceholder(loading: true),
+ FetchStatus.error => BottomSheetEmptyContentPlaceholder(
+ message: localizations.actionSheetReadReceiptsErrorReadCount),
+ FetchStatus.success => userIds.isEmpty
+ ? BottomSheetEmptyContentPlaceholder(
+ message: localizations.actionSheetReadReceiptsZeroReadCount)
+ : InsetShadowBox(
+ top: 8, bottom: 8,
+ color: DesignVariables.of(context).bgContextMenu,
+ child: ListView.builder(
+ padding: EdgeInsets.symmetric(vertical: 8),
+ itemCount: userIds.length,
+ itemBuilder: (context, index) =>
+ ReadReceiptsUserItem(userId: userIds[index])))
+ };
+ }
+}
+
+
+// TODO: deduplicate the code with [ViewReactionsUserItem]
+@visibleForTesting
+class ReadReceiptsUserItem extends StatelessWidget {
+ const ReadReceiptsUserItem({super.key, required this.userId});
+
+ final int userId;
+
+ void _onPressed(BuildContext context) {
+ // Dismiss the action sheet.
+ Navigator.pop(context);
+
+ Navigator.push(context,
+ ProfilePage.buildRoute(context: context, userId: userId));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final store = PerAccountStoreWidget.of(context);
+ final designVariables = DesignVariables.of(context);
+
+ return InkWell(
+ onTap: () => _onPressed(context),
+ splashFactory: NoSplash.splashFactory,
+ overlayColor: WidgetStateColor.fromMap({
+ WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20),
+ }),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(spacing: 8, children: [
+ Avatar(
+ size: 32,
+ borderRadius: 3,
+ backgroundColor: designVariables.bgContextMenu,
+ userId: userId),
+ Flexible(
+ child: Text(
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 17,
+ height: 17 / 17,
+ color: designVariables.textMessage,
+ ).merge(weightVariableTextStyle(context, wght: 500)),
+ store.userDisplayName(userId))),
+ ])));
+ }
+}
diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index 03fa0f32bd..838dfff006 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -1,8 +1,11 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'theme.dart';
+
/// An app-wide [Typography] for Zulip, customized from the Material default.
///
/// Include this in the app-wide [MaterialApp.theme].
@@ -415,3 +418,109 @@ TextBaseline localizedTextBaseline(BuildContext context) {
ScriptCategory.tall => TextBaseline.alphabetic,
};
}
+
+/// A text widget with an embedded link.
+///
+/// The text and link are given in [markup], in a simple HTML-like markup.
+/// The markup string must not contain arbitrary user-controlled text.
+///
+/// The portion of the text that is the link will be styled as a link,
+/// and will respond to taps by calling the [onTap] callback.
+///
+/// If the entire text is meant to be a link, there's no need for this widget;
+/// instead, use [Text] inside a [GestureDetector], with [GestureDetector.onTap]
+/// invoking [PlatformActions.launchUrl].
+///
+/// TODO(#1285): Integrate this with l10n so that the markup can be parsed
+/// from the constant translated string, with placeholders for any variables,
+/// rather than the string that results from interpolating variables.
+/// That way it'll be fine to interpolate variables with arbitrary text.
+/// TODO(#1285): Generalize this to other styling, like code font and italics.
+/// TODO(#1553): Generalize this to multiple links in one string.
+class TextWithLink extends StatefulWidget {
+ const TextWithLink({super.key, this.style, required this.onTap, required this.markup});
+
+ final TextStyle? style;
+
+ /// A callback to be called when the user taps the link.
+ ///
+ /// Consider using [PlatformActions.launchUrl] to open a web page,
+ /// or [Navigator.push] to open a page of the app.
+ final VoidCallback onTap;
+
+ /// The text to display, in a simple HTML-like markup.
+ ///
+ /// This string must contain the tags `` and `` as substrings,
+ /// in that order, and must contain no other `<` characters.
+ ///
+ /// In particular this means the string must not contain any arbitrary
+ /// user-controlled text, which might have '<' characters.
+ ///
+ /// The contents other than the two tags will be shown as text.
+ /// The portion between the tags will be the link.
+ //
+ // (Why the name ``? Well, it matches Zulip web's practice;
+ // and here's the reasoning for that name there:
+ // https://github.com/zulip/zulip/pull/18075#discussion_r611067127
+ // )
+ final String markup;
+
+ @override
+ State createState() => _TextWithLinkState();
+}
+
+class _TextWithLinkState extends State {
+ late final GestureRecognizer _recognizer;
+
+ @override
+ void initState() {
+ super.initState();
+ _recognizer = TapGestureRecognizer()
+ ..onTap = widget.onTap;
+ }
+
+ @override
+ void dispose() {
+ _recognizer.dispose();
+ super.dispose();
+ }
+
+ static final _markupPattern = RegExp(r'^([^<]*)([^<]*)([^<]*)$');
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+
+ final match = _markupPattern.firstMatch(widget.markup);
+ final InlineSpan span;
+ if (match == null) {
+ // TODO(log): The markup text was invalid.
+ // Probably a translation (used by this widget's caller) didn't carry the
+ // syntax through correctly.
+ // This can also happen if the markup string contains user-controlled
+ // text (which is a bug) and that introduced a '<' character.
+ // Fall back to showing plain text.
+ // (It's important not to try to interpret any markup here, in case it
+ // comes buggily from user-controlled text.)
+ span = TextSpan(text: widget.markup);
+ } else {
+ span = TextSpan(children: [
+ TextSpan(text: match.group(1)),
+ TextSpan(text: match.group(2), recognizer: _recognizer,
+ style: TextStyle(
+ decoration: TextDecoration.underline,
+ decorationStyle: TextDecorationStyle.solid,
+ // We use the default value for this, because there's no obvious
+ // way to map the thickness value from the Figma design as it is
+ // a percentage of the font size.
+ decorationThickness: 1,
+ // decorationOffset: // TODO(upstream): https://github.com/flutter/flutter/issues/30541
+ color: designVariables.link,
+ decorationColor: designVariables.link)),
+ TextSpan(text: match.group(3)),
+ ]);
+ }
+
+ return Text.rich(span, style: widget.style);
+ }
+}
diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart
index 5aae0df89c..8680640f6b 100644
--- a/lib/widgets/theme.dart
+++ b/lib/widgets/theme.dart
@@ -179,6 +179,7 @@ class DesignVariables extends ThemeExtension {
labelMenuButton: const Color(0xff222222),
labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5),
labelTime: const Color(0x00000000).withValues(alpha: 0.49),
+ link: const Color(0xff066bd0), // from "Zulip Web UI kit"
listMenuItemBg: const Color(0xffcbcdd6),
listMenuItemIcon: const Color(0xff9194a3),
listMenuItemText: const Color(0xff2d303c),
@@ -269,6 +270,7 @@ class DesignVariables extends ThemeExtension {
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(0xff00aaff), // from "Zulip Web UI kit"
listMenuItemBg: const Color(0xff2d303c),
listMenuItemIcon: const Color(0xff767988),
listMenuItemText: const Color(0xffcbcdd6),
@@ -368,6 +370,7 @@ class DesignVariables extends ThemeExtension {
required this.labelMenuButton,
required this.labelSearchPrompt,
required this.labelTime,
+ required this.link,
required this.listMenuItemBg,
required this.listMenuItemIcon,
required this.listMenuItemText,
@@ -458,6 +461,7 @@ class DesignVariables extends ThemeExtension {
final Color labelMenuButton;
final Color labelSearchPrompt;
final Color labelTime;
+ final Color link;
final Color listMenuItemBg;
final Color listMenuItemIcon;
final Color listMenuItemText;
@@ -543,6 +547,7 @@ class DesignVariables extends ThemeExtension {
Color? labelMenuButton,
Color? labelSearchPrompt,
Color? labelTime,
+ Color? link,
Color? listMenuItemBg,
Color? listMenuItemIcon,
Color? listMenuItemText,
@@ -623,6 +628,7 @@ class DesignVariables extends ThemeExtension {
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,
@@ -710,6 +716,7 @@ class DesignVariables extends ThemeExtension {
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/test/api/route/messages_test.dart b/test/api/route/messages_test.dart
index a862b512b6..24be32fba2 100644
--- a/test/api/route/messages_test.dart
+++ b/test/api/route/messages_test.dart
@@ -829,4 +829,15 @@ void main() {
});
});
});
+
+ test('smoke getReadReceipts', () {
+ return FakeApiConnection.with_((connection) async {
+ final response = GetReadReceiptsResult(userIds: [7, 6543, 210]);
+ connection.prepare(json: response.toJson());
+ await getReadReceipts(connection, messageId: 123321);
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('GET')
+ ..url.path.equals('/api/v1/messages/123321/read_receipts');
+ });
+ });
}
diff --git a/test/example_data.dart b/test/example_data.dart
index 267d75d8ed..8761465db9 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -1224,6 +1224,7 @@ InitialSnapshot initialSnapshot({
int? realmWaitingPeriodThreshold,
bool? realmAllowMessageEditing,
int? realmMessageContentEditLimitSeconds,
+ bool? realmEnableReadReceipts,
bool? realmPresenceDisabled,
Map? realmDefaultExternalAccounts,
int? maxFileUploadSizeMib,
@@ -1271,6 +1272,7 @@ InitialSnapshot initialSnapshot({
realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0,
realmAllowMessageEditing: realmAllowMessageEditing ?? true,
realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds,
+ realmEnableReadReceipts: realmEnableReadReceipts ?? true,
realmPresenceDisabled: realmPresenceDisabled ?? false,
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25,
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 41e0462233..244535c4a1 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -32,6 +32,7 @@ import 'package:zulip/widgets/icons.dart';
import 'package:zulip/widgets/inbox.dart';
import 'package:zulip/widgets/message_list.dart';
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
+import 'package:zulip/widgets/read_receipts.dart';
import 'package:zulip/widgets/subscription_list.dart';
import 'package:zulip/widgets/user.dart';
import '../api/fake_api.dart';
@@ -62,6 +63,7 @@ Future setupToMessageActionSheet(WidgetTester tester, {
List? mutedUserIds,
bool? realmAllowMessageEditing,
int? realmMessageContentEditLimitSeconds,
+ bool? realmEnableReadReceipts,
bool shouldSetServerEmojiData = true,
bool useLegacyServerEmojiData = false,
Future Function()? beforeLongPress,
@@ -77,6 +79,7 @@ Future setupToMessageActionSheet(WidgetTester tester, {
eg.initialSnapshot(
realmAllowMessageEditing: realmAllowMessageEditing,
realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds,
+ realmEnableReadReceipts: realmEnableReadReceipts,
));
store = await testBinding.globalStore.perAccount(selfAccount.id);
await store.addUsers([
@@ -1278,6 +1281,52 @@ void main() {
});
});
+ group('ViewReadReceiptsButton', () {
+ final findButtonInSheet = find.descendant(
+ of: find.byType(BottomSheet),
+ matching: find.byIcon(ZulipIcons.check_check));
+
+ Future tapButton(WidgetTester tester) async {
+ await tester.ensureVisible(findButtonInSheet);
+ await tester.tap(findButtonInSheet);
+ await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+ }
+
+ testWidgets('smoke', (tester) async {
+ await setupToMessageActionSheet(tester,
+ message: eg.streamMessage(), narrow: CombinedFeedNarrow());
+
+ await tapButton(tester);
+
+ // The message action sheet exits and the view-reactions sheet enters.
+ //
+ // This just pumps through twice the duration of the latest transition.
+ // Ideally we'd check that the two expected transitions were triggered
+ // and that they started at the same time, and pump through the
+ // longer of the two durations.
+ // TODO(upstream) support this in TransitionDurationObserver
+ await transitionDurationObserver.pumpPastTransition(tester);
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ // message action sheet exited
+ check(find.ancestor(of: find.byIcon(ZulipIcons.check_check),
+ matching: find.byType(BottomSheet))).findsNothing();
+
+ // receipts sheet opened
+ check(find.ancestor(of: find.byType(ReadReceipts),
+ matching: find.byType(BottomSheet))).findsOne();
+ });
+
+ testWidgets('realm-level read receipts disabled -> button is absent', (tester) async {
+ await setupToMessageActionSheet(tester,
+ message: eg.streamMessage(),
+ narrow: CombinedFeedNarrow(),
+ realmEnableReadReceipts: false);
+
+ check(findButtonInSheet).findsNothing();
+ });
+ });
+
group('StarButton', () {
Future tapButton(WidgetTester tester, {bool starred = false}) async {
// Starred messages include the same icon so we need to
diff --git a/test/widgets/read_receipts_test.dart b/test/widgets/read_receipts_test.dart
new file mode 100644
index 0000000000..a716fc2985
--- /dev/null
+++ b/test/widgets/read_receipts_test.dart
@@ -0,0 +1,162 @@
+import 'dart:io';
+
+import 'package:checks/checks.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_checks/flutter_checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/route/messages.dart';
+import 'package:zulip/model/narrow.dart';
+import 'package:zulip/model/store.dart';
+import 'package:zulip/widgets/content.dart';
+import 'package:zulip/widgets/icons.dart';
+import 'package:zulip/widgets/message_list.dart';
+import 'package:zulip/widgets/profile.dart';
+import 'package:zulip/widgets/read_receipts.dart';
+
+import '../api/fake_api.dart';
+import '../example_data.dart' as eg;
+import '../model/binding.dart';
+import '../model/test_store.dart';
+import '../stdlib_checks.dart';
+import 'test_app.dart';
+
+void main() {
+ TestZulipBinding.ensureInitialized();
+
+ late PerAccountStore store;
+ late FakeApiConnection connection;
+ late TransitionDurationObserver transitionDurationObserver;
+
+ Future setupReceiptsSheet(WidgetTester tester, {
+ required int messageId,
+ required List users,
+ ValueGetter>? prepareReceiptsResponseSuccess,
+ ValueGetter