Skip to content

Commit e36b99c

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 998736c commit e36b99c

10 files changed

+740
-19
lines changed

lib/model/message.dart

+314-9
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,140 @@ 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+
required int zulipFeatureLevel,
52+
required String? realmEmptyTopicDisplayName,
53+
}) {
54+
if (destination case DmDestination(:final userIds)) {
55+
assert(userIds.contains(selfUserId));
56+
}
57+
return switch (destination) {
58+
StreamDestination() => StreamOutboxMessage(
59+
localMessageId: localMessageId,
60+
selfUserId: selfUserId,
61+
conversation: StreamConversation(
62+
destination.streamId,
63+
destination.topic.interpretAsServer(
64+
zulipFeatureLevel: zulipFeatureLevel,
65+
realmEmptyTopicDisplayName: realmEmptyTopicDisplayName)),
66+
content: content,
67+
),
68+
DmDestination() => DmOutboxMessage(
69+
localMessageId: localMessageId,
70+
selfUserId: selfUserId,
71+
conversation: DmConversation(allRecipientIds: destination.userIds),
72+
content: content,
73+
),
74+
};
75+
}
76+
77+
/// ID corresponding to [MessageEvent.localMessageId], which uniquely
78+
/// identifies a locally echoed message in events from the same event queue.
79+
///
80+
/// See also [sendMessage].
81+
final int localMessageId;
82+
@override
83+
int? get id => null;
84+
@override
85+
final int senderId;
86+
@override
87+
final int timestamp;
88+
final String content;
89+
90+
OutboxMessageLifecycle get state => _state;
91+
OutboxMessageLifecycle _state;
92+
set state(OutboxMessageLifecycle value) {
93+
// See [OutboxMessageLifecycle] for valid state transitions.
94+
assert(_state != value);
95+
switch (value) {
96+
case OutboxMessageLifecycle.sending:
97+
assert(false);
98+
case OutboxMessageLifecycle.sent:
99+
assert(_state == OutboxMessageLifecycle.sending);
100+
case OutboxMessageLifecycle.failed:
101+
assert(_state == OutboxMessageLifecycle.sending || _state == OutboxMessageLifecycle.sent);
102+
}
103+
_state = value;
104+
}
105+
106+
/// Whether the [OutboxMessage] will be hidden to [MessageListView] or not.
107+
///
108+
/// When set to false with [unhide], this cannot be toggled back to true again.
109+
bool get hidden => _hidden;
110+
bool _hidden = true;
111+
void unhide() {
112+
assert(_hidden);
113+
_hidden = false;
114+
}
115+
}
116+
117+
class StreamOutboxMessage extends OutboxMessage<StreamConversation> {
118+
StreamOutboxMessage({
119+
required super.localMessageId,
120+
required super.selfUserId,
121+
required this.conversation,
122+
required super.content,
123+
});
124+
125+
@override
126+
final StreamConversation conversation;
127+
}
128+
129+
class DmOutboxMessage extends OutboxMessage<DmConversation> {
130+
DmOutboxMessage({
131+
required super.localMessageId,
132+
required super.selfUserId,
133+
required this.conversation,
134+
required super.content,
135+
});
136+
137+
@override
138+
final DmConversation conversation;
139+
}
11140

12141
/// The portion of [PerAccountStore] for messages and message lists.
13142
mixin MessageStore {
14143
/// All known messages, indexed by [Message.id].
15144
Map<int, Message> get messages;
16145

146+
/// Messages sent by the user, indexed by [OutboxMessage.localMessageId].
147+
Map<int, OutboxMessage> get outboxMessages;
148+
17149
Set<MessageListView> get debugMessageListViews;
18150

19151
void registerMessageList(MessageListView view);
@@ -24,6 +156,11 @@ mixin MessageStore {
24156
required String content,
25157
});
26158

159+
/// Remove from [outboxMessages] given the [localMessageId].
160+
///
161+
/// The message to remove must already exist.
162+
void removeOutboxMessage(int localMessageId);
163+
27164
/// Reconcile a batch of just-fetched messages with the store,
28165
/// mutating the list.
29166
///
@@ -38,14 +175,43 @@ mixin MessageStore {
38175
}
39176

40177
class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
41-
MessageStoreImpl({required super.core})
178+
MessageStoreImpl({required super.core, required this.realmEmptyTopicDisplayName})
42179
// There are no messages in InitialSnapshot, so we don't have
43180
// a use case for initializing MessageStore with nonempty [messages].
44-
: messages = {};
181+
: messages = {},
182+
_outboxMessages = {},
183+
_outboxMessageDebounceTimers = {},
184+
_outboxMessageSendTimeLimitTimers = {};
185+
186+
/// A fresh ID to use for [OutboxMessage.localMessageId],
187+
/// unique within the [PerAccountStore] instance.
188+
int _nextLocalMessageId = 0;
189+
190+
final String? realmEmptyTopicDisplayName;
45191

46192
@override
47193
final Map<int, Message> messages;
48194

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

51217
@override
@@ -84,17 +250,124 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
84250
// [InheritedNotifier] to rebuild in the next frame) before the owner's
85251
// `dispose` or `onNewStore` is called. Discussion:
86252
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893
253+
254+
for (final localMessageId in outboxMessages.keys) {
255+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
256+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
257+
}
258+
_outboxMessages.clear();
259+
assert(_outboxMessageDebounceTimers.isEmpty);
260+
assert(_outboxMessageSendTimeLimitTimers.isEmpty);
87261
}
88262

