1
+ import 'dart:async' ;
2
+ import 'dart:collection' ;
1
3
import 'dart:convert' ;
2
4
5
+ import 'package:flutter/foundation.dart' ;
6
+
3
7
import '../api/model/events.dart' ;
4
8
import '../api/model/model.dart' ;
5
9
import '../api/route/messages.dart' ;
@@ -8,12 +12,129 @@ import 'message_list.dart';
8
12
import 'store.dart' ;
9
13
10
14
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
+
17
+ /// States outlining where an [OutboxMessage] is, in its lifecycle.
18
+ ///
19
+ /// ```
20
+ //// ┌─────────────────────────────────────┐
21
+ /// │ Event received, │
22
+ /// Send │ or we abandoned │
23
+ /// immediately. │ 200. the queue. ▼
24
+ /// (create) ──────────────► sending ──────► sent ────────────────► (delete)
25
+ /// │ ▲
26
+ /// │ 4xx or User │
27
+ /// │ other error. cancels. │
28
+ /// └────────► failed ────────────────────┘
29
+ /// ```
30
+ enum OutboxMessageLifecycle {
31
+ sending,
32
+ sent,
33
+ failed,
34
+ }
35
+
36
+ /// A message sent by the self-user.
37
+ sealed class OutboxMessage <T extends Recipient > implements MessageBase <T > {
38
+ /// Construct a new [OutboxMessage] with a unique [localMessageId] .
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
+ return switch (destination) {
53
+ StreamDestination () => StreamOutboxMessage (
54
+ localMessageId: localMessageId,
55
+ selfUserId: selfUserId,
56
+ recipient: StreamRecipient (destination.streamId, destination.topic),
57
+ content: content,
58
+ ),
59
+ DmDestination () => DmOutboxMessage (
60
+ localMessageId: localMessageId,
61
+ selfUserId: selfUserId,
62
+ recipient: DmRecipient .fromDmDestination (destination, selfUserId: selfUserId),
63
+ content: content,
64
+ ),
65
+ };
66
+ }
67
+
68
+ @override
69
+ int ? get id => null ;
70
+
71
+ @override
72
+ final int senderId;
73
+ @override
74
+ final int timestamp;
75
+ final String content;
76
+
77
+ /// ID corresponding to [MessageEvent.localMessageId] , which identifies a
78
+ /// locally echoed message.
79
+ final int localMessageId;
80
+
81
+ OutboxMessageLifecycle get state => _state;
82
+ OutboxMessageLifecycle _state;
83
+ set state (OutboxMessageLifecycle value) {
84
+ // See [OutboxMessageLifecycle] for valid state transitions.
85
+ switch (value) {
86
+ case OutboxMessageLifecycle .sending:
87
+ assert (false );
88
+ case OutboxMessageLifecycle .sent:
89
+ case OutboxMessageLifecycle .failed:
90
+ assert (_state == OutboxMessageLifecycle .sending);
91
+ }
92
+ _state = value;
93
+ }
94
+
95
+ /// Whether the [OutboxMessage] will be hidden to [MessageListView] or not.
96
+ ///
97
+ /// When set to false with [unhide] , this cannot be toggled back to true again.
98
+ bool get hidden => _hidden;
99
+ bool _hidden = true ;
100
+ void unhide () {
101
+ assert (_hidden);
102
+ _hidden = false ;
103
+ }
104
+ }
105
+
106
+ class StreamOutboxMessage extends OutboxMessage <StreamRecipient > {
107
+ StreamOutboxMessage ({
108
+ required super .localMessageId,
109
+ required super .selfUserId,
110
+ required this .recipient,
111
+ required super .content,
112
+ });
113
+
114
+ @override
115
+ final StreamRecipient recipient;
116
+ }
117
+
118
+ class DmOutboxMessage extends OutboxMessage <DmRecipient > {
119
+ DmOutboxMessage ({
120
+ required super .localMessageId,
121
+ required super .selfUserId,
122
+ required this .recipient,
123
+ required super .content,
124
+ });
125
+
126
+ @override
127
+ final DmRecipient recipient;
128
+ }
11
129
12
130
/// The portion of [PerAccountStore] for messages and message lists.
13
131
mixin MessageStore {
14
132
/// All known messages, indexed by [Message.id] .
15
133
Map <int , Message > get messages;
16
134
135
+ /// Messages sent by the user, indexed by [OutboxMessage.localMessageId] .
136
+ Map <int , OutboxMessage > get outboxMessages;
137
+
17
138
Set <MessageListView > get debugMessageListViews;
18
139
19
140
void registerMessageList (MessageListView view);
@@ -24,6 +145,11 @@ mixin MessageStore {
24
145
required String content,
25
146
});
26
147
148
+ /// Remove from [outboxMessages] given the [localMessageId] .
149
+ ///
150
+ /// The message to remove must already exist.
151
+ void removeOutboxMessage (int localMessageId);
152
+
27
153
/// Reconcile a batch of just-fetched messages with the store,
28
154
/// mutating the list.
29
155
///
@@ -41,11 +167,28 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
41
167
MessageStoreImpl ({required super .core})
42
168
// There are no messages in InitialSnapshot, so we don't have
43
169
// a use case for initializing MessageStore with nonempty [messages].
44
- : messages = {};
170
+ : messages = {},
171
+ _outboxMessages = {};
172
+
173
+ /// A fresh ID to use for [OutboxMessage.localMessageId] , unique within the
174
+ /// current event queue.
175
+ int _nextLocalMessageId = 0 ;
45
176
46
177
@override
47
178
final Map <int , Message > messages;
48
179
180
+ @override
181
+ late final UnmodifiableMapView <int , OutboxMessage > outboxMessages =
182
+ UnmodifiableMapView (_outboxMessages);
183
+ final Map <int , OutboxMessage > _outboxMessages;
184
+
185
+ /// The timers for debouncing outbox messages, indexed by
186
+ /// [OutboxMessage.localMessageId] .
187
+ ///
188
+ /// It is guaranteed that all timers tracked here are active, and each
189
+ /// corresponds to an outbox message where [OutboxMessage.hidden] is true.
190
+ final Map <int , Timer > _outboxMessageDebounceTimers = {};
191
+
49
192
final Set <MessageListView > _messageListViews = {};
50
193
51
194
@override
@@ -84,17 +227,135 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
84
227
// [InheritedNotifier] to rebuild in the next frame) before the owner's
85
228
// `dispose` or `onNewStore` is called. Discussion:
86
229
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893
230
+
231
+ for (final timer in _outboxMessageDebounceTimers.values) {
232
+ timer.cancel ();
233
+ }
234
+ _outboxMessageDebounceTimers.clear ();
87
235
}
88
236
89
237
@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
- );
238
+ Future <void > sendMessage ({required MessageDestination destination, required String content}) async {
239
+ if (! debugOutboxEnabled) {
240
+ await _apiSendMessage (connection,
241
+ destination: destination,
242
+ content: content,
243
+ readBySender: true );
244
+ return ;
245
+ }
246
+
247
+ final localMessageId = _nextLocalMessageId++ ;
248
+ assert (! outboxMessages.containsKey (localMessageId));
249
+ assert (! _outboxMessageDebounceTimers.containsKey (localMessageId));
250
+ _outboxMessages[localMessageId] = OutboxMessage .fromDestination (destination,
251
+ localMessageId: localMessageId,
252
+ selfUserId: selfUserId,
253
+ content: content);
254
+ _outboxMessageDebounceTimers[localMessageId] =
255
+ Timer (kLocalEchoDebounceDuration, () => _unhideOutboxMessage (localMessageId));
256
+
257
+ try {
258
+ await _apiSendMessage (connection,
259
+ destination: destination,
260
+ content: content,
261
+ readBySender: true ,
262
+ queueId: queueId,
263
+ localId: localMessageId.toString ());
264
+ _updateOutboxMessage (localMessageId, newState: OutboxMessageLifecycle .sent);
265
+ } catch (e) {
266
+ final outboxMessage = outboxMessages[localMessageId];
267
+ if (outboxMessage == null ) {
268
+ // The message event arrived while the message was being sent;
269
+ // nothing else to do here.
270
+ assert (! _outboxMessageDebounceTimers.containsKey (localMessageId));
271
+ rethrow ;
272
+ }
273
+ // This should be called before `_unhideOutboxMessage(localMessageId)`
274
+ // to avoid unnecessarily notifying the listeners twice.
275
+ _updateOutboxMessage (localMessageId, newState: OutboxMessageLifecycle .failed);
276
+ if (outboxMessage.hidden) {
277
+ _unhideOutboxMessage (localMessageId);
278
+ }
279
+ rethrow ;
280
+ }
281
+ }
282
+
283
+ @override
284
+ void removeOutboxMessage (int localMessageId) {
285
+ final removed = _outboxMessages.remove (localMessageId);
286
+ _outboxMessageDebounceTimers.remove (localMessageId)? .cancel ();
287
+ if (removed == null ) {
288
+ assert (false , 'Removing unknown outbox message with localMessageId: $localMessageId ' );
289
+ return ;
290
+ }
291
+ for (final view in _messageListViews) {
292
+ view.removeOutboxMessageIfExists (removed);
293
+ }
294
+ }
295
+
296
+ /// Unhide the [OutboxMessage] with the given [localMessageId]
297
+ /// and notify listeners.
298
+ ///
299
+ /// The outbox message must exist and have been hidden.
300
+ void _unhideOutboxMessage (int localMessageId) {
301
+ final outboxMessage = outboxMessages[localMessageId];
302
+ final debounceTimer = _outboxMessageDebounceTimers.remove (localMessageId);
303
+ assert (debounceTimer != null ,
304
+ 'Every hidden outbox message should have a debounce timer.' );
305
+ if (outboxMessage == null || debounceTimer == null ) {
306
+ assert (false );
307
+ return ;
308
+ }
309
+ outboxMessage.unhide ();
310
+ debounceTimer.cancel ();
311
+ for (final view in _messageListViews) {
312
+ view.handleOutboxMessage (outboxMessage);
313
+ }
314
+ }
315
+
316
+ /// Update the state of the [OutboxMessage] with the given [localMessageId] ,
317
+ /// and notify listeners if necessary.
318
+ ///
319
+ /// This is a no-op if no outbox message with [localMessageId] exists.
320
+ void _updateOutboxMessage (int localMessageId, {
321
+ required OutboxMessageLifecycle newState,
322
+ }) {
323
+ final outboxMessage = outboxMessages[localMessageId];
324
+ if (outboxMessage == null ) {
325
+ return ;
326
+ }
327
+ outboxMessage.state = newState;
328
+ if (outboxMessage.hidden) {
329
+ return ;
330
+ }
331
+ for (final view in _messageListViews) {
332
+ view.notifyListenersIfOutboxMessagePresent (localMessageId);
333
+ }
334
+ }
335
+
336
+ /// In debug mode, controls whether outbox messages should be created when
337
+ /// [sendMessage] is called.
338
+ ///
339
+ /// Outside of debug mode, this is always true and the setter has no effect.
340
+ static bool get debugOutboxEnabled {
341
+ bool result = true ;
342
+ assert (() {
343
+ result = _debugOutboxEnabled;
344
+ return true ;
345
+ }());
346
+ return result;
347
+ }
348
+ static bool _debugOutboxEnabled = true ;
349
+ static set debugOutboxEnabled (bool value) {
350
+ assert (() {
351
+ _debugOutboxEnabled = value;
352
+ return true ;
353
+ }());
354
+ }
355
+
356
+ @visibleForTesting
357
+ static void debugReset () {
358
+ _debugOutboxEnabled = true ;
98
359
}
99
360
100
361
@override
@@ -132,6 +393,12 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
132
393
// See [fetchedMessages] for reasoning.
133
394
messages[event.message.id] = event.message;
134
395
396
+ if (event.localMessageId != null ) {
397
+ final localMessageId = int .parse (event.localMessageId! , radix: 10 );
398
+ _outboxMessages.remove (localMessageId);
399
+ _outboxMessageDebounceTimers.remove (localMessageId)? .cancel ();
400
+ }
401
+
135
402
for (final view in _messageListViews) {
136
403
view.handleMessageEvent (event);
137
404
}
0 commit comments