Skip to content

Commit e7fe06c

Browse files
sirpengignprice
authored andcommitted
msglist: Add MarkAsRead widget
1 parent 1eebcb6 commit e7fe06c

File tree

3 files changed

+507
-7
lines changed

3 files changed

+507
-7
lines changed

assets/l10n/app_en.arb

+26
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": "Message when marking messages as read has completed.",
340+
"placeholders": {
341+
"num": {"type": "int", "example": "4"}
342+
}
343+
},
344+
"markAsReadInProgress": "Marking messages as read...",
345+
"@markAsReadInProgress": {
346+
"description": "Progress 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

+181-4
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

78
import '../api/model/model.dart';
9+
import '../api/model/narrow.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(narrow: widget.narrow);
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,60 @@ class ScrollToBottomButton extends StatelessWidget {
345351
}
346352
}
347353

354+
class MarkAsReadWidget extends StatelessWidget {
355+
const MarkAsReadWidget({super.key, required this.narrow});
356+
357+
final Narrow narrow;
358+
359+
void _handlePress(BuildContext context) async {
360+
if (!context.mounted) return;
361+
try {
362+
await markNarrowAsRead(context, narrow);
363+
} catch (e) {
364+
if (!context.mounted) return;
365+
final zulipLocalizations = ZulipLocalizations.of(context);
366+
await showErrorDialog(context: context,
367+
title: zulipLocalizations.errorMarkAsReadFailedTitle,
368+
message: e.toString());
369+
}
370+
// TODO: clear Unreads.oldUnreadsMissing when `narrow` is [AllMessagesNarrow]
371+
// In the rare case that the user had more than 50K total unreads
372+
// on the server, the client won't have known about all of them;
373+
// this was communicated to the client via `oldUnreadsMissing`.
374+
//
375+
// However, since we successfully marked **everything** as read,
376+
// we know that we now have a correct data set of unreads.
377+
}
378+
379+
@override
380+
Widget build(BuildContext context) {
381+
final zulipLocalizations = ZulipLocalizations.of(context);
382+
final store = PerAccountStoreWidget.of(context);
383+
final unreadCount = store.unreads.countInNarrow(narrow);
384+
return AnimatedCrossFade(
385+
duration: const Duration(milliseconds: 300),
386+
crossFadeState: (unreadCount > 0) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
387+
firstChild: const SizedBox.shrink(),
388+
secondChild: SizedBox(width: double.infinity,
389+
// Design referenced from:
390+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0
391+
child: ColoredBox(
392+
// TODO(#368): this should pull from stream color
393+
color: Colors.transparent,
394+
child: Padding(
395+
padding: const EdgeInsets.all(10),
396+
child: FilledButton.icon(
397+
style: FilledButton.styleFrom(
398+
padding: const EdgeInsets.all(10),
399+
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w200),
400+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
401+
),
402+
onPressed: () => _handlePress(context),
403+
icon: const Icon(Icons.playlist_add_check),
404+
label: Text(zulipLocalizations.markAsReadLabel(unreadCount)))))));
405+
}
406+
}
407+
348408
class RecipientHeader extends StatelessWidget {
349409
const RecipientHeader({super.key, required this.message});
350410

@@ -635,3 +695,120 @@ final _kMessageTimestampStyle = TextStyle(
635695
fontSize: 12,
636696
fontWeight: FontWeight.w400,
637697
color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor());
698+
699+
Future<void> markNarrowAsRead(BuildContext context, Narrow narrow) async {
700+
final store = PerAccountStoreWidget.of(context);
701+
final connection = store.connection;
702+
if (connection.zulipFeatureLevel! < 155) { // TODO(server-6)
703+
return await _legacyMarkNarrowAsRead(context, narrow);
704+
}
705+
706+
// Compare web's `mark_all_as_read` in web/src/unread_ops.js
707+
// and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js .
708+
final zulipLocalizations = ZulipLocalizations.of(context);
709+
final scaffoldMessenger = ScaffoldMessenger.of(context);
710+
// Use [AnchorCode.oldest], because [AnchorCode.firstUnread]
711+
// will be the oldest non-muted unread message, which would
712+
// result in muted unreads older than the first unread not
713+
// being processed.
714+
Anchor anchor = AnchorCode.oldest;
715+
int responseCount = 0;
716+
int updatedCount = 0;
717+
718+
final apiNarrow = switch (narrow) {
719+
// Since there's a database index on is:unread, it's a fast
720+
// search query and thus worth using as an optimization
721+
// when processing all messages.
722+
AllMessagesNarrow() => [ApiNarrowIsUnread()],
723+
_ => narrow.apiEncode(),
724+
};
725+
while (true) {
726+
final result = await updateMessageFlagsForNarrow(connection,
727+
anchor: anchor,
728+
// [AnchorCode.oldest] is an anchor ID lower than any valid
729+
// message ID; and follow-up requests will have already
730+
// processed the anchor ID, so we just want this to be
731+
// unconditionally false.
732+
includeAnchor: false,
733+
// There is an upper limit of 5000 messages per batch
734+
// (numBefore + numAfter <= 5000) enforced on the server.
735+
// See `update_message_flags_in_narrow` in zerver/views/message_flags.py .
736+
// zulip-mobile uses `numAfter` of 5000, but web uses 1000
737+
// for more responsive feedback. See zulip@f0d87fcf6.
738+
numBefore: 0,
739+
numAfter: 1000,
740+
narrow: apiNarrow,
741+
op: UpdateMessageFlagsOp.add,
742+
flag: MessageFlag.read);
743+
if (!context.mounted) {
744+
scaffoldMessenger.clearSnackBars();
745+
return;
746+
}
747+
responseCount++;
748+
updatedCount += result.updatedCount;
749+
750+
if (result.foundNewest) {
751+
if (responseCount > 1) {
752+
// We previously showed an in-progress [SnackBar], so say we're done.
753+
// There may be a backlog of [SnackBar]s accumulated in the queue
754+
// so be sure to clear them out here.
755+
scaffoldMessenger
756+
..clearSnackBars()
757+
..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
758+
content: Text(zulipLocalizations.markAsReadComplete(updatedCount))));
759+
}
760+
return;
761+
}
762+
763+
if (result.lastProcessedId == null) {
764+
// No messages were in the range of the request.
765+
// This should be impossible given that `foundNewest` was false
766+
// (and that our `numAfter` was positive.)
767+
await showErrorDialog(context: context,
768+
title: zulipLocalizations.errorMarkAsReadFailedTitle,
769+
message: zulipLocalizations.errorInvalidResponse);
770+
return;
771+
}
772+
anchor = NumericAnchor(result.lastProcessedId!);
773+
774+
// The task is taking a while, so tell the user we're working on it.
775+
// No need to say how many messages, as the [MarkAsUnread] widget
776+
// should follow along.
777+
// TODO: Ideally we'd have a progress widget here that showed up based
778+
// on actual time elapsed -- so it could appear before the first
779+
// batch returns, if that takes a while -- and that then stuck
780+
// around continuously until the task ends. For now we use a
781+
// series of [SnackBar]s, which may feel a bit janky.
782+
// There is complexity in tracking the status of each [SnackBar],
783+
// due to having no way to determine which is currently active,
784+
// or if there is an active one at all. Resetting the [SnackBar] here
785+
// results in the same message popping in and out and the user experience
786+
// is better for now if we allow them to run their timer through
787+
// and clear the backlog later.
788+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
789+
content: Text(zulipLocalizations.markAsReadInProgress)));
790+
}
791+
}
792+
793+
Future<void> _legacyMarkNarrowAsRead(BuildContext context, Narrow narrow) async {
794+
final store = PerAccountStoreWidget.of(context);
795+
final connection = store.connection;
796+
switch (narrow) {
797+
case AllMessagesNarrow():
798+
await markAllAsRead(connection);
799+
case StreamNarrow(:final streamId):
800+
await markStreamAsRead(connection, streamId: streamId);
801+
case TopicNarrow(:final streamId, :final topic):
802+
await markTopicAsRead(connection, streamId: streamId, topicName: topic);
803+
case DmNarrow():
804+
final unreadDms = store.unreads.dms[narrow];
805+
// Silently ignore this race-condition as the outcome
806+
// (no unreads in this narrow) was the desired end-state
807+
// of pushing the button.
808+
if (unreadDms == null) return;
809+
await updateMessageFlags(connection,
810+
messages: unreadDms,
811+
op: UpdateMessageFlagsOp.add,
812+
flag: MessageFlag.read);
813+
}
814+
}

0 commit comments

Comments
 (0)