Skip to content

Commit b5e0fbd

Browse files
committed
message: Create an outbox message on send; manage its states
While we do create outbox messages, there are in no way user-visible changes since the outbox messages don't end up in message list views. We create skeletons for helpers needed from message list view, but don't implement them yet, to make the diff smaller. For testing, similar to TypingNotifier.debugEnable, we add MessageStoreImpl.debugOutboxEnable for tests that do not intend to cover outbox messages. Some of the delays to fake responses added in tests are not necessary because the future of sendMessage is not completed immediately, but we still add them to keep the tests realistic.
1 parent b4cffe6 commit b5e0fbd

File tree

9 files changed

+702
-17
lines changed

9 files changed

+702
-17
lines changed

lib/model/message.dart

Lines changed: 302 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import 'dart:async';
2+
import 'dart:collection';
13
import 'dart:convert';
24

5+
import 'package:flutter/foundation.dart';
6+
37
import '../api/model/events.dart';
48
import '../api/model/model.dart';
59
import '../api/route/messages.dart';
@@ -8,12 +12,134 @@ import 'message_list.dart';
812
import 'store.dart';
913

1014
const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809
15+
const kLocalEchoDebounceDuration = Duration(milliseconds: 300);
16+
const kSendMessageTimeLimit = Duration(seconds: 10);
17+
18+
/// States outlining where an [OutboxMessage] is, in its lifecycle.
19+
///
20+
/// ```
21+
//// ┌─────────────────────────────────────┐
22+
/// │ Event received, │
23+
/// Send │ or we abandoned │
24+
/// immediately. │ 200. the queue. ▼
25+
/// (create) ──────────────► sending ──────► sent ────────────────► (delete)
26+
/// │ ▲
27+
/// │ 4xx or User │
28+
/// │ other error. cancels. │
29+
/// └────────► failed ────────────────────┘
30+
/// ```
31+
enum OutboxMessageLifecycle {
32+
sending,
33+
sent,
34+
failed,
35+
}
36+
37+
/// A message sent by the self-user.
38+
sealed class OutboxMessage<T extends Conversation> implements MessageBase<T> {
39+
OutboxMessage({
40+
required this.localMessageId,
41+
required int selfUserId,
42+
required this.content,
43+
}) : senderId = selfUserId,
44+
timestamp = (DateTime.timestamp().millisecondsSinceEpoch / 1000).toInt(),
45+
_state = OutboxMessageLifecycle.sending;
46+
47+
static OutboxMessage fromDestination(MessageDestination destination, {
48+
required int localMessageId,
49+
required int selfUserId,
50+
required String content,
51+
}) {
52+
if (destination case DmDestination(:final userIds)) {
53+
assert(userIds.contains(selfUserId));
54+
}
55+
return switch (destination) {
56+
StreamDestination() => StreamOutboxMessage(
57+
localMessageId: localMessageId,
58+
selfUserId: selfUserId,
59+
conversation: StreamConversation(destination.streamId, destination.topic),
60+
content: content,
61+
),
62+
DmDestination() => DmOutboxMessage(
63+
localMessageId: localMessageId,
64+
selfUserId: selfUserId,
65+
conversation: DmConversation(allRecipientIds: destination.userIds),
66+
content: content,
67+
),
68+
};
69+
}
70+
71+
/// ID corresponding to [MessageEvent.localMessageId], which uniquely
72+
/// identifies a locally echoed message in events from the same event queue.
73+
///
74+
/// See also [sendMessage].
75+
final int localMessageId;
76+
@override
77+
int? get id => null;
78+
@override
79+
final int senderId;
80+
@override
81+
final int timestamp;
82+
final String content;
83+
84+
OutboxMessageLifecycle get state => _state;
85+
OutboxMessageLifecycle _state;
86+
set state(OutboxMessageLifecycle value) {
87+
// See [OutboxMessageLifecycle] for valid state transitions.
88+
assert(_state != value);
89+
switch (value) {
90+
case OutboxMessageLifecycle.sending:
91+
assert(false);
92+
case OutboxMessageLifecycle.sent:
93+
assert(_state == OutboxMessageLifecycle.sending);
94+
case OutboxMessageLifecycle.failed:
95+
assert(_state == OutboxMessageLifecycle.sending || _state == OutboxMessageLifecycle.sent);
96+
}
97+
_state = value;
98+
}
99+
100+
/// Whether the [OutboxMessage] will be hidden to [MessageListView] or not.
101+
///
102+
/// When set to false with [unhide], this cannot be toggled back to true again.
103+
bool get hidden => _hidden;
104+
bool _hidden = true;
105+
void unhide() {
106+
assert(_hidden);
107+
_hidden = false;
108+
}
109+
}
110+
111+
class StreamOutboxMessage extends OutboxMessage<StreamConversation> {
112+
StreamOutboxMessage({
113+
required super.localMessageId,
114+
required super.selfUserId,
115+
required this.conversation,
116+
required super.content,
117+
});
118+
119+
@override
120+
final StreamConversation conversation;
121+
}
122+
123+
class DmOutboxMessage extends OutboxMessage<DmConversation> {
124+
DmOutboxMessage({
125+
required super.localMessageId,
126+
required super.selfUserId,
127+
required this.conversation,
128+
required super.content,
129+
});
130+
131+
@override
132+
final DmConversation conversation;
133+
}
11134

