Skip to content

Commit 8c76491

Browse files
committed
msglist: Fetch more messages despite previous muted batch
Fixes: zulip#1256
1 parent 7467fa6 commit 8c76491

File tree

5 files changed

+417
-45
lines changed

5 files changed

+417
-45
lines changed

lib/model/message_list.dart

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,32 @@ mixin _MessageSequence {
145145
/// The corresponding item index is [middleItem].
146146
int middleMessage = 0;
147147

148+
/// The ID of the oldest known message so far in this narrow.
149+
///
150+
/// This is used as the anchor for fetching the next batch of older messages
151+
/// and will be `null` if no messages of this narrow have been fetched yet.
152+
/// A non-null value for this doesn't always mean [haveOldest] is `true`.
153+
///
154+
/// The related message may not appear in [messages] because it
155+
/// is muted in one way or another. Also, the related message may not stay
156+
/// in the narrow for reasons such as message moves or deletions,
157+
/// but that's fine for what this is used for.
158+
int? get oldMessageId => _oldMessageId;
159+
int? _oldMessageId;
160+
161+
/// The ID of the newest known message so far in this narrow.
162+
///
163+
/// This is used as the anchor for fetching the next batch of newer messages
164+
/// and will be `null` if no messages of this narrow have been fetched yet.
165+
/// A non-null value for this doesn't always mean [haveNewest] is `true`.
166+
///
167+
/// The related message may not appear in [messages] because it
168+
/// is muted in one way or another. Also, the related message may not stay
169+
/// in the narrow for reasons such as message moves or deletions,
170+
/// but that's fine for what this is used for.
171+
int? get newMessageId => _newMessageId;
172+
int? _newMessageId;
173+
148174
/// Whether [messages] and [items] represent the results of a fetch.
149175
///
150176
/// This allows the UI to distinguish "still working on fetching messages"
@@ -409,6 +435,8 @@ mixin _MessageSequence {
409435
generation += 1;
410436
messages.clear();
411437
middleMessage = 0;
438+
_oldMessageId = null;
439+
_newMessageId = null;
412440
outboxMessages.clear();
413441
_haveOldest = false;
414442
_haveNewest = false;
@@ -815,9 +843,13 @@ class MessageListView with ChangeNotifier, _MessageSequence {
815843
}
816844

817845
/// Fetch messages, starting from scratch.
846+
///
847+
/// This makes sure there is at least one non-muted message fetched; if any.
848+
/// It may do so my repeatedly calling [fetchOlder] and [fetchNewer].
818849
Future<void> fetchInitial() async {
819850
assert(!fetched && !haveOldest && !haveNewest && !busyFetchingMore);
820851
assert(messages.isEmpty && contents.isEmpty);
852+
assert(oldMessageId == null && newMessageId == null);
821853

822854
if (narrow case KeywordSearchNarrow(keyword: '')) {
823855
// The server would reject an empty keyword search; skip the request.
@@ -841,6 +873,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
841873
);
842874
if (this.generation > generation) return;
843875

876+
_oldMessageId = result.messages.firstOrNull?.id;
877+
_newMessageId = result.messages.lastOrNull?.id;
878+
844879
_adjustNarrowForTopicPermalink(result.messages.firstOrNull);
845880

846881
store.reconcileMessages(result.messages);
@@ -868,6 +903,12 @@ class MessageListView with ChangeNotifier, _MessageSequence {
868903
_syncOutboxMessagesFromStore();
869904
}
870905

906+
while (messages.isEmpty && !(haveOldest && haveNewest)) {
907+
await fetchOlder(partOfInitialFetch: true);
908+
if (messages.isNotEmpty) break;
909+
await fetchNewer(partOfInitialFetch: true);
910+
}
911+
871912
_setStatus(FetchingStatus.idle, was: FetchingStatus.fetchInitial);
872913
}
873914

@@ -911,16 +952,18 @@ class MessageListView with ChangeNotifier, _MessageSequence {
911952
/// then this method does nothing and immediately returns.
912953
/// That makes this method suitable to call frequently, e.g. every frame,
913954
/// whenever it looks likely to be useful to have more messages.
914-
Future<void> fetchOlder() async {
955+
Future<void> fetchOlder({bool partOfInitialFetch = false}) async {
915956
if (haveOldest) return;
916957
if (busyFetchingMore) return;
917-
assert(fetched);
918-
assert(messages.isNotEmpty);
958+
assert(partOfInitialFetch || fetched);
959+
assert(oldMessageId != null);
919960
await _fetchMore(
920-
anchor: NumericAnchor(messages[0].id),
961+
anchor: NumericAnchor(oldMessageId!),
921962
numBefore: kMessageListFetchBatchSize,
922963
numAfter: 0,
964+
partOfInitialFetch: partOfInitialFetch,
923965
processResult: (result) {
966+
_oldMessageId = result.messages.firstOrNull?.id ?? oldMessageId;
924967
store.reconcileMessages(result.messages);
925968
store.recentSenders.handleMessages(result.messages); // TODO(#824)
926969

@@ -941,16 +984,18 @@ class MessageListView with ChangeNotifier, _MessageSequence {
941984
/// then this method does nothing and immediately returns.
942985
/// That makes this method suitable to call frequently, e.g. every frame,
943986
/// whenever it looks likely to be useful to have more messages.
944-
Future<void> fetchNewer() async {
987+
Future<void> fetchNewer({bool partOfInitialFetch = false}) async {
945988
if (haveNewest) return;
946989
if (busyFetchingMore) return;
947-
assert(fetched);
948-
assert(messages.isNotEmpty);
990+
assert(partOfInitialFetch || fetched);
991+
assert(newMessageId != null);
949992
await _fetchMore(
950-
anchor: NumericAnchor(messages.last.id),
993+
anchor: NumericAnchor(newMessageId!),
951994
numBefore: 0,
952995
numAfter: kMessageListFetchBatchSize,
996+
partOfInitialFetch: partOfInitialFetch,
953997
processResult: (result) {
998+
_newMessageId = result.messages.lastOrNull?.id ?? newMessageId;
954999
store.reconcileMessages(result.messages);
9551000
store.recentSenders.handleMessages(result.messages); // TODO(#824)
9561001

@@ -971,12 +1016,15 @@ class MessageListView with ChangeNotifier, _MessageSequence {
9711016
required Anchor anchor,
9721017
required int numBefore,
9731018
required int numAfter,
1019+
required bool partOfInitialFetch,
9741020
required void Function(GetMessagesResult) processResult,
9751021
}) async {
9761022
assert(narrow is! TopicNarrow
9771023
// We only intend to send "with" in [fetchInitial]; see there.
9781024
|| (narrow as TopicNarrow).with_ == null);
979-
_setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle);
1025+
if (!partOfInitialFetch) {
1026+
_setStatus(FetchingStatus.fetchingMore, was: FetchingStatus.idle);
1027+
}
9801028
final generation = this.generation;
9811029
bool hasFetchError = false;
9821030
try {
@@ -998,7 +1046,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
9981046

9991047
processResult(result);
10001048
} finally {
1001-
if (this.generation == generation) {
1049+
if (this.generation == generation && !partOfInitialFetch) {
10021050
if (hasFetchError) {
10031051
_setStatus(FetchingStatus.backoff, was: FetchingStatus.fetchingMore);
10041052
unawaited((_fetchBackoffMachine ??= BackoffMachine())

lib/widgets/message_list.dart

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -874,8 +874,6 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
874874
model.fetchInitial();
875875
}
876876

877-
bool _prevFetched = false;
878-
879877
void _modelChanged() {
880878
// When you're scrolling quickly, our mark-as-read requests include the
881879
// messages *between* _messagesRecentlyInViewport and the messages currently
@@ -902,14 +900,25 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
902900
// This method was called because that just changed.
903901
});
904902

905-
if (!_prevFetched && model.fetched && model.messages.isEmpty) {
903+
if (scrollController.hasClients && model.fetched) {
904+
// This is needed here in order to fetch more messages if the previous
905+
// batch is all muted, or its visible (non-muted) messages combined with
906+
// the previous messages are less than a screenful. In such cases, there
907+
// will be no change in the scroll metrics to fire a notification that
908+
// will fetch more messages.
909+
// Calling this method in here (inside `_modelChanged`) causes an
910+
// apparently inevitable double-fetch glitch which is described inside
911+
// the method itself.
912+
_fetchMoreIfNeeded(scrollController.position);
913+
}
914+
915+
if (model.messages.isEmpty && model.haveOldest && model.haveNewest) {
906916
// If the fetch came up empty, there's nothing to read,
907917
// so opening the keyboard won't be bothersome and could be helpful.
908918
// It's definitely helpful if we got here from the new-DM page.
909919
MessageListPage.ancestorOf(context)
910920
.composeBoxState?.controller.requestFocusIfUnfocused();
911921
}
912-
_prevFetched = model.fetched;
913922
}
914923

915924
/// Find the range of message IDs on screen, as a (first, last) tuple,
@@ -1032,26 +1041,20 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
10321041
_scrollToBottomVisible.value = true;
10331042
}
10341043

1044+
_fetchMoreIfNeeded(scrollMetrics);
1045+
}
1046+
1047+
void _fetchMoreIfNeeded(ScrollMetrics scrollMetrics) {
10351048
if (scrollMetrics.extentBefore < kFetchMessagesBufferPixels) {
1036-
// This ends up firing a second time shortly after we fetch a batch while
1037-
// there is a fling going on.
1049+
// This ends up firing a second time shortly after we fetch a batch.
10381050
// The result is that each time we decide to fetch a batch, we end up
10391051
// fetching two batches in quick succession. This is basically harmless
10401052
// but makes things a bit more complicated to reason about.
10411053
// The cause is that this gets called again with minScrollExtent
10421054
// still not yet updated to account for the newly-added messages.
1043-
// This relates to how [SchedulerBinding] executes different tasks when
1044-
// producing a new frame, like first executing transient callbacks
1045-
// (typically ticking animations) followed by persistent callbacks
1046-
// (typically the build/layout/paint pipeline), and so on.
1047-
// So when there is a new message batch received, the related widgets are
1048-
// marked dirty for the next frame. With the ongoing fling, the underlying
1049-
// animation registers transient callback(s) for [ScrollPosition.setPixels]
1050-
// to be executed in the transient callbacks phase at the start of
1051-
// the frame. It will then notify its listeners, eventually calling
1052-
// `_scrollChanged` and in turn current method with old minScrollExtent,
1053-
// causing the second batch fetch. Then in the persistent callbacks phase,
1054-
// minScrollExtent will be updated, effective in the next frame.
1055+
// This relates to `_modelChanged` (and in turn the current method) being
1056+
// called right away as a listener of the model, before the next frame
1057+
// being drawn with the newly-added messages, updating minScrollExtent.
10551058
model.fetchOlder();
10561059
}
10571060
if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {

test/model/message_list_test.dart

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,132 @@ void main() {
727727
});
728728
});
729729

730+
group('oldMessageId, newMessageId', () {
731+
group('fetchInitial', () {
732+
test('visible messages', () async {
733+
await prepare();
734+
check(model)..oldMessageId.isNull()..newMessageId.isNull();
735+
736+
connection.prepare(json: newestResult(
737+
foundOldest: true,
738+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)),
739+
).toJson());
740+
await model.fetchInitial();
741+
742+
checkNotifiedOnce();
743+
check(model)
744+
..messages.length.equals(100)
745+
..oldMessageId.equals(100)..newMessageId.equals(199);
746+
});
747+
748+
test('invisible messages', () async {
749+
final mutedUser = eg.user();
750+
await prepare(users: [mutedUser], mutedUserIds: [mutedUser.userId]);
751+
check(model)..oldMessageId.isNull()..newMessageId.isNull();
752+
753+
connection.prepare(json: newestResult(
754+
foundOldest: true,
755+
messages: List.generate(100,
756+
(i) => eg.dmMessage(id: 100 + i, from: eg.selfUser, to: [mutedUser])),
757+
).toJson());
758+
await model.fetchInitial();
759+
760+
checkNotifiedOnce();
761+
check(model)
762+
..messages.isEmpty()
763+
..oldMessageId.equals(100)..newMessageId.equals(199);
764+
});
765+
766+
test('no messages found', () async {
767+
await prepare();
768+
check(model)..oldMessageId.isNull()..newMessageId.isNull();
769+
770+
connection.prepare(json: newestResult(
771+
foundOldest: true,
772+
messages: [],
773+
).toJson());
774+
await model.fetchInitial();
775+
776+
checkNotifiedOnce();
777+
check(model)
778+
..messages.isEmpty()
779+
..oldMessageId.isNull()..newMessageId.isNull();
780+
});
781+
});
782+
783+
group('fetching more', () {
784+
test('visible messages', () async {
785+
await prepare(anchor: AnchorCode.firstUnread);
786+
check(model)..oldMessageId.isNull()..newMessageId.isNull();
787+
788+
await prepareMessages(
789+
foundOldest: false, foundNewest: false,
790+
anchorMessageId: 250,
791+
messages: List.generate(100, (i) => eg.streamMessage(id: 200 + i)));
792+
check(model)
793+
..messages.length.equals(100)
794+
..oldMessageId.equals(200)..newMessageId.equals(299);
795+
796+
connection.prepare(json: olderResult(
797+
anchor: 200, foundOldest: true,
798+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)),
799+
).toJson());
800+
await model.fetchOlder();
801+
checkNotified(count: 2);
802+
check(model)
803+
..messages.length.equals(200)
804+
..oldMessageId.equals(100)..newMessageId.equals(299);
805+
806+
connection.prepare(json: newerResult(
807+
anchor: 299, foundNewest: true,
808+
messages: List.generate(100, (i) => eg.streamMessage(id: 300 + i)),
809+
).toJson());
810+
await model.fetchNewer();
811+
checkNotified(count: 2);
812+
check(model)
813+
..messages.length.equals(300)
814+
..oldMessageId.equals(100)..newMessageId.equals(399);
815+
});
816+
817+
test('invisible messages', () async {
818+
final mutedUser = eg.user();
819+
await prepare(anchor: AnchorCode.firstUnread,
820+
users: [mutedUser], mutedUserIds: [mutedUser.userId]);
821+
check(model)..oldMessageId.isNull()..newMessageId.isNull();
822+
823+
await prepareMessages(
824+
foundOldest: false, foundNewest: false,
825+
anchorMessageId: 250,
826+
messages: List.generate(100, (i) => eg.streamMessage(id: 200 + i)));
827+
check(model)
828+
..messages.length.equals(100)
829+
..oldMessageId.equals(200)..newMessageId.equals(299);
830+
831+
connection.prepare(json: olderResult(
832+
anchor: 200, foundOldest: true,
833+
messages: List.generate(100,
834+
(i) => eg.dmMessage(id: 100 + i, from: eg.selfUser, to: [mutedUser])),
835+
).toJson());
836+
await model.fetchOlder();
837+
checkNotified(count: 2);
838+
check(model)
839+
..messages.length.equals(100)
840+
..oldMessageId.equals(100)..newMessageId.equals(299);
841+
842+
connection.prepare(json: newerResult(
843+
anchor: 299, foundNewest: true,
844+
messages: List.generate(100,
845+
(i) => eg.dmMessage(id: 300 + i, from: eg.selfUser, to: [mutedUser])),
846+
).toJson());
847+
await model.fetchNewer();
848+
checkNotified(count: 2);
849+
check(model)
850+
..messages.length.equals(100)
851+
..oldMessageId.equals(100)..newMessageId.equals(399);
852+
});
853+
});
854+
});
855+
730856
// TODO(#1569): test jumpToEnd
731857

732858
group('MessageEvent', () {
@@ -3226,6 +3352,8 @@ void checkInvariants(MessageListView model) {
32263352
check(model)
32273353
..messages.isEmpty()
32283354
..outboxMessages.isEmpty()
3355+
..oldMessageId.isNull()
3356+
..newMessageId.isNull()
32293357
..haveOldest.isFalse()
32303358
..haveNewest.isFalse()
32313359
..busyFetchingMore.isFalse();
@@ -3414,6 +3542,8 @@ extension MessageListViewChecks on Subject<MessageListView> {
34143542
Subject<List<MessageListItem>> get items => has((x) => x.items, 'items');
34153543
Subject<int> get middleItem => has((x) => x.middleItem, 'middleItem');
34163544
Subject<bool> get fetched => has((x) => x.fetched, 'fetched');
3545+
Subject<int?> get oldMessageId => has((x) => x.oldMessageId, 'oldMessageId');
3546+
Subject<int?> get newMessageId => has((x) => x.newMessageId, 'newMessageId');
34173547
Subject<bool> get haveOldest => has((x) => x.haveOldest, 'haveOldest');
34183548
Subject<bool> get haveNewest => has((x) => x.haveNewest, 'haveNewest');
34193549
Subject<bool> get busyFetchingMore => has((x) => x.busyFetchingMore, 'busyFetchingMore');

0 commit comments

Comments
 (0)