89263
@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,
264+
Future<void> sendMessage({required MessageDestination destination, required String content}) async {
265+
if (!debugOutboxEnable) {
266+
await _apiSendMessage(connection,
267+
destination: destination,
268+
content: content,
269+
readBySender: true);
270+
return;
271+
}
272+
273+
final localMessageId = _nextLocalMessageId++;
274+
assert(!outboxMessages.containsKey(localMessageId));
275+
_outboxMessages[localMessageId] = OutboxMessage.fromDestination(destination,
276+
localMessageId: localMessageId,
277+
selfUserId: selfUserId,
95278
content: content,
96-
readBySender: true,
97-
);
279+
zulipFeatureLevel: zulipFeatureLevel,
280+
realmEmptyTopicDisplayName: realmEmptyTopicDisplayName);
281+
_outboxMessageDebounceTimers[localMessageId] = Timer(kLocalEchoDebounceDuration, () {
282+
assert(outboxMessages.containsKey(localMessageId));
283+
_unhideOutboxMessage(localMessageId);
284+
});
285+
_outboxMessageSendTimeLimitTimers[localMessageId] = Timer(kSendMessageTimeLimit, () {
286+
assert(outboxMessages.containsKey(localMessageId));
287+
// This should be called before `_unhideOutboxMessage(localMessageId)`
288+
// to avoid unnecessarily notifying the listeners twice.
289+
_updateOutboxMessage(localMessageId, newState: OutboxMessageLifecycle.failed);
290+
_unhideOutboxMessage(localMessageId);
291+
});
292+
293+
try {
294+
await _apiSendMessage(connection,
295+
destination: destination,
296+
content: content,
297+
readBySender: true,
298+
queueId: queueId,
299+
localId: localMessageId.toString());
300+
if (_outboxMessages[localMessageId]?.state == OutboxMessageLifecycle.failed) {
301+
// Reached time limit while request was pending.
302+
// No state update is needed.
303+
return;
304+
}
305+
_updateOutboxMessage(localMessageId, newState: OutboxMessageLifecycle.sent);
306+
} catch (e) {
307+
// This should be called before `_unhideOutboxMessage(localMessageId)`
308+
// to avoid unnecessarily notifying the listeners twice.
309+
_updateOutboxMessage(localMessageId, newState: OutboxMessageLifecycle.failed);
310+
_unhideOutboxMessage(localMessageId);
311+
rethrow;
312+
}
313+
}
314+
315+
/// Unhide the [OutboxMessage] with the given [localMessageId],
316+
/// and notify listeners if necessary.
317+
///
318+
/// This is a no-op if the outbox message does not exist or is not hidden.
319+
void _unhideOutboxMessage(int localMessageId) {
320+
final outboxMessage = outboxMessages[localMessageId];
321+
if (outboxMessage == null || !outboxMessage.hidden) {
322+
return;
323+
}
324+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
325+
outboxMessage.unhide();
326+
// TODO: uncomment this once message list support is added:
327+
// for (final view in _messageListViews) {
328+
// view.handleOutboxMessage(outboxMessage);
329+
// }
330+
}
331+
332+
/// Update the state of the [OutboxMessage] with the given [localMessageId],
333+
/// and notify listeners if necessary.
334+
///
335+
/// This is a no-op if the outbox message does not exists, or that
336+
/// [OutboxMessage.state] already equals [newState].
337+
void _updateOutboxMessage(int localMessageId, {
338+
required OutboxMessageLifecycle newState,
339+
}) {
340+
final outboxMessage = outboxMessages[localMessageId];
341+
if (outboxMessage == null || outboxMessage.state == newState) {
342+
return;
343+
}
344+
if (newState == OutboxMessageLifecycle.failed) {
345+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
346+
}
347+
outboxMessage.state = newState;
348+
if (outboxMessage.hidden) {
349+
return;
350+
}
351+
// TODO: uncomment this once message list support is added:
352+
// for (final view in _messageListViews) {
353+
// view.notifyListenersIfOutboxMessagePresent(localMessageId);
354+
// }
355+
}
356+
357+
358+
@override
359+
void removeOutboxMessage(int localMessageId) {
360+
final removed = _outboxMessages.remove(localMessageId);
361+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
362+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
363+
if (removed == null) {
364+
assert(false, 'Removing unknown outbox message with localMessageId: $localMessageId');
365+
return;
366+
}
367+
// TODO: uncomment this once message list support is added:
368+
// for (final view in _messageListViews) {
369+
// view.removeOutboxMessageIfExists(removed);
370+
// }
98371
}
99372

100373
@override
@@ -132,6 +405,13 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
132405
// See [fetchedMessages] for reasoning.
133406
messages[event.message.id] = event.message;
134407

408+
if (event.localMessageId != null) {
409+
final localMessageId = int.parse(event.localMessageId!, radix: 10);
410+
_outboxMessages.remove(localMessageId);
411+
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
412+
_outboxMessageSendTimeLimitTimers.remove(localMessageId)?.cancel();
413+
}
414+
135415
for (final view in _messageListViews) {
136416
view.handleMessageEvent(event);
137417
}
@@ -325,4 +605,29 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
325605
// [Poll] is responsible for notifying the affected listeners.
326606
poll.handleSubmessageEvent(event);
327607
}
608+
609+
/// In debug mode, controls whether outbox messages should be created when
610+
/// [sendMessage] is called.
611+
///
612+
/// Outside of debug mode, this is always true and the setter has no effect.
613+
static bool get debugOutboxEnable {
614+
bool result = true;
615+
assert(() {
616+
result = _debugOutboxEnable;
617+
return true;
618+
}());
619+
return result;
620+
}
621+
static bool _debugOutboxEnable = true;
622+
static set debugOutboxEnable(bool value) {
623+
assert(() {
624+
_debugOutboxEnable = value;
625+
return true;
626+
}());
627+
}
628+
629+
@visibleForTesting
630+
static void debugReset() {
631+
_debugOutboxEnable = true;
632+
}
328633
}

0 commit comments

Comments
 (0)