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,134 @@ 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
+ }) {
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
+ }
11
134
12
135
/// The portion of [PerAccountStore] for messages and message lists.
13
136
mixin MessageStore {
14
137
/// All known messages, indexed by [Message.id] .
15
138
Map <int , Message > get messages;
16
139
140
+ /// Messages sent by the user, indexed by [OutboxMessage.localMessageId] .
141
+ Map <int , OutboxMessage > get outboxMessages;
142
+
17
143
Set <MessageListView > get debugMessageListViews;
18
144
19
145
void registerMessageList (MessageListView view);
@@ -24,6 +150,11 @@ mixin MessageStore {
24
150
required String content,
25
151
});
26
152
153
+ /// Remove from [outboxMessages] given the [localMessageId] .
154
+ ///
155
+ /// The message to remove must already exist.
156
+ void removeOutboxMessage (int localMessageId);
157
+
27
158
/// Reconcile a batch of just-fetched messages with the store,
28
159
/// mutating the list.
29
160
///
@@ -41,11 +172,38 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
41
172
MessageStoreImpl ({required super .core})
42
173
// There are no messages in InitialSnapshot, so we don't have
43
174
// 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 ;
45
183
46
184
@override
47
185
final Map <int , Message > messages;
48
186
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
+
49
207
final Set <MessageListView > _messageListViews = {};
50
208
51
209
@override
@@ -84,17 +242,120 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
84
242
// [InheritedNotifier] to rebuild in the next frame) before the owner's
85
243
// `dispose` or `onNewStore` is called. Discussion:
86
244
// 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
+ // }
87
343
}
88
344
345
+
89
346
@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
+ // }
98
359
}
99
360
100
361
@override
@@ -132,6 +393,13 @@ 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
+ _outboxMessageSendTimeLimitTimers.remove (localMessageId)? .cancel ();
401
+ }
402
+
135
403
for (final view in _messageListViews) {
136
404
view.handleMessageEvent (event);
137
405
}
@@ -325,4 +593,29 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
325
593
// [Poll] is responsible for notifying the affected listeners.
326
594
poll.handleSubmessageEvent (event);
327
595
}
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
+ }
328
621
}
0 commit comments