Skip to content

Commit f6e803a

Browse files
committed
action_sheet: Add Mark as unread from here button
Fixes: zulip#613
1 parent 22c0f3e commit f6e803a

File tree

3 files changed

+155
-0
lines changed

3 files changed

+155
-0
lines changed

assets/l10n/app_en.arb

+12
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
"@actionSheetOptionCopyMessageLink": {
5252
"description": "Label for copy message link button on action sheet."
5353
},
54+
"actionSheetMarkAsUnread": "Mark as unread from here",
55+
"@actionSheetMarkAsUnread": {
56+
"description": "Label for mark as unread button on action sheet."
57+
},
5458
"actionSheetOptionShare": "Share",
5559
"@actionSheetOptionShare": {
5660
"description": "Label for share button on action sheet."
@@ -432,6 +436,14 @@
432436
"@errorMarkAsReadFailedTitle": {
433437
"description": "Error title when mark as read action failed."
434438
},
439+
"markAsUnreadInProgress": "Marking messages as unread...",
440+
"@markAsUnreadInProgress": {
441+
"description": "Progress message when marking messages as unread."
442+
},
443+
"errorMarkAsUnreadFailedTitle": "Mark as unread failed",
444+
"@errorMarkAsUnreadFailedTitle": {
445+
"description": "Error title when mark as unread action failed."
446+
},
435447
"today": "Today",
436448
"@today": {
437449
"description": "Term to use to reference the current day."

lib/widgets/action_sheet.dart

+69
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes
4848
CopyMessageTextButton(message: message, messageListContext: context),
4949
CopyMessageLinkButton(message: message, messageListContext: context),
5050
ShareButton(message: message, messageListContext: context),
51+
if (message.flags.contains(MessageFlag.read)) MarkAsUnread(
52+
message: message,
53+
messageListContext: context),
5154
]);
5255
});
5356
}
@@ -400,3 +403,69 @@ class ShareButton extends MessageActionSheetMenuItemButton {
400403
}
401404
}
402405
}
406+
407+
class MarkAsUnread extends MessageActionSheetMenuItemButton {
408+
MarkAsUnread({
409+
super.key,
410+
required super.message,
411+
required super.messageListContext,
412+
});
413+
414+
@override IconData get icon => Icons.mark_chat_unread_outlined;
415+
416+
@override
417+
String label(ZulipLocalizations zulipLocalizations) {
418+
return zulipLocalizations.actionSheetMarkAsUnread;
419+
}
420+
421+
@override void onPressed(BuildContext context) async {
422+
Navigator.of(context).pop();
423+
final zulipLocalizations = ZulipLocalizations.of(messageListContext);
424+
425+
try {
426+
final store = PerAccountStoreWidget.of(messageListContext);
427+
final narrow = SendableNarrow.ofMessage(message, selfUserId: store.selfUserId);
428+
final connection = store.connection;
429+
Anchor anchor = NumericAnchor(message.id);
430+
final apiNarrow = narrow.apiEncode();
431+
432+
while (true) {
433+
final result = await updateMessageFlagsForNarrow(connection,
434+
anchor: anchor,
435+
includeAnchor: true,
436+
numBefore: 0,
437+
numAfter: 1000,
438+
narrow: apiNarrow,
439+
op: UpdateMessageFlagsOp.remove,
440+
flag: MessageFlag.read);
441+
442+
if (!context.mounted) return;
443+
444+
if (result.foundNewest) return;
445+
446+
if (result.lastProcessedId == null) {
447+
// No messages were in the range of the request.
448+
// This should be impossible given that `foundNewest` was false
449+
// (and that our `numAfter` was positive.)
450+
await showErrorDialog(context: messageListContext,
451+
title: zulipLocalizations.errorMarkAsUnreadFailedTitle,
452+
message: zulipLocalizations.errorInvalidResponse);
453+
return;
454+
}
455+
anchor = NumericAnchor(result.lastProcessedId!);
456+
}
457+
} catch (e) {
458+
if (!messageListContext.mounted) return;
459+
460+
var errorMessage = zulipLocalizations.errorInvalidResponse;
461+
switch (e) {
462+
case ZulipApiException():
463+
errorMessage = e.message;
464+
default:
465+
}
466+
467+
await showErrorDialog(context: context,
468+
title: zulipLocalizations.errorMarkAsUnreadFailedTitle, message: errorMessage);
469+
}
470+
}
471+
}

test/widgets/action_sheet_test.dart

+74
Original file line numberDiff line numberDiff line change
@@ -545,4 +545,78 @@ void main() {
545545
check(mockSharePlus.sharedString).isNull();
546546
});
547547
});
548+
549+
group('MarkAsUnread', () {
550+
Future<void> tapButton(WidgetTester tester) async {
551+
await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false));
552+
await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined));
553+
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
554+
}
555+
556+
testWidgets('not visible if message is not read', (WidgetTester tester) async {
557+
final unreadMessage = eg.streamMessage(flags: []);
558+
await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage));
559+
560+
check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate().length).equals(0);
561+
});
562+
563+
testWidgets('visible if message is read', (WidgetTester tester) async {
564+
final message = eg.streamMessage(flags: [MessageFlag.read]);
565+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
566+
567+
check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate().length).equals(1);
568+
});
569+
570+
testWidgets('success', (WidgetTester tester) async {
571+
final message = eg.streamMessage(flags: [MessageFlag.read]);
572+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
573+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
574+
575+
final connection = store.connection as FakeApiConnection;
576+
connection.prepare(json: UpdateMessageFlagsForNarrowResult(
577+
processedCount: 11,
578+
updatedCount: 3,
579+
firstProcessedId: null,
580+
lastProcessedId: null,
581+
foundOldest: false,
582+
foundNewest: true).toJson());
583+
584+
await tapButton(tester);
585+
await tester.pump(Duration.zero);
586+
587+
check(connection.lastRequest).isA<http.Request>()
588+
..method.equals('POST')
589+
..url.path.equals('/api/v1/messages/flags/narrow')
590+
..bodyFields.deepEquals({
591+
'num_before': '0',
592+
'num_after': '1000',
593+
'op': 'remove',
594+
'flag': 'read',
595+
'include_anchor': 'true',
596+
'anchor': '${message.id}',
597+
'narrow': '[{"operator":"stream","operand":${message.streamId}},{"operator":"topic","operand":"${message.topic}"}]',
598+
});
599+
});
600+
601+
testWidgets('request has an error', (WidgetTester tester) async {
602+
final message = eg.streamMessage(flags: [MessageFlag.read]);
603+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
604+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
605+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
606+
607+
final connection = store.connection as FakeApiConnection;
608+
609+
connection.prepare(httpStatus: 400, json: {
610+
'code': 'BAD_REQUEST',
611+
'msg': 'Invalid message(s)',
612+
'result': 'error',
613+
});
614+
await tapButton(tester);
615+
await tester.pump(Duration.zero); // error arrives; error dialog shows
616+
617+
await tester.tap(find.byWidget(checkErrorDialog(tester,
618+
expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle,
619+
expectedMessage: 'Invalid message(s)')));
620+
});
621+
});
548622
}

0 commit comments

Comments
 (0)