Skip to content

Commit 22e9f22

Browse files
committed
msglist: Track three fetch request statuses separately
This change is necessary when there is a need to fetch more messages in both directions, older and newer, and when fetching in one direction avoids fetching in another direction at the same time, because of the `if (busyFetchingNewer) return` line in both `fetchOlder` and `fetchNewer`. This scenario happens when a conversation is opened in its first unread, such that the number of messages in the initial batch is so low (because they're muted in one way or another) that it's already past the certain point where the scroll metrics listener in the widget code triggers both `fetchOlder` and `fetchNewer`. In 2025-11, that code first calls `fetchOlder` then `fetchNewer`, and for the reason mentioned above, `fetchNewer` will not fetch any new messages. But that's fine, because as soon as older messages from `fetchOlder` arrives, there will be a change in the scroll metrics, so `fetchNewer` will be called again, fetching new messages. But imagine if the number of messages in the initial batch occupies less than a screenful, and then `fetchOlder` returns no messages or a few messages that combined with the initial batch messages are still less than a screenful; in that case, there will be no change in the scroll metrics to call `fetchNewer`. With the three fetch request types being separated, especially the two request types for older and newer messages, each direction can fetch more messages independently without interfering with one another. Another benefit of this change is that if there's a backoff in one direction, it will not affect the other one. Fixes: #1256
1 parent 813ee69 commit 22e9f22

File tree

5 files changed

+299
-120
lines changed

5 files changed

+299
-120
lines changed

lib/model/message_list.dart

Lines changed: 95 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,33 @@ class MessageListOutboxMessageItem extends MessageListMessageBaseItem {
8383
]);
8484
}
8585