12135
/// The portion of [PerAccountStore] for messages and message lists.
13136
mixin MessageStore {
14137
/// All known messages, indexed by [Message.id].
15138
Map<int, Message> get messages;
16139

140+
/// Messages sent by the user, indexed by [OutboxMessage.localMessageId].
141+
Map<int, OutboxMessage> get outboxMessages;
142+
17143
Set<MessageListView> get debugMessageListViews;
18144

19145
void registerMessageList(MessageListView view);
@@ -24,6 +150,11 @@ mixin MessageStore {
24150
required String content,
25151
});
26152

153+
/// Remove from [outboxMessages] given the [localMessageId].
154+
///
155+
/// The message to remove must already exist.
156+
void removeOutboxMessage(int localMessageId);
157+
27158
/// Reconcile a batch of just-fetched messages with the store,
28159
/// mutating the list.
29160
///
@@ -41,11 +172,38 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
41172
MessageStoreImpl({required super.core})
42173
// There are no messages in InitialSnapshot, so we don't have
43174
// a use case for initializing MessageStore with nonempty [messages].
44-
: messages = {};
175+
: messages = {},
176+
_outboxMessages = {},
177+
_outboxMessageDebounceTimers = {},
178+
_outboxMessageSendTimeLimitTimers = {};
179+
180+
/// A fresh ID to use for [OutboxMessage.localMessageId],
181+
/// unique within the [PerAccountStore] instance.
182+
int _nextLocalMessageId = 0;
45183

46184
@override
47185
final Map<int, Message> messages;
48186

187+
@override
188+
late final UnmodifiableMapView<int, OutboxMessage> outboxMessages =
189+
UnmodifiableMapView(_outboxMessages);
190+
final Map<int, OutboxMessage> _outboxMessages;
191+
192+
/// A map of timers to unhide outbox messages after a delay,
193+
/// indexed by [OutboxMessage.localMessageId].
194+
///
195+
/// If the outbox message was unhidden prior to the timeout,
196+
/// its timer gets removed and cancelled.
197+
final Map<int, Timer> _outboxMessageDebounceTimers;
198+
199+
/// A map of timers to update outbox messages state to
200+
/// [OutboxMessageLifecycle.failed] after a delay,
201+
/// indexed by [OutboxMessage.localMessageId].
202+
///
203+
/// If the outbox message's state is set to [OutboxMessageLifecycle.failed]
204+
/// within the time limit, its timer gets removed and cancelled.
205+
final Map<int, Timer> _outboxMessageSendTimeLimitTimers;
206+
49207
final Set<MessageListView> _messageListViews = {};
50208

