@@ -56,9 +56,13 @@ class MessageListLoadingItem extends MessageListItem {
5656 final MessageListDirection direction;
5757
5858 const MessageListLoadingItem (this .direction);
59+
5960}
6061
61- enum MessageListDirection { older }
62+ enum MessageListDirection {
63+ older,
64+ newer
65+ }
6266
6367/// Indicates we've reached the oldest message in the narrow.
6468class MessageListHistoryStartItem extends MessageListItem {
@@ -85,9 +89,7 @@ mixin _MessageSequence {
8589 bool _fetched = false ;
8690
8791 /// Whether we know we have the oldest messages for this narrow.
88- ///
89- /// (Currently we always have the newest messages for the narrow,
90- /// once [fetched] is true, because we start from the newest.)
92+
9193 bool get haveOldest => _haveOldest;
9294 bool _haveOldest = false ;
9395
@@ -118,6 +120,38 @@ mixin _MessageSequence {
118120
119121 BackoffMachine ? _fetchOlderCooldownBackoffMachine;
120122
123+
124+ /// Whether we are currently fetching the next batch of newer messages.
125+ ///
126+ /// When this is true, [fetchNewer] is a no-op.
127+ /// That method is called frequently by Flutter's scrolling logic,
128+ /// and this field helps us avoid spamming the same request just to get
129+ /// the same response each time.
130+ ///
131+ /// See also [fetchNewerCoolingDown] .
132+ bool get fetchingNewer => _fetchingNewer;
133+ bool _fetchingNewer = false ;
134+
135+ /// Whether [fetchNewer] had a request error recently.
136+ ///
137+ /// When this is true, [fetchNewer] is a no-op.
138+ /// That method is called frequently by Flutter's scrolling logic,
139+ /// and this field helps us avoid spamming the same request and getting
140+ /// the same error each time.
141+ ///
142+ /// "Recently" is decided by a [BackoffMachine] that resets
143+ /// when a [fetchNewer] request succeeds.
144+ ///
145+ /// See also [fetchingNewer] .
146+ bool get fetchNewerCoolingDown => _fetchNewerCoolingDown;
147+ bool _fetchNewerCoolingDown = false ;
148+
149+ BackoffMachine ? _fetchNewerCooldownBackoffMachine;
150+
151+ /// Whether we know we have the newest messages for this narrow.
152+ bool get haveNewest => _haveNewest;
153+ bool _haveNewest = false ;
154+
121155 /// The parsed message contents, as a list parallel to [messages] .
122156 ///
123157 /// The i'th element is the result of parsing the i'th element of [messages] .
@@ -133,7 +167,8 @@ mixin _MessageSequence {
133167 /// before, between, or after the messages.
134168 ///
135169 /// This information is completely derived from [messages] and
136- /// the flags [haveOldest] , [fetchingOlder] and [fetchOlderCoolingDown] .
170+ /// the flags [haveOldest] , [fetchingOlder] and [fetchOlderCoolingDown]
171+ /// and [haveNewest] , [fetchingNewer] and [fetchNewerCoolingDown] .
137172 /// It exists as an optimization, to memoize that computation.
138173 final QueueList <MessageListItem > items = QueueList ();
139174
@@ -152,6 +187,7 @@ mixin _MessageSequence {
152187 case MessageListLoadingItem ():
153188 switch (item.direction) {
154189 case MessageListDirection .older: return - 1 ;
190+ case MessageListDirection .newer: return 1 ;
155191 }
156192 case MessageListRecipientHeaderItem (: var message):
157193 case MessageListDateSeparatorItem (: var message):
@@ -271,6 +307,10 @@ mixin _MessageSequence {
271307 _fetchOlderCooldownBackoffMachine = null ;
272308 contents.clear ();
273309 items.clear ();
310+ _fetchingNewer = false ;
311+ _fetchNewerCoolingDown = false ;
312+ _fetchNewerCooldownBackoffMachine = null ;
313+ _haveNewest = false ;
274314 }
275315
276316 /// Redo all computations from scratch, based on [messages] .
@@ -318,24 +358,53 @@ mixin _MessageSequence {
318358 void _updateEndMarkers () {
319359 assert (fetched);
320360 assert (! (fetchingOlder && fetchOlderCoolingDown));
361+ assert (! (fetchingNewer && fetchNewerCoolingDown));
362+
321363 final effectiveFetchingOlder = fetchingOlder || fetchOlderCoolingDown;
364+ final effectiveFetchingNewer = fetchingNewer || fetchNewerCoolingDown;
365+
322366 assert (! (effectiveFetchingOlder && haveOldest));
367+ assert (! (effectiveFetchingNewer && haveNewest));
368+
369+ // Handle start marker (older messages)
323370 final startMarker = switch ((effectiveFetchingOlder, haveOldest)) {
324371 (true , _) => const MessageListLoadingItem (MessageListDirection .older),
325372 (_, true ) => const MessageListHistoryStartItem (),
326373 (_, _) => null ,
327374 };
375+
376+ // Handle end marker (newer messages)
377+ final endMarker = switch ((effectiveFetchingNewer, haveNewest)) {
378+ (true , _) => const MessageListLoadingItem (MessageListDirection .newer),
379+ (_, _) => null , // No "history end" marker needed since we start from newest
380+ };
381+
328382 final hasStartMarker = switch (items.firstOrNull) {
329383 MessageListLoadingItem () => true ,
330384 MessageListHistoryStartItem () => true ,
331385 _ => false ,
332386 };
387+
388+ final hasEndMarker = switch (items.lastOrNull) {
389+ MessageListLoadingItem () => true ,
390+ _ => false ,
391+ };
392+
393+ // Update start marker
333394 switch ((startMarker != null , hasStartMarker)) {
334395 case (true , true ): items[0 ] = startMarker! ;
335396 case (true , _ ): items.addFirst (startMarker! );
336397 case (_, true ): items.removeFirst ();
337398 case (_, _ ): break ;
338399 }
400+
401+ // Update end marker
402+ switch ((endMarker != null , hasEndMarker)) {
403+ case (true , true ): items[items.length - 1 ] = endMarker! ;
404+ case (true , _ ): items.add (endMarker! );
405+ case (_, true ): items.removeLast ();
406+ case (_, _ ): break ;
407+ }
339408 }
340409
341410 /// Recompute [items] from scratch, based on [messages] , [contents] , and flags.
@@ -408,16 +477,20 @@ bool _sameDay(DateTime date1, DateTime date2) {
408477/// * Add listeners with [addListener] .
409478/// * Fetch messages with [fetchInitial] . When the fetch completes, this object
410479/// will notify its listeners (as it will any other time the data changes.)
411- /// * Fetch more messages as needed with [fetchOlder] .
480+ /// * Fetch more messages as needed with [fetchOlder] or [fetchNewer] .
412481/// * On reassemble, call [reassemble] .
413482/// * When the object will no longer be used, call [dispose] to free
414483/// resources on the [PerAccountStore].
415484class MessageListView with ChangeNotifier , _MessageSequence {
416- MessageListView ._({required this .store, required this .narrow});
485+ MessageListView ._({required this .store, required this .narrow, this .anchorMessageId });
417486
487+ // Anchor message ID is used to fetch messages from a specific point in the list.
488+ // It is set when the user navigates to a message list page with a specific anchor message.
489+ int ? anchorMessageId;
490+ int ? get anchorIndex => anchorMessageId != null ? findItemWithMessageId (anchorMessageId! ) : null ;
418491 factory MessageListView .init (
419- {required PerAccountStore store, required Narrow narrow}) {
420- final view = MessageListView ._(store: store, narrow: narrow);
492+ {required PerAccountStore store, required Narrow narrow, int ? anchorMessageId }) {
493+ final view = MessageListView ._(store: store, narrow: narrow, anchorMessageId : anchorMessageId );
421494 store.registerMessageList (view);
422495 return view;
423496 }
@@ -496,20 +569,30 @@ class MessageListView with ChangeNotifier, _MessageSequence {
496569 }
497570 }
498571
572+
573+
499574 /// Fetch messages, starting from scratch.
500575 Future <void > fetchInitial () async {
501576 // TODO(#80): fetch from anchor firstUnread, instead of newest
502- // TODO(#82): fetch from a given message ID as anchor
503- assert (! fetched && ! haveOldest && ! fetchingOlder && ! fetchOlderCoolingDown);
577+
578+ assert (! fetched && ! haveOldest && ! fetchingOlder && ! fetchOlderCoolingDown && ! fetchingNewer && ! fetchNewerCoolingDown && ! haveNewest );
504579 assert (messages.isEmpty && contents.isEmpty);
505580 // TODO schedule all this in another isolate
506581 final generation = this .generation;
507582 final result = await getMessages (store.connection,
508583 narrow: narrow.apiEncode (),
509- anchor: AnchorCode .newest,
510- numBefore: kMessageListFetchBatchSize,
511- numAfter: 0 ,
584+ anchor: anchorMessageId != null
585+ ? NumericAnchor (anchorMessageId! )
586+ : AnchorCode .newest,
587+ numBefore: anchorMessageId != null
588+ ? kMessageListFetchBatchSize ~ / 2 // Fetch messages before and after anchor
589+ : kMessageListFetchBatchSize, // Fetch only older messages when no anchor
590+ numAfter: anchorMessageId != null
591+ ? kMessageListFetchBatchSize ~ / 2 // Fetch messages before and after anchor
592+ : 0 , // Don't fetch newer messages when no anchor
512593 );
594+ anchorMessageId ?? = result.messages.last.id;
595+
513596 if (this .generation > generation) return ;
514597 store.reconcileMessages (result.messages);
515598 store.recentSenders.handleMessages (result.messages); // TODO(#824)
@@ -520,10 +603,12 @@ class MessageListView with ChangeNotifier, _MessageSequence {
520603 }
521604 _fetched = true ;
522605 _haveOldest = result.foundOldest;
606+ _haveNewest = result.foundNewest;
523607 _updateEndMarkers ();
524608 notifyListeners ();
525609 }
526610
611+
527612 /// Fetch the next batch of older messages, if applicable.
528613 Future <void > fetchOlder () async {
529614 if (haveOldest) return ;
@@ -589,6 +674,76 @@ class MessageListView with ChangeNotifier, _MessageSequence {
589674 }
590675 }
591676
677+ /// Fetch the next batch of newer messages, if applicable.
678+ Future <void > fetchNewer () async {
679+ if (haveNewest) return ;
680+ if (fetchingNewer) return ;
681+ if (fetchNewerCoolingDown) return ;
682+ assert (fetched);
683+ assert (messages.isNotEmpty);
684+
685+ _fetchingNewer = true ;
686+ _updateEndMarkers ();
687+ notifyListeners ();
688+
689+ final generation = this .generation;
690+ bool hasFetchError = false ;
691+
692+ try {
693+ final GetMessagesResult result;
694+ try {
695+ result = await getMessages (store.connection,
696+ narrow: narrow.apiEncode (),
697+ anchor: NumericAnchor (messages.last.id),
698+ includeAnchor: false ,
699+ numBefore: 0 ,
700+ numAfter: kMessageListFetchBatchSize,
701+ );
702+ } catch (e) {
703+ hasFetchError = true ;
704+ rethrow ;
705+ }
706+ if (this .generation > generation) return ;
707+
708+ if (result.messages.isNotEmpty
709+ && result.messages.first.id == messages.last.id) {
710+ // TODO(server-6): includeAnchor should make this impossible
711+ result.messages.removeAt (0 );
712+ }
713+
714+ store.reconcileMessages (result.messages);
715+ store.recentSenders.handleMessages (result.messages);
716+
717+ final fetchedMessages = _allMessagesVisible
718+ ? result.messages
719+ : result.messages.where (_messageVisible);
720+
721+ _insertAllMessages (messages.length, fetchedMessages);
722+
723+ _haveNewest = result.foundNewest;
724+
725+ } finally {
726+ if (this .generation == generation) {
727+ _fetchingNewer = false ;
728+ if (hasFetchError) {
729+ assert (! fetchNewerCoolingDown);
730+ _fetchNewerCoolingDown = true ;
731+ unawaited ((_fetchNewerCooldownBackoffMachine ?? = BackoffMachine ())
732+ .wait ().then ((_) {
733+ if (this .generation != generation) return ;
734+ _fetchNewerCoolingDown = false ;
735+ _updateEndMarkers ();
736+ notifyListeners ();
737+ }));
738+ } else {
739+ _fetchNewerCooldownBackoffMachine = null ;
740+ }
741+ _updateEndMarkers ();
742+ notifyListeners ();
743+ }
744+ }
745+ }
746+
592747 void handleUserTopicEvent (UserTopicEvent event) {
593748 switch (_canAffectVisibility (event)) {
594749 case VisibilityEffect .none:
0 commit comments