Skip to content

Commit bccaead

Browse files
committed
msglist: Fetch older messages on scrolling up
Fixes: #78
1 parent 18bb0c6 commit bccaead

File tree

4 files changed

+303
-33
lines changed

4 files changed

+303
-33
lines changed

lib/model/message_list.dart

+86-12
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ class MessageListMessageItem extends MessageListItem {
2727
MessageListMessageItem(this.message, this.content);
2828
}
2929

30+
/// Indicates the app is loading more messages at the top or bottom.
31+
class MessageListLoadingItem extends MessageListItem {
32+
final MessageListDirection direction;
33+
34+
const MessageListLoadingItem(this.direction);
35+
}
36+
37+
enum MessageListDirection { older, newer }
38+
3039
/// Indicates we've reached the oldest message in the narrow.
3140
class MessageListHistoryStartItem extends MessageListItem {
3241
const MessageListHistoryStartItem();
@@ -55,6 +64,10 @@ mixin _MessageSequence {
5564
bool get haveOldest => _haveOldest;
5665
bool _haveOldest = false;
5766

67+
/// Whether we are currently fetching the next batch of older messages.
68+
bool get fetchingOlder => _fetchingOlder;
69+
bool _fetchingOlder = false;
70+
5871
/// The parsed message contents, as a list parallel to [messages].
5972
///
6073
/// The i'th element is the result of parsing the i'th element of [messages].
@@ -70,7 +83,7 @@ mixin _MessageSequence {
7083
/// before, between, or after the messages.
7184
///
7285
/// This information is completely derived from [messages] and
73-
/// the flag [haveOldest].
86+
/// the flags [haveOldest] and [fetchingOlder].
7487
/// It exists as an optimization, to memoize that computation.
7588
final QueueList<MessageListItem> items = QueueList();
7689

@@ -86,6 +99,11 @@ mixin _MessageSequence {
8699
static int _compareItemToMessageId(MessageListItem item, int messageId) {
87100
switch (item) {
88101
case MessageListHistoryStartItem(): return -1;
102+
case MessageListLoadingItem():
103+
switch (item.direction) {
104+
case MessageListDirection.older: return -1;
105+
case MessageListDirection.newer: return 1;
106+
}
89107
case MessageListMessageItem(:var message): return message.id.compareTo(messageId);
90108
}
91109
}
@@ -115,6 +133,19 @@ mixin _MessageSequence {
115133
_processMessage(messages.length - 1);
116134
}
117135

136+
void _insertAllMessages(int index, Iterable<Message> toInsert) {
137+
// TODO parse/process messages in smaller batches, to not drop frames.
138+
// On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages.
139+
// (Before that, ~2-5ms in jsonDecode and 0ms in fromJson,
140+
// so skip worrying about those steps.)
141+
assert(contents.length == messages.length);
142+
messages.insertAll(index, toInsert);
143+
contents.insertAll(index, toInsert.map(
144+
(message) => parseContent(message.content)));
145+
assert(contents.length == messages.length);
146+
_reprocessAll();
147+
}
148+
118149
/// Redo all computations from scratch, based on [messages].
119150
void _recompute() {
120151
assert(contents.length == messages.length);
@@ -137,12 +168,22 @@ mixin _MessageSequence {
137168

138169
/// Update [items] to include markers at start and end as appropriate.
139170
void _updateEndMarkers() {
140-
switch ((items.firstOrNull, haveOldest)) {
141-
case (MessageListHistoryStartItem(), true): break;
142-
case (MessageListHistoryStartItem(), _ ): items.removeFirst();
143-
144-
case (_, true): items.addFirst(const MessageListHistoryStartItem());
145-
case (_, _): break;
171+
assert(!(haveOldest && fetchingOlder));
172+
final startMarker = switch ((fetchingOlder, haveOldest)) {
173+
(true, _) => const MessageListLoadingItem(MessageListDirection.older),
174+
(_, true) => const MessageListHistoryStartItem(),
175+
(_, _) => null,
176+
};
177+
final hasStartMarker = switch (items.firstOrNull) {
178+
MessageListLoadingItem() => true,
179+
MessageListHistoryStartItem() => true,
180+
_ => false,
181+
};
182+
switch ((startMarker != null, hasStartMarker)) {
183+
case (true, true): items[0] = startMarker!;
184+
case (true, _ ): items.addFirst(startMarker!);
185+
case (_, true): items.removeFirst();
186+
case (_, _ ): break;
146187
}
147188
}
148189

@@ -164,13 +205,12 @@ mixin _MessageSequence {
164205
/// Lifecycle:
165206
/// * Create with [init].
166207
/// * Add listeners with [addListener].
167-
/// * Fetch messages with [fetch]. When the fetch completes, this object
208+
/// * Fetch messages with [fetchInitial]. When the fetch completes, this object
168209
/// will notify its listeners (as it will any other time the data changes.)
210+
/// * Fetch more messages as needed with [fetchOlder].
169211
/// * On reassemble, call [reassemble].
170212
/// * When the object will no longer be used, call [dispose] to free
171213
/// resources on the [PerAccountStore].
172-
///
173-
/// TODO support fetching another batch
174214
class MessageListView with ChangeNotifier, _MessageSequence {
175215
MessageListView._({required this.store, required this.narrow});
176216

@@ -190,10 +230,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
190230
final PerAccountStore store;
191231
final Narrow narrow;
192232

193-
Future<void> fetch() async {
233+
/// Fetch messages, starting from scratch.
234+
Future<void> fetchInitial() async {
194235
// TODO(#80): fetch from anchor firstUnread, instead of newest
195236
// TODO(#82): fetch from a given message ID as anchor
196-
assert(!fetched && !haveOldest);
237+
assert(!fetched && !haveOldest && !fetchingOlder);
197238
assert(messages.isEmpty && contents.isEmpty);
198239
// TODO schedule all this in another isolate
199240
final result = await getMessages(store.connection,
@@ -211,6 +252,39 @@ class MessageListView with ChangeNotifier, _MessageSequence {
211252
notifyListeners();
212253
}
213254

255+
/// Fetch the next batch of older messages, if applicable.
256+
Future<void> fetchOlder() async {
257+
if (haveOldest) return;
258+
if (fetchingOlder) return;
259+
assert(fetched);
260+
assert(messages.isNotEmpty);
261+
_fetchingOlder = true;
262+
_updateEndMarkers();
263+
notifyListeners();
264+
try {
265+
final result = await getMessages(store.connection,
266+
narrow: narrow.apiEncode(),
267+
anchor: NumericAnchor(messages[0].id),
268+
includeAnchor: false,
269+
numBefore: kMessageListFetchBatchSize,
270+
numAfter: 0,
271+
);
272+
273+
if (result.messages.isNotEmpty
274+
&& result.messages.last.id == messages[0].id) {
275+
// TODO(server-6): includeAnchor should make this impossible
276+
result.messages.removeLast();
277+
}
278+
279+
_insertAllMessages(0, result.messages);
280+
_haveOldest = result.foundOldest;
281+
} finally {
282+
_fetchingOlder = false;
283+
_updateEndMarkers();
284+
notifyListeners();
285+
}
286+
}
287+
214288
/// Add [message] to this view, if it belongs here.
215289
///
216290
/// Called in particular when we get a [MessageEvent].

lib/widgets/message_list.dart

+30-1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ class MessageListAppBarTitle extends StatelessWidget {
123123
}
124124
}
125125

126+
/// The approximate height of a short message in the message list.
127+
const _kShortMessageHeight = 80;
128+
129+
/// The point at which we fetch more history, in pixels from the start or end.
130+
///
131+
/// When the user scrolls to within this distance of the start (or end) of the
132+
/// history we currently have, we make a request to fetch the next batch of
133+
/// older (or newer) messages.
134+
//
135+
// When the user reaches this point, they're at least halfway through the
136+
// previous batch.
137+
const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2) * _kShortMessageHeight;
138+
126139
class MessageList extends StatefulWidget {
127140
const MessageList({super.key, required this.narrow});
128141

@@ -160,7 +173,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
160173
void _initModel(PerAccountStore store) {
161174
model = MessageListView.init(store: store, narrow: widget.narrow);
162175
model!.addListener(_modelChanged);
163-
model!.fetch();
176+
model!.fetchInitial();
164177
}
165178

166179
void _modelChanged() {
@@ -176,6 +189,17 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
176189
} else {
177190
_scrollToBottomVisibleValue.value = true;
178191
}
192+
193+
final extentRemainingAboveViewport = scrollMetrics.maxScrollExtent - scrollMetrics.pixels;
194+
if (extentRemainingAboveViewport < kFetchMessagesBufferPixels) {
195+
// TODO: This ends up firing a second time shortly after we fetch a batch.
196+
// The result is that each time we decide to fetch a batch, we end up
197+
// fetching two batches in quick succession. This is basically harmless
198+
// but makes things a bit more complicated to reason about.
199+
// The cause seems to be that this gets called again with maxScrollExtent
200+
// still not yet updated to account for the newly-added messages.
201+
model?.fetchOlder();
202+
}
179203
}
180204

181205
void _scrollChanged() {
@@ -251,6 +275,11 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
251275
child: Padding(
252276
padding: EdgeInsets.symmetric(vertical: 16.0),
253277
child: Text("No earlier messages."))), // TODO use an icon
278+
MessageListLoadingItem() =>
279+
const Center(
280+
child: Padding(
281+
padding: EdgeInsets.symmetric(vertical: 16.0),
282+
child: CircularProgressIndicator())), // TODO perhaps a different indicator
254283
MessageListMessageItem(:var message, :var content) =>
255284
MessageItem(
256285
trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11),

0 commit comments

Comments
 (0)