@@ -2,15 +2,19 @@ import 'dart:math';
22
33import 'package:collection/collection.dart' ;
44import 'package:flutter/material.dart' ;
5+ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart' ;
56import 'package:intl/intl.dart' ;
67
78import '../api/model/model.dart' ;
9+ import '../api/model/narrow.dart' ;
10+ import '../api/route/messages.dart' ;
811import '../model/message_list.dart' ;
912import '../model/narrow.dart' ;
1013import '../model/store.dart' ;
1114import 'action_sheet.dart' ;
1215import 'compose_box.dart' ;
1316import 'content.dart' ;
17+ import 'dialog.dart' ;
1418import 'icons.dart' ;
1519import 'page.dart' ;
1620import '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+
348408class RecipientHeader extends StatelessWidget {
349409 const RecipientHeader ({super .key, required this .message});
350410
@@ -635,3 +695,122 @@ 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,
801+ streamId: streamId);
802+ case TopicNarrow (: final streamId, : final topic):
803+ await markTopicAsRead (connection,
804+ streamId: streamId, topicName: topic);
805+ case DmNarrow ():
806+ final unreadDms = store.unreads.dms[narrow];
807+ // Silently ignore this race-condition as the outcome
808+ // (no unreads in this narrow) was the desired end-state
809+ // of pushing the button.
810+ if (unreadDms == null ) return ;
811+ await updateMessageFlags (connection,
812+ messages: unreadDms,
813+ op: UpdateMessageFlagsOp .add,
814+ flag: MessageFlag .read);
815+ }
816+ }
0 commit comments