Skip to content

Commit 932ffbc

Browse files
wip
1 parent ae7939a commit 932ffbc

File tree

4 files changed

+492
-105
lines changed

4 files changed

+492
-105
lines changed

lib/model/message_list.dart

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import 'narrow.dart';
1414
import 'store.dart';
1515

1616
/// The number of messages to fetch in each request.
17-
const kMessageListFetchBatchSize = 100; // TODO tune
17+
const kMessageListFetchBatchSize = 5; // TODO tune
1818

1919
/// A message, or one of its siblings shown in the message list.
2020
///
@@ -58,7 +58,7 @@ class MessageListLoadingItem extends MessageListItem {
5858
const MessageListLoadingItem(this.direction);
5959
}
6060

61-
enum MessageListDirection { older }
61+
enum MessageListDirection { older, newer }
6262

6363
/// Indicates we've reached the oldest message in the narrow.
6464
class MessageListHistoryStartItem extends MessageListItem {
@@ -85,9 +85,6 @@ mixin _MessageSequence {
8585
bool _fetched = false;
8686

8787
/// 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.)
9188
bool get haveOldest => _haveOldest;
9289
bool _haveOldest = false;
9390

@@ -118,6 +115,40 @@ mixin _MessageSequence {
118115

119116
BackoffMachine? _fetchOlderCooldownBackoffMachine;
120117

118+
/// Whether we know we have the newest messages for this narrow.
119+
bool get haveNewest => _haveNewest;
120+
bool _haveNewest = false;
121+
122+
/// Whether we are currently fetching the next batch of newer messages.
123+
///
124+
/// When this is true, [fetchNewer] is a no-op.
125+
/// That method is called frequently by Flutter's scrolling logic,
126+
/// and this field helps us avoid spamming the same request just to get
127+
/// the same response each time.
128+
///
129+
/// See also [fetchNewerCoolingDown].
130+
bool get fetchingNewer => _fetchingNewer;
131+
bool _fetchingNewer = false;
132+
133+
/// Whether [fetchNewer] had a request error recently.
134+
///
135+
/// When this is true, [fetchNewer] is a no-op.
136+
/// That method is called frequently by Flutter's scrolling logic,
137+
/// and this field mitigates spamming the same request and getting
138+
/// the same error each time.
139+
///
140+
/// "Recently" is decided by a [BackoffMachine] that resets
141+
/// when a [fetchNewer] request succeeds.
142+
///
143+
/// See also [fetchingNewer].
144+
bool get fetchNewerCoolingDown => _fetchNewerCoolingDown;
145+
bool _fetchNewerCoolingDown = false;
146+
147+
BackoffMachine? _fetchNewerCooldownBackoffMachine;
148+
149+
int? get firstUnreadMessageId => _firstUnreadMessageId;
150+
int? _firstUnreadMessageId;
151+
121152
/// The parsed message contents, as a list parallel to [messages].
122153
///
123154
/// The i'th element is the result of parsing the i'th element of [messages].
@@ -151,6 +182,7 @@ mixin _MessageSequence {
151182
case MessageListHistoryStartItem(): return -1;
152183
case MessageListLoadingItem():
153184
switch (item.direction) {
185+
case MessageListDirection.newer: return 1;
154186
case MessageListDirection.older: return -1;
155187
}
156188
case MessageListRecipientHeaderItem(:var message):
@@ -269,6 +301,11 @@ mixin _MessageSequence {
269301
_fetchingOlder = false;
270302
_fetchOlderCoolingDown = false;
271303
_fetchOlderCooldownBackoffMachine = null;
304+
_haveNewest = false;
305+
_fetchingNewer = false;
306+
_fetchNewerCoolingDown = false;
307+
_fetchNewerCooldownBackoffMachine = null;
308+
_firstUnreadMessageId = null;
272309
contents.clear();
273310
items.clear();
274311
}
@@ -317,7 +354,8 @@ mixin _MessageSequence {
317354
/// Update [items] to include markers at start and end as appropriate.
318355
void _updateEndMarkers() {
319356
assert(fetched);
320-
assert(!(fetchingOlder && fetchOlderCoolingDown));
357+
assert(!(fetchingOlder && fetchOlderCoolingDown)
358+
|| !(fetchingNewer && fetchNewerCoolingDown));
321359
final effectiveFetchingOlder = fetchingOlder || fetchOlderCoolingDown;
322360
assert(!(effectiveFetchingOlder && haveOldest));
323361
final startMarker = switch ((effectiveFetchingOlder, haveOldest)) {
@@ -336,6 +374,21 @@ mixin _MessageSequence {
336374
case (_, true): items.removeFirst();
337375
case (_, _ ): break;
338376
}
377+
378+
final effectiveFetchingNewer = fetchingNewer || fetchNewerCoolingDown;
379+
final endMarker = switch (effectiveFetchingNewer) {
380+
true => const MessageListLoadingItem(MessageListDirection.newer),
381+
false => null,
382+
};
383+
final hasEndMarker = switch (items.lastOrNull) {
384+
MessageListLoadingItem() => true,
385+
_ => false,
386+
};
387+
switch ((endMarker != null, hasEndMarker)) {
388+
case (true, false): items.add(endMarker!);
389+
case (false, true ): items.removeLast();
390+
case (_, _ ): break;
391+
}
339392
}
340393

341394
/// Recompute [items] from scratch, based on [messages], [contents], and flags.
@@ -500,15 +553,17 @@ class MessageListView with ChangeNotifier, _MessageSequence {
500553
Future<void> fetchInitial() async {
501554
// TODO(#80): fetch from anchor firstUnread, instead of newest
502555
// TODO(#82): fetch from a given message ID as anchor
503-
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
556+
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown
557+
&& !haveNewest && !fetchingNewer && !fetchNewerCoolingDown);
504558
assert(messages.isEmpty && contents.isEmpty);
505559
// TODO schedule all this in another isolate
506560
final generation = this.generation;
507561
final result = await getMessages(store.connection,
508562
narrow: narrow.apiEncode(),
509-
anchor: AnchorCode.newest,
563+
anchor: AnchorCode.firstUnread,
510564
numBefore: kMessageListFetchBatchSize,
511-
numAfter: 0,
565+
// Results will include the anchor message, so fetch one less.
566+
numAfter: kMessageListFetchBatchSize - 1,
512567
);
513568
if (this.generation > generation) return;
514569
store.reconcileMessages(result.messages);
@@ -520,6 +575,8 @@ class MessageListView with ChangeNotifier, _MessageSequence {
520575
}
521576
_fetched = true;
522577
_haveOldest = result.foundOldest;
578+
_haveNewest = result.foundNewest;
579+
_firstUnreadMessageId = result.anchor;
523580
_updateEndMarkers();
524581
notifyListeners();
525582
}
@@ -541,7 +598,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
541598
try {
542599
result = await getMessages(store.connection,
543600
narrow: narrow.apiEncode(),
544-
anchor: NumericAnchor(messages[0].id),
601+
anchor: NumericAnchor(messages.first.id),
545602
includeAnchor: false,
546603
numBefore: kMessageListFetchBatchSize,
547604
numAfter: 0,
@@ -553,7 +610,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
553610
if (this.generation > generation) return;
554611

555612
if (result.messages.isNotEmpty
556-
&& result.messages.last.id == messages[0].id) {
613+
&& result.messages.last.id == messages.first.id) {
557614
// TODO(server-6): includeAnchor should make this impossible
558615
result.messages.removeLast();
559616
}
@@ -589,6 +646,72 @@ class MessageListView with ChangeNotifier, _MessageSequence {
589646
}
590647
}
591648

649+
/// Fetch the next batch of newer messages, if applicable.
650+
Future<void> fetchNewer() async {
651+
if (haveNewest) return;
652+
if (fetchingNewer) return;
653+
if (fetchNewerCoolingDown) return;
654+
assert(fetched);
655+
assert(messages.isNotEmpty);
656+
_fetchingNewer = true;
657+
// TODO handle markers
658+
_updateEndMarkers();
659+
notifyListeners();
660+
final generation = this.generation;
661+
bool hasFetchError = false;
662+
try {
663+
final GetMessagesResult result;
664+
try {
665+
result = await getMessages(store.connection,
666+
narrow: narrow.apiEncode(),
667+
anchor: NumericAnchor(messages.last.id),
668+
includeAnchor: false,
669+
numBefore: 0,
670+
numAfter: kMessageListFetchBatchSize,
671+
);
672+
} catch (e) {
673+
hasFetchError = true;
674+
rethrow;
675+
}
676+
if (this.generation > generation) return;
677+
678+
if (result.messages.isNotEmpty
679+
&& result.messages.first.id == messages.last.id) {
680+
// TODO(server-6): includeAnchor should make this impossible
681+
result.messages.removeAt(0);
682+
}
683+
684+
store.reconcileMessages(result.messages);
685+
store.recentSenders.handleMessages(result.messages); // TODO(#824)
686+
687+
final fetchedMessages = _allMessagesVisible
688+
? result.messages // Avoid unnecessarily copying the list.
689+
: result.messages.where(_messageVisible);
690+
691+
_insertAllMessages(messages.length, fetchedMessages);
692+
_haveNewest = result.foundNewest;
693+
} finally {
694+
if (this.generation == generation) {
695+
_fetchingNewer = false;
696+
if (hasFetchError) {
697+
assert(!fetchNewerCoolingDown);
698+
_fetchNewerCoolingDown = true;
699+
final machine = (_fetchNewerCooldownBackoffMachine ??= BackoffMachine());
700+
unawaited(machine.wait().then((_) {
701+
if (this.generation != generation) return;
702+
_fetchNewerCoolingDown = false;
703+
_updateEndMarkers();
704+
notifyListeners();
705+
}));
706+
} else {
707+
_fetchNewerCooldownBackoffMachine = null;
708+
}
709+
_updateEndMarkers();
710+
notifyListeners();
711+
}
712+
}
713+
}
714+
592715
void handleUserTopicEvent(UserTopicEvent event) {
593716
switch (_canAffectVisibility(event)) {
594717
case VisibilityEffect.none:

lib/widgets/message_list.dart

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
509509
// still not yet updated to account for the newly-added messages.
510510
model?.fetchOlder();
511511
}
512+
if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {
513+
model?.fetchNewer();
514+
}
512515
}
513516

514517
void _scrollChanged() {
@@ -566,10 +569,26 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
566569
}
567570

568571
Widget _buildListView(BuildContext context) {
569-
final length = model!.items.length;
572+
const newestMessageId = 10000000000000000;
570573
const centerSliverKey = ValueKey('center sliver');
571574

572-
Widget sliver = SliverStickyHeaderList(
575+
// TODO(#311) If we have a bottom nav, it will pad the bottom
576+
// inset, and this shouldn't be necessary
577+
final hasComposeBox = ComposeBox.hasComposeBox(widget.narrow);
578+
579+
// TODO make sticky headers work
580+
assert(model!.firstUnreadMessageId! != 0);
581+
final List<MessageListItem> olderItems, newerItems;
582+
if (model!.firstUnreadMessageId! == newestMessageId) {
583+
olderItems = model!.items;
584+
newerItems = const [];
585+
} else {
586+
final firstUnreadItemIndex = model!.findItemWithMessageId(model!.firstUnreadMessageId!);
587+
olderItems = model!.items.slice(0, firstUnreadItemIndex);
588+
newerItems = model!.items.slice(firstUnreadItemIndex);
589+
}
590+
591+
Widget topSliver = SliverStickyHeaderList(
573592
headerPlacement: HeaderPlacement.scrollingStart,
574593
delegate: SliverChildBuilderDelegate(
575594
// To preserve state across rebuilds for individual [MessageItem]
@@ -591,26 +610,56 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
591610
final valueKey = key as ValueKey<int>;
592611
final index = model!.findItemWithMessageId(valueKey.value);
593612
if (index == -1) return null;
594-
return length - 1 - (index - 3);
613+
return olderItems.length - 1 - index;
595614
},
596-
childCount: length + 3,
615+
childCount: olderItems.length,
597616
(context, i) {
598-
// To reinforce that the end of the feed has been reached:
599-
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
600-
if (i == 0) return const SizedBox(height: 36);
617+
final data = olderItems[olderItems.length - 1 - i];
618+
return _buildItem(data, i);
619+
}));
601620

602-
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
621+
Widget bottomSliver = SliverStickyHeaderList(
622+
key: hasComposeBox ? centerSliverKey : null,
623+
headerPlacement: HeaderPlacement.scrollingStart,
624+
delegate: SliverChildBuilderDelegate(
625+
// To preserve state across rebuilds for individual [MessageItem]
626+
// widgets as the size of [MessageListView.items] changes we need
627+
// to match old widgets by their key to their new position in
628+
// the list.
629+
//
630+
// The keys are of type [ValueKey] with a value of [Message.id]
631+
// and here we use a O(log n) binary search method. This could
632+
// be improved but for now it only triggers for materialized
633+
// widgets. As a simple test, flinging through Combined feed in
634+
// CZO on a Pixel 5, this only runs about 10 times per rebuild
635+
// and the timing for each call is <100 microseconds.
636+
//
637+
// Non-message items (e.g., start and end markers) that do not
638+
// have state that needs to be preserved have not been given keys
639+
// and will not trigger this callback.
640+
findChildIndexCallback: (Key key) {
641+
final valueKey = key as ValueKey<int>;
642+
final index = model!.findItemWithMessageId(valueKey.value);
643+
if (index == -1) return null;
644+
return index;
645+
},
646+
childCount: newerItems.length + 3,
647+
(context, i) {
648+
if (i == newerItems.length) return TypingStatusWidget(narrow: widget.narrow);
603649

604-
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
650+
if (i == newerItems.length + 1) return MarkAsReadWidget(narrow: widget.narrow);
605651

606-
final data = model!.items[length - 1 - (i - 3)];
652+
// To reinforce that the end of the feed has been reached:
653+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
654+
if (i == newerItems.length + 2) return const SizedBox(height: 36);
655+
656+
final data = newerItems[i];
607657
return _buildItem(data, i);
608658
}));
609659

610-
if (!ComposeBox.hasComposeBox(widget.narrow)) {
611-
// TODO(#311) If we have a bottom nav, it will pad the bottom inset,
612-
// and this can be removed; also remove mention in MessageList dartdoc
613-
sliver = SliverSafeArea(sliver: sliver);
660+
if (!hasComposeBox) {
661+
topSliver = SliverSafeArea(sliver: topSliver);
662+
bottomSliver = SliverSafeArea(key: centerSliverKey, sliver: bottomSliver);
614663
}
615664

616665
return CustomScrollView(
@@ -626,17 +675,12 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
626675
},
627676

628677
controller: scrollController,
629-
semanticChildCount: length + 2,
630-
anchor: 1.0,
678+
semanticChildCount: model!.items.length + 2,
679+
anchor: 0.75,
631680
center: centerSliverKey,
632-
633681
slivers: [
634-
sliver,
635-
636-
// This is a trivial placeholder that occupies no space. Its purpose is
637-
// to have the key that's passed to [ScrollView.center], and so to cause
638-
// the above [SliverStickyHeaderList] to run from bottom to top.
639-
const SliverToBoxAdapter(key: centerSliverKey),
682+
topSliver,
683+
bottomSliver,
640684
]);
641685
}
642686

0 commit comments

Comments
 (0)