86-
/// The status of outstanding or recent fetch requests from a [MessageListView].
87-
enum FetchingStatus {
88-
/// The model has not made any fetch requests (since its last reset, if any).
86+
/// The status of outstanding or recent `fetchInitial` request from a [MessageListView].
87+
enum FetchingInitialStatus {
88+
/// The model has not made a `fetchInitial` request (since its last reset, if any).
8989
unstarted,
9090

9191
/// The model has made a `fetchInitial` request, which hasn't succeeded.
92-
fetchInitial,
92+
fetching,
9393

94-
/// The model made a successful `fetchInitial` request,
95-
/// and has no outstanding requests or backoff.
94+
/// The model made a successful `fetchInitial` request.
9695
idle,
96+
}
97+
98+
/// The status of outstanding or recent "fetch more" request from a [MessageListView].
99+
///
100+
/// By a "fetch more" request we mean a `fetchOlder` or a `fetchNewer` request.
101+
enum FetchingMoreStatus {
102+
/// The model has not made the "fetch more" request (since its last reset, if any).
103+
unstarted,
104+
105+
/// The model has made the "fetch more" request, which hasn't succeeded.
106+
fetching,
97107

98-
/// The model has an active `fetchOlder` or `fetchNewer` request.
99-
fetchingMore,
108+
/// The model made a successful "fetch more" request,
109+
/// and has no outstanding request of the same kind or backoff.
110+
idle,
100111

101-
/// The model is in a backoff period from a failed request.
112+
/// The model is in a backoff period from the failed "fetch more" request.
102113
backoff,
103114
}
104115

@@ -125,7 +136,7 @@ mixin _MessageSequence {
125136
///
126137
/// This may or may not represent all the message history that
127138
/// conceptually belongs in this message list.
128-
/// That information is expressed in [fetched], [haveOldest], [haveNewest].
139+
/// That information is expressed in [initialFetched], [haveOldest], [haveNewest].
129140
///
130141
/// This also may or may not represent all the message history that
131142
/// conceptually belongs in this narrow because some messages might be
@@ -165,12 +176,15 @@ mixin _MessageSequence {
165176
int? get newMessageId => _newMessageId;
166177
int? _newMessageId;
167178

168-
/// Whether [messages] and [items] represent the results of a fetch.
179+
/// Whether the first batch of messages for this narrow is fetched yet.
169180
///
170-
/// This allows the UI to distinguish "still working on fetching messages"
171-
/// from "there are in fact no messages here".
172-
bool get fetched => switch (_status) {
173-
FetchingStatus.unstarted || FetchingStatus.fetchInitial => false,
181+
/// Some or all of the fetched messages may not make it to [messages]
182+
/// and [items] if they're muted in one way or another.
183+
///
184+
/// This allows the UI to distinguish "still working on fetching first batch
185+
/// of messages" from "there are in fact no messages here".
186+
bool get initialFetched => switch (_fetchInitialStatus) {
187+
.unstarted || .fetching => false,
174188
_ => true,
175189
};
176190

@@ -188,18 +202,37 @@ mixin _MessageSequence {
188202

189203
/// Whether this message list is currently busy when it comes to
190204
/// fetching more messages.
205+
bool get busyFetchingMore => busyFetchingOlder || busyFetchingNewer;
206+
207+
/// Whether this message list is currently busy when it comes to
208+
/// fetching older messages.
209+
///
210+
/// Here "busy" means a new call to fetch older messages would do nothing,
211+
/// rather than make any request to the server,
212+
/// as a result of an existing recent request.
213+
/// This is true both when the recent request is still outstanding,
214+
/// and when it failed and the backoff from that is still in progress.
215+
bool get busyFetchingOlder => switch(_fetchOlderStatus) {
216+
.fetching || .backoff => true,
217+
_ => false,
218+
};
219+
220+
/// Whether this message list is currently busy when it comes to
221+
/// fetching newer messages.
191222
///
192-
/// Here "busy" means a new call to fetch more messages would do nothing,
223+
/// Here "busy" means a new call to fetch older messages would do nothing,
193224
/// rather than make any request to the server,
194225
/// as a result of an existing recent request.
195226
/// This is true both when the recent request is still outstanding,
196227
/// and when it failed and the backoff from that is still in progress.
197-
bool get busyFetchingMore => switch (_status) {
198-
FetchingStatus.fetchingMore || FetchingStatus.backoff => true,
228+
bool get busyFetchingNewer => switch(_fetchNewerStatus) {
229+
.fetching || .backoff => true,
199230
_ => false,
200231
};
201232

202-
FetchingStatus _status = FetchingStatus.unstarted;
233+
FetchingInitialStatus _fetchInitialStatus = .unstarted;
234+
FetchingMoreStatus _fetchOlderStatus = .unstarted;
235+
FetchingMoreStatus _fetchNewerStatus = .unstarted;
203236

204237
BackoffMachine? _fetchBackoffMachine;
205238

@@ -434,7 +467,9 @@ mixin _MessageSequence {
434467
outboxMessages.clear();
435468
_haveOldest = false;
436469
_haveNewest = false;
437-
_status = FetchingStatus.unstarted;
470+
_fetchInitialStatus = .unstarted;
471+
_fetchOlderStatus = .unstarted;
472+
_fetchNewerStatus = .unstarted;
438473
_fetchBackoffMachine = null;
439474
contents.clear();
440475
items.clear();
@@ -786,16 +821,28 @@ class MessageListView with ChangeNotifier, _MessageSequence {
786821
}
787822
}
788823

789-
void _setStatus(FetchingStatus value, {FetchingStatus? was}) {
790-
assert(was == null || _status == was);
791-
_status = value;
792-
if (!fetched) return;
824+
void _setInitialStatus(FetchingInitialStatus value, {FetchingInitialStatus? was}) {
825+
assert(was == null || _fetchInitialStatus == was);
826+
_fetchInitialStatus = value;
827+
if (!initialFetched) return;
828+
notifyListeners();
829+
}
830+
831+
void _setOlderStatus(FetchingMoreStatus value, {FetchingMoreStatus? was}) {
832+
assert(was == null || _fetchOlderStatus == was);
833+
_fetchOlderStatus = value;
834+
notifyListeners();
835+
}
836+
837+
void _setNewerStatus(FetchingMoreStatus value, {FetchingMoreStatus? was}) {
838+
assert(was == null || _fetchNewerStatus == was);
839+
_fetchNewerStatus = value;
793840
notifyListeners();
794841
}
795842

796843
/// Fetch messages, starting from scratch.
797844
Future<void> fetchInitial() async {
798-
assert(!fetched && !haveOldest && !haveNewest && !busyFetchingMore);
845+
assert(!initialFetched && !haveOldest && !haveNewest && !busyFetchingMore);
799846
assert(messages.isEmpty && contents.isEmpty);
800847
assert(oldMessageId == null && newMessageId == null);
801848

@@ -805,11 +852,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
805852
// probably better if the UI code doesn't take it to this point.
806853
_haveOldest = true;
807854
_haveNewest = true;
808-
_setStatus(FetchingStatus.idle, was: FetchingStatus.unstarted);
855+
_setInitialStatus(.idle, was: .unstarted);
809856
return;
810857
}
811858

812-
_setStatus(FetchingStatus.fetchInitial, was: FetchingStatus.unstarted);
859+
_setInitialStatus(.fetching, was: .unstarted);
813860
// TODO schedule all this in another isolate
814861
final generation = this.generation;
815862
final result = await getMessages(store.connection,
@@ -850,7 +897,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
850897
_syncOutboxMessagesFromStore();
851898
}
852899

853-
_setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial);
900+
_setInitialStatus(.idle, was: .fetching);
854901
}
855902

856903
/// Update [narrow] for the result of a "with" narrow (topic permalink) fetch.
@@ -888,23 +935,24 @@ class MessageListView with ChangeNotifier, _MessageSequence {
888935
/// Fetch the next batch of older messages, if applicable.
889936
///
890937
/// If there are no older messages to fetch (i.e. if [haveOldest]),
891-
/// or if this message list is already busy fetching more messages
892-
/// (i.e. if [busyFetchingMore], which includes backoff from failed requests),
938+
/// or if this message list is already busy fetching older messages
939+
/// (i.e. if [busyFetchingOlder], which includes backoff from failed requests),
893940
/// then this method does nothing and immediately returns.
894941
/// That makes this method suitable to call frequently, e.g. every frame,
895-
/// whenever it looks likely to be useful to have more messages.
942+
/// whenever it looks likely to be useful to have older messages.
896943
Future<void> fetchOlder() async {
897944
final generation = this.generation;
898945
int visibleMessageCount = 0;
899946
do {
900947
if (haveOldest) return;
901-
if (busyFetchingMore) return;
902-
assert(fetched);
948+
if (busyFetchingOlder) return;
949+
assert(initialFetched);
903950
assert(oldMessageId != null);
904951
await _fetchMore(
905952
anchor: NumericAnchor(oldMessageId!),
906953
numBefore: kMessageListFetchBatchSize,
907954
numAfter: 0,
955+
setStatus: _setOlderStatus,
908956
processResult: (result) {
909957
_oldMessageId = result.messages.firstOrNull?.id ?? oldMessageId;
910958
store.reconcileMessages(result.messages);
@@ -925,23 +973,24 @@ class MessageListView with ChangeNotifier, _MessageSequence {
925973
/// Fetch the next batch of newer messages, if applicable.
926974
///
927975
/// If there are no newer messages to fetch (i.e. if [haveNewest]),
928-
/// or if this message list is already busy fetching more messages
929-
/// (i.e. if [busyFetchingMore], which includes backoff from failed requests),
976+
/// or if this message list is already busy fetching newer messages
977+
/// (i.e. if [busyFetchingNewer], which includes backoff from failed requests),
930978
/// then this method does nothing and immediately returns.
931979
/// That makes this method suitable to call frequently, e.g. every frame,
932-
/// whenever it looks likely to be useful to have more messages.
980+
/// whenever it looks likely to be useful to have newer messages.
933981
Future<void> fetchNewer() async {
934982
final generation = this.generation;
935983
int visibleMessageCount = 0;
936984
do {
937985
if (haveNewest) return;
938-
if (busyFetchingMore) return;
939-
assert(fetched);
986+
if (busyFetchingNewer) return;
987+
assert(initialFetched);
940988
assert(newMessageId != null);
941989
await _fetchMore(
942990
anchor: NumericAnchor(newMessageId!),
943991
numBefore: 0,
944992
numAfter: kMessageListFetchBatchSize,
993+
setStatus: _setNewerStatus,
945994
processResult: (result) {
946995
_newMessageId = result.messages.lastOrNull?.id ?? newMessageId;
947996
store.reconcileMessages(result.messages);
@@ -967,12 +1016,13 @@ class MessageListView with ChangeNotifier, _MessageSequence {
9671016
required Anchor anchor,
9681017
required int numBefore,
9691018
required int numAfter,
1019+
required void Function(FetchingMoreStatus value, {FetchingMoreStatus? was}) setStatus,
9701020
required void Function(GetMessagesResult) processResult,
9711021
}) async {
9721022
assert(narrow is! TopicNarrow
9731023
// We only intend to send "with" in [fetchInitial]; see there.
9741024
|| (narrow as TopicNarrow).with_ == null);
975-
_setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle);
1025+
setStatus(.fetching);
9761026
final generation = this.generation;
9771027
bool hasFetchError = false;
9781028
try {
@@ -993,14 +1043,14 @@ class MessageListView with ChangeNotifier, _MessageSequence {
9931043
} finally {
9941044
if (this.generation == generation) {
9951045
if (hasFetchError) {
996-
_setStatus(FetchingStatus.backoff, was: FetchingStatus.fetchingMore);
1046+
setStatus(.backoff, was: .fetching);
9971047
unawaited((_fetchBackoffMachine ??= BackoffMachine())
9981048
.wait().then((_) {
9991049
if (this.generation != generation) return;
1000-
_setStatus(FetchingStatus.idle, was: FetchingStatus.backoff);
1050+
setStatus(.idle, was: .backoff);
10011051
}));
10021052
} else {
1003-
_setStatus(FetchingStatus.idle, was: FetchingStatus.fetchingMore);
1053+
setStatus(.idle, was: .fetching);
10041054
_fetchBackoffMachine = null;
10051055
}
10061056
}
@@ -1012,7 +1062,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
10121062
/// This will set [anchor] to [AnchorCode.newest],
10131063
/// and cause messages to be re-fetched from scratch.
10141064
void jumpToEnd() {
1015-
assert(fetched);
1065+
assert(initialFetched);
10161066
assert(!haveNewest);
10171067
assert(anchor != AnchorCode.newest);
10181068
_anchor = AnchorCode.newest;
@@ -1094,7 +1144,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
10941144
// TODO get the newly-unmuted messages from the message store
10951145
// For now, we simplify the task by just refetching this message list
10961146
// from scratch.
1097-
if (fetched) {
1147+
if (initialFetched) {
10981148
_reset();
10991149
notifyListeners();
11001150
fetchInitial();
@@ -1122,7 +1172,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
11221172
// TODO get the newly-unmuted messages from the message store
11231173
// For now, we simplify the task by just refetching this message list
11241174
// from scratch.
1125-
if (fetched) {
1175+
if (initialFetched) {
11261176
_reset();
11271177
notifyListeners();
11281178
fetchInitial();

lib/widgets/message_list.dart

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
10261026
Widget build(BuildContext context) {
10271027
final zulipLocalizations = ZulipLocalizations.of(context);
10281028

1029-
if (!model.fetched) return const Center(child: CircularProgressIndicator());
1029+
if (!model.initialFetched) return const Center(child: CircularProgressIndicator());
10301030

10311031
if (model.items.isEmpty && model.haveNewest && model.haveOldest) {
10321032
final String header;
@@ -1206,11 +1206,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
12061206
// Else if we're busy with fetching, then show a loading indicator.
12071207
//
12081208
// This applies even if the fetch is over, but failed, and we're still
1209-
// in backoff from it; and even if the fetch is/was for the other direction.
1210-
// The loading indicator really means "busy, working on it"; and that's the
1211-
// right summary even if the fetch is internally queued behind other work.
1209+
// in backoff from it.
12121210
return model.haveOldest ? const _MessageListHistoryStart()
1213-
: model.busyFetchingMore ? const _MessageListLoadingMore()
1211+
: model.busyFetchingOlder ? const _MessageListLoadingMore()
12141212
: const SizedBox.shrink();
12151213
}
12161214

@@ -1225,7 +1223,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
12251223
// https://chat.zulip.org/#narrow/channel/48-mobile/topic/space.20at.20end.20of.20thread/near/2203391
12261224
const SizedBox(height: 12),
12271225
]);
1228-
} else if (model.busyFetchingMore) {
1226+
} else if (model.busyFetchingNewer) {
12291227
// See [_buildStartCap] for why this condition shows a loading indicator.
12301228
return const _MessageListLoadingMore();
12311229
} else {

0 commit comments

Comments
 (0)