@@ -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 (narrow: model! .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,79 @@ 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
+ // TODO(server-6) remove feature level check and always use markNarrowAsRead
360
+ void _handlePress (BuildContext context) async {
361
+ if (! context.mounted) return ;
362
+ final store = PerAccountStoreWidget .of (context);
363
+ final connection = store.connection;
364
+ try {
365
+ if (connection.zulipFeatureLevel! >= 155 ) {
366
+ await markNarrowAsRead (context, connection, narrow);
367
+ } else {
368
+ switch (narrow) {
369
+ case AllMessagesNarrow ():
370
+ await markAllAsRead (connection);
371
+ case StreamNarrow (: final streamId):
372
+ await markStreamAsRead (connection,
373
+ streamId: streamId);
374
+ case TopicNarrow (: final streamId, : final topic):
375
+ await markTopicAsRead (connection,
376
+ streamId: streamId, topicName: topic);
377
+ case DmNarrow ():
378
+ final unreadDms = store.unreads.dms[narrow];
379
+ // Silently ignore this situation as the outcome
380
+ // (no unreads in this narrow) was the desired end-state
381
+ // of pushing the button.
382
+ if (unreadDms == null ) return ;
383
+ await updateMessageFlags (connection,
384
+ messages: unreadDms,
385
+ op: UpdateMessageFlagsOp .add,
386
+ flag: MessageFlag .read);
387
+ }
388
+ }
389
+ } catch (e) {
390
+ if (! context.mounted) return ;
391
+ final zulipLocalizations = ZulipLocalizations .of (context);
392
+ showErrorDialog (context: context,
393
+ title: zulipLocalizations.errorDialogTitle,
394
+ message: e.toString ());
395
+ }
396
+ }
397
+
398
+ @override
399
+ Widget build (BuildContext context) {
400
+ final zulipLocalizations = ZulipLocalizations .of (context);
401
+ final store = PerAccountStoreWidget .of (context);
402
+ final unreadCount = store.unreads.countInNarrow (narrow);
403
+ return AnimatedCrossFade (
404
+ duration: const Duration (milliseconds: 300 ),
405
+ crossFadeState: (unreadCount > 0 ) ? CrossFadeState .showSecond : CrossFadeState .showFirst,
406
+ firstChild: const SizedBox .shrink (),
407
+ secondChild: SizedBox (width: double .infinity,
408
+ // Design referenced from:
409
+ // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0
410
+ child: ColoredBox (
411
+ // TODO(#368): this should pull from stream color
412
+ color: Colors .transparent,
413
+ child: Padding (
414
+ padding: const EdgeInsets .all (10 ),
415
+ child: FilledButton .icon (
416
+ style: FilledButton .styleFrom (
417
+ padding: const EdgeInsets .all (10 ),
418
+ textStyle: const TextStyle (fontSize: 18 , fontWeight: FontWeight .w200),
419
+ shape: RoundedRectangleBorder (borderRadius: BorderRadius .circular (5 )),
420
+ ),
421
+ onPressed: () => _handlePress (context),
422
+ icon: const Icon (Icons .playlist_add_check),
423
+ label: Text (zulipLocalizations.markAsReadLabel (unreadCount)))))));
424
+ }
425
+ }
426
+
348
427
class RecipientHeader extends StatelessWidget {
349
428
const RecipientHeader ({super .key, required this .message});
350
429
@@ -635,3 +714,76 @@ final _kMessageTimestampStyle = TextStyle(
635
714
fontSize: 12 ,
636
715
fontWeight: FontWeight .w400,
637
716
color: const HSLColor .fromAHSL (0.4 , 0 , 0 , 0.2 ).toColor ());
717
+
718
+ Future <void > markNarrowAsRead (BuildContext context, ApiConnection connection, Narrow narrow) async {
719
+ // Compare web's `mark_all_as_read` in web/src/unread_ops.js
720
+ // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js .
721
+ final zulipLocalizations = ZulipLocalizations .of (context);
722
+ // Use [AnchorCode.oldest], because [AnchorCode.firstUnread]
723
+ // will be the oldest non-muted unread message, which would
724
+ // result in muted unreads older than the first unread not
725
+ // being processed.
726
+ Anchor anchor = AnchorCode .oldest;
727
+ int responseCount = 0 ;
728
+ int updatedCount = 0 ;
729
+ while (true ) {
730
+ final result = await updateMessageFlagsForNarrow (connection,
731
+ anchor: anchor,
732
+ // [AnchorCode.oldest] is an anchor ID lower than any valid
733
+ // message ID; and follow-up requests will have already
734
+ // processed the anchor ID, so we just want this to be
735
+ // unconditionally false.
736
+ includeAnchor: false ,
737
+ // There is an upper limit of 5000 messages per batch
738
+ // (numBefore + numAfter <= 5000) enforced on the server.
739
+ // See `update_message_flags_in_narrow` in zerver/views/message_flags.py .
740
+ // zulip-mobile uses `numAfter` of 5000, but web uses 1000
741
+ // for more responsive feedback. See zulip@f0d87fcf6.
742
+ numBefore: 0 ,
743
+ numAfter: 1000 ,
744
+ narrow: narrow.apiEncode (),
745
+ op: UpdateMessageFlagsOp .add,
746
+ flag: MessageFlag .read);
747
+ if (! context.mounted) return ;
748
+ responseCount++ ;
749
+ updatedCount += result.updatedCount;
750
+
751
+ if (result.foundNewest) {
752
+ if (responseCount > 1 ) {
753
+ // We previously showed an in-progress [SnackBar], so say we're done.
754
+ // There may be a backlog of [SnackBar]s accumulated in the queue
755
+ // so be sure to clear them out here.
756
+ ScaffoldMessenger .of (context)
757
+ ..clearSnackBars ()
758
+ ..showSnackBar (SnackBar (behavior: SnackBarBehavior .floating,
759
+ content: Text (zulipLocalizations.markAsReadComplete (updatedCount))));
760
+ }
761
+ break ;
762
+ }
763
+
764
+ if (result.lastProcessedId == null ) {
765
+ // No messages were in the range of the request.
766
+ // This should be impossible given that `foundNewest` was false
767
+ // (and that our `numAfter` was positive.)
768
+ await showErrorDialog (context: context,
769
+ title: zulipLocalizations.errorMarkAsReadFailedTitle,
770
+ message: zulipLocalizations.errorInvalidResponse);
771
+ return ;
772
+ }
773
+ anchor = NumericAnchor (result.lastProcessedId! );
774
+
775
+ // The task is taking a while, so tell the user we're working on it.
776
+ // No need to say how many messages, as the [MarkAsUnread] widget
777
+ // should follow along.
778
+ // TODO: Ideally we'd have a progress widget here that showed up based
779
+ // on actual time elapsed -- so it could appear before the first
780
+ // batch returns, if that takes a while -- and that then stuck
781
+ // around continuously until the task ends. But we don't have an
782
+ // off-the-shelf way to wire up such a thing, and marking a giant
783
+ // number of messages unread isn't a common enough flow to be worth
784
+ // substantial effort on UI polish. So for now, we use a SnackBar,
785
+ // even though they may feel a bit janky.
786
+ ScaffoldMessenger .of (context).showSnackBar (SnackBar (behavior: SnackBarBehavior .floating,
787
+ content: Text (zulipLocalizations.markAsReadInProgress)));
788
+ }
789
+ }
0 commit comments