Skip to content

Commit af14f10

Browse files
committed
action_sheet: Add "Mark Topic As Read" button
Adds button to mark all messages in a topic as read. The button: - Appears only when the topic has unread messages - Uses mark_topic_as_read API for server feature level < 155 - Uses messages/flags/narrow API for server feature level >= 155 - Shows error dialog if the request fails fixes: zulip#1225
1 parent abec662 commit af14f10

12 files changed

+284
-1
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -680,5 +680,13 @@
680680
"emojiPickerSearchEmoji": "Search emoji",
681681
"@emojiPickerSearchEmoji": {
682682
"description": "Hint text for the emoji picker search text field."
683+
},
684+
"actionSheetOptionMarkTopicAsRead": "Mark Topic As Read",
685+
"@actionSheetOptionMarkTopicAsRead": {
686+
"description": "Option to mark a specific topic as read in the action sheet."
687+
},
688+
"errorMarkTopicAsReadFailed": "Failed to mark the topic as read. Please try again.",
689+
"@errorMarkTopicAsReadFailed": {
690+
"description": "Error message displayed when marking a topic as read fails."
683691
}
684692
}

lib/generated/l10n/zulip_localizations.dart

+12
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,18 @@ abstract class ZulipLocalizations {
10161016
/// In en, this message translates to:
10171017
/// **'Search emoji'**
10181018
String get emojiPickerSearchEmoji;
1019+
1020+
/// Option to mark a specific topic as read in the action sheet.
1021+
///
1022+
/// In en, this message translates to:
1023+
/// **'Mark Topic As Read'**
1024+
String get actionSheetOptionMarkTopicAsRead;
1025+
1026+
/// Error message displayed when marking a topic as read fails.
1027+
///
1028+
/// In en, this message translates to:
1029+
/// **'Failed to mark the topic as read. Please try again.'**
1030+
String get errorMarkTopicAsReadFailed;
10191031
}
10201032

10211033
class _ZulipLocalizationsDelegate extends LocalizationsDelegate<ZulipLocalizations> {

lib/generated/l10n/zulip_localizations_ar.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Search emoji';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/generated/l10n/zulip_localizations_en.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Search emoji';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/generated/l10n/zulip_localizations_ja.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Search emoji';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/generated/l10n/zulip_localizations_nb.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Search emoji';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/generated/l10n/zulip_localizations_pl.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Szukaj emoji';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/generated/l10n/zulip_localizations_ru.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Поиск эмодзи';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/generated/l10n/zulip_localizations_sk.dart

+6
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
537537

538538
@override
539539
String get emojiPickerSearchEmoji => 'Hľadať emotikon';
540+
541+
@override
542+
String get actionSheetOptionMarkTopicAsRead => 'Mark Topic As Read';
543+
544+
@override
545+
String get errorMarkTopicAsReadFailed => 'Failed to mark the topic as read. Please try again.';
540546
}

lib/widgets/action_sheet.dart

+64
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';
88

99
import '../api/exception.dart';
1010
import '../api/model/model.dart';
11+
import '../api/model/narrow.dart';
1112
import '../api/route/channels.dart';
1213
import '../api/route/messages.dart';
1314
import '../generated/l10n/zulip_localizations.dart';
@@ -240,6 +241,14 @@ void showTopicActionSheet(BuildContext context, {
240241
pageContext: context);
241242
}));
242243

244+
final unreadCount = store.unreads.countInTopicNarrow(channelId, topic);
245+
if (unreadCount > 0) {
246+
optionButtons.add(MarkTopicAsReadButton(
247+
channelId: channelId,
248+
topic: topic,
249+
pageContext: context));
250+
}
251+
243252
if (optionButtons.isEmpty) {
244253
// TODO(a11y): This case makes a no-op gesture handler; as a consequence,
245254
// we're presenting some UI (to people who use screen-reader software) as
@@ -372,6 +381,61 @@ class UserTopicUpdateButton extends ActionSheetMenuItemButton {
372381
}
373382
}
374383

