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,122 @@ 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 ();
87
259
}
88
260
89
261
@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,
262
+ Future <void > sendMessage ({required MessageDestination destination, required String content}) async {
263
+ if (! debugOutboxEnable) {
264
+ await _apiSendMessage (connection,
265
+ destination: destination,
266
+ content: content,
267
+ readBySender: true );
268
+ return ;
269
+ }
270
+
271
+ final localMessageId = _nextLocalMessageId++ ;
272
+ assert (! outboxMessages.containsKey (localMessageId));
273
+ _outboxMessages[localMessageId] = OutboxMessage .fromDestination (destination,
274
+ localMessageId: localMessageId,
275
+ selfUserId: selfUserId,
95
276
content: content,
96
- readBySender: true ,
97
- );
277
+ zulipFeatureLevel: zulipFeatureLevel,
278
+ realmEmptyTopicDisplayName: realmEmptyTopicDisplayName);
279
+ _outboxMessageDebounceTimers[localMessageId] = Timer (kLocalEchoDebounceDuration, () {
280
+ assert (outboxMessages.containsKey (localMessageId));
281
+ _unhideOutboxMessage (localMessageId);
282
+ });
283
+ _outboxMessageSendTimeLimitTimers[localMessageId] = Timer (kSendMessageTimeLimit, () {
284
+ assert (outboxMessages.containsKey (localMessageId));
285
+ // This should be called before `_unhideOutboxMessage(localMessageId)`
286
+ // to avoid unnecessarily notifying the listeners twice.
287
+ _updateOutboxMessage (localMessageId, newState: OutboxMessageLifecycle .failed);
288
+ _unhideOutboxMessage (localMessageId);
289
+ });
290
+
291
+ try {
292
+ await _apiSendMessage (connection,
293
+ destination: destination,
294
+ content: content,
295
+ readBySender: true ,
296
+ queueId: queueId,
297
+ localId: localMessageId.toString ());
298
+ if (_outboxMessages[localMessageId]? .state == OutboxMessageLifecycle .failed) {
299
+ // Reached time limit while request was pending.
300
+ // No state update is needed.
301
+ return ;
302
+ }
303
+ _updateOutboxMessage (localMessageId, newState: OutboxMessageLifecycle .sent);
304
+ } catch (e) {
305
+ // This should be called before `_unhideOutboxMessage(localMessageId)`
306
+ // to avoid unnecessarily notifying the listeners twice.
307
+ _updateOutboxMessage (localMessageId, newState: OutboxMessageLifecycle .failed);
308
+ _unhideOutboxMessage (localMessageId);
309
+ rethrow ;
310
+ }
311
+ }
312
+
313
+ /// Unhide the [OutboxMessage] with the given [localMessageId] ,
314
+ /// and notify listeners if necessary.
315
+ ///
316
+ /// This is a no-op if the outbox message does not exist or is not hidden.
317
+ void _unhideOutboxMessage (int localMessageId) {
318
+ final outboxMessage = outboxMessages[localMessageId];
319
+ if (outboxMessage == null || ! outboxMessage.hidden) {
320
+ return ;
321
+ }
322
+ _outboxMessageDebounceTimers.remove (localMessageId)? .cancel ();
323
+ outboxMessage.unhide ();
324
+ // TODO: uncomment this once message list support is added:
325
+ // for (final view in _messageListViews) {
326
+ // view.handleOutboxMessage(outboxMessage);
327
+ // }
328
+ }
329
+
330
+ /// Update the state of the [OutboxMessage] with the given [localMessageId] ,
331
+ /// and notify listeners if necessary.
332
+ ///
333
+ /// This is a no-op if the outbox message does not exists, or that
334
+ /// [OutboxMessage.state] already equals [newState] .
335
+ void _updateOutboxMessage (int localMessageId, {
336
+ required OutboxMessageLifecycle newState,
337
+ }) {
338
+ final outboxMessage = outboxMessages[localMessageId];
339
+ if (outboxMessage == null || outboxMessage.state == newState) {
340
+ return ;
341
+ }
342
+ if (newState == OutboxMessageLifecycle .failed) {
343
+ _outboxMessageSendTimeLimitTimers.remove (localMessageId)? .cancel ();
344
+ }
345
+ outboxMessage.state = newState;
346
+ if (outboxMessage.hidden) {
347
+ return ;
348
+ }
349
+ // TODO: uncomment this once message list support is added:
350
+ // for (final view in _messageListViews) {
351
+ // view.notifyListenersIfOutboxMessagePresent(localMessageId);
352
+ // }
353
+ }
354
+
355
+
356
+ @override
357
+ void removeOutboxMessage (int localMessageId) {
358
+ final removed = _outboxMessages.remove (localMessageId);
359
+ _outboxMessageDebounceTimers.remove (localMessageId)? .cancel ();
360
+ _outboxMessageSendTimeLimitTimers.remove (localMessageId)? .cancel ();
361
+ if (removed == null ) {
362
+ assert (false , 'Removing unknown outbox message with localMessageId: $localMessageId ' );
363
+ return ;
364
+ }
365
+ // TODO: uncomment this once message list support is added:
366
+ // for (final view in _messageListViews) {
367
+ // view.removeOutboxMessageIfExists(removed);
368
+ // }
98
369
}
99
370
100
371
@override
@@ -132,6 +403,13 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
132
403
// See [fetchedMessages] for reasoning.
133
404
messages[event.message.id] = event.message;
134
405
406
+ if (event.localMessageId != null ) {
407
+ final localMessageId = int .parse (event.localMessageId! , radix: 10 );
408
+ _outboxMessages.remove (localMessageId);
409
+ _outboxMessageDebounceTimers.remove (localMessageId)? .cancel ();
410
+ _outboxMessageSendTimeLimitTimers.remove (localMessageId)? .cancel ();
411
+ }
412
+
135
413
for (final view in _messageListViews) {
136
414
view.handleMessageEvent (event);
137
415
}
@@ -325,4 +603,29 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
325
603
// [Poll] is responsible for notifying the affected listeners.
326
604
poll.handleSubmessageEvent (event);
327
605
}
606
+
607
+ /// In debug mode, controls whether outbox messages should be created when
608
+ /// [sendMessage] is called.
609
+ ///
610
+ /// Outside of debug mode, this is always true and the setter has no effect.
611
+ static bool get debugOutboxEnable {
612
+ bool result = true ;
613
+ assert (() {
614
+ result = _debugOutboxEnable;
615
+ return true ;
616
+ }());
617
+ return result;
618
+ }
619
+ static bool _debugOutboxEnable = true ;
620
+ static set debugOutboxEnable (bool value) {
621
+ assert (() {
622
+ _debugOutboxEnable = value;
623
+ return true ;
624
+ }());
625
+ }
626
+
627
+ @visibleForTesting
628
+ static void debugReset () {
629
+ _debugOutboxEnable = true ;
630
+ }
328
631
}
0 commit comments