Skip to content

Commit 2ef3c40

Browse files
wip
1 parent 28b3536 commit 2ef3c40

File tree

4 files changed

+337
-32
lines changed

4 files changed

+337
-32
lines changed

lib/model/message_list.dart

Lines changed: 111 additions & 9 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 = 20; // TODO tune
1818

1919
/// A message, or one of its siblings shown in the message list.
2020
///
@@ -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].
@@ -269,6 +300,11 @@ mixin _MessageSequence {
269300
_fetchingOlder = false;
270301
_fetchOlderCoolingDown = false;
271302
_fetchOlderCooldownBackoffMachine = null;
303+
_haveNewest = false;
304+
_fetchingNewer = false;
305+
_fetchNewerCoolingDown = false;
306+
_fetchNewerCooldownBackoffMachine = null;
307+
_firstUnreadMessageId = null;
272308
contents.clear();
273309
items.clear();
274310
}
@@ -500,15 +536,16 @@ class MessageListView with ChangeNotifier, _MessageSequence {
500536
Future<void> fetchInitial() async {
501537
// TODO(#80): fetch from anchor firstUnread, instead of newest
502538
// TODO(#82): fetch from a given message ID as anchor
503-
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
539+
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown
540+
&& !haveNewest && !fetchingNewer && !fetchNewerCoolingDown);
504541
assert(messages.isEmpty && contents.isEmpty);
505542
// TODO schedule all this in another isolate
506543
final generation = this.generation;
507544
final result = await getMessages(store.connection,
508545
narrow: narrow.apiEncode(),
509-
anchor: AnchorCode.newest,
546+
anchor: AnchorCode.firstUnread,
510547
numBefore: kMessageListFetchBatchSize,
511-
numAfter: 0,
548+
numAfter: kMessageListFetchBatchSize,
512549
);
513550
if (this.generation > generation) return;
514551
store.reconcileMessages(result.messages);
@@ -520,6 +557,8 @@ class MessageListView with ChangeNotifier, _MessageSequence {
520557
}
521558
_fetched = true;
522559
_haveOldest = result.foundOldest;
560+
_haveNewest = result.foundNewest;
561+
_firstUnreadMessageId = result.anchor;
523562
_updateEndMarkers();
524563
notifyListeners();
525564
}
@@ -541,7 +580,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
541580
try {
542581
result = await getMessages(store.connection,
543582
narrow: narrow.apiEncode(),
544-
anchor: NumericAnchor(messages[0].id),
583+
anchor: NumericAnchor(messages.first.id),
545584
includeAnchor: false,
546585
numBefore: kMessageListFetchBatchSize,
547586
numAfter: 0,
@@ -553,7 +592,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
553592
if (this.generation > generation) return;
554593

555594
if (result.messages.isNotEmpty
556-
&& result.messages.last.id == messages[0].id) {
595+
&& result.messages.last.id == messages.first.id) {
557596
// TODO(server-6): includeAnchor should make this impossible
558597
result.messages.removeLast();
559598
}
@@ -589,6 +628,69 @@ class MessageListView with ChangeNotifier, _MessageSequence {
589628
}
590629
}
591630

631+
/// Fetch the next batch of newer messages, if applicable.
632+
Future<void> fetchNewer() async {
633+
if (haveNewest) return;
634+
if (fetchingNewer) return;
635+
if (fetchNewerCoolingDown) return;
636+
assert(fetched);
637+
assert(messages.isNotEmpty);
638+
_fetchingNewer = true;
639+
// TODO handle markers
640+
_updateEndMarkers();
641+
notifyListeners();
642+
final generation = this.generation;
643+
bool hasFetchError = false;
644+
try {
645+
final GetMessagesResult result;
646+
try {
647+
result = await getMessages(store.connection,
648+
narrow: narrow.apiEncode(),
649+
anchor: NumericAnchor(messages.last.id),
650+
includeAnchor: true,
651+
numBefore: 0,
652+
numAfter: kMessageListFetchBatchSize,
653+
);
654+
} catch (e) {
655+
hasFetchError = true;
656+
rethrow;
657+
}
658+
if (this.generation > generation) return;
659+
660+
// Remove the anchor.
661+
result.messages.removeAt(0);
662+
663+
store.reconcileMessages(result.messages);
664+
store.recentSenders.handleMessages(result.messages); // TODO(#824)
665+
666+
final fetchedMessages = _allMessagesVisible
667+
? result.messages // Avoid unnecessarily copying the list.
668+
: result.messages.where(_messageVisible);
669+
670+
_insertAllMessages(messages.length, fetchedMessages);
671+
_haveNewest = result.foundNewest;
672+
} finally {
673+
if (this.generation == generation) {
674+
_fetchingNewer = false;
675+
if (hasFetchError) {
676+
assert(!fetchNewerCoolingDown);
677+
_fetchNewerCoolingDown = true;
678+
unawaited((_fetchNewerCooldownBackoffMachine ??= BackoffMachine())
679+
.wait().then((_) {
680+
if (this.generation != generation) return;
681+
_fetchNewerCoolingDown = false;
682+
_updateEndMarkers();
683+
notifyListeners();
684+
}));
685+
} else {
686+
_fetchNewerCooldownBackoffMachine = null;
687+
}
688+
_updateEndMarkers();
689+
notifyListeners();
690+
}
691+
}
692+
}
693+
592694
void handleUserTopicEvent(UserTopicEvent event) {
593695
switch (_canAffectVisibility(event)) {
594696
case VisibilityEffect.none:

lib/widgets/message_list.dart

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
510510
// still not yet updated to account for the newly-added messages.
511511
model?.fetchOlder();
512512
}
513+
if (scrollMetrics.extentAfter != 0 && scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {
514+
model?.fetchNewer();
515+
}
513516
}
514517

515518
void _scrollChanged() {
@@ -562,10 +565,17 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
562565
}
563566

564567
Widget _buildListView(BuildContext context) {
565-
final length = model!.items.length;
566568
const centerSliverKey = ValueKey('center sliver');
567569

568-
Widget sliver = SliverStickyHeaderList(
570+
// TODO fix borked header
571+
// TODO handle firstUnread == newest
572+
// TODO maybe handle firstUnread == oldest
573+
final firstUnreadItemIndex = model!.findItemWithMessageId(model!.firstUnreadMessageId!);
574+
final olderItems = model!.items.sublist(0, firstUnreadItemIndex + 1);
575+
final newerItems = model!.items.sublist(firstUnreadItemIndex + 1);
576+
577+
// TODO dedup code
578+
Widget olderItemsSliver = SliverStickyHeaderList(
569579
headerPlacement: HeaderPlacement.scrollingStart,
570580
delegate: SliverChildBuilderDelegate(
571581
// To preserve state across rebuilds for individual [MessageItem]
@@ -587,26 +597,56 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
587597
final valueKey = key as ValueKey<int>;
588598
final index = model!.findItemWithMessageId(valueKey.value);
589599
if (index == -1) return null;
590-
return length - 1 - (index - 3);
600+
return olderItems.length - 1 - index;
591601
},
592-
childCount: length + 3,
602+
childCount: olderItems.length,
593603
(context, i) {
594-
// To reinforce that the end of the feed has been reached:
595-
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
596-
if (i == 0) return const SizedBox(height: 36);
604+
final data = olderItems[olderItems.length - 1 - i];
605+
return _buildItem(data, i);
606+
}));
607+
608+
Widget newerItemsSliver = SliverStickyHeaderList(
609+
headerPlacement: HeaderPlacement.scrollingStart,
610+
delegate: SliverChildBuilderDelegate(
611+
// To preserve state across rebuilds for individual [MessageItem]
612+
// widgets as the size of [MessageListView.items] changes we need
613+
// to match old widgets by their key to their new position in
614+
// the list.
615+
//
616+
// The keys are of type [ValueKey] with a value of [Message.id]
617+
// and here we use a O(log n) binary search method. This could
618+
// be improved but for now it only triggers for materialized
619+
// widgets. As a simple test, flinging through Combined feed in
620+
// CZO on a Pixel 5, this only runs about 10 times per rebuild
621+
// and the timing for each call is <100 microseconds.
622+
//
623+
// Non-message items (e.g., start and end markers) that do not
624+
// have state that needs to be preserved have not been given keys
625+
// and will not trigger this callback.
626+
findChildIndexCallback: (Key key) {
627+
final valueKey = key as ValueKey<int>;
628+
final index = model!.findItemWithMessageId(valueKey.value);
629+
if (index == -1) return null;
630+
return index;
631+
},
632+
childCount: newerItems.length + 3,
633+
(context, i) {
634+
if (i == newerItems.length) return TypingStatusWidget(narrow: widget.narrow);
597635

598-
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
636+
if (i == newerItems.length + 1) return MarkAsReadWidget(narrow: widget.narrow);
599637

600-
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
638+
// To reinforce that the end of the feed has been reached:
639+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
640+
if (i == newerItems.length + 2) return const SizedBox(height: 36);
601641

602-
final data = model!.items[length - 1 - (i - 3)];
642+
final data = newerItems[i];
603643
return _buildItem(data, i);
604644
}));
605645

606646
if (!ComposeBox.hasComposeBox(widget.narrow)) {
607647
// TODO(#311) If we have a bottom nav, it will pad the bottom
608648
// inset, and this shouldn't be necessary
609-
sliver = SliverSafeArea(sliver: sliver);
649+
newerItemsSliver = SliverSafeArea(sliver: newerItemsSliver);
610650
}
611651

612652
return CustomScrollView(
@@ -622,17 +662,13 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
622662
},
623663

624664
controller: scrollController,
625-
semanticChildCount: length + 2,
665+
semanticChildCount: model!.items.length + 2,
626666
anchor: 1.0,
627667
center: centerSliverKey,
628-
629668
slivers: [
630-
sliver,
631-
632-
// This is a trivial placeholder that occupies no space. Its purpose is
633-
// to have the key that's passed to [ScrollView.center], and so to cause
634-
// the above [SliverStickyHeaderList] to run from bottom to top.
669+
olderItemsSliver,
635670
const SliverToBoxAdapter(key: centerSliverKey),
671+
newerItemsSliver,
636672
]);
637673
}
638674

test/example_data.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,25 @@ GetMessagesResult newestGetMessagesResult({
477477
);
478478
}
479479

480+
/// A GetMessagesResult the server might return on an `anchor=oldest` request.
481+
GetMessagesResult oldestGetMessagesResult({
482+
required bool foundNewest,
483+
bool historyLimited = false,
484+
required List<Message> messages,
485+
}) {
486+
return GetMessagesResult(
487+
// These anchor, foundAnchor, and foundOldest values are what the server
488+
// appears to always return when the request had `anchor=oldest`.
489+
anchor: 0,
490+
foundAnchor: false,
491+
foundOldest: true,
492+
493+
foundNewest: foundNewest,
494+
historyLimited: historyLimited,
495+
messages: messages,
496+
);
497+
}
498+
480499
/// A GetMessagesResult the server might return when we request older messages.
481500
GetMessagesResult olderGetMessagesResult({
482501
required int anchor,
@@ -495,6 +514,24 @@ GetMessagesResult olderGetMessagesResult({
495514
);
496515
}
497516

517+
/// A GetMessagesResult the server might return when we request newer messages.
518+
GetMessagesResult newerGetMessagesResult({
519+
required int anchor,
520+
bool foundAnchor = false, // the value if the server understood includeAnchor false
521+
required bool foundNewest,
522+
bool historyLimited = false,
523+
required List<Message> messages,
524+
}) {
525+
return GetMessagesResult(
526+
anchor: anchor,
527+
foundAnchor: foundAnchor,
528+
foundNewest: foundNewest,
529+
foundOldest: false, // empirically always this, even when anchor happens to be oldest
530+
historyLimited: historyLimited,
531+
messages: messages,
532+
);
533+
}
534+
498535
PollWidgetData pollWidgetData({
499536
required String question,
500537
required List<String> options,

0 commit comments

Comments
 (0)