51209
@override
@@ -84,17 +242,120 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
84242
// [InheritedNotifier] to rebuild in the next frame) before the owner's
85243
// `dispose` or `onNewStore` is called. Discussion:
86244
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893
245+
246+
for (final localMessageId in outboxMessages.keys) {
247+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
248+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
249+
}
250+
_outboxMessages.clear();
251+
}
252+
253+
@override
254+
Future<void> sendMessage({required MessageDestination destination, required String content}) async {
255+
if (!debugOutboxEnable) {
256+
await _apiSendMessage(connection,
257+
destination: destination,
258+
content: content,
259+
readBySender: true);
260+
return;
261+
}
262+
263+
final localMessageId = _nextLocalMessageId++;
264+
assert(!outboxMessages.containsKey(localMessageId));
265+
_outboxMessages[localMessageId] = OutboxMessage.fromDestination(destination,
266+
localMessageId: localMessageId,
267+
selfUserId: selfUserId,
268+
content: content);
269+
_outboxMessageDebounceTimers[localMessageId] = Timer(kLocalEchoDebounceDuration, () {
270+
assert(outboxMessages.containsKey(localMessageId));
271+
_unhideOutboxMessage(localMessageId);
272+
});
273+
_outboxMessageSendTimeLimitTimers[localMessageId] = Timer(kSendMessageTimeLimit, () {
274+
assert(outboxMessages.containsKey(localMessageId));
275+
// This should be called before `_unhideOutboxMessage(localMessageId)`
276+
// to avoid unnecessarily notifying the listeners twice.
277+
_updateOutboxMessage(localMessageId, newState: OutboxMessageLifecycle.failed);
278+
_unhideOutboxMessage(localMessageId);
279+
});
280+
281+
try {
282+
await _apiSendMessage(connection,
283+
destination: destination,
284+
content: content,
285+
readBySender: true,
286+
queueId: queueId,
287+
localId: localMessageId.toString());
288+
if (_outboxMessages[localMessageId]?.state == OutboxMessageLifecycle.failed) {
289+
// Reached time limit while request was pending.
290+
// No state update is needed.
291+
return;
292+
}
293+
_updateOutboxMessage(localMessageId, newState: OutboxMessageLifecycle.sent);
294+
} catch (e) {
295+
// This should be called before `_unhideOutboxMessage(localMessageId)`
296+
// to avoid unnecessarily notifying the listeners twice.
297+
_updateOutboxMessage(localMessageId, newState: OutboxMessageLifecycle.failed);
298+
_unhideOutboxMessage(localMessageId);
299+
rethrow;
300+
}
301+
}
302+
303+
/// Unhide the [OutboxMessage] with the given [localMessageId],
304+
/// and notify listeners if necessary.
305+
///
306+
/// This is a no-op if the outbox message does not exist or is not hidden.
307+
void _unhideOutboxMessage(int localMessageId) {
308+
final outboxMessage = outboxMessages[localMessageId];
309+
if (outboxMessage == null || !outboxMessage.hidden) {
310+
return;
311+
}
312+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
313+
outboxMessage.unhide();
314+
// TODO: uncomment this once message list support is added:
315+
// for (final view in _messageListViews) {
316+
// view.handleOutboxMessage(outboxMessage);
317+
// }
318+
}
319+
320+
/// Update the state of the [OutboxMessage] with the given [localMessageId],
321+
/// and notify listeners if necessary.
322+
///
323+
/// This is a no-op if the outbox message does not exists, or that
324+
/// [OutboxMessage.state] already equals [newState].
325+
void _updateOutboxMessage(int localMessageId, {
326+
required OutboxMessageLifecycle newState,
327+
}) {
328+
final outboxMessage = outboxMessages[localMessageId];
329+
if (outboxMessage == null || outboxMessage.state == newState) {
330+
return;
331+
}
332+
if (newState == OutboxMessageLifecycle.failed) {
333+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
334+
}
335+
outboxMessage.state = newState;
336+
if (outboxMessage.hidden) {
337+
return;
338+
}
339+
// TODO: uncomment this once message list support is added:
340+
// for (final view in _messageListViews) {
341+
// view.notifyListenersIfOutboxMessagePresent(localMessageId);
342+
// }
87343
}
88344

