Skip to content

Commit 0731b86

Browse files
committed
msglist: Add MarkAsRead widget
1 parent 33c0f92 commit 0731b86

File tree

3 files changed

+174
-6
lines changed

3 files changed

+174
-6
lines changed

assets/l10n/app_en.arb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@
289289
"num": {"type": "int", "example": "4"}
290290
}
291291
},
292+
"errorInvalidResponse": "The server sent an invalid response",
293+
"@errorInvalidResponse": {
294+
"description": "Error message when an API call returned an invalid response."
295+
},
292296
"errorNetworkRequestFailed": "Network request failed",
293297
"@errorNetworkRequestFailed": {
294298
"description": "Error message when a network request fails."
@@ -323,6 +327,28 @@
323327
"@serverUrlValidationErrorUnsupportedScheme": {
324328
"description": "Error message when URL has an unsupported scheme."
325329
},
330+
"markAsReadLabel": "Mark {num, plural, =1{1 message} other{{num} messages}} as read",
331+
"@markAsReadLabel": {
332+
"description": "Button text to mark messages as read.",
333+
"placeholders": {
334+
"num": {"type": "int", "example": "4"}
335+
}
336+
},
337+
"markAsReadComplete": "Marked {num, plural, =1{1 message} other{{num} messages}} as read.",
338+
"@markAsReadComplete": {
339+
"description": "SnackBar message when marking messages as read",
340+
"placeholders": {
341+
"num": {"type": "int", "example": "4"}
342+
}
343+
},
344+
"markAsReadInProgress": "Marking messages as read...",
345+
"@markAsReadInProgress": {
346+
"description": "SnackBar message when marking messages as read"
347+
},
348+
"errorMarkAsReadFailedTitle": "Mark as read failed",
349+
"@errorMarkAsReadFailedTitle": {
350+
"description": "Error title when mark as read action failed."
351+
},
326352
"userRoleOwner": "Owner",
327353
"@userRoleOwner": {
328354
"description": "Label for UserRole.owner"

lib/widgets/message_list.dart

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ import 'dart:math';
22

33
import 'package:collection/collection.dart';
44
import 'package:flutter/material.dart';
5+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
56
import 'package:intl/intl.dart';
67

8+
import '../api/core.dart';
79
import '../api/model/model.dart';
10+
import '../api/route/messages.dart';
811
import '../model/message_list.dart';
912
import '../model/narrow.dart';
1013
import '../model/store.dart';
1114
import 'action_sheet.dart';
1215
import 'compose_box.dart';
1316
import 'content.dart';
17+
import 'dialog.dart';
1418
import 'icons.dart';
1519
import 'page.dart';
1620
import 'profile.dart';
@@ -274,10 +278,10 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
274278
final valueKey = key as ValueKey;
275279
final index = model!.findItemWithMessageId(valueKey.value);
276280
if (index == -1) return null;
277-
return length - 1 - index;
281+
return length - 1 - (index - 1);
278282
},
279283
controller: scrollController,
280-
itemCount: length,
284+
itemCount: length + 1,
281285
// Setting reverse: true means the scroll starts at the bottom.
282286
// Flipping the indexes (in itemBuilder) means the start/bottom
283287
// has the latest messages.
@@ -286,7 +290,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
286290
// TODO on new message when scrolled up, anchor scroll to what's in view
287291
reverse: true,
288292
itemBuilder: (context, i) {
289-
final data = model!.items[length - 1 - i];
293+
if (i == 0) return MarkAsReadWidget(model: model!);
294+
295+
final data = model!.items[length - 1 - (i - 1)];
290296
switch (data) {
291297
case MessageListHistoryStartItem():
292298
return const Center(
@@ -305,7 +311,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
305311
case MessageListMessageItem():
306312
return MessageItem(
307313
key: ValueKey(data.message.id),
308-
trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11),
314+
trailing: i == 1 ? const SizedBox(height: 8) : const SizedBox(height: 11),
309315
item: data);
310316
}
311317
});
@@ -345,6 +351,67 @@ class ScrollToBottomButton extends StatelessWidget {
345351
}
346352
}
347353

