Skip to content

Commit 2f5ec06

Browse files
committed
action_sheet: Add "Mark as unread from here" button
Fixes: zulip#131
1 parent 71eec7c commit 2f5ec06

File tree

4 files changed

+134
-1
lines changed

4 files changed

+134
-1
lines changed

assets/l10n/app_en.arb

Lines changed: 19 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+
"actionSheetOptionMarkAsUnread": "Mark as unread from here",
55+
"@actionSheetOptionMarkAsUnread": {
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."
@@ -436,6 +440,21 @@
436440
"@errorMarkAsReadFailedTitle": {
437441
"description": "Error title when mark as read action failed."
438442
},
443+
"markAsUnreadComplete": "Marked {num, plural, =1{1 message} other{{num} messages}} as unread.",
444+
"@markAsUnreadComplete": {
445+
"description": "Message when marking messages as unread has completed.",
446+
"placeholders": {
447+
"num": {"type": "int", "example": "4"}
448+
}
449+
},
450+
"markAsUnreadInProgress": "Marking messages as unread...",
451+
"@markAsUnreadInProgress": {
452+
"description": "Progress message when marking messages as unread."
453+
},
454+
"errorMarkAsUnreadFailedTitle": "Mark as unread failed",
455+
"@errorMarkAsUnreadFailedTitle": {
456+
"description": "Error title when mark as unread action failed."
457+
},
439458
"today": "Today",
440459
"@today": {
441460
"description": "Term to use to reference the current day."

lib/widgets/action_sheet.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../api/model/model.dart';
88
import '../api/route/messages.dart';
99
import '../model/internal_link.dart';
1010
import '../model/narrow.dart';
11+
import 'actions.dart';
1112
import 'clipboard.dart';
1213
import 'compose_box.dart';
1314
import 'dialog.dart';
@@ -28,6 +29,10 @@ void showMessageActionSheet({required BuildContext context, required Message mes
2829
// any message list, so that's fine.
2930
final messageListPage = MessageListPage.ancestorOf(context);
3031
final isComposeBoxOffered = messageListPage.composeBoxController != null;
32+
final narrow = messageListPage.narrow;
33+
final isMessageRead = message.flags.contains(MessageFlag.read);
34+
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
35+
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
3136

3237
final hasThumbsUpReactionVote = message.reactions
3338
?.aggregated.any((reactionWithVotes) =>
@@ -46,6 +51,11 @@ void showMessageActionSheet({required BuildContext context, required Message mes
4651
message: message,
4752
messageListContext: context,
4853
),
54+
if (showMarkAsUnreadButton) MarkAsUnreadButton(
55+
message: message,
56+
messageListContext: context,
57+
narrow: narrow,
58+
),
4959
CopyMessageTextButton(message: message, messageListContext: context),
5060
CopyMessageLinkButton(message: message, messageListContext: context),
5161
ShareButton(message: message, messageListContext: context),
@@ -278,6 +288,29 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
278288
}
279289
}
280290

291+
class MarkAsUnreadButton extends MessageActionSheetMenuItemButton {
292+
MarkAsUnreadButton({
293+
super.key,
294+
required super.message,
295+
required super.messageListContext,
296+
required this.narrow,
297+
});
298+
299+
final Narrow narrow;
300+
301+
@override IconData get icon => Icons.mark_chat_unread_outlined;
302+
303+
@override
304+
String label(ZulipLocalizations zulipLocalizations) {
305+
return zulipLocalizations.actionSheetOptionMarkAsUnread;
306+
}
307+
308+
@override void onPressed(BuildContext context) async {
309+
Navigator.of(context).pop();
310+
markNarrowAsUnreadFromMessage(messageListContext, message, narrow);
311+
}
312+
}
313+
281314
class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
282315
CopyMessageTextButton({
283316
super.key,

lib/widgets/actions.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ Future<void> markNarrowAsRead(BuildContext context, Narrow narrow) async {
6262
}
6363
}
6464

65+
Future<void> markNarrowAsUnreadFromMessage(
66+
BuildContext context,
67+
Message message,
68+
Narrow narrow,
69+
) async {
70+
final connection = PerAccountStoreWidget.of(context).connection;
71+
assert(connection.zulipFeatureLevel! >= 155); // TODO(server-6)
72+
final zulipLocalizations = ZulipLocalizations.of(context);
73+
await updateMessageFlagsStartingFromAnchor(
74+
context: context,
75+
apiNarrow: narrow.apiEncode(),
76+
startingAnchor: NumericAnchor(message.id),
77+
includeAnchor: true,
78+
op: UpdateMessageFlagsOp.remove,
79+
flag: MessageFlag.read,
80+
onCompletedMessage: zulipLocalizations.markAsUnreadComplete,
81+
progressMessage: zulipLocalizations.markAsUnreadInProgress,
82+
onFailedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle);
83+
}
84+
6585
/// Updates message flags by applying given operation `op` using given `flag`
6686
/// the update happens on given `apiNarrow` starting from given `startingAnchor`
6787
///

test/widgets/action_sheet_test.dart

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import 'compose_box_checks.dart';
3131
import 'dialog_checks.dart';
3232
import 'test_app.dart';
3333

34+
late FakeApiConnection connection;
35+
3436
/// Simulates loading a [MessageListPage] and long-pressing on [message].
3537
Future<void> setupToMessageActionSheet(WidgetTester tester, {
3638
required Message message,
@@ -46,7 +48,7 @@ Future<void> setupToMessageActionSheet(WidgetTester tester, {
4648
await store.addStream(stream);
4749
await store.addSubscription(eg.subscription(stream));
4850
}
49-
final connection = store.connection as FakeApiConnection;
51+
connection = store.connection as FakeApiConnection;
5052

5153
// prepare message list data
5254
connection.prepare(json: GetMessagesResult(
@@ -385,6 +387,65 @@ void main() {
385387
});
386388
});
387389

390+
group('MarkAsUnread', () {
391+
testWidgets('not visible if message is not read', (WidgetTester tester) async {
392+
final unreadMessage = eg.streamMessage(flags: []);
393+
await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage));
394+
395+
check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty();
396+
});
397+
398+
testWidgets('visible if message is read', (WidgetTester tester) async {
399+
final readMessage = eg.streamMessage(flags: [MessageFlag.read]);
400+
await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage));
401+
402+
check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single;
403+
});
404+
405+
group('onPressed', () {
406+
testWidgets('smoke test', (WidgetTester tester) async {
407+
final message = eg.streamMessage(flags: [MessageFlag.read]);
408+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
409+
410+
connection.prepare(json: UpdateMessageFlagsForNarrowResult(
411+
processedCount: 11, updatedCount: 3,
412+
firstProcessedId: 1, lastProcessedId: 1980,
413+
foundOldest: true, foundNewest: true).toJson());
414+
415+
await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false));
416+
await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false));
417+
await tester.pumpAndSettle();
418+
check(connection.lastRequest).isA<http.Request>()
419+
..method.equals('POST')
420+
..url.path.equals('/api/v1/messages/flags/narrow')
421+
..bodyFields.deepEquals({
422+
'anchor': '${message.id}',
423+
'include_anchor': 'true',
424+
'num_before': '0',
425+
'num_after': '1000',
426+
'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()),
427+
'op': 'remove',
428+
'flag': 'read',
429+
});
430+
});
431+
432+
testWidgets('shows error when fails', (WidgetTester tester) async {
433+
final message = eg.streamMessage(flags: [MessageFlag.read]);
434+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
435+
436+
connection.prepare(exception: http.ClientException('Oops'));
437+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
438+
439+
await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false));
440+
await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false));
441+
await tester.pumpAndSettle();
442+
checkErrorDialog(tester,
443+
expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle,
444+
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
445+
});
446+
});
447+
});
448+
388449
group('CopyMessageTextButton', () {
389450
setUp(() async {
390451
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(

0 commit comments

Comments
 (0)