@@ -2,15 +2,19 @@ import 'dart:math';
2
2
3
3
import 'package:collection/collection.dart' ;
4
4
import 'package:flutter/material.dart' ;
5
+ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart' ;
5
6
import 'package:intl/intl.dart' ;
6
7
7
8
import '../api/model/model.dart' ;
9
+ import '../api/model/narrow.dart' ;
10
+ import '../api/route/messages.dart' ;
8
11
import '../model/message_list.dart' ;
9
12
import '../model/narrow.dart' ;
10
13
import '../model/store.dart' ;
11
14
import 'action_sheet.dart' ;
12
15
import 'compose_box.dart' ;
13
16
import 'content.dart' ;
17
+ import 'dialog.dart' ;
14
18
import 'icons.dart' ;
15
19
import 'page.dart' ;
16
20
import 'profile.dart' ;
@@ -274,10 +278,10 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
274
278
final valueKey = key as ValueKey ;
275
279
final index = model! .findItemWithMessageId (valueKey.value);
276
280
if (index == - 1 ) return null ;
277
- return length - 1 - index;
281
+ return length - 1 - ( index - 1 ) ;
278
282
},
279
283
controller: scrollController,
280
- itemCount: length,
284
+ itemCount: length + 1 ,
281
285
// Setting reverse: true means the scroll starts at the bottom.
282
286
// Flipping the indexes (in itemBuilder) means the start/bottom
283
287
// has the latest messages.
@@ -286,7 +290,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
286
290
// TODO on new message when scrolled up, anchor scroll to what's in view
287
291
reverse: true ,
288
292
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 )];
290
296
switch (data) {
291
297
case MessageListHistoryStartItem ():
292
298
return const Center (
@@ -305,7 +311,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
305
311
case MessageListMessageItem ():
306
312
return MessageItem (
307
313
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 ),
309
315
item: data);
310
316
}
311
317
});
@@ -345,6 +351,60 @@ class ScrollToBottomButton extends StatelessWidget {
345
351
}
346
352
}
347
353
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
+
348
408
class RecipientHeader extends StatelessWidget {
349
409
const RecipientHeader ({super .key, required this .message});
350
410
@@ -635,3 +695,120 @@ final _kMessageTimestampStyle = TextStyle(
635
695
fontSize: 12 ,
636
696
fontWeight: FontWeight .w400,
637
697
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