354+
class MarkAsReadWidget extends StatelessWidget {
355+
const MarkAsReadWidget({super.key, required this.model});
356+
357+
final MessageListView model;
358+
359+
void _handlePress(BuildContext context) async {
360+
if (!context.mounted) return;
361+
final narrow = model.narrow;
362+
final connection = model.store.connection;
363+
if (connection.zulipFeatureLevel! >= 155) {
364+
await markNarrowAsRead(context, connection, narrow);
365+
} else {
366+
switch (narrow) {
367+
case AllMessagesNarrow():
368+
await markAllAsRead(connection);
369+
case StreamNarrow():
370+
await markStreamAsRead(connection,
371+
streamId: narrow.streamId);
372+
case TopicNarrow():
373+
await markTopicAsRead(connection,
374+
streamId: narrow.streamId, topicName: narrow.topic);
375+
case DmNarrow():
376+
final unreadDms = model.store.unreads.dms[narrow];
377+
if (unreadDms == null) {
378+
return;
379+
}
380+
await updateMessageFlags(connection,
381+
messages: unreadDms,
382+
op: UpdateMessageFlagsOp.add,
383+
flag: MessageFlag.read);
384+
}
385+
}
386+
}
387+
388+
@override
389+
Widget build(BuildContext context) {
390+
final zulipLocalizations = ZulipLocalizations.of(context);
391+
final unreadCount = model.store.unreads.countInNarrow(model.narrow);
392+
return AnimatedCrossFade(
393+
duration: const Duration(milliseconds: 300),
394+
crossFadeState: (unreadCount > 0) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
395+
firstChild: const SizedBox.shrink(),
396+
secondChild: SizedBox(width: double.infinity,
397+
// Design referenced from:
398+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0
399+
child: ColoredBox(
400+
color: const HSLColor.fromAHSL(1, 204, 0.58, 0.92).toColor(),
401+
child: Padding(
402+
padding: const EdgeInsets.all(10),
403+
child: FilledButton.icon(
404+
style: FilledButton.styleFrom(
405+
padding: const EdgeInsets.all(10),
406+
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w200),
407+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
408+
),
409+
onPressed: () => _handlePress(context),
410+
icon: const Icon(Icons.playlist_add_check),
411+
label: Text(zulipLocalizations.markAsReadLabel(unreadCount)))))));
412+
}
413+
}
414+
348415
class RecipientHeader extends StatelessWidget {
349416
const RecipientHeader({super.key, required this.message});
350417

@@ -635,3 +702,75 @@ final _kMessageTimestampStyle = TextStyle(
635702
fontSize: 12,
636703
fontWeight: FontWeight.w400,
637704
color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor());
705+
706+
Future<void> markNarrowAsRead(BuildContext context, ApiConnection connection, Narrow narrow) async {
707+
final zulipLocalizations = ZulipLocalizations.of(context);
708+
// Use [AnchorCode.oldest], because [AnchorCode.firstUnread]
709+
// will be the oldest non-muted unread message, which would
710+
// result in muted unreads older than the first unread not
711+
// being processed.
712+
Anchor anchor = AnchorCode.oldest;
713+
int responseCount = 0;
714+
int updatedCount = 0;
715+
while (true) {
716+
final result = await updateMessageFlagsForNarrow(connection,
717+
anchor: anchor,
718+
// anchor="oldest" is an anchor ID lower than any valid
719+
// message ID; and follow-up requests will have already
720+
// processed the anchor ID, so we just want this to be
721+
// unconditionally false.
722+
includeAnchor: false,
723+
// There is an upper limit of 5000 messages per batch
724+
// (that is, numBefore + numAfter <= 5000) enforced
725+
// on the server at zulip:zerver/views/message_flags.py.
726+
// zulip-mobile:src/action-sheets/index.js uses `numAfter:5000`
727+
// here, but web uses 1000 for more responsive feedback.
728+
// See zulip@f0d87fcf6.
729+
numBefore: 0,
730+
numAfter: 1000,
731+
narrow: narrow.apiEncode(),
732+
op: UpdateMessageFlagsOp.add,
733+
flag: MessageFlag.read);
734+
if (!context.mounted) return;
735+
responseCount++;
736+
updatedCount += result.updatedCount;
737+
738+
if (result.foundNewest) {
739+
if (responseCount > 1) {
740+
// We previously showed an in-progress SnackBar, so say we're done.
741+
// There may be a backlog of SnackBars accumulated in the queue
742+
// so be sure to clear them out here.
743+
ScaffoldMessenger.of(context)
744+
..clearSnackBars()
745+
..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
746+
content: Text(zulipLocalizations.markAsReadComplete(updatedCount))));
747+
}
748+
break;
749+
}
750+
751+
if (result.lastProcessedId == null) {
752+
// No messages were in the range of the request.
753+
// This should be impossible given that found_newest was false
754+
// (and that our num_after was positive.)
755+
await showErrorDialog(context: context,
756+
title: zulipLocalizations.errorMarkAsReadFailedTitle,
757+
message: zulipLocalizations.errorInvalidResponse);
758+
return;
759+
}
760+
anchor = NumericAnchor(result.lastProcessedId!);
761+
762+
// The task is taking a while, so tell the user we're working on it.
763+
// No need to say how many messages, as the MarkAsUnread widget should
764+
// follow along.
765+
// TODO: Ideally we'd have a progress widget here that showed up based
766+
// on actual time elapsed -- so it could appear before the first
767+
// batch returns, if that takes a while -- and that then stuck
768+
// around continuously until the task ends. But we don't have an
769+
// off-the-shelf way to wire up such a thing, and marking a giant
770+
// number of messages unread isn't a common enough flow to be worth
771+
// substantial effort on UI polish. So for now, we use a SnackBar,
772+
// even though they may feel a bit janky.
773+
ScaffoldMessenger.of(context).showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
774+
content: Text(zulipLocalizations.markAsReadInProgress)));
775+
}
776+
}

test/widgets/message_list_test.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:io';
22

33
import 'package:checks/checks.dart';
44
import 'package:flutter/material.dart';
5+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
56
import 'package:flutter_test/flutter_test.dart';
67
import 'package:zulip/api/model/events.dart';
78
import 'package:zulip/api/model/model.dart';
@@ -50,6 +51,8 @@ void main() {
5051

5152
await tester.pumpWidget(
5253
MaterialApp(
54+
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
55+
supportedLocales: ZulipLocalizations.supportedLocales,
5356
home: GlobalStoreWidget(
5457
child: PerAccountStoreWidget(
5558
accountId: eg.selfAccount.id,
@@ -71,7 +74,7 @@ void main() {
7174
testWidgets('basic', (tester) async {
7275
await setupMessageListPage(tester, foundOldest: false,
7376
messages: List.generate(200, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser)));
74-
check(itemCount(tester)).equals(201);
77+
check(itemCount(tester)).equals(202);
7578

7679
// Fling-scroll upward...
7780
await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000);
@@ -84,7 +87,7 @@ void main() {
8487
await tester.pump(Duration.zero); // Allow a frame for the response to arrive.
8588

8689
// Now we have more messages.
87-
check(itemCount(tester)).equals(301);
90+
check(itemCount(tester)).equals(302);
8891
});
8992

9093
testWidgets('observe double-fetch glitch', (tester) async {

0 commit comments

Comments
 (0)