384+
class MarkTopicAsReadButton extends ActionSheetMenuItemButton {
385+
const MarkTopicAsReadButton({
386+
super.key,
387+
required this.channelId,
388+
required this.topic,
389+
required super.pageContext,
390+
});
391+
392+
final int channelId;
393+
final TopicName topic;
394+
395+
@override IconData get icon => Icons.mark_chat_read_outlined;
396+
397+
@override
398+
String label(ZulipLocalizations zulipLocalizations) {
399+
return zulipLocalizations.actionSheetOptionMarkTopicAsRead;
400+
}
401+
402+
@override void onPressed() async {
403+
final store = PerAccountStoreWidget.of(pageContext);
404+
final connection = store.connection;
405+
final zulipLocalizations = ZulipLocalizations.of(pageContext);
406+
407+
try {
408+
if (connection.zulipFeatureLevel! >= 155) {
409+
await updateMessageFlagsForNarrow(connection,
410+
anchor: AnchorCode.oldest,
411+
numBefore: 0,
412+
numAfter: 1000,
413+
narrow: TopicNarrow(channelId, topic).apiEncode()
414+
..add(ApiNarrowIs(IsOperand.unread)),
415+
op: UpdateMessageFlagsOp.add,
416+
flag: MessageFlag.read);
417+
} else {
418+
await markTopicAsRead(connection,
419+
streamId: channelId,
420+
topicName: topic);
421+
}
422+
} catch (e) {
423+
if (!pageContext.mounted) return;
424+
425+
String? errorMessage;
426+
switch (e) {
427+
case ZulipApiException():
428+
errorMessage = e.message;
429+
default:
430+
}
431+
432+
showErrorDialog(context: pageContext,
433+
title: zulipLocalizations.errorMarkTopicAsReadFailed,
434+
message: errorMessage);
435+
}
436+
}
437+
}
438+
375439
/// Show a sheet of actions you can take on a message in the message list.
376440
///
377441
/// Must have a [MessageListPage] ancestor.

lib/widgets/message_list.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,7 @@ class _MarkAsReadWidgetState extends State<MarkAsReadWidget> {
830830
backgroundColor: WidgetStatePropertyAll(messageListTheme.unreadMarker),
831831
),
832832
onPressed: _loading ? null : () => _handlePress(context),
833-
icon: const Icon(Icons.playlist_add_check),
833+
icon: const Icon(Icons.mark_chat_read_outlined),
834834
label: Text(zulipLocalizations.markAllAsReadLabel))))));
835835
}
836836
}

test/widgets/action_sheet_test.dart

+157
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,163 @@ void main() {
10611061
});
10621062
});
10631063

