diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 157307c3d4..27a922e5c2 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -532,6 +532,15 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) { } } +/// The topic servers understand to mean "there is no topic". +/// +/// This should match +/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 +/// or similar logic at the latest `main`. +// This is hardcoded in the server, and therefore untranslated; that's +// zulip/zulip#3639. +const String kNoTopicTopic = '(no topic)'; + /// The name of a Zulip topic. // TODO(dart): Can we forbid calling Object members on this extension type? // (The lack of "implements Object" ought to do that, but doesn't.) @@ -586,6 +595,30 @@ extension type const TopicName(String _value) { /// using [canonicalize]. bool isSameAs(TopicName other) => canonicalize() == other.canonicalize(); + /// Convert this topic to match how it would appear on a message object from + /// the server, assuming the topic is originally for a send-message request. + /// + /// For a client that does not support empty topics, + /// a modern server (FL>=334) would convert "(no topic)" and empty topics to + /// `store.realmEmptyTopicDisplayName`. + /// + /// See also: https://zulip.com/api/send-message#parameter-topic + TopicName interpretAsServer({ + required int zulipFeatureLevel, + required String? realmEmptyTopicDisplayName, + }) { + if (zulipFeatureLevel < 334) { + assert(_value.isNotEmpty); + return this; + } + if (_value == kNoTopicTopic || _value.isEmpty) { + // TODO(#1250): this assumes that the 'support_empty_topics' + // client_capability is false; update this when we set it to true + return TopicName(realmEmptyTopicDisplayName!); + } + return TopicName(_value); + } + TopicName.fromJson(this._value); String toJson() => apiName; diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 6a42158b75..23f92485d7 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -169,15 +169,6 @@ const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; -/// The topic servers understand to mean "there is no topic". -/// -/// This should match -/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 -/// or similar logic at the latest `main`. -// This is hardcoded in the server, and therefore untranslated; that's -// zulip/zulip#3639. -const String kNoTopicTopic = '(no topic)'; - /// https://zulip.com/api/send-message Future sendMessage( ApiConnection connection, { diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 94435631ac..6c1b89dd2a 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -120,6 +120,11 @@ abstract class ZulipBinding { /// This wraps [url_launcher.closeInAppWebView]. Future closeInAppWebView(); + /// Provides access to the current UTC date and time. + /// + /// Outside tests, this just calls [DateTime.timestamp]. + DateTime utcNow(); + /// Provides access to a new stopwatch. /// /// Outside tests, this just calls the [Stopwatch] constructor. @@ -381,6 +386,9 @@ class LiveZulipBinding extends ZulipBinding { return url_launcher.closeInAppWebView(); } + @override + DateTime utcNow() => DateTime.timestamp(); + @override Stopwatch stopwatch() => Stopwatch(); diff --git a/lib/model/message.dart b/lib/model/message.dart index fd5de1adbd..36e380a6da 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,19 +1,310 @@ +import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../log.dart'; +import 'binding.dart'; import 'message_list.dart'; import 'store.dart'; const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 +const kLocalEchoDebounceDuration = Duration(milliseconds: 300); // TODO(#1441) find the right values for this +const kSendMessageRetryWaitPeriod = Duration(seconds: 10); // TODO(#1441) find the right values for this + +/// States of an [OutboxMessage] since its creation from a +/// [MessageStore.sendMessage] call and before its eventual deletion. +/// +/// ``` +/// 4xx or other User restores +/// error. the draft. +/// ┌──────┬─────────────────┬──► failed ──────────┐ +/// │ ▲ ▲ ▼ +/// (create) ─► hidden └─── waiting └─ waitPeriodExpired ─┴► (delete) +/// │ ▲ │ ▲ +/// └────────────┘ └──────────┘ +/// Debounce Wait period +/// timed out. timed out. +/// +/// Event received. +/// Or we abandoned the queue. +/// (any state) ────────────────────────────► (delete) +/// ``` +/// +/// During its lifecycle, it is guaranteed that the outbox message is deleted +/// as soon a message event with a matching [MessageEvent.localMessageId] +/// arrives. +enum OutboxMessageState { + /// The [sendMessage] request has started but hasn't finished, and the + /// outbox message is hidden to the user. + /// + /// This is the initial state when an [OutboxMessage] is created. + hidden, + + /// The [sendMessage] request has started but hasn't finished, and the + /// outbox message is shown to the user. + /// + /// This state can be reached after staying in [hidden] for + /// [kLocalEchoDebounceDuration]. + waiting, + + /// The message was assumed not delivered after some time it was sent. + /// + /// This state can be reached when the message event hasn't arrived in + /// [kSendMessageRetryWaitPeriod] since the outbox message's creation. + waitPeriodExpired, + + /// The message could not be delivered. + /// + /// This state can be reached when we got a 4xx or other error in the HTTP + /// response. + failed, +} + +/// A message sent by the self-user. +sealed class OutboxMessage extends MessageBase { + OutboxMessage({ + required this.localMessageId, + required int selfUserId, + required super.timestamp, + required this.content, + }) : _state = OutboxMessageState.hidden, + super(senderId: selfUserId); + + /// As in [MessageEvent.localMessageId]. + /// + /// This uniquely identifies this outbox message's corresponding message object + /// in events from the same event queue. + /// + /// See also: + /// * [MessageStoreImpl.sendMessage], where this ID is assigned. + final int localMessageId; + @override + int? get id => null; + final String content; + + OutboxMessageState get state => _state; + OutboxMessageState _state; + set state(OutboxMessageState value) { + // See [OutboxMessageState] for valid state transitions. + assert(_state != value); + switch (value) { + case OutboxMessageState.hidden: + assert(false); + case OutboxMessageState.waiting: + assert(_state == OutboxMessageState.hidden); + case OutboxMessageState.waitPeriodExpired: + assert(_state == OutboxMessageState.waiting); + case OutboxMessageState.failed: + assert(_state == OutboxMessageState.hidden + || _state == OutboxMessageState.waiting + || _state == OutboxMessageState.waitPeriodExpired); + } + _state = value; + } + + /// Whether the [OutboxMessage] is hidden to [MessageListView] or not. + bool get hidden => _state == OutboxMessageState.hidden; +} + +class StreamOutboxMessage extends OutboxMessage { + StreamOutboxMessage({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.content, + }); + + @override + final StreamConversation conversation; +} + +class DmOutboxMessage extends OutboxMessage { + DmOutboxMessage({ + required super.localMessageId, + required super.selfUserId, + required super.timestamp, + required this.conversation, + required super.content, + }) : assert(conversation.allRecipientIds.contains(selfUserId)); + + @override + final DmConversation conversation; +} + +/// Manages the outbox messages portion of [MessageStore]. +mixin _OutboxMessageStore on PerAccountStoreBase { + late final UnmodifiableMapView outboxMessages = + UnmodifiableMapView(_outboxMessages); + final Map _outboxMessages = {}; + + /// A map of timers to show outbox messages after a delay, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request failed within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageDebounceTimers = {}; + + /// A map of timers to update outbox messages state to + /// [OutboxMessageState.waitPeriodExpired] after a delay, + /// indexed by [OutboxMessage.localMessageId]. + /// + /// If the send message request failed within the time limit, + /// the outbox message's timer gets removed and cancelled. + final Map _outboxMessageWaitPeriodTimers = {}; + + /// A fresh ID to use for [OutboxMessage.localMessageId], + /// unique within this instance. + int _nextLocalMessageId = 0; + + Set get _messageListViews; + + /// Update the state of the [OutboxMessage] with the given [localMessageId], + /// and notify listeners if necessary. + /// + /// This is a no-op if the outbox message does not exist, or that + /// [OutboxMessage.state] already equals [newState]. + void _updateOutboxMessage(int localMessageId, { + required OutboxMessageState newState, + }) { + final outboxMessage = outboxMessages[localMessageId]; + if (outboxMessage == null || outboxMessage.state == newState) { + return; + } + final wasHidden = outboxMessage.state == OutboxMessageState.hidden; + outboxMessage.state = newState; + for (final view in _messageListViews) { + if (wasHidden) { + view.addOutboxMessage(outboxMessage); + } else { + view.notifyListenersIfOutboxMessagePresent(localMessageId); + } + } + } + + /// Send a message and create an entry of [OutboxMessage]. + Future outboxSendMessage({ + required MessageDestination destination, + required String content, + required String? realmEmptyTopicDisplayName, + }) async { + final localMessageId = _nextLocalMessageId++; + assert(!outboxMessages.containsKey(localMessageId)); + + final now = (ZulipBinding.instance.utcNow().millisecondsSinceEpoch / 1000).toInt(); + _outboxMessages[localMessageId] = switch (destination) { + StreamDestination(:final streamId, :final topic) => StreamOutboxMessage( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: now, + conversation: StreamConversation( + streamId, + topic.interpretAsServer( + // Because either of the values can get updated, the actual topic + // can change, for example, between "(no topic)" and "general chat", + // or between different names of "general chat". This should be + // uncommon during the lifespan of an outbox message. + // + // There's also an unavoidable race that has the same effect: + // an admin could change the name of "general chat" + // (i.e. the value of realmEmptyTopicDisplayName) concurrently with + // the user making the send request, so that the setting in effect + // by the time the request arrives is different from the setting the + // client last heard about. The realm update events do not have + // information about this race for us to update the prediction + // correctly. + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), + displayRecipient: null), + content: content), + DmDestination(:final userIds) => DmOutboxMessage( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: now, + conversation: DmConversation(allRecipientIds: userIds), + content: content), + }; + + _outboxMessageDebounceTimers[localMessageId] = Timer(kLocalEchoDebounceDuration, () { + assert(outboxMessages.containsKey(localMessageId)); + _outboxMessageDebounceTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + }); + + _outboxMessageWaitPeriodTimers[localMessageId] = Timer(kSendMessageRetryWaitPeriod, () { + assert(outboxMessages.containsKey(localMessageId)); + _outboxMessageWaitPeriodTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waitPeriodExpired); + }); + + try { + await _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true, + queueId: queueId, + localId: localMessageId.toString()); + } catch (e) { + // `localMessageId` is not necessarily in the store. This is because + // message event can still arrive before the send request fails to + // networking issues. + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.failed); + rethrow; + } + } + + void removeOutboxMessage(int localMessageId) { + final removed = _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + if (removed == null) { + assert(false, 'Removing unknown outbox message with localMessageId: $localMessageId'); + return; + } + for (final view in _messageListViews) { + view.removeOutboxMessage(removed); + } + } + + void _handleMessageEventOutbox(MessageEvent event) { + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!, radix: 10); + // The outbox message can be missing if the user removes it (to be + // implemented in #1441) before the event arrives. + // Nothing to do in that case. + _outboxMessages.remove(localMessageId); + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + } + } + + /// Remove all outbox messages, and cancel pending timers. + void _clearOutboxMessages() { + for (final localMessageId in outboxMessages.keys) { + _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); + _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); + } + _outboxMessages.clear(); + assert(_outboxMessageDebounceTimers.isEmpty); + assert(_outboxMessageWaitPeriodTimers.isEmpty); + } +} /// The portion of [PerAccountStore] for messages and message lists. mixin MessageStore { /// All known messages, indexed by [Message.id]. Map get messages; + /// Messages sent by the user, indexed by [OutboxMessage.localMessageId]. + Map get outboxMessages; + Set get debugMessageListViews; void registerMessageList(MessageListView view); @@ -24,6 +315,11 @@ mixin MessageStore { required String content, }); + /// Remove from [outboxMessages] given the [localMessageId]. + /// + /// The message to remove must exist. + void removeOutboxMessage(int localMessageId); + /// Reconcile a batch of just-fetched messages with the store, /// mutating the list. /// @@ -37,15 +333,18 @@ mixin MessageStore { void reconcileMessages(List messages); } -class MessageStoreImpl extends PerAccountStoreBase with MessageStore { - MessageStoreImpl({required super.core}) +class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMessageStore { + MessageStoreImpl({required super.core, required this.realmEmptyTopicDisplayName}) // There are no messages in InitialSnapshot, so we don't have // a use case for initializing MessageStore with nonempty [messages]. : messages = {}; + final String? realmEmptyTopicDisplayName; + @override final Map messages; + @override final Set _messageListViews = {}; @override @@ -96,17 +395,21 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // [InheritedNotifier] to rebuild in the next frame) before the owner's // `dispose` or `onNewStore` is called. Discussion: // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 + + _clearOutboxMessages(); } @override Future sendMessage({required MessageDestination destination, required String content}) { - // TODO implement outbox; see design at - // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 - return _apiSendMessage(connection, - destination: destination, - content: content, - readBySender: true, - ); + if (!debugOutboxEnable) { + return _apiSendMessage(connection, + destination: destination, + content: content, + readBySender: true); + } + return outboxSendMessage( + destination: destination, content: content, + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName); } @override @@ -144,6 +447,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // See [fetchedMessages] for reasoning. messages[event.message.id] = event.message; + _handleMessageEventOutbox(event); + for (final view in _messageListViews) { view.handleMessageEvent(event); } @@ -330,4 +635,29 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore { // [Poll] is responsible for notifying the affected listeners. poll.handleSubmessageEvent(event); } + + /// In debug mode, controls whether outbox messages should be created when + /// [sendMessage] is called. + /// + /// Outside of debug mode, this is always true and the setter has no effect. + static bool get debugOutboxEnable { + bool result = true; + assert(() { + result = _debugOutboxEnable; + return true; + }()); + return result; + } + static bool _debugOutboxEnable = true; + static set debugOutboxEnable(bool value) { + assert(() { + _debugOutboxEnable = value; + return true; + }()); + } + + @visibleForTesting + static void debugReset() { + _debugOutboxEnable = true; + } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 58a0e1bb95..e4b3f2150e 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -10,6 +10,7 @@ import '../api/route/messages.dart'; import 'algorithms.dart'; import 'channel.dart'; import 'content.dart'; +import 'message.dart'; import 'narrow.dart'; import 'store.dart'; @@ -626,6 +627,18 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Add [outboxMessage] if it belongs to the view. + void addOutboxMessage(OutboxMessage outboxMessage) { + // TODO(#1441) implement this + } + + /// Remove the [outboxMessage] from the view. + /// + /// This is a no-op if the message is not found. + void removeOutboxMessage(OutboxMessage outboxMessage) { + // TODO(#1441) implement this + } + void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { case VisibilityEffect.none: @@ -787,6 +800,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Notify listeners if the given outbox message is present in this view. + void notifyListenersIfOutboxMessagePresent(int localMessageId) { + // TODO(#1441) implement this + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// This will redo from scratch any computations we can, such as parsing diff --git a/lib/model/store.dart b/lib/model/store.dart index 939120113e..b56c43e6c8 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -385,6 +385,12 @@ abstract class PerAccountStoreBase { /// This returns null if [reference] fails to parse as a URL. Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference); + /// Always equal to `connection.zulipFeatureLevel` + /// and `account.zulipFeatureLevel`. + int get zulipFeatureLevel => connection.zulipFeatureLevel!; + + String get zulipVersion => account.zulipVersion; + //////////////////////////////// // Data attached to the self-account on the realm. @@ -492,7 +498,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), ), channels: channels, - messages: MessageStoreImpl(core: core), + messages: MessageStoreImpl(core: core, + realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName), unreads: Unreads( initial: initialSnapshot.unreadMsgs, core: core, @@ -558,11 +565,6 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the realm or the server. - /// Always equal to `connection.zulipFeatureLevel` - /// and `account.zulipFeatureLevel`. - int get zulipFeatureLevel => connection.zulipFeatureLevel!; - - String get zulipVersion => account.zulipVersion; final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting final bool realmMandatoryTopics; // TODO(#668): update this realm setting /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. @@ -731,6 +733,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Map get messages => _messages.messages; @override + Map get outboxMessages => _messages.outboxMessages; + @override void registerMessageList(MessageListView view) => _messages.registerMessageList(view); @override @@ -910,6 +914,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return _messages.sendMessage(destination: destination, content: content); } + @override + void removeOutboxMessage(int localMessageId) => _messages.removeOutboxMessage(localMessageId); + static List _sortCustomProfileFields(List initialCustomProfileFields) { // TODO(server): The realm-wide field objects have an `order` property, // but the actual API appears to be that the fields should be shown in diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 8791c1b9d9..2216b2b98f 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -30,6 +30,7 @@ extension TopicNameChecks on Subject { } extension StreamConversationChecks on Subject { + Subject get topic => has((x) => x.topic, 'topic'); Subject get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient'); } diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index b1552deb5b..fac180ed9d 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -161,6 +161,27 @@ void main() { doCheck(eg.t('✔ a'), eg.t('✔ b'), false); }); + + test('interpretAsServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + void doCheck(TopicName topicA, TopicName expected, int zulipFeatureLevel) { + check(topicA.interpretAsServer( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).equals(expected); + } + + check(() => doCheck(eg.t(''), eg.t(''), 333)) + .throws(); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 333); + doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333); + doCheck(eg.t('other topic'), eg.t('other topic'), 333); + + doCheck(eg.t(''), eg.t(emptyTopicDisplayName), 334); + doCheck(eg.t('(no topic)'), eg.t(emptyTopicDisplayName), 334); + doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 334); + doCheck(eg.t('other topic'), eg.t('other topic'), 334); + }); }); group('DmMessage', () { diff --git a/test/example_data.dart b/test/example_data.dart index f31337d303..1c1020a805 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -68,6 +68,20 @@ ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { data: {}, message: 'Invalid API key'); } +//////////////////////////////////////////////////////////////// +// Time values. +// + +final timeInPast = DateTime.utc(2025, 4, 1, 8, 30, 0); + +/// The UNIX timestamp, in UTC seconds. +/// +/// This is the commonly used format in the Zulip API for timestamps. +int utcTimestamp([DateTime? dateTime]) { + dateTime ??= timeInPast; + return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; +} + //////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // @@ -469,7 +483,7 @@ StreamMessage streamMessage({ 'last_edit_timestamp': lastEditTimestamp, 'subject': topic ?? _defaultTopic, 'submessages': submessages ?? [], - 'timestamp': timestamp ?? 1678139636, + 'timestamp': timestamp ?? utcTimestamp(), 'type': 'stream', }) as Map); } @@ -510,7 +524,7 @@ DmMessage dmMessage({ 'last_edit_timestamp': lastEditTimestamp, 'subject': '', 'submessages': submessages ?? [], - 'timestamp': timestamp ?? 1678139636, + 'timestamp': timestamp ?? utcTimestamp(), 'type': 'private', }) as Map); } @@ -622,8 +636,8 @@ UserTopicEvent userTopicEvent( ); } -MessageEvent messageEvent(Message message) => - MessageEvent(id: 0, message: message, localMessageId: null); +MessageEvent messageEvent(Message message, {int? localMessageId}) => + MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); DeleteMessageEvent deleteMessageEvent(List messages) { assert(messages.isNotEmpty); @@ -659,7 +673,7 @@ UpdateMessageEvent updateMessageEditEvent( messageId: messageId, messageIds: [messageId], flags: flags ?? origMessage.flags, - editTimestamp: editTimestamp ?? 1234567890, // TODO generate timestamp + editTimestamp: editTimestamp ?? utcTimestamp(), moveData: null, origContent: 'some probably-mismatched old Markdown', origRenderedContent: origMessage.content, @@ -690,7 +704,7 @@ UpdateMessageEvent _updateMessageMoveEvent( messageId: messageIds.first, messageIds: messageIds, flags: flags, - editTimestamp: 1234567890, // TODO generate timestamp + editTimestamp: utcTimestamp(), moveData: UpdateMessageMoveData( origStreamId: origStreamId, newStreamId: newStreamId ?? origStreamId, diff --git a/test/fake_async_checks.dart b/test/fake_async_checks.dart new file mode 100644 index 0000000000..51c653123a --- /dev/null +++ b/test/fake_async_checks.dart @@ -0,0 +1,6 @@ +import 'package:checks/checks.dart'; +import 'package:fake_async/fake_async.dart'; + +extension FakeTimerChecks on Subject { + Subject get duration => has((t) => t.duration, 'duration'); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index ced2a4d4b3..0401cfb448 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -241,6 +241,9 @@ class TestZulipBinding extends ZulipBinding { _closeInAppWebViewCallCount++; } + @override + DateTime utcNow() => clock.now().toUtc(); + @override Stopwatch stopwatch() => clock.stopwatch(); diff --git a/test/model/message_checks.dart b/test/model/message_checks.dart new file mode 100644 index 0000000000..b56cd89a79 --- /dev/null +++ b/test/model/message_checks.dart @@ -0,0 +1,9 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; + +extension OutboxMessageChecks on Subject> { + Subject get localMessageId => has((x) => x.localMessageId, 'localMessageId'); + Subject get state => has((x) => x.state, 'state'); + Subject get hidden => has((x) => x.hidden, 'hidden'); +} diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 1f774e32b9..97dfc82158 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1,10 +1,15 @@ +import 'dart:async'; import 'dart:convert'; import 'package:checks/checks.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -13,12 +18,18 @@ import '../api/fake_api.dart'; import '../api/model/model_checks.dart'; import '../api/model/submessage_checks.dart'; import '../example_data.dart' as eg; +import '../fake_async.dart'; +import '../fake_async_checks.dart'; import '../stdlib_checks.dart'; +import 'binding.dart'; +import 'message_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; import 'test_store.dart'; void main() { + TestZulipBinding.ensureInitialized(); + // These "late" variables are the common state operated on by each test. // Each test case calls [prepare] to initialize them. late Subscription subscription; @@ -37,10 +48,16 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [store] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { - final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); + Future prepare({ + Narrow narrow = const CombinedFeedNarrow(), + ZulipStream? stream, + int? zulipFeatureLevel, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); - store = eg.store(); + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + store = eg.store(account: account, + initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); await store.addStream(stream); await store.addSubscription(subscription); connection = store.connection as FakeApiConnection; @@ -49,8 +66,12 @@ void main() { ..addListener(() { notifiedCount++; }); + addTearDown(messageList.dispose); check(messageList).fetched.isFalse(); checkNotNotified(); + + // This cleans up possibly pending timers from [MessageStoreImpl]. + addTearDown(store.dispose); } /// Perform the initial message fetch for [messageList]. @@ -75,6 +96,360 @@ void main() { checkNotified(count: messageList.fetched ? messages.length : 0); } + test('dispose cancels pending timers', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + final store = eg.store(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + (store.connection as FakeApiConnection).prepare( + json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content'); + check(async.pendingTimers).deepEquals(>[ + (it) => it.isA().duration.equals(kLocalEchoDebounceDuration), + (it) => it.isA().duration.equals(kSendMessageRetryWaitPeriod), + ]); + + store.dispose(); + check(async.pendingTimers).isEmpty(); + })); + + group('sendMessage', () { + final stream = eg.stream(); + final streamDestination = StreamDestination(stream.streamId, eg.t('some topic')); + late StreamMessage message; + + test('outbox messages get unique localMessageId', () async { + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage(destination: streamDestination, content: 'content'); + } + // [store.outboxMessages] has the same number of keys (localMessageId) + // as the number of sent messages, which are guaranteed to be distinct. + check(store.outboxMessages).keys.length.equals(10); + }); + + late Future sendMessageFuture; + late OutboxMessage outboxMessage; + + Future prepareSendMessageToSucceed({ + MessageDestination? destination, + Duration delay = Duration.zero, + int? zulipFeatureLevel, + }) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream, zulipFeatureLevel: zulipFeatureLevel); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(json: SendMessageResult(id: 1).toJson(), delay: delay); + sendMessageFuture = store.sendMessage( + destination: destination ?? streamDestination, content: 'content'); + outboxMessage = store.outboxMessages.values.single; + } + + Future prepareSendMessageToFail({ + Duration delay = Duration.zero, + }) async { + message = eg.streamMessage(stream: stream); + await prepare(stream: stream); + await prepareMessages([eg.streamMessage(stream: stream)]); + connection.prepare(apiException: eg.apiBadRequest(), delay: delay); + sendMessageFuture = store.sendMessage( + destination: streamDestination, content: 'content'); + + // This allows `async.elapse` to not fail when `sendMessageFuture` throws. + // The caller should still await the future since this does not await it. + unawaited(check(sendMessageFuture).throws()); + + outboxMessage = store.outboxMessages.values.single; + } + + test('while message is being sent, message event arrives, then the send succeeds', () => awaitFakeAsync((async) async { + // Send message with a delay in response, leaving time for the message + // event to arrive. + await prepareSendMessageToSucceed(delay: Duration(seconds: 1)); + check(connection.lastRequest).isA() + ..bodyFields['queue_id'].equals(store.queueId) + ..bodyFields['local_id'].equals('${outboxMessage.localMessageId}'); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Handle the message event before `future` completes, i.e. while the + // message is being sent. + await store.handleEvent(eg.messageEvent(message, + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Complete the send request. The outbox message should no longer get + // updated because it is not in the store any more. + async.elapse(const Duration(seconds: 1)); + await sendMessageFuture; + check(outboxMessage).state.equals(OutboxMessageState.hidden); + })); + + test('while message is being sent, message event arrives, then the send fails', () => awaitFakeAsync((async) async { + // Set up an error to fail `sendMessage` with a delay, leaving time for + // the message event to arrive. + await prepareSendMessageToFail(delay: const Duration(seconds: 1)); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Handle the message event before `future` completes, i.e. while the + // message is being sent. + await store.handleEvent(eg.messageEvent(message, + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Complete the send request with an error. The outbox message should no + // longer be updated because it is not in the store any more. + async.elapse(const Duration(seconds: 1)); + await check(sendMessageFuture).throws(); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + })); + + test('message is sent successfully, message event arrives before debounce timeout', () async { + // Set up to successfully send the message immediately. + await prepareSendMessageToSucceed(); + await sendMessageFuture; + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Handle the event after the message is sent but before the debounce + // timeout. + await store.handleEvent(eg.messageEvent(message, + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + // The outbox message should remain hidden since the send + // request was successful. + check(outboxMessage).state.equals(OutboxMessageState.hidden); + }); + + test('DM message is sent successfully, message event arrives before debounce timeout', () async { + // Set up to successfully send the message immediately. + await prepareSendMessageToSucceed(destination: DmDestination( + userIds: [eg.selfUser.userId, eg.otherUser.userId])); + await sendMessageFuture; + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Handle the event after the message is sent but before the debounce + // timeout. + await store.handleEvent(eg.messageEvent( + eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]), + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + // The outbox message should remain hidden since the send + // request was successful. + check(outboxMessage).state.equals(OutboxMessageState.hidden); + }); + + test('message is sent successfully, message event arrives after debounce timeout', () => awaitFakeAsync((async) async { + // Set up to successfully send the message immediately. + await prepareSendMessageToSucceed(); + await sendMessageFuture; + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Pass enough time without handling the message event, to expire + // the debounce timer. + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single.identicalTo(outboxMessage); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Handle the event when the outbox message is in waiting state. + // The outbox message should be removed without errors. + await store.handleEvent(eg.messageEvent(message, + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + // The outbox message should no longer be updated because it is not in + // the store any more. + check(outboxMessage).state.equals(OutboxMessageState.waiting); + })); + + test('message failed to send before debounce timeout', () => awaitFakeAsync((async) async { + // Set up to fail the send request, but do not complete it yet, to + // check the initial states. + await prepareSendMessageToFail(); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + check(async.pendingTimers).deepEquals(>[ + (it) => it.isA().duration.equals(kLocalEchoDebounceDuration), + (it) => it.isA().duration.equals(kSendMessageRetryWaitPeriod), + (it) => it.isA().duration.equals(Duration.zero), // timer for send-message response + ]); + + // Complete the send request with an error. + await check(sendMessageFuture).throws(); + check(store.outboxMessages).values.single.identicalTo(outboxMessage); + check(outboxMessage).state.equals(OutboxMessageState.failed); + // Both the debounce timer and wait period timer should have been cancelled. + check(async.pendingTimers).isEmpty(); + })); + + test('message failed to send after debounce timeout', () => awaitFakeAsync((async) async { + // Set up to fail the send request, but only after the debounce timeout. + await prepareSendMessageToFail( + delay: kLocalEchoDebounceDuration + const Duration(milliseconds: 1)); + check(outboxMessage).state.equals(OutboxMessageState.hidden); + + // Wait for just enough time for the debounce timer to expire, but not + // for the send request to complete. + async.elapse(kLocalEchoDebounceDuration); + check(store.outboxMessages).values.single.identicalTo(outboxMessage); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Complete the send request with an error. + async.elapse(const Duration(milliseconds: 1)); + await check(sendMessageFuture).throws(); + check(store.outboxMessages).values.single.identicalTo(outboxMessage); + check(outboxMessage).state.equals(OutboxMessageState.failed); + })); + + test('message failed to send, message event arrives', () async { + // Set up to fail the send request immediately. + await prepareSendMessageToFail(); + await check(sendMessageFuture).throws(); + check(outboxMessage).state.equals(OutboxMessageState.failed); + + // Handle the event when the outbox message is in failed state. + // The outbox message should be removed without errors. + await store.handleEvent(eg.messageEvent(message, + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + // The outbox message should no longer be updated because it is not in + // the store any more. + check(outboxMessage).state.equals(OutboxMessageState.failed); + }); + + test('send request pending until after kSendMessageRetryWaitPeriod, completes successfully, then message event arrives', () => awaitFakeAsync((async) async { + // Send a message, but keep it pending until after reaching + // [kSendMessageRetryWaitPeriod]. + await prepareSendMessageToSucceed( + delay: kSendMessageRetryWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Wait till we reach [kSendMessageRetryWaitPeriod] after the send request + // was initiated, but before it actually completes. + assert(kSendMessageRetryWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageRetryWaitPeriod - kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waitPeriodExpired); + + // Wait till the send request completes successfully. + async.elapse(const Duration(seconds: 1)); + await sendMessageFuture; + // The outbox message should remain in the store … + check(store.outboxMessages).values.single.identicalTo(outboxMessage); + // … and stay in the waitPeriodExpired state. + check(outboxMessage).state.equals(OutboxMessageState.waitPeriodExpired); + + // Handle the message event. The outbox message should get removed + // without errors. + await store.handleEvent(eg.messageEvent(message, + localMessageId: outboxMessage.localMessageId)); + check(store.outboxMessages).isEmpty(); + // The outbox message should no longer be updated because it is not in + // the store any more. + check(outboxMessage).state.equals(OutboxMessageState.waitPeriodExpired); + })); + + test('send request pending until after kSendMessageRetryWaitPeriod, then fails', () => awaitFakeAsync((async) async { + // Send a message, but keep it pending until after reaching + // [kSendMessageRetryWaitPeriod]. + await prepareSendMessageToFail( + delay: kSendMessageRetryWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Wait till we reach [kSendMessageRetryWaitPeriod] after the send request + // was initiated, but before it fails. + assert(kSendMessageRetryWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageRetryWaitPeriod - kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waitPeriodExpired); + + // Wait till the send request fails. + async.elapse(Duration(seconds: 1)); + await check(sendMessageFuture).throws(); + // The outbox message should remain in the store … + check(store.outboxMessages).values.single.identicalTo(outboxMessage); + // … and transition to failed state. + check(outboxMessage).state.equals(OutboxMessageState.failed); + })); + + test('send request completes, message event does not arrive after kSendMessageRetryWaitPeriod', () => awaitFakeAsync((async) async { + // Send a message and have it complete successfully without wait. + await prepareSendMessageToSucceed(); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Wait till we reach [kSendMessageRetryWaitPeriod] after the send request + // was initiated. + assert(kSendMessageRetryWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageRetryWaitPeriod - kLocalEchoDebounceDuration); + // The outbox message should transition to waitPeriodExpired state. + check(outboxMessage).state.equals(OutboxMessageState.waitPeriodExpired); + })); + + test('send request fails, message event does not arrive after kSendMessageRetryWaitPeriod', () => awaitFakeAsync((async) async { + // Send a message and have it fail without wait. + await prepareSendMessageToFail(); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.failed); + + // Wait till we reach [kSendMessageRetryWaitPeriod] after the send request + // was initiated. + assert(kSendMessageRetryWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageRetryWaitPeriod - kLocalEchoDebounceDuration); + // The outbox message should stay in failed state, + // and it should not transition to waitPeriodExpired state. + check(outboxMessage).state.equals(OutboxMessageState.failed); + })); + + test('when sending to empty topic, interpret topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + // Send a message and have it complete successfully without wait. + await prepareSendMessageToSucceed( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 334); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).conversation.isA() + .topic.equals(eg.t(eg.defaultRealmEmptyTopicDisplayName)); + })); + + test('legacy: when sending to empty topic, interpret topic like the server does when creating outbox message', () => awaitFakeAsync((async) async { + // Send a message and have it complete successfully without wait. + await prepareSendMessageToSucceed( + destination: StreamDestination(stream.streamId, TopicName('(no topic)')), + zulipFeatureLevel: 333); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).conversation.isA() + .topic.equals(eg.t('(no topic)')); + })); + + test('set timestamp to now when creating outbox messages', () => awaitFakeAsync( + initialTime: eg.timeInPast, + (async) async { + await prepareSendMessageToSucceed(); + check(outboxMessage).timestamp.equals(eg.utcTimestamp(eg.timeInPast)); + })); + }); + + test('removeOutboxMessage', () async { + final stream = eg.stream(); + await prepare(stream: stream); + await prepareMessages([]); + + for (int i = 0; i < 10; i++) { + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t('topic')), + content: 'content'); + } + + final localMessageIds = store.outboxMessages.keys.toList(); + store.removeOutboxMessage(localMessageIds.removeAt(5)); + check(store.outboxMessages.keys).deepEquals(localMessageIds); + }); + group('reconcileMessages', () { test('from empty', () async { await prepare(); diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index 06c82ed117..535e6d7e61 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -2,38 +2,37 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import '../example_data.dart' as eg; import 'narrow_checks.dart'; -/// A [MessageBase] subclass for testing. -// TODO(#1441): switch to outbox-messages instead -sealed class _TestMessage extends MessageBase { - @override - final int? id = null; - - _TestMessage() : super(senderId: eg.selfUser.userId, timestamp: 123456789); -} - -class _TestStreamMessage extends _TestMessage { - @override - final StreamConversation conversation; - - _TestStreamMessage({required ZulipStream stream, required String topic}) - : conversation = StreamConversation( - stream.streamId, TopicName(topic), displayRecipient: null); -} - -class _TestDmMessage extends _TestMessage { - @override - final DmConversation conversation; - - _TestDmMessage({required List allRecipientIds}) - : conversation = DmConversation(allRecipientIds: allRecipientIds); -} - void main() { + int nextLocalMessageId = 1; + + StreamOutboxMessage streamOutboxMessage({ + required ZulipStream stream, + required String topic, + }) { + return StreamOutboxMessage( + localMessageId: nextLocalMessageId++, + selfUserId: eg.selfUser.userId, + timestamp: 123456789, + conversation: StreamConversation( + stream.streamId, TopicName(topic), displayRecipient: null), + content: 'content'); + } + + DmOutboxMessage dmOutboxMessage({required List allRecipientIds}) { + return DmOutboxMessage( + localMessageId: nextLocalMessageId++, + selfUserId: allRecipientIds[0], + timestamp: 123456789, + conversation: DmConversation(allRecipientIds: allRecipientIds), + content: 'content'); + } + group('SendableNarrow', () { test('ofMessage: stream message', () { final message = eg.streamMessage(); @@ -61,11 +60,11 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1]))).isFalse(); + dmOutboxMessage(allRecipientIds: [1]))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic'))).isTrue(); + streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -91,13 +90,13 @@ void main() { eg.streamMessage(stream: stream, topic: 'topic'))).isTrue(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1]))).isFalse(); + dmOutboxMessage(allRecipientIds: [1]))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: otherStream, topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: otherStream, topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic2'))).isFalse(); + streamOutboxMessage(stream: stream, topic: 'topic2'))).isFalse(); check(narrow.containsMessage( - _TestStreamMessage(stream: stream, topic: 'topic'))).isTrue(); + streamOutboxMessage(stream: stream, topic: 'topic'))).isTrue(); }); }); @@ -223,13 +222,13 @@ void main() { final narrow = DmNarrow(allRecipientIds: [1, 2], selfUserId: 2); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [2]))).isFalse(); + dmOutboxMessage(allRecipientIds: [2]))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [2, 3]))).isFalse(); + dmOutboxMessage(allRecipientIds: [2, 3]))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [1, 2]))).isTrue(); + dmOutboxMessage(allRecipientIds: [1, 2]))).isTrue(); }); }); @@ -245,9 +244,9 @@ void main() { eg.streamMessage(flags: [MessageFlag.wildcardMentioned]))).isTrue(); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + dmOutboxMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); }); }); @@ -261,9 +260,9 @@ void main() { eg.streamMessage(flags:[MessageFlag.starred]))).isTrue(); check(narrow.containsMessage( - _TestStreamMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); + streamOutboxMessage(stream: eg.stream(), topic: 'topic'))).isFalse(); check(narrow.containsMessage( - _TestDmMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); + dmOutboxMessage(allRecipientIds: [eg.selfUser.userId]))).isFalse(); }); }); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 1dfbb51273..405a46dc9f 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -569,7 +569,8 @@ void main() { group('PerAccountStore.sendMessage', () { test('smoke', () async { - final store = eg.store(); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + queueId: 'fb67bf8a-c031-47cc-84cf-ed80accacda8')); final connection = store.connection as FakeApiConnection; final stream = eg.stream(); connection.prepare(json: SendMessageResult(id: 12345).toJson()); @@ -585,6 +586,8 @@ void main() { 'topic': 'world', 'content': 'hello', 'read_by_sender': 'true', + 'queue_id': 'fb67bf8a-c031-47cc-84cf-ed80accacda8', + 'local_id': store.outboxMessages.keys.single.toString(), }); }); }); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 65740a8d1e..a83d2ae02d 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -14,6 +14,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/message.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; @@ -258,6 +259,8 @@ void main() { Future prepareWithContent(WidgetTester tester, String content) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -295,6 +298,8 @@ void main() { Future prepareWithTopic(WidgetTester tester, String topic) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -612,6 +617,8 @@ void main() { }); testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -718,6 +725,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'), @@ -772,6 +781,8 @@ void main() { }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); + MessageStoreImpl.debugOutboxEnable = false; + addTearDown(MessageStoreImpl.debugReset); channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 0262df378e..b6eccfbc82 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -841,7 +841,8 @@ void main() { connection.prepare(json: SendMessageResult(id: 1).toJson()); await tester.tap(find.byIcon(ZulipIcons.send)); - await tester.pump(); + await tester.pump(Duration.zero); + final localMessageId = store.outboxMessages.keys.single; check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -850,8 +851,12 @@ void main() { 'to': '${otherChannel.streamId}', 'topic': 'new topic', 'content': 'Some text', - 'read_by_sender': 'true'}); - await tester.pumpAndSettle(); + 'read_by_sender': 'true', + 'queue_id': store.queueId, + 'local_id': localMessageId.toString()}); + // Remove the outbox message and its timers created when sending message. + await store.handleEvent( + eg.messageEvent(message, localMessageId: localMessageId)); }); testWidgets('Move to narrow with existing messages', (tester) async {