Skip to content

Commit bc011d8

Browse files
chrisbobbeclaude
andcommitted
msglist: Drop outbox message when its message arrives in a fetch
Fixes part of #2397. When the event queue is stuck but other requests are succeeding (see e.g. #1884 and #514 for ways that can happen), a sent message can arrive in a message fetch with no message event to remove the corresponding outbox message. The user would see the message stuck in a "sending" state, next to the delivered copy of the same message if that copy is in view. Now reconcileMessages drops any outbox message whose message is in the store, recognized by the message ID recorded from the send response (#2397). Message lists respond by removing their copy of the outbox message, through a new method named for this cause of removal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0c303cb commit bc011d8

3 files changed

Lines changed: 82 additions & 5 deletions

File tree

lib/model/message.dart

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,8 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage
435435
ifAbsent: () => _reconcileUnrecognizedMessage(message),
436436
(current) => _reconcileRecognizedMessage(current, message));
437437
}
438+
439+
_removeDeliveredOutboxMessages();
438440
}
439441

440442
Message _reconcileUnrecognizedMessage(Message incoming) {
@@ -952,15 +954,19 @@ const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441
952954
/// timed out. not finished when
953955
/// wait period timed out.
954956
///
955-
/// Event received. Or [sendMessage]
956-
/// request succeeds and we're sending to
957-
/// an unsubscribed channel.
957+
/// Event received. Or message found in a
958+
/// fetch after [sendMessage] request succeeds.
959+
/// Or [sendMessage] request succeeds and
960+
/// we're sending to an unsubscribed channel.
958961
/// (any state) ───────────────────────────────────────► (delete)
959962
/// ```
960963
///
961964
/// During its lifecycle, it is guaranteed that the outbox message is deleted
962965
/// as soon a message event with a matching [MessageEvent.localMessageId]
963966
/// arrives.
967+
/// Once the [sendMessage] request has succeeded, the outbox message is also
968+
/// deleted as soon as a message with a matching [OutboxMessage.messageId]
969+
/// is found in a fetch; see [MessageStoreImpl.reconcileMessages].
964970
/// If we're sending to an unsubscribed channel, we don't expect an event
965971
/// (see "third buggy behavior" in #1798) so in that case
966972
/// the outbox message is deleted when the [sendMessage] request succeeds.
@@ -1001,8 +1007,9 @@ enum OutboxMessageState {
10011007
///
10021008
/// A request remains "outstanding" even after the [sendMessage] HTTP request
10031009
/// completes, whether with success or failure.
1004-
/// The outbox-message persists until either the corresponding [MessageEvent]
1005-
/// arrives to replace it, or the user discards it (perhaps to try again).
1010+
/// The outbox-message persists until either the corresponding message
1011+
/// arrives to replace it (in a [MessageEvent] or, once the request has
1012+
/// succeeded, in a fetch), or the user discards it (perhaps to try again).
10061013
/// For details, see the state diagram at [OutboxMessageState],
10071014
/// and [MessageStore.takeOutboxMessage].
10081015
sealed class OutboxMessage<T extends Conversation> extends MessageBase<T> {
@@ -1117,6 +1124,9 @@ mixin _OutboxMessageStore on HasChannelStore {
11171124
/// unique within this instance.
11181125
int _nextLocalMessageId = 1;
11191126

1127+
/// As in [MessageStoreImpl.messages].
1128+
Map<int, Message> get messages;
1129+
11201130
/// As in [MessageStoreImpl._messageListViews].
11211131
Set<MessageListView> get _messageListViews;
11221132

@@ -1317,6 +1327,40 @@ mixin _OutboxMessageStore on HasChannelStore {
13171327
return removed;
13181328
}
13191329

1330+
/// Remove any outbox messages whose anticipated [Message],
1331+
/// as identified by [OutboxMessage.messageId], is in [messages],
1332+
/// updating message-list views accordingly.
1333+
///
1334+
/// This is how outbox messages get removed when their messages
1335+
/// are received through a fetch instead of a [MessageEvent];
1336+
/// see [MessageStoreImpl.reconcileMessages].
1337+
void _removeDeliveredOutboxMessages() {
1338+
assert(!_disposed);
1339+
// This runs on every fetch (see [MessageStoreImpl.reconcileMessages]);
1340+
// return cheaply in the common case of an empty outbox.
1341+
if (_outboxMessages.isEmpty) return;
1342+
final delivered = _outboxMessages.values
1343+
.where((outboxMessage) {
1344+
final messageId = outboxMessage._messageId;
1345+
return messageId != null && messages.containsKey(messageId);
1346+
})
1347+
.toList();
1348+
for (final outboxMessage in delivered) {
1349+
_removeDeliveredOutboxMessage(outboxMessage);
1350+
}
1351+
}
1352+
1353+
/// Remove [outboxMessage], whose anticipated [Message]
1354+
/// (see [OutboxMessage.messageId]) must be in [messages],
1355+
/// updating message-list views accordingly.
1356+
void _removeDeliveredOutboxMessage(OutboxMessage outboxMessage) {
1357+
assert(messages.containsKey(outboxMessage._messageId));
1358+
_removeOutboxMessage(outboxMessage.localMessageId);
1359+
for (final view in _messageListViews) {
1360+
view.handleOutboxMessageDelivered(outboxMessage);
1361+
}
1362+
}
1363+
13201364
void _handleMessageEventOutbox(MessageEvent event) {
13211365
if (event.localMessageId != null) {
13221366
final localMessageId = int.parse(event.localMessageId!, radix: 10);

lib/model/message_list.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,16 @@ class MessageListView with ChangeNotifier, _MessageSequence {
11001100
}
11011101
}
11021102

1103+
/// Remove the [outboxMessage] from the view, its anticipated message
1104+
/// (see [OutboxMessage.messageId]) having been received in a fetch.
1105+
///
1106+
/// This is a no-op if the message is not found.
1107+
void handleOutboxMessageDelivered(OutboxMessage outboxMessage) {
1108+
if (_removeOutboxMessage(outboxMessage)) {
1109+
notifyListeners();
1110+
}
1111+
}
1112+
11031113
void handleUserTopicEvent(UserTopicEvent event) {
11041114
switch (_canAffectVisibility(event)) {
11051115
case UserTopicVisibilityEffect.none:

test/model/message_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,29 @@ void main() {
463463
checkNotifiedOnce();
464464
}));
465465

466+
test('waiting -> (delete) because message found in fetch', () => awaitFakeAsync((async) async {
467+
// Regression test for: https://github.com/zulip/zulip-flutter/issues/2397
468+
await prepareOutboxMessage();
469+
async.elapse(kLocalEchoDebounceDuration);
470+
checkState().equals(OutboxMessageState.waiting);
471+
checkNotifiedOnce();
472+
473+
store.reconcileMessages([message]);
474+
check(store.outboxMessages).isEmpty();
475+
checkNotifiedOnce();
476+
}));
477+
478+
test('no delete when unrelated message found in fetch', () => awaitFakeAsync((async) async {
479+
await prepareOutboxMessage();
480+
async.elapse(kLocalEchoDebounceDuration);
481+
checkState().equals(OutboxMessageState.waiting);
482+
checkNotifiedOnce();
483+
484+
store.reconcileMessages([eg.streamMessage(stream: stream)]);
485+
checkState().equals(OutboxMessageState.waiting);
486+
checkNotNotified();
487+
}));
488+
466489
test('waitPeriodExpired -> (delete) when event arrives before send request fails', () => awaitFakeAsync((async) async {
467490
// Set up an error to fail `sendMessage` with a delay, leaving time for
468491
// the message event to arrive.

0 commit comments

Comments
 (0)