From 8a2daf32a9d7879c305ee4a5694815afd300038e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 21:31:02 -0400 Subject: [PATCH 1/5] api [nfc]: Add TopicName.interpretAsServer The point of this helper is to replicate what a topic sent from the client will become, after being processed by the server. This important when trying to create a local copy of a stream message, whose topic can get translated when it's delivered by the server. --- lib/api/model/model.dart | 36 ++++++++++++++++++++++++++++++++++ lib/api/route/messages.dart | 9 --------- test/api/model/model_test.dart | 21 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 90769e815b..3f29eea124 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -550,6 +550,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.) @@ -604,6 +613,33 @@ 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, + }) { + assert(_value.trim() == _value); + // TODO(server-10) simplify this away + 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/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', () { From 06cf4d2a8df4232138434b344219673ff8df01d5 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 21:33:41 -0400 Subject: [PATCH 2/5] store [nfc]: Move zulip{FeatureLevel,Version} to PerAccountStoreBase --- lib/model/store.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 939120113e..5fd3dd6518 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. @@ -558,11 +564,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]. From db08953447795c8ae8799aa88ffa2f2e32dee860 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 00:04:54 -0400 Subject: [PATCH 3/5] test [nfc]: Generate timestamps --- test/example_data.dart | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/test/example_data.dart b/test/example_data.dart index f31337d303..8e64692d05 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); } @@ -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, From e3bac3a8df3ee303e745a6eaca8ae51cef5c24cb Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 13:03:37 -0400 Subject: [PATCH 4/5] binding [nfc]: Add utcNow This will be the same as `DateTime.timestamp()` in live code (therefore the NFC). For testing, utcNow uses a clock instance that can be controlled by FakeAsync. We could have made call sites of `DateTime.now()` use it too, but those for now don't need it for testing. --- lib/model/binding.dart | 8 ++++++++ test/model/binding.dart | 3 +++ 2 files changed, 11 insertions(+) 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/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(); From babdaeec3f45f6f81eb6170e0b051c158e0ea51d Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 25 Mar 2025 16:32:30 -0400 Subject: [PATCH 5/5] message: Create an outbox message on send; manage its states While we do create outbox messages, there are in no way user-visible changes since the outbox messages don't end up in message list views. We create skeletons for helpers needed from message list view, but don't implement them yet, to make the diff smaller. For testing, similar to TypingNotifier.debugEnable, we add MessageStoreImpl.debugOutboxEnable for tests that do not intend to cover outbox messages. Some of the delays to fake responses added in tests are not necessary because the future of sendMessage is not completed immediately, but we still add them to keep the tests realistic. --- lib/model/message.dart | 346 ++++++++++++++++++++++++- lib/model/message_list.dart | 18 ++ lib/model/store.dart | 8 +- test/api/model/model_checks.dart | 1 + test/example_data.dart | 4 +- test/fake_async_checks.dart | 6 + test/model/message_checks.dart | 9 + test/model/message_test.dart | 381 +++++++++++++++++++++++++++- test/model/narrow_test.dart | 81 +++--- test/model/store_test.dart | 5 +- test/widgets/compose_box_test.dart | 11 + test/widgets/message_list_test.dart | 11 +- 12 files changed, 821 insertions(+), 60 deletions(-) create mode 100644 test/fake_async_checks.dart create mode 100644 test/model/message_checks.dart diff --git a/lib/model/message.dart b/lib/model/message.dart index fd5de1adbd..cf8557ad6b 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,19 +1,308 @@ +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 value for this +const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441) find the right value 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 + /// [kSendMessageOfferRestoreWaitPeriod] 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; + + /// 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. + void _updateOutboxMessage(int localMessageId, { + required OutboxMessageState newState, + }) { + final outboxMessage = outboxMessages[localMessageId]; + if (outboxMessage == null) { + return; + } + final oldState = outboxMessage.state; + // See [OutboxMessageState] for valid state transitions. + assert(newState != outboxMessage.state); + switch (newState) { + case OutboxMessageState.hidden: + assert(false); + case OutboxMessageState.waiting: + assert(oldState == OutboxMessageState.hidden); + case OutboxMessageState.waitPeriodExpired: + assert(oldState == OutboxMessageState.waiting); + case OutboxMessageState.failed: + assert(oldState == OutboxMessageState.hidden + || oldState == OutboxMessageState.waiting + || oldState == OutboxMessageState.waitPeriodExpired); + } + outboxMessage._state = newState; + for (final view in _messageListViews) { + if (oldState == OutboxMessageState.hidden) { + 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; + _outboxMessages[localMessageId] = switch (destination) { + StreamDestination(:final streamId, :final topic) => StreamOutboxMessage( + localMessageId: localMessageId, + selfUserId: selfUserId, + timestamp: now, + conversation: StreamConversation( + streamId, + topic.interpretAsServer( + // Doing this interpretation just once on creating the outbox message + // allows an uncommon bug, because either of these values can change. + // During the outbox message's life, a predicted "(no topic)" topic + // could become stale/wrong when zulipFeatureLevel changes, + // or a predicted "general chat" topic could become stale/wrong + // when realmEmptyTopicDisplayName changes. + // + // Shrug. The same effect is caused by an unavoidable race: + // 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. + 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), + 'The timer should have been canceled when the outbox message was removed.'); + _outboxMessageDebounceTimers.remove(localMessageId); + _updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); + }); + + _outboxMessageWaitPeriodTimers[localMessageId] = Timer(kSendMessageOfferRestoreWaitPeriod, () { + assert(outboxMessages.containsKey(localMessageId), + 'The timer should have been canceled when the outbox message was removed.'); + _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 the + // message event can still arrive, before the send request fails with + // 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 +313,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 +331,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 +393,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 +445,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 +633,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 5fd3dd6518..b56c43e6c8 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -498,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, @@ -732,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 @@ -911,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/example_data.dart b/test/example_data.dart index 8e64692d05..1c1020a805 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -636,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); 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/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..023b0fb753 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 selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + store = eg.store(account: selfAccount, + 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(kSendMessageOfferRestoreWaitPeriod), + ]); + + 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(kSendMessageOfferRestoreWaitPeriod), + (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 kSendMessageOfferRestoreWaitPeriod, completes successfully, then message event arrives', () => awaitFakeAsync((async) async { + // Send a message, but keep it pending until after reaching + // [kSendMessageOfferRestoreWaitPeriod]. + await prepareSendMessageToSucceed( + delay: kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Wait till we reach [kSendMessageOfferRestoreWaitPeriod] after the send + // request was initiated, but before it actually completes. + assert(kSendMessageOfferRestoreWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageOfferRestoreWaitPeriod - 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 kSendMessageOfferRestoreWaitPeriod, then fails', () => awaitFakeAsync((async) async { + // Send a message, but keep it pending until after reaching + // [kSendMessageOfferRestoreWaitPeriod]. + await prepareSendMessageToFail( + delay: kSendMessageOfferRestoreWaitPeriod + Duration(seconds: 1)); + async.elapse(kLocalEchoDebounceDuration); + check(outboxMessage).state.equals(OutboxMessageState.waiting); + + // Wait till we reach [kSendMessageOfferRestoreWaitPeriod] after the send + // request was initiated, but before it fails. + assert(kSendMessageOfferRestoreWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageOfferRestoreWaitPeriod - 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 kSendMessageOfferRestoreWaitPeriod', () => 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 [kSendMessageOfferRestoreWaitPeriod] after the send + // request was initiated. + assert(kSendMessageOfferRestoreWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageOfferRestoreWaitPeriod - 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 kSendMessageOfferRestoreWaitPeriod', () => 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 [kSendMessageOfferRestoreWaitPeriod] after the send + // request was initiated. + assert(kSendMessageOfferRestoreWaitPeriod > kLocalEchoDebounceDuration); + async.elapse(kSendMessageOfferRestoreWaitPeriod - 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 {