345+
89346
@override
90-
Future<void> sendMessage({required MessageDestination destination, required String content}) {
91-
// TODO implement outbox; see design at
92-
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739
93-
return _apiSendMessage(connection,
94-
destination: destination,
95-
content: content,
96-
readBySender: true,
97-
);
347+
void removeOutboxMessage(int localMessageId) {
348+
final removed = _outboxMessages.remove(localMessageId);
349+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
350+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
351+
if (removed == null) {
352+
assert(false, 'Removing unknown outbox message with localMessageId: $localMessageId');
353+
return;
354+
}
355+
// TODO: uncomment this once message list support is added:
356+
// for (final view in _messageListViews) {
357+
// view.removeOutboxMessageIfExists(removed);
358+
// }
98359
}
99360

100361
@override
@@ -132,6 +393,13 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
132393
// See [fetchedMessages] for reasoning.
133394
messages[event.message.id] = event.message;
134395

396+
if (event.localMessageId != null) {
397+
final localMessageId = int.parse(event.localMessageId!, radix: 10);
398+
_outboxMessages.remove(localMessageId);
399+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
400+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
401+
}
402+
135403
for (final view in _messageListViews) {
136404
view.handleMessageEvent(event);
137405
}
@@ -325,4 +593,29 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
325593
// [Poll] is responsible for notifying the affected listeners.
326594
poll.handleSubmessageEvent(event);
327595
}
596+
597+
/// In debug mode, controls whether outbox messages should be created when
598+
/// [sendMessage] is called.
599+
///
600+
/// Outside of debug mode, this is always true and the setter has no effect.
601+
static bool get debugOutboxEnable {
602+
bool result = true;
603+
assert(() {
604+
result = _debugOutboxEnable;
605+
return true;
606+
}());
607+
return result;
608+
}
609+
static bool _debugOutboxEnable = true;
610+
static set debugOutboxEnable(bool value) {
611+
assert(() {
612+
_debugOutboxEnable = value;
613+
return true;
614+
}());
615+
}
616+
617+
@visibleForTesting
618+
static void debugReset() {
619+
_debugOutboxEnable = true;
620+
}
328621
}

lib/model/store.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
725725
@override
726726
Map<int, Message> get messages => _messages.messages;
727727
@override
728+
Map<int, OutboxMessage> get outboxMessages => _messages.outboxMessages;
729+
@override
728730
void registerMessageList(MessageListView view) =>
729731
_messages.registerMessageList(view);
730732
@override
@@ -904,6 +906,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
904906
return _messages.sendMessage(destination: destination, content: content);
905907
}
906908

909+
@override
910+
void removeOutboxMessage(int localMessageId) => _messages.removeOutboxMessage(localMessageId);
911+
907912
static List<CustomProfileField> _sortCustomProfileFields(List<CustomProfileField> initialCustomProfileFields) {
908913
// TODO(server): The realm-wide field objects have an `order` property,
909914
// but the actual API appears to be that the fields should be shown in

test/example_data.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,8 +622,8 @@ UserTopicEvent userTopicEvent(
622622
);
623623
}
624624

625-
MessageEvent messageEvent(Message message) =>
626-
MessageEvent(id: 0, message: message, localMessageId: null);
625+
MessageEvent messageEvent(Message message, {int? localMessageId}) =>
626+
MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString());
627627

628628
DeleteMessageEvent deleteMessageEvent(List<StreamMessage> messages) {
629629
assert(messages.isNotEmpty);

0 commit comments

Comments
 (0)