Skip to content

Commit 64f3451

Browse files
committed
action_sheet: Add "Mark as unread from here" button
Fixes: #131
1 parent 7285325 commit 64f3451

File tree

3 files changed

+152
-0
lines changed

3 files changed

+152
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
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,10 @@
432436
"@errorMarkAsReadFailedTitle": {
433437
"description": "Error title when mark as read action failed."
434438
},
439+
"errorMarkAsUnreadFailedTitle": "Mark as unread failed",
440+
"@errorMarkAsUnreadFailedTitle": {
441+
"description": "Error title when mark as unread action failed."
442+
},
435443
"today": "Today",
436444
"@today": {
437445
"description": "Term to use to reference the current day."

lib/widgets/action_sheet.dart

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
3636
&& reactionWithVotes.userIds.contains(store.selfUserId))
3737
?? false;
3838

39+
final isMessageRead = message.flags.contains(MessageFlag.read);
40+
3941
showDraggableScrollableModalBottomSheet<void>(
4042
context: context,
4143
builder: (BuildContext _) {
@@ -49,6 +51,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
4951
CopyMessageTextButton(message: message, messageListContext: context),
5052
CopyMessageLinkButton(message: message, messageListContext: context),
5153
ShareButton(message: message, messageListContext: context),
54+
if (isMessageRead) MarkAsUnreadButton(message: message, messageListContext: context),
5255
]);
5356
});
5457
}
@@ -402,3 +405,70 @@ class ShareButton extends MessageActionSheetMenuItemButton {
402405
}
403406
}
404407
}
408+
409+
class MarkAsUnreadButton extends MessageActionSheetMenuItemButton {
410+
MarkAsUnreadButton({
411+
super.key,
412+
required super.message,
413+
required super.messageListContext,
414+
}) : narrow = MessageListPage.ancestorOf(messageListContext).narrow;
415+
416+
final Narrow narrow;
417+
418+
@override IconData get icon => Icons.mark_chat_unread_outlined;
419+
420+
@override
421+
String label(ZulipLocalizations zulipLocalizations) {
422+
return zulipLocalizations.actionSheetMarkAsUnread;
423+
}
424+
425+
@override void onPressed(BuildContext context) async {
426+
Navigator.of(context).pop();
427+
final zulipLocalizations = ZulipLocalizations.of(messageListContext);
428+
429+
try {
430+
final store = PerAccountStoreWidget.of(messageListContext);
431+
final connection = store.connection;
432+
Anchor anchor = NumericAnchor(message.id);
433+
final apiNarrow = narrow.apiEncode();
434+
435+
while (true) {
436+
final result = await updateMessageFlagsForNarrow(connection,
437+
anchor: anchor,
438+
includeAnchor: true,
439+
numBefore: 0,
440+
numAfter: 1000,
441+
narrow: apiNarrow,
442+
op: UpdateMessageFlagsOp.remove,
443+
flag: MessageFlag.read);
444+
445+
if (!context.mounted) return;
446+
447+
if (result.foundNewest) return;
448+
449+
if (result.lastProcessedId == null) {
450+
// No messages were in the range of the request.
451+
// This should be impossible given that `foundNewest` was false
452+
// (and that our `numAfter` was positive.)
453+
await showErrorDialog(context: messageListContext,
454+
title: zulipLocalizations.errorMarkAsUnreadFailedTitle,
455+
message: zulipLocalizations.errorInvalidResponse);
456+
return;
457+
}
458+
anchor = NumericAnchor(result.lastProcessedId!);
459+
}
460+
} catch (e) {
461+
if (!messageListContext.mounted) return;
462+
463+
var errorMessage = zulipLocalizations.errorInvalidResponse;
464+
switch (e) {
465+
case ZulipApiException():
466+
errorMessage = e.message;
467+
default:
468+
}
469+
470+
await showErrorDialog(context: messageListContext,
471+
title: zulipLocalizations.errorMarkAsUnreadFailedTitle, message: errorMessage);
472+
}
473+
}
474+
}

test/widgets/action_sheet_test.dart

Lines changed: 74 additions & 0 deletions
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)