1064+
group('MarkTopicAsReadButton', () {
1065+
Future<void> setupToTopicActionSheetWithUnreadMessages(WidgetTester tester, {
1066+
int? zulipFeatureLevel,
1067+
ZulipStream? channel,
1068+
}) async {
1069+
addTearDown(testBinding.reset);
1070+
1071+
final effectiveChannel = channel ?? eg.stream();
1072+
const topicName = TopicName('test topic');
1073+
final message = eg.streamMessage(stream: effectiveChannel, topic: 'test topic');
1074+
final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel);
1075+
await testBinding.globalStore.add(account, eg.initialSnapshot(
1076+
realmUsers: [eg.selfUser],
1077+
streams: [effectiveChannel],
1078+
subscriptions: [eg.subscription(effectiveChannel)],
1079+
zulipFeatureLevel: zulipFeatureLevel));
1080+
store = await testBinding.globalStore.perAccount(account.id);
1081+
connection = store.connection as FakeApiConnection;
1082+
1083+
connection.prepare(json: eg.newestGetMessagesResult(
1084+
foundOldest: true, messages: [message]).toJson());
1085+
1086+
await store.addMessage(message);
1087+
store.unreads.streams[effectiveChannel.streamId] ??= {};
1088+
store.unreads.streams[effectiveChannel.streamId]![topicName] ??= QueueList<int>();
1089+
store.unreads.streams[effectiveChannel.streamId]![topicName]!.add(message.id);
1090+
1091+
await tester.pumpWidget(TestZulipApp(accountId: account.id,
1092+
child: MessageListPage(initNarrow: TopicNarrow(effectiveChannel.streamId, topicName))));
1093+
await tester.pumpAndSettle();
1094+
1095+
await tester.longPress(find.byType(ZulipAppBar));
1096+
await tester.pump(const Duration(milliseconds: 250));
1097+
}
1098+
1099+
Future<void> setupToTopicActionSheetWithNoUnreadMessages(WidgetTester tester) async {
1100+
addTearDown(testBinding.reset);
1101+
1102+
final channel = eg.stream();
1103+
const topicName = TopicName('test topic');
1104+
final message = eg.streamMessage(stream: channel, topic: 'test topic', flags: [MessageFlag.read]);
1105+
1106+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
1107+
realmUsers: [eg.selfUser],
1108+
streams: [channel],
1109+
subscriptions: [eg.subscription(channel)]));
1110+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
1111+
connection = store.connection as FakeApiConnection;
1112+
1113+
connection.prepare(json: eg.newestGetMessagesResult(
1114+
foundOldest: true, messages: [message]).toJson());
1115+
1116+
await store.addMessage(message);
1117+
1118+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1119+
child: MessageListPage(initNarrow: TopicNarrow(channel.streamId, topicName))));
1120+
await tester.pumpAndSettle();
1121+
1122+
await tester.longPress(find.byType(ZulipAppBar));
1123+
await tester.pump(const Duration(milliseconds: 250));
1124+
}
1125+
1126+
Future<void> tapMarkTopicAsReadButton(WidgetTester tester) async {
1127+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
1128+
await tester.tap(find.text(zulipLocalizations.actionSheetOptionMarkTopicAsRead));
1129+
await tester.pump();
1130+
}
1131+
1132+
group('visibility', () {
1133+
testWidgets('shows button when topic has unread messages', (tester) async {
1134+
await setupToTopicActionSheetWithUnreadMessages(tester);
1135+
1136+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
1137+
check(find.text(zulipLocalizations.actionSheetOptionMarkTopicAsRead)).findsOne();
1138+
});
1139+
1140+
testWidgets('hides button when topic has no unread messages', (tester) async {
1141+
await setupToTopicActionSheetWithNoUnreadMessages(tester);
1142+
1143+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
1144+
check(find.text(zulipLocalizations.actionSheetOptionMarkTopicAsRead)).findsNothing();
1145+
});
1146+
});
1147+
1148+
group('API requests', () {
1149+
testWidgets('sends mark_topic_as_read request for server feature level < 155', (tester) async {
1150+
final channel = eg.stream();
1151+
await setupToTopicActionSheetWithUnreadMessages(tester,
1152+
zulipFeatureLevel: 154,
1153+
channel: channel);
1154+
1155+
connection.prepare(json: {'result': 'success'});
1156+
await tapMarkTopicAsReadButton(tester);
1157+
await tester.pumpAndSettle();
1158+
1159+
check(connection.lastRequest).isA<http.Request>()
1160+
..method.equals('POST')
1161+
..url.path.equals('/api/v1/mark_topic_as_read')
1162+
..bodyFields.deepEquals({
1163+
'stream_id': '${channel.streamId}',
1164+
'topic_name': 'test topic',
1165+
});
1166+
// await tester.pumpAndSettle();
1167+
});
1168+
1169+
testWidgets('sends messages/flags/narrow request for server feature level >= 155', (tester) async {
1170+
final channel = eg.stream();
1171+
await setupToTopicActionSheetWithUnreadMessages(tester,
1172+
zulipFeatureLevel: 155,
1173+
channel: channel);
1174+
1175+
connection.prepare(json: UpdateMessageFlagsForNarrowResult(
1176+
processedCount: 11,
1177+
updatedCount: 3,
1178+
firstProcessedId: 1,
1179+
lastProcessedId: 1980,
1180+
foundOldest: true,
1181+
foundNewest: true).toJson());
1182+
1183+
await tapMarkTopicAsReadButton(tester);
1184+
await tester.pumpAndSettle();
1185+
1186+
check(connection.lastRequest).isA<http.Request>()
1187+
..method.equals('POST')
1188+
..url.path.equals('/api/v1/messages/flags/narrow')
1189+
..bodyFields.deepEquals({
1190+
'anchor': 'oldest',
1191+
'num_before': '0',
1192+
'num_after': '1000',
1193+
'narrow': jsonEncode([
1194+
{'operator': 'stream', 'operand': channel.streamId},
1195+
{'operator': 'topic', 'operand': 'test topic'},
1196+
{'operator': 'is', 'operand': 'unread'},
1197+
]),
1198+
'op': 'add',
1199+
'flag': 'read',
1200+
});
1201+
});
1202+
1203+
testWidgets('shows error dialog when mark-as-read request fails', (tester) async {
1204+
final channel = eg.stream();
1205+
await setupToTopicActionSheetWithUnreadMessages(tester,
1206+
zulipFeatureLevel: 154,
1207+
channel: channel);
1208+
1209+
prepareRawContentResponseError();
1210+
await tapMarkTopicAsReadButton(tester);
1211+
await tester.pumpAndSettle();
1212+
1213+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
1214+
checkErrorDialog(tester,
1215+
expectedTitle: zulipLocalizations.errorMarkTopicAsReadFailed,
1216+
expectedMessage: 'Invalid message(s)');
1217+
});
1218+
});
1219+
});
1220+
10641221
group('MessageActionSheetCancelButton', () {
10651222
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
10661223

0 commit comments

Comments
 (0)