@@ -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
8
+ import '../api/core.dart' ;
7
9
import '../api/model/model.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 (model: model! );
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,67 @@ class ScrollToBottomButton extends StatelessWidget {
345
351
}
346
352
}
347
353
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
+
348
415
class RecipientHeader extends StatelessWidget {
349
416
const RecipientHeader ({super .key, required this .message});
350
417
@@ -635,3 +702,75 @@ final _kMessageTimestampStyle = TextStyle(
635
702
fontSize: 12 ,
636
703
fontWeight: FontWeight .w400,
637
704
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
+ }
0 commit comments