diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..1e00c52242 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -801,6 +801,10 @@ "@messageIsMovedLabel": { "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, + "messageIsntSentLabel": "MESSAGE ISN'T SENT. CHECK YOUR CONNECTION.", + "@messageIsntSentLabel": { + "description": "Label for a message that isn't sent. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "pollVoterNames": "({voterNames})", "@pollVoterNames": { "description": "The list of people who voted for a poll option, wrapped in parentheses.", 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/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index d09393e774..86a62969d2 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1167,6 +1167,12 @@ abstract class ZulipLocalizations { /// **'MOVED'** String get messageIsMovedLabel; + /// Label for a message that isn't sent. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'** + String get messageIsntSentLabel; + /// The list of people who voted for a poll option, wrapped in parentheses. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index c2478f4613..6fc0f40ca1 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 289ba33af2..6095ac49a3 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 00537f73a2..5330bea77e 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3c063e91da..6e0b7b64a3 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 64705fbe02..cb277b0114 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRZENIESIONO'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 911fc281b2..dcaca41b65 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0cb42c3a37..285506e028 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -625,6 +625,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageIsntSentLabel => 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.'; + @override String pollVoterNames(String voterNames) { return '($voterNames)'; diff --git a/lib/model/message.dart b/lib/model/message.dart index 962d79eeab..96f95ae0c9 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -1,5 +1,9 @@ +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'; @@ -8,12 +12,308 @@ 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 implements MessageBase { + OutboxMessage({ + required this.localMessageId, + required int selfUserId, + required this.content, + }) : senderId = selfUserId, + timestamp = (DateTime.timestamp().millisecondsSinceEpoch / 1000).toInt(), + _state = OutboxMessageState.hidden; + + static OutboxMessage fromDestination(MessageDestination destination, { + required int localMessageId, + required int selfUserId, + required String content, + required int zulipFeatureLevel, + required String? realmEmptyTopicDisplayName, + }) { + return switch (destination) { + StreamDestination(:final streamId, :final topic) => StreamOutboxMessage( + localMessageId: localMessageId, + selfUserId: selfUserId, + 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, + conversation: DmConversation(allRecipientIds: userIds), + content: content), + }; + } + + /// 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; + @override + final int senderId; + @override + final int timestamp; + 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 this.conversation, + required super.content, + }); + + @override + final StreamConversation conversation; +} + +class DmOutboxMessage extends OutboxMessage { + DmOutboxMessage({ + required super.localMessageId, + required super.selfUserId, + 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 wasFirstShown = outboxMessage.state == OutboxMessageState.hidden; + outboxMessage.state = newState; + for (final view in _messageListViews) { + if (wasFirstShown) { + 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)); + _outboxMessages[localMessageId] = OutboxMessage.fromDestination(destination, + localMessageId: localMessageId, selfUserId: selfUserId, content: content, + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName); + + _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 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 +324,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 +342,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 @@ -84,17 +392,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 @@ -132,6 +444,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); } @@ -325,4 +639,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..a5914f9538 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'; @@ -36,20 +37,47 @@ class MessageListDateSeparatorItem extends MessageListItem { } /// A message to show in the message list. -class MessageListMessageItem extends MessageListItem { - final Message message; - ZulipMessageContent content; +sealed class MessageListMessageBaseItem extends MessageListItem { + MessageBase get message; + ZulipMessageContent get content; bool showSender; bool isLastInBlock; + MessageListMessageBaseItem({ + required this.showSender, + required this.isLastInBlock, + }); +} + +class MessageListMessageItem extends MessageListMessageBaseItem { + @override + final Message message; + @override + ZulipMessageContent content; + MessageListMessageItem( this.message, this.content, { - required this.showSender, - required this.isLastInBlock, + required super.showSender, + required super.isLastInBlock, }); } +class MessageListOutboxMessageItem extends MessageListMessageBaseItem { + @override + final OutboxMessage message; + @override + final ZulipContent content; + + MessageListOutboxMessageItem( + this.message, { + required super.showSender, + required super.isLastInBlock, + }) : content = ZulipContent(nodes: [ + ParagraphNode(links: [], nodes: [TextNode(message.content)]), + ]); +} + /// Indicates the app is loading more messages at the top. // TODO(#80): or loading at the bottom, by adding a [MessageListDirection.newer] class MessageListLoadingItem extends MessageListItem { @@ -77,7 +105,15 @@ mixin _MessageSequence { /// See also [contents] and [items]. final List messages = []; - /// Whether [messages] and [items] represent the results of a fetch. + /// The messages sent by the self-user. + /// + /// See also [items]. + // Usually this should not have that many items, so we do not anticipate + // performance issues with unoptimized O(N) iterations through this list. + final List outboxMessages = []; + + /// Whether [messages], [outboxMessages], and [items] represent the results + /// of a fetch. /// /// This allows the UI to distinguish "still working on fetching messages" /// from "there are in fact no messages here". @@ -129,11 +165,12 @@ mixin _MessageSequence { /// The messages and their siblings in the UI, in order. /// /// This has a [MessageListMessageItem] corresponding to each element - /// of [messages], in order. It may have additional items interspersed - /// before, between, or after the messages. + /// of [messages], followed by each element in [outboxMessages] in order. + /// It may have additional items interspersed before, between, or after the + /// messages. /// - /// This information is completely derived from [messages] and - /// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown]. + /// This information is completely derived from [messages], [outboxMessages] + /// and the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown]. /// It exists as an optimization, to memoize that computation. final QueueList items = QueueList(); @@ -158,6 +195,7 @@ mixin _MessageSequence { if (message.id == null) return 1; // TODO(#1441): test return message.id! <= messageId ? -1 : 1; case MessageListMessageItem(:var message): return message.id.compareTo(messageId); + case MessageListOutboxMessageItem(): return 1; } } @@ -265,6 +303,7 @@ mixin _MessageSequence { void _reset() { generation += 1; messages.clear(); + outboxMessages.clear(); _fetched = false; _haveOldest = false; _fetchingOlder = false; @@ -283,36 +322,81 @@ mixin _MessageSequence { _reprocessAll(); } - /// Append to [items] based on the index-th message and its content. + /// Append to [items] an auxillary item like a date separator and update + /// properties of the previous message item, if necessary. /// - /// The previous messages in the list must already have been processed. - /// This message must already have been parsed and reflected in [contents]. - void _processMessage(int index) { - // This will get more complicated to handle the ways that messages interact - // with the display of neighboring messages: sender headings #175 - // and date separators #173. - final message = messages[index]; - final content = contents[index]; - bool canShareSender; - if (index == 0 || !haveSameRecipient(messages[index - 1], message)) { + /// Returns whether an item has been appended or not. + /// + /// The caller must append a [MessageListMessageBaseItem] for [message] + /// after this. + bool _maybeAppendAuxillaryItem(MessageBase message, { + required MessageBase? prevMessage, + }) { + if (prevMessage == null || !haveSameRecipient(prevMessage, message)) { items.add(MessageListRecipientHeaderItem(message)); - canShareSender = false; + return true; } else { - assert(items.last is MessageListMessageItem); - final prevMessageItem = items.last as MessageListMessageItem; - assert(identical(prevMessageItem.message, messages[index - 1])); + final prevMessageItem = items.last as MessageListMessageBaseItem; + assert(identical(prevMessageItem.message, prevMessage)); assert(prevMessageItem.isLastInBlock); prevMessageItem.isLastInBlock = false; if (!messagesSameDay(prevMessageItem.message, message)) { items.add(MessageListDateSeparatorItem(message)); - canShareSender = false; + return true; } else { - canShareSender = (prevMessageItem.message.senderId == message.senderId); + return false; } } + } + + /// Append to [items] based on the index-th message and its content. + /// + /// The previous messages in the list must already have been processed. + /// This message must already have been parsed and reflected in [contents]. + void _processMessage(int index) { + final prevMessage = index == 0 ? null : messages[index - 1]; + final message = messages[index]; + final content = contents[index]; + + final appended = _maybeAppendAuxillaryItem(message, prevMessage: prevMessage); items.add(MessageListMessageItem(message, content, - showSender: !canShareSender, isLastInBlock: true)); + showSender: appended || prevMessage?.senderId != message.senderId, + isLastInBlock: true)); + } + + /// Append to [items] based on the index-th outbox message. + /// + /// All [messages] and previous messages in [outboxMessages] must already have + /// been processed. + void _processOutboxMessage(int index) { + final prevMessage = index == 0 ? messages.lastOrNull : outboxMessages[index - 1]; + final message = outboxMessages[index]; + + final appended = _maybeAppendAuxillaryItem(message, prevMessage: prevMessage); + items.add(MessageListOutboxMessageItem(message, + showSender: appended || prevMessage?.senderId != message.senderId, + isLastInBlock: true)); + } + + /// Remove items associated with [outboxMessages] from [items]. + /// + /// This is efficient due to the expected small size of [outboxMessages]. + void _removeOutboxMessageItems() { + // This loop relies on the assumption that all [MessageListMessageItem] + // items comes before those associated with outbox messages. If there + // is no [MessageListMessageItem] at all, this will end up removing + // end markers as well. + while (items.isNotEmpty && items.last is! MessageListMessageItem) { + items.removeLast(); + } + assert(items.none((e) => e is MessageListOutboxMessageItem)); + + if (items.isNotEmpty) { + final lastItem = items.last as MessageListMessageItem; + lastItem.isLastInBlock = true; + } + _updateEndMarkers(); } /// Update [items] to include markers at start and end as appropriate. @@ -339,23 +423,29 @@ mixin _MessageSequence { } } - /// Recompute [items] from scratch, based on [messages], [contents], and flags. + /// Recompute [items] from scratch, based on [messages], [contents], + /// [outboxMessages] and flags. void _reprocessAll() { items.clear(); for (var i = 0; i < messages.length; i++) { _processMessage(i); } + for (var i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } _updateEndMarkers(); } } @visibleForTesting -bool haveSameRecipient(Message prevMessage, Message message) { - if (prevMessage is StreamMessage && message is StreamMessage) { - if (prevMessage.streamId != message.streamId) return false; - if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false; - } else if (prevMessage is DmMessage && message is DmMessage) { - if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) { +bool haveSameRecipient(MessageBase prevMessage, MessageBase message) { + final prevConversation = prevMessage.conversation; + final conversation = message.conversation; + if (prevConversation is StreamConversation && conversation is StreamConversation) { + if (prevConversation.streamId != conversation.streamId) return false; + if (prevConversation.topic.canonicalize() != conversation.topic.canonicalize()) return false; + } else if (prevConversation is DmConversation && conversation is DmConversation) { + if (!_equalIdSequences(prevConversation.allRecipientIds, conversation.allRecipientIds)) { return false; } } else { @@ -374,7 +464,7 @@ bool haveSameRecipient(Message prevMessage, Message message) { } @visibleForTesting -bool messagesSameDay(Message prevMessage, Message message) { +bool messagesSameDay(MessageBase prevMessage, MessageBase message) { // TODO memoize [DateTime]s... also use memoized for showing date/time in msglist final prevTime = DateTime.fromMillisecondsSinceEpoch(prevMessage.timestamp * 1000); final time = DateTime.fromMillisecondsSinceEpoch(message.timestamp * 1000); @@ -439,19 +529,20 @@ class MessageListView with ChangeNotifier, _MessageSequence { /// one way or another. /// /// See also [_allMessagesVisible]. - bool _messageVisible(Message message) { + bool _messageVisible(MessageBase message) { switch (narrow) { case CombinedFeedNarrow(): - return switch (message) { - StreamMessage() => - store.isTopicVisible(message.streamId, message.topic), - DmMessage() => true, + return switch (message.conversation) { + StreamConversation(:final streamId, :final topic) => + store.isTopicVisible(streamId, topic), + DmConversation() => true, }; case ChannelNarrow(:final streamId): - assert(message is StreamMessage && message.streamId == streamId); - if (message is! StreamMessage) return false; - return store.isTopicVisibleInStream(streamId, message.topic); + assert(message is MessageBase + && message.conversation.streamId == streamId); + if (message is! MessageBase) return false; + return store.isTopicVisibleInStream(streamId, message.conversation.topic); case TopicNarrow(): case DmNarrow(): @@ -502,7 +593,7 @@ class MessageListView with ChangeNotifier, _MessageSequence { // TODO(#80): fetch from anchor firstUnread, instead of newest // TODO(#82): fetch from a given message ID as anchor assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown); - assert(messages.isEmpty && contents.isEmpty); + assert(messages.isEmpty && contents.isEmpty && outboxMessages.isEmpty); // TODO schedule all this in another isolate final generation = this.generation; final result = await getMessages(store.connection, @@ -520,6 +611,9 @@ class MessageListView with ChangeNotifier, _MessageSequence { _addMessage(message); } } + for (final outboxMessage in store.outboxMessages.values) { + _maybeAddOutboxMessage(outboxMessage); + } _fetched = true; _haveOldest = result.foundOldest; _updateEndMarkers(); @@ -626,6 +720,45 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Add [outboxMessage] if it belongs to the view. + /// + /// Returns true if the message was added, false otherwise. + bool _maybeAddOutboxMessage(OutboxMessage outboxMessage) { + assert(outboxMessages.none( + (message) => message.localMessageId == outboxMessage.localMessageId)); + if (!outboxMessage.hidden + && narrow.containsMessage(outboxMessage) + && _messageVisible(outboxMessage)) { + outboxMessages.add(outboxMessage); + _processOutboxMessage(outboxMessages.length - 1); + return true; + } + return false; + } + + void addOutboxMessage(OutboxMessage outboxMessage) { + if (!fetched) return; + if (_maybeAddOutboxMessage(outboxMessage)) { + notifyListeners(); + } + } + + /// Remove the [outboxMessage] from the view. + /// + /// This is a no-op if the message is not found. + void removeOutboxMessage(OutboxMessage outboxMessage) { + final removed = outboxMessages.remove(outboxMessage); + if (!removed) { + return; + } + + _removeOutboxMessageItems(); + for (int i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } + notifyListeners(); + } + void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { case VisibilityEffect.none: @@ -661,14 +794,29 @@ class MessageListView with ChangeNotifier, _MessageSequence { void handleMessageEvent(MessageEvent event) { final message = event.message; if (!narrow.containsMessage(message) || !_messageVisible(message)) { + assert(event.localMessageId == null || outboxMessages.none((message) => + message.localMessageId == int.parse(event.localMessageId!, radix: 10))); return; } if (!_fetched) { // TODO mitigate this fetch/event race: save message to add to list later return; } + // We always remove all outbox message items + // to ensure that message items come before them. + _removeOutboxMessageItems(); // TODO insert in middle instead, when appropriate _addMessage(message); + if (event.localMessageId != null) { + final localMessageId = int.parse(event.localMessageId!); + // [outboxMessages] is epxected to be short, so removing the corresponding + // outbox message and reprocessing them all in linear time is efficient. + outboxMessages.removeWhere( + (message) => message.localMessageId == localMessageId); + } + for (int i = 0; i < outboxMessages.length; i++) { + _processOutboxMessage(i); + } notifyListeners(); } @@ -787,6 +935,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Notify listeners if the given outbox message is present in this view. + void notifyListenersIfOutboxMessagePresent(int localMessageId) { + final isAnyPresent = + outboxMessages.any((message) => message.localMessageId == localMessageId); + if (isAnyPresent) { + notifyListeners(); + } + } + /// 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 049ee73922..dea42ce86f 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. @@ -490,7 +496,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, @@ -554,11 +561,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]. @@ -725,6 +727,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 @@ -904,6 +908,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/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index f00f873b1a..a8e0b8b40b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -687,6 +688,12 @@ class _MessageListState extends State with PerAccountStoreAwareStat header: header, trailingWhitespace: i == 1 ? 8 : 11, item: data); + case MessageListOutboxMessageItem(): + final header = RecipientHeader(message: data.message, narrow: widget.narrow); + return MessageItem( + header: header, + trailingWhitespace: 11, + item: data); } } } @@ -968,25 +975,33 @@ class MessageItem extends StatelessWidget { this.trailingWhitespace, }); - final MessageListMessageItem item; + final MessageListMessageBaseItem item; final Widget header; final double? trailingWhitespace; @override Widget build(BuildContext context) { - final message = item.message; final messageListTheme = MessageListTheme.of(context); + + final item = this.item; + Widget child = ColoredBox( + color: messageListTheme.bgMessageRegular, + child: Column(children: [ + switch (item) { + MessageListMessageItem() => MessageWithPossibleSender(item: item), + MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item), + }, + if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), + ])); + if (item case MessageListMessageItem(:final message)) { + child = _UnreadMarker( + isRead: message.flags.contains(MessageFlag.read), + child: child); + } return StickyHeaderItem( allowOverflow: !item.isLastInBlock, header: header, - child: _UnreadMarker( - isRead: message.flags.contains(MessageFlag.read), - child: ColoredBox( - color: messageListTheme.bgMessageRegular, - child: Column(children: [ - MessageWithPossibleSender(item: item), - if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!), - ])))); + child: child); } } @@ -1323,14 +1338,14 @@ String formatHeaderDate( } } -/// A Zulip message, showing the sender's name and avatar if specified. -// Design referenced from: -// - https://github.com/zulip/zulip-mobile/issues/5511 -// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev -class MessageWithPossibleSender extends StatelessWidget { - const MessageWithPossibleSender({super.key, required this.item}); +// TODO(i18n): web seems to ignore locale in formatting time, but we could do better +final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); - final MessageListMessageItem item; +class _SenderRow extends StatelessWidget { + const _SenderRow({required this.message, required this.showTimestamp}); + + final MessageBase message; + final bool showTimestamp; @override Widget build(BuildContext context) { @@ -1338,14 +1353,12 @@ class MessageWithPossibleSender extends StatelessWidget { final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); - final message = item.message; final sender = store.getUser(message.senderId); - - Widget? senderRow; - if (item.showSender) { - final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); - senderRow = Row( + final time = _kMessageTimestampFormat + .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), @@ -1361,7 +1374,9 @@ class MessageWithPossibleSender extends StatelessWidget { userId: message.senderId), const SizedBox(width: 8), Flexible( - child: Text(message.senderFullName, // TODO(#716): use `store.senderDisplayName` + child: Text(message is Message + ? store.senderDisplayName(message as Message) + : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), @@ -1377,16 +1392,33 @@ class MessageWithPossibleSender extends StatelessWidget { ), ], ]))), - const SizedBox(width: 4), - Text(time, - style: TextStyle( - color: messageListTheme.labelTime, - fontSize: 16, - height: (18 / 16), - fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], - ).merge(weightVariableTextStyle(context))), - ]); - } + if (showTimestamp) ...[ + const SizedBox(width: 4), + Text(time, + style: TextStyle( + color: messageListTheme.labelTime, + fontSize: 16, + height: (18 / 16), + fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')], + ).merge(weightVariableTextStyle(context))), + ] + ])); + } +} + +/// A Zulip message, showing the sender's name and avatar if specified. +// Design referenced from: +// - https://github.com/zulip/zulip-mobile/issues/5511 +// - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev +class MessageWithPossibleSender extends StatelessWidget { + const MessageWithPossibleSender({super.key, required this.item}); + + final MessageListMessageItem item; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final message = item.message; final localizations = ZulipLocalizations.of(context); String? editStateText; @@ -1415,9 +1447,7 @@ class MessageWithPossibleSender extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), + if (item.showSender) _SenderRow(message: message, showTimestamp: true), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), @@ -1446,5 +1476,100 @@ class MessageWithPossibleSender extends StatelessWidget { } } -// TODO(i18n): web seems to ignore locale in formatting time, but we could do better -final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); +/// A placeholder for Zulip message sent by the self-user. +/// +/// See also [OutboxMessage]. +class OutboxMessageWithPossibleSender extends StatelessWidget { + const OutboxMessageWithPossibleSender({super.key, required this.item}); + + final MessageListOutboxMessageItem item; + + void _handlePress(BuildContext context) { + final content = item.message.content.endsWith('\n') + ? item.message.content : '${item.message.content}\n'; + + final composeBoxController = + MessageListPage.ancestorOf(context).composeBoxController; + composeBoxController!.content.insertPadded(content); + if (!composeBoxController.contentFocusNode.hasFocus) { + composeBoxController.contentFocusNode.requestFocus(); + } + + if (composeBoxController case StreamComposeBoxController(:final topic)) { + final conversation = item.message.conversation; + if (conversation is StreamConversation) { + topic.setTopic(conversation.topic); + } + } + + final store = PerAccountStoreWidget.of(context); + assert(store.outboxMessages.containsKey(item.message.localMessageId)); + store.removeOutboxMessage(item.message.localMessageId); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final isComposeBoxOffered = + MessageListPage.ancestorOf(context).composeBoxController != null; + + final GestureTapCallback? handleTap; + final double opacity; + final Widget bottom; + switch (item.message.state) { + case OutboxMessageState.hidden: + assert(false, + 'Hidden OutboxMessage messages should not appear in message lists'); + handleTap = null; + opacity = 1.0; + bottom = SizedBox.shrink(); + + case OutboxMessageState.waiting: + handleTap = null; + opacity = 1.0; + bottom = LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withFadedAlpha(0.5), + backgroundColor: designVariables.foreground.withFadedAlpha(0.2)); + + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + handleTap = isComposeBoxOffered ? () => _handlePress(context) : null; + opacity = 0.6; + bottom = Text( + zulipLocalizations.messageIsntSentLabel, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.btnLabelAttLowIntDanger, + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing( + context, 0.006, baseFontSize: 12), + ).merge(weightVariableTextStyle(context, wght: 400))); + } + + return GestureDetector( + onTap: handleTap, + behavior: HitTestBehavior.opaque, + child: Opacity(opacity: opacity, child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column(children: [ + if (item.showSender) + _SenderRow(message: item.message, showTimestamp: false), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // This is adapated from [MessageContent]. + // TODO(#576): Offer InheritedMessage ancestor once we are ready + // to support local echoing images and lightbox. + DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: item.content.nodes)), + + bottom, + ])), + ])))); + } +} 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 afa378aa7e..664bea40b8 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -622,8 +622,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_list_test.dart b/test/model/message_list_test.dart index 2c5f0711dc..52efba7c25 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -8,8 +8,10 @@ import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; +import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/algorithms.dart'; import 'package:zulip/model/content.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'; @@ -20,6 +22,7 @@ import '../example_data.dart' as eg; import '../fake_async.dart'; import '../stdlib_checks.dart'; import 'content_checks.dart'; +import 'message_checks.dart'; import 'recent_senders_test.dart' as recent_senders_test; import 'test_store.dart'; @@ -46,8 +49,11 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] 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, + }) async { + stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); @@ -76,6 +82,19 @@ void main() { checkNotifiedOnce(); } + Future prepareOutboxMessages({ + required int count, + required ZulipStream stream, + String topic = 'some topic', + }) async { + for (int i = 0; i < count; i++) { + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await store.sendMessage( + destination: StreamDestination(stream.streamId, eg.t(topic)), + content: 'content'); + } + } + void checkLastRequest({ required ApiNarrow narrow, required String anchor, @@ -166,6 +185,25 @@ void main() { ..haveOldest.isTrue(); }); + test('only outbox messages found', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + + await prepareOutboxMessages(count: 1, stream: stream, topic: 'topic'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + connection.prepare(json: + newestResult(foundOldest: true, messages: []).toJson()); + await model.fetchInitial(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..messages.isEmpty() + ..outboxMessages.length.equals(1); + })); + // TODO(#824): move this test test('recent senders track all the messages', () async { const narrow = CombinedFeedNarrow(); @@ -360,37 +398,198 @@ void main() { }); }); - test('MessageEvent', () async { - final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); + group('MessageEvent', () { + test('in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); - check(model).messages.length.equals(30); - await store.addMessage(eg.streamMessage(stream: stream)); - checkNotifiedOnce(); - check(model).messages.length.equals(31); + check(model).messages.length.equals(30); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + }); + + test('not in narrow', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + check(model).messages.length.equals(30); + final otherStream = eg.stream(); + await store.addMessage(eg.streamMessage(stream: otherStream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); + + test('before fetch', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await store.addMessage(eg.streamMessage(stream: stream)); + checkNotNotified(); + check(model).fetched.isFalse(); + }); + + test('when there are outbox messages', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream))); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(5); + })); + + test('when no matching localMessageId is found ', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic')); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + + // Initially, the outbox message should be hidden to + // the view until its debounce timeout expires. + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other'); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.isEmpty(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: localMessageId)); + checkNotifiedOnce(); + check(model) + ..messages.length.equals(31) + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + })); + + test('when a matching localMessageId is found', () => awaitFakeAsync((async) async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final localMessageId = store.outboxMessages.keys.first; + check(model) + ..messages.length.equals(30) + ..outboxMessages.length.equals(5) + ..outboxMessages.any((message) => + message.localMessageId.equals(localMessageId)); + + await store.handleEvent(eg.messageEvent(eg.streamMessage(stream: stream), + localMessageId: localMessageId)); + checkNotified(count: 2); + check(model) + ..messages.length.equals(31) + ..outboxMessages.length.equals(4) + ..outboxMessages.every((message) => + message.localMessageId.not((m) => m.equals(localMessageId))); + })); }); - test('MessageEvent, not in narrow', () async { + group('addOutboxMessage', () { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await prepareMessages(foundOldest: true, messages: - List.generate(30, (i) => eg.streamMessage(stream: stream))); - check(model).messages.length.equals(30); - final otherStream = eg.stream(); - await store.addMessage(eg.streamMessage(stream: otherStream)); - checkNotNotified(); - check(model).messages.length.equals(30); + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream))); + await prepareOutboxMessages(count: 5, stream: stream); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model).outboxMessages.length.equals(5); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareOutboxMessages(count: 5, stream: stream); + check(model).outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model).outboxMessages.isEmpty(); + })); + + test('before fetch', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareOutboxMessages(count: 5, stream: stream); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model) + ..fetched.isFalse() + ..outboxMessages.isEmpty(); + })); }); - test('MessageEvent, before fetch', () async { + group('removeOutboxMessage', () { final stream = eg.stream(); - await prepare(narrow: ChannelNarrow(stream.streamId)); - await store.addMessage(eg.streamMessage(stream: stream)); - checkNotNotified(); - check(model).fetched.isFalse(); + + test('in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareOutboxMessages(count: 5, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + check(model).outboxMessages.length.equals(5); + + store.removeOutboxMessage(store.outboxMessages.keys.first); + checkNotifiedOnce(); + check(model).outboxMessages.length.equals(4); + })); + + test('not in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: eg.topicNarrow(stream.streamId, 'topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(stream: stream, topic: 'topic'))); + await prepareOutboxMessages(count: 5, stream: stream, topic: 'other'); + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + check(model).outboxMessages.isEmpty(); + + store.removeOutboxMessage(store.outboxMessages.keys.first); + checkNotNotified(); + check(model).outboxMessages.isEmpty(); + })); + + test('removed outbox message is the only message in narrow', () => awaitFakeAsync((async) async { + await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 1, stream: stream); + async.elapse(kLocalEchoDebounceDuration); + checkNotifiedOnce(); + check(model).outboxMessages.single; + + store.removeOutboxMessage(store.outboxMessages.keys.first); + checkNotifiedOnce(); + check(model).outboxMessages.isEmpty(); + })); }); group('UserTopicEvent', () { @@ -688,6 +887,38 @@ void main() { }); }); + group('notifyListenersIfOutboxMessagePresent', () { + final stream = eg.stream(); + + test('message present', () => awaitFakeAsync((async) async { + await prepare(narrow: const CombinedFeedNarrow(), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotifiedOnce(); + })); + + test('message not present', () => awaitFakeAsync((async) async { + await prepare( + narrow: eg.topicNarrow(stream.streamId, 'some topic'), stream: stream); + await prepareMessages(foundOldest: true, messages: []); + await prepareOutboxMessages(count: 5, + stream: stream, topic: 'other topic'); + + async.elapse(kLocalEchoDebounceDuration); + checkNotNotified(); + + model.notifyListenersIfOutboxMessagePresent( + store.outboxMessages.keys.first); + checkNotNotified(); + })); + }); + group('messageContentChanged', () { test('message present', () async { await prepare(narrow: const CombinedFeedNarrow()); @@ -821,6 +1052,25 @@ void main() { checkNotifiedOnce(); }); + test('channel -> new channel (with outbox messages): remove moved messages; outbox messages unaffected', () => awaitFakeAsync((async) async { + await prepareNarrow(narrow, initialMessages + movedMessages); + connection.prepare(json: SendMessageResult(id: 1).toJson()); + await prepareOutboxMessages(count: 5, stream: stream); + + async.elapse(kLocalEchoDebounceDuration); + checkNotified(count: 5); + final outboxMessages = model.outboxMessages; + + await store.handleEvent(eg.updateMessageEventMoveFrom( + origMessages: movedMessages, + newTopicStr: 'new', + newStreamId: otherStream.streamId, + )); + checkHasMessages(initialMessages); + check(model).outboxMessages.identicalTo(outboxMessages); + checkNotifiedOnce(); + })); + test('unrelated channel -> new channel: unaffected', () async { final thirdStream = eg.stream(); await prepareNarrow(narrow, initialMessages); @@ -1912,6 +2162,7 @@ void checkInvariants(MessageListView model) { if (!model.fetched) { check(model) ..messages.isEmpty() + ..outboxMessages.isEmpty() ..haveOldest.isFalse() ..fetchingOlder.isFalse() ..fetchOlderCoolingDown.isFalse(); @@ -1924,17 +2175,26 @@ void checkInvariants(MessageListView model) { check(model).fetchOlderCoolingDown.isFalse(); } - for (final message in model.messages) { - check(model.store.messages)[message.id].isNotNull().identicalTo(message); + final allMessages = [...model.messages, ...model.outboxMessages]; + + for (final message in allMessages) { + if (message is Message) { + check(model.store.messages)[message.id].isNotNull().identicalTo(message); + } else if (message is OutboxMessage) { + check(model.store.outboxMessages)[message.localMessageId].isNotNull().identicalTo(message); + } else { + assert(false); + } check(model.narrow.containsMessage(message)).isTrue(); - if (message is! StreamMessage) continue; + if (message is! MessageBase) continue; + final conversation = message.conversation; switch (model.narrow) { case CombinedFeedNarrow(): - check(model.store.isTopicVisible(message.streamId, message.topic)) + check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) .isTrue(); case ChannelNarrow(): - check(model.store.isTopicVisibleInStream(message.streamId, message.topic)) + check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) .isTrue(); case TopicNarrow(): case DmNarrow(): @@ -1964,26 +2224,33 @@ void checkInvariants(MessageListView model) { if (model.fetchingOlder || model.fetchOlderCoolingDown) { check(model.items[i++]).isA(); } - for (int j = 0; j < model.messages.length; j++) { + for (int j = 0; j < allMessages.length; j++) { bool forcedShowSender = false; if (j == 0 - || !haveSameRecipient(model.messages[j-1], model.messages[j])) { + || !haveSameRecipient(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); + .message.identicalTo(allMessages[j]); forcedShowSender = true; - } else if (!messagesSameDay(model.messages[j-1], model.messages[j])) { + } else if (!messagesSameDay(allMessages[j-1], allMessages[j])) { check(model.items[i++]).isA() - .message.identicalTo(model.messages[j]); + .message.identicalTo(allMessages[j]); forcedShowSender = true; } - check(model.items[i++]).isA() - ..message.identicalTo(model.messages[j]) - ..content.identicalTo(model.contents[j]) + if (j < model.messages.length) { + check(model.items[i]).isA() + ..message.identicalTo(model.messages[j]) + ..content.identicalTo(model.contents[j]); + } else { + check(model.items[i]).isA() + .message.identicalTo(model.outboxMessages[j-model.messages.length]); + } + check(model.items[i++]).isA() ..showSender.equals( - forcedShowSender || model.messages[j].senderId != model.messages[j-1].senderId) + forcedShowSender || allMessages[j].senderId != allMessages[j-1].senderId) ..isLastInBlock.equals( i == model.items.length || switch (model.items[i]) { MessageListMessageItem() + || MessageListOutboxMessageItem() || MessageListDateSeparatorItem() => false, MessageListRecipientHeaderItem() || MessageListHistoryStartItem() @@ -2001,17 +2268,22 @@ extension MessageListDateSeparatorItemChecks on Subject get message => has((x) => x.message, 'message'); } -extension MessageListMessageItemChecks on Subject { - Subject get message => has((x) => x.message, 'message'); +extension MessageListMessageBaseItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); Subject get content => has((x) => x.content, 'content'); Subject get showSender => has((x) => x.showSender, 'showSender'); Subject get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock'); } +extension MessageListMessageItemChecks on Subject { + Subject get message => has((x) => x.message, 'message'); +} + extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); Subject> get messages => has((x) => x.messages, 'messages'); + Subject> get outboxMessages => has((x) => x.outboxMessages, 'outboxMessages'); Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); Subject get fetched => has((x) => x.fetched, 'fetched'); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 1f774e32b9..b3ea79fec6 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,7 +18,10 @@ 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 'message_checks.dart'; import 'message_list_test.dart'; import 'store_checks.dart'; import 'test_store.dart'; @@ -37,10 +45,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 +63,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 +93,385 @@ 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 message = eg.streamMessage(stream: stream); + final streamDestination = StreamDestination(stream.streamId, eg.t('some topic')); + + 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 { + 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 { + 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + })); + + 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + })); + + 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + }); + + 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + }); + + 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + })); + + 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 + ]); + checkNotNotified(); + + // 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(); + checkNotifiedOnce(); + })); + + 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + })); + + 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + }); + + 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + + // 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); + checkNotifiedOnce(); + })); + + 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + })); + + 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); + checkNotifiedOnce(); + + // 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); + checkNotifiedOnce(); + })); + + 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); + checkNotifiedOnce(); + + // 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); + checkNotNotified(); + })); + + 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)); + checkNotifiedOnce(); + })); + + 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)')); + checkNotifiedOnce(); + })); + }); + + 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); + checkNotNotified(); + }); + group('reconcileMessages', () { test('from empty', () async { await prepare(); diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index 06c82ed117..fd0179c96a 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -2,38 +2,35 @@ 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, + conversation: StreamConversation( + stream.streamId, TopicName(topic), displayRecipient: null), + content: 'content'); + } + + DmOutboxMessage dmOutboxMessage({required List allRecipientIds}) { + return DmOutboxMessage( + localMessageId: nextLocalMessageId++, + selfUserId: allRecipientIds[0], + conversation: DmConversation(allRecipientIds: allRecipientIds), + content: 'content'); + } + group('SendableNarrow', () { test('ofMessage: stream message', () { final message = eg.streamMessage(); @@ -61,11 +58,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 +88,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 +220,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 +242,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 +258,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 e02fd97a15..4d281f3b13 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'; @@ -252,6 +253,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]); @@ -289,6 +292,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]); @@ -606,6 +611,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); @@ -712,6 +719,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'), @@ -766,6 +775,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 18717d2c93..521a6c5cd5 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -11,15 +11,18 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; +import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.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'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; +import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -841,7 +844,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 +854,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 { @@ -1348,6 +1356,214 @@ void main() { }); }); + group('OutboxMessageWithPossibleSender', () { + final stream = eg.stream(); + const content = 'outbox message content'; + + final topicInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeTopicController); + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + + Finder outboxMessageFinder = find.descendant( + of: find.byType(MessageItem), + matching: find.text(content, findRichText: true)).hitTestable(); + + Finder messageIsntSentErrorFinder = find.text( + 'MESSAGE ISN\'T SENT. CHECK YOUR CONNECTION.').hitTestable(); + + Future sendMessageAndSucceed(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(json: SendMessageResult(id: 1).toJson(), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future sendMessageAndFail(WidgetTester tester) async { + // Send a message and fail. Dismiss the error dialog as it pops up. + connection.prepare(apiException: eg.apiBadRequest()); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + await tester.tap(find.byWidget( + checkErrorDialog(tester, expectedTitle: 'Message not sent'))); + await tester.pump(); + check(outboxMessageFinder).findsOne(); + check(messageIsntSentErrorFinder).findsOne(); + } + + testWidgets('sent message appear in message list after debounce timeout', (tester) async { + await setupMessageListPage(tester, + narrow: eg.topicNarrow(stream.streamId, 'topic'), streams: [stream], + messages: []); + await sendMessageAndSucceed(tester); + check(outboxMessageFinder).findsNothing(); + + await tester.pump(kLocalEchoDebounceDuration); + check(outboxMessageFinder).findsOne(); + check(find.descendant( + of: find.byType(MessageItem), + matching: find.byType(LinearProgressIndicator))).findsOne(); + + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: store.outboxMessages.keys.single)); + }); + + testWidgets('in channel narrow, failed to send message, retrieve both topic and content to compose box', (tester) async { + await setupMessageListPage(tester, + narrow: ChannelNarrow(stream.streamId), streams: [stream], + messages: []); + + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await tester.enterText(topicInputFinder, 'test topic'); + await sendMessageAndFail(tester); + + final controller = tester.state(find.byType(ComposeBox)).controller; + controller as StreamComposeBoxController; + await tester.enterText(topicInputFinder, 'different topic'); + check(controller.content).text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(outboxMessageFinder).findsNothing(); + check(controller.topic).text.equals('test topic'); + check(controller.content).text.equals('$content\n\n'); + + await tester.pump(kLocalEchoDebounceDuration); + }); + + testWidgets('in topic narrow, failed to send message, retrieve the content to compose box', (tester) async { + await setupMessageListPage(tester, + narrow: eg.topicNarrow(stream.streamId, 'topic'), streams: [stream], + messages: []); + await sendMessageAndFail(tester); + + final controller = tester.state(find.byType(ComposeBox)).controller; + check(controller.content).text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(outboxMessageFinder).findsNothing(); + check(controller.content).text.equals('$content\n\n'); + + await tester.pump(kLocalEchoDebounceDuration); + }); + + testWidgets('message sent, reaches wait period time limit and fail, retrieve the content to compose box, then message event arrives', (tester) async { + await setupMessageListPage(tester, + narrow: eg.topicNarrow(stream.streamId, 'topic'), streams: [stream], + messages: []); + await sendMessageAndSucceed(tester); + await tester.pump(kSendMessageRetryWaitPeriod); + check(messageIsntSentErrorFinder).findsOne(); + final localMessageId = store.outboxMessages.keys.single; + + final controller = tester.state(find.byType(ComposeBox)).controller; + check(controller.content).text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(outboxMessageFinder).findsNothing(); + check(controller.content).text.equals('$content\n\n'); + check(store.outboxMessages).isEmpty(); + + // While `localMessageId` is no longer in store, there should be no error + // when a message event refers to it. + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: localMessageId)); + }); + + + testWidgets('tapping does nothing if compose box is not offered', (tester) async { + final messages = [eg.streamMessage(stream: stream, topic: 'some topic')]; + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), streams: [stream], subscriptions: [eg.subscription(stream)], + messages: messages); + + // Navigate to a message list page in a topic narrow, + // which has a compose box. + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + await tester.tap(find.text('some topic')); + await tester.pump(); // handle tap + await tester.pump(); // wait for navigation + await sendMessageAndFail(tester); + + // Navigate back to the message list page without a compose box, + // where the failed to send message should still be visible. + await tester.pageBack(); + await tester.pump(); // handle tap + await tester.pump(); // wait for navigation + check(contentInputFinder).findsNothing(); + check(outboxMessageFinder).findsOne(); + check(messageIsntSentErrorFinder).findsOne(); + + // Tap the failed to send message. + // This should not remove it from the message list. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(outboxMessageFinder).findsOne(); + }); + + testWidgets('tapping does nothing if message is still being sent', (tester) async { + await setupMessageListPage(tester, + narrow: eg.topicNarrow(stream.streamId, 'topic'), streams: [stream], + messages: []); + final controller = tester.state(find.byType(ComposeBox)).controller; + + // Send a message and wait until the debounce timer expires but before + // the message is successfully sent. + await sendMessageAndSucceed(tester, + delay: kLocalEchoDebounceDuration + Duration(seconds: 1)); + await tester.pump(kLocalEchoDebounceDuration); + check(controller.content).text.isNotNull().isEmpty(); + + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(outboxMessageFinder).findsOne(); + check(controller.content).text.isNotNull().isEmpty(); + + // Wait till the send request completes. The outbox message should + // remain visible because the message event didn't arrive. + await tester.pump(Duration(seconds: 1)); + check(outboxMessageFinder).findsOne(); + check(controller.content).text.isNotNull().isEmpty(); + + // Dispose pending timers from the message store. + store.dispose(); + }); + + testWidgets('tapping does nothing if message was successfully sent and before message event arrives', (tester) async { + await setupMessageListPage(tester, + narrow: eg.topicNarrow(stream.streamId, 'topic'), streams: [stream], + messages: []); + final controller = tester.state(find.byType(ComposeBox)).controller; + + // Send a message and wait until the debounce timer expires. + await sendMessageAndSucceed(tester); + await tester.pump(kLocalEchoDebounceDuration); + check(controller.content).text.isNotNull().isEmpty(); + + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(outboxMessageFinder).findsOne(); + check(controller.content).text.isNotNull().isEmpty(); + + // Dispose pending timers from the message store. + store.dispose(); + }); + }); + group('Starred messages', () { testWidgets('unstarred message', (tester) async { final message = eg.streamMessage(flags: []);