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,140 @@ 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
+ 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
+ }
11
140
12
141
/// The portion of [PerAccountStore] for messages and message lists.
13
142
mixin MessageStore {
14
143
/// All known messages, indexed by [Message.id] .
15
144
Map <int , Message > get messages;
16
145
146
+ /// Messages sent by the user, indexed by [OutboxMessage.localMessageId] .
147
+ Map <int , OutboxMessage > get outboxMessages;
148
+
17
149
Set <MessageListView > get debugMessageListViews;
18
150
19
151
void registerMessageList (MessageListView view);
@@ -24,6 +156,11 @@ mixin MessageStore {
24
156
required String content,
25
157
});
26
158
159
+ /// Remove from [outboxMessages] given the [localMessageId] .
160
+ ///
161
+ /// The message to remove must already exist.
162
+ void removeOutboxMessage (int localMessageId);
163
+
27
164
/// Reconcile a batch of just-fetched messages with the store,
28
165
/// mutating the list.
29
166
///
@@ -38,14 +175,43 @@ mixin MessageStore {
38
175
}
39
176
40
177
class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
41
- MessageStoreImpl ({required super .core})
178
+ MessageStoreImpl ({required super .core, required this .realmEmptyTopicDisplayName })
42
179
// There are no messages in InitialSnapshot, so we don't have
43
180
// 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;
45
191
46
192
@override
47
193
final Map <int , Message > messages;
48
194
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
+
49
215
final Set <MessageListView > _messageListViews = {};
50
216
51
217
@override
@@ -84,17 +250,124 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
84
250
// [InheritedNotifier] to rebuild in the next frame) before the owner's
85
251
// `dispose` or `onNewStore` is called. Discussion:
86
252
// 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);
87
261
}
88
262
89
263
@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,
95
278
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
+ // }
98
371
}
99
372
100
373
@override
@@ -132,6 +405,13 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
132
405
// See [fetchedMessages] for reasoning.
133
406
messages[event.message.id] = event.message;
134
407
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
+
135
415
for (final view in _messageListViews) {
136
416
view.handleMessageEvent (event);
137
417
}
@@ -325,4 +605,29 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
325
605
// [Poll] is responsible for notifying the affected listeners.
326
606
poll.handleSubmessageEvent (event);
327
607
}
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
+ }
328
633
}
0 commit comments