Skip to content

Commit d7d8b2b

Browse files
committed
msglist: Support outbox messages in message list
This adds some overhead in magnitude of O(1) (where the constant is the number of outbox messages in a view, expected to be small) on message event handling. We add outboxMessages as a list independent from messages on _MessageSequence. Because outbox messages are not rendered (the raw content is shown as plain text), we leave the 1-1 relationship between `messages` and `contents` unchanged. When computing `items`, we now start to look at `outboxMessages` as well, with the guarantee that the items related to outbox messages always come after those for other messages. Look for places that call `_processOutboxMessage(int index)` for references, and the changes to `checkInvariants` on how this affects the message list invariants. `addOutboxMessage` is similar to `handleMessage`. However, outbox messages do not rely on the fetched state, i.e. they can be synchronously updated when the message list view was first initialized.
1 parent e238300 commit d7d8b2b

File tree

7 files changed

+859
-33
lines changed

7 files changed

+859
-33
lines changed

lib/model/message.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,8 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes
542542
}
543543
}
544544

545+
// TODO predict outbox message moves using propagateMode
546+
545547
for (final view in _messageListViews) {
546548
view.messagesMoved(messageMove: messageMove, messageIds: event.messageIds);
547549
}

lib/model/message_list.dart

Lines changed: 214 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ class MessageListMessageItem extends MessageListMessageBaseItem {
6363
});
6464
}
6565

66+
class MessageListOutboxMessageItem extends MessageListMessageBaseItem {
67+
@override
68+
final OutboxMessage message;
69+
@override
70+
final ZulipContent content;
71+
72+
MessageListOutboxMessageItem(
73+
this.message, {
74+
required super.showSender,
75+
required super.isLastInBlock,
76+
}) : content = ZulipContent(nodes: [
77+
ParagraphNode(links: [], nodes: [TextNode(message.content)]),
78+
]);
79+
}
80+
6681
/// Indicates the app is loading more messages at the top.
6782
// TODO(#80): or loading at the bottom, by adding a [MessageListDirection.newer]
6883
class MessageListLoadingItem extends MessageListItem {
@@ -90,7 +105,16 @@ mixin _MessageSequence {
90105
/// See also [contents] and [items].
91106
final List<Message> messages = [];
92107

93-
/// Whether [messages] and [items] represent the results of a fetch.
108+
/// The messages sent by the self-user.
109+
///
110+
/// See also [items].
111+
///
112+
/// Usually this should not have that many items, so we do not anticipate
113+
/// performance issues with unoptimized O(N) iterations through this list.
114+
final List<OutboxMessage> outboxMessages = [];
115+
116+
/// Whether [messages], [outboxMessages], and [items] represent the results
117+
/// of a fetch.
94118
///
95119
/// This allows the UI to distinguish "still working on fetching messages"
96120
/// from "there are in fact no messages here".
@@ -142,11 +166,12 @@ mixin _MessageSequence {
142166
/// The messages and their siblings in the UI, in order.
143167
///
144168
/// This has a [MessageListMessageItem] corresponding to each element
145-
/// of [messages], in order. It may have additional items interspersed
146-
/// before, between, or after the messages.
169+
/// of [messages], followed by each element in [outboxMessages] in order.
170+
/// It may have additional items interspersed before, between, or after the
171+
/// messages.
147172
///
148-
/// This information is completely derived from [messages] and
149-
/// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
173+
/// This information is completely derived from [messages], [outboxMessages]
174+
/// and the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
150175
/// It exists as an optimization, to memoize that computation.
151176
final QueueList<MessageListItem> items = QueueList();
152177

@@ -168,9 +193,10 @@ mixin _MessageSequence {
168193
}
169194
case MessageListRecipientHeaderItem(:var message):
170195
case MessageListDateSeparatorItem(:var message):
171-
if (message.id == null) return 1; // TODO(#1441): test
196+
if (message.id == null) return 1;
172197
return message.id! <= messageId ? -1 : 1;
173198
case MessageListMessageItem(:var message): return message.id.compareTo(messageId);
199+
case MessageListOutboxMessageItem(): return 1;
174200
}
175201
}
176202

@@ -274,10 +300,46 @@ mixin _MessageSequence {
274300
_reprocessAll();
275301
}
276302

303+
/// Append [outboxMessage] to [outboxMessages], and update derived data
304+
/// accordingly.
305+
///
306+
/// The caller is responsible for ensuring this is an appropriate thing to do
307+
/// given [narrow], our state of being caught up, and other concerns.
308+
void _addOutboxMessage(OutboxMessage outboxMessage) {
309+
assert(!outboxMessages.contains(outboxMessage));
310+
outboxMessages.add(outboxMessage);
311+
_processOutboxMessage(outboxMessages.length - 1);
312+
}
313+
314+
/// Remove the [outboxMessage] from the view.
315+
///
316+
/// Returns true if the outbox message was removed, false otherwise.
317+
bool _removeOutboxMessage(OutboxMessage outboxMessage) {
318+
if (!outboxMessages.remove(outboxMessage)) {
319+
return false;
320+
}
321+
_reprocessOutboxMessages();
322+
return true;
323+
}
324+
325+
/// Remove all outbox messages that satisfy [test] from [outboxMessages].
326+
///
327+
/// Returns true if any outbox messages were removed, false otherwise.
328+
bool _removeOutboxMessagesWhere(bool Function(OutboxMessage) test) {
329+
final count = outboxMessages.length;
330+
outboxMessages.removeWhere(test);
331+
if (outboxMessages.length == count) {
332+
return false;
333+
}
334+
_reprocessOutboxMessages();
335+
return true;
336+
}
337+
277338
/// Reset all [_MessageSequence] data, and cancel any active fetches.
278339
void _reset() {
279340
generation += 1;
280341
messages.clear();
342+
outboxMessages.clear();
281343
_fetched = false;
282344
_haveOldest = false;
283345
_fetchingOlder = false;
@@ -301,7 +363,8 @@ mixin _MessageSequence {
301363
///
302364
/// Returns whether an item has been appended or not.
303365
///
304-
/// The caller must append a [MessageListMessageBaseItem] after this.
366+
/// The caller must append a [MessageListMessageBaseItem] for [message]
367+
/// after this.
305368
bool _maybeAppendAuxillaryItem(MessageBase message, {
306369
required MessageBase? prevMessage,
307370
}) {
@@ -328,6 +391,7 @@ mixin _MessageSequence {
328391
/// The previous messages in the list must already have been processed.
329392
/// This message must already have been parsed and reflected in [contents].
330393
void _processMessage(int index) {
394+
assert(items.lastOrNull is! MessageListOutboxMessageItem);
331395
final prevMessage = index == 0 ? null : messages[index - 1];
332396
final message = messages[index];
333397
final content = contents[index];
@@ -338,6 +402,20 @@ mixin _MessageSequence {
338402
isLastInBlock: true));
339403
}
340404

405+
/// Append to [items] based on the index-th outbox message.
406+
///
407+
/// All [messages] and previous messages in [outboxMessages] must already have
408+
/// been processed.
409+
void _processOutboxMessage(int index) {
410+
final prevMessage = index == 0 ? messages.lastOrNull : outboxMessages[index - 1];
411+
final message = outboxMessages[index];
412+
413+
final appended = _maybeAppendAuxillaryItem(message, prevMessage: prevMessage);
414+
items.add(MessageListOutboxMessageItem(message,
415+
showSender: appended || prevMessage?.senderId != message.senderId,
416+
isLastInBlock: true));
417+
}
418+
341419
/// Update [items] to include markers at start and end as appropriate.
342420
void _updateEndMarkers() {
343421
assert(fetched);
@@ -362,12 +440,55 @@ mixin _MessageSequence {
362440
}
363441
}
364442

365-
/// Recompute [items] from scratch, based on [messages], [contents], and flags.
443+
/// Remove items associated with [outboxMessages] from [items].
444+
///
445+
/// This is designed to be idempotent; repeated calls will not change the
446+
/// content of [items].
447+
///
448+
/// This is efficient due to the expected small size of [outboxMessages].
449+
void _removeOutboxMessageItems() {
450+
// This loop relies on the assumption that all items that follow
451+
// the last [MessageListMessageItem] are derived from outbox messages.
452+
// If there is no [MessageListMessageItem] at all,
453+
// this will end up removing end markers.
454+
while (items.isNotEmpty && items.last is! MessageListMessageItem) {
455+
items.removeLast();
456+
}
457+
assert(items.none((e) => e is MessageListOutboxMessageItem));
458+
459+
if (items.isNotEmpty) {
460+
final lastItem = items.last as MessageListMessageItem;
461+
lastItem.isLastInBlock = true;
462+
}
463+
464+
if (fetched) {
465+
// Restore the end markers in case they were removed; only do so when
466+
// [fetched] is true, since the markers are not there otherwise.
467+
_updateEndMarkers();
468+
}
469+
}
470+
471+
/// Recompute the portion of [items] derived from outbox messages,
472+
/// based on [outboxMessages] and [messages].
473+
///
474+
/// All [messages] should have been processed when this is called.
475+
void _reprocessOutboxMessages() {
476+
_removeOutboxMessageItems();
477+
for (var i = 0; i < outboxMessages.length; i++) {
478+
_processOutboxMessage(i);
479+
}
480+
}
481+
482+
/// Recompute [items] from scratch, based on [messages], [contents],
483+
/// [outboxMessages] and flags.
366484
void _reprocessAll() {
367485
items.clear();
368486
for (var i = 0; i < messages.length; i++) {
369487
_processMessage(i);
370488
}
489+
for (var i = 0; i < outboxMessages.length; i++) {
490+
_processOutboxMessage(i);
491+
}
371492
_updateEndMarkers();
372493
}
373494
}
@@ -412,7 +533,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
412533

413534
factory MessageListView.init(
414535
{required PerAccountStore store, required Narrow narrow}) {
415-
final view = MessageListView._(store: store, narrow: narrow);
536+
final view = MessageListView._(store: store, narrow: narrow)
537+
.._syncOutboxMessages()
538+
.._reprocessOutboxMessages();
416539
store.registerMessageList(view);
417540
return view;
418541
}
@@ -510,11 +633,13 @@ class MessageListView with ChangeNotifier, _MessageSequence {
510633
_adjustNarrowForTopicPermalink(result.messages.firstOrNull);
511634
store.reconcileMessages(result.messages);
512635
store.recentSenders.handleMessages(result.messages); // TODO(#824)
636+
_removeOutboxMessageItems();
513637
for (final message in result.messages) {
514638
if (_messageVisible(message)) {
515639
_addMessage(message);
516640
}
517641
}
642+
_reprocessOutboxMessages();
518643
_fetched = true;
519644
_haveOldest = result.foundOldest;
520645
_updateEndMarkers();
@@ -621,16 +746,51 @@ class MessageListView with ChangeNotifier, _MessageSequence {
621746
}
622747
}
623748

749+
bool _shouldAddOutboxMessage(OutboxMessage outboxMessage, {
750+
bool wasUnmuted = false,
751+
}) {
752+
return !outboxMessage.hidden
753+
&& narrow.containsMessage(outboxMessage)
754+
&& (_messageVisible(outboxMessage) || wasUnmuted);
755+
}
756+
757+
/// Copy outbox messages from the store, keeping the ones belong to the view.
758+
///
759+
/// This does not recompute [items]. The caller is expected to call
760+
/// [_reprocessOutboxMessages] later to keep [items] up-to-date.
761+
///
762+
/// This assumes that [outboxMessages] is empty.
763+
void _syncOutboxMessages() {
764+
assert(outboxMessages.isEmpty);
765+
for (final outboxMessage in store.outboxMessages.values) {
766+
if (_shouldAddOutboxMessage(outboxMessage)) {
767+
outboxMessages.add(outboxMessage);
768+
}
769+
}
770+
}
771+
624772
/// Add [outboxMessage] if it belongs to the view.
625773
void addOutboxMessage(OutboxMessage outboxMessage) {
626-
// TODO(#1441) implement this
774+
assert(outboxMessages.none(
775+
(message) => message.localMessageId == outboxMessage.localMessageId));
776+
if (_shouldAddOutboxMessage(outboxMessage)) {
777+
_addOutboxMessage(outboxMessage);
778+
if (fetched) {
779+
// Only need to notify listeners when [fetched] is true, because
780+
// otherwise the message list just shows a loading indicator with
781+
// no other items.
782+
notifyListeners();
783+
}
784+
}
627785
}
628786

629787
/// Remove the [outboxMessage] from the view.
630788
///
631789
/// This is a no-op if the message is not found.
632790
void removeOutboxMessage(OutboxMessage outboxMessage) {
633-
// TODO(#1441) implement this
791+
if (_removeOutboxMessage(outboxMessage)) {
792+
notifyListeners();
793+
}
634794
}
635795

636796
void handleUserTopicEvent(UserTopicEvent event) {
@@ -639,10 +799,17 @@ class MessageListView with ChangeNotifier, _MessageSequence {
639799
return;
640800

641801
case VisibilityEffect.muted:
642-
if (_removeMessagesWhere((message) =>
643-
(message is StreamMessage
644-
&& message.streamId == event.streamId
645-
&& message.topic == event.topicName))) {
802+
bool removed = _removeOutboxMessagesWhere((message) =>
803+
message is StreamOutboxMessage
804+
&& message.conversation.streamId == event.streamId
805+
&& message.conversation.topic == event.topicName);
806+
807+
removed |= _removeMessagesWhere((message) =>
808+
message is StreamMessage
809+
&& message.streamId == event.streamId
810+
&& message.topic == event.topicName);
811+
812+
if (removed) {
646813
notifyListeners();
647814
}
648815

@@ -655,6 +822,18 @@ class MessageListView with ChangeNotifier, _MessageSequence {
655822
notifyListeners();
656823
fetchInitial();
657824
}
825+
826+
outboxMessages.clear();
827+
for (final outboxMessage in store.outboxMessages.values) {
828+
if (_shouldAddOutboxMessage(
829+
outboxMessage,
830+
wasUnmuted: outboxMessage is StreamOutboxMessage
831+
&& outboxMessage.conversation.streamId == event.streamId
832+
&& outboxMessage.conversation.topic == event.topicName,
833+
)) {
834+
outboxMessages.add(outboxMessage);
835+
}
836+
}
658837
}
659838
}
660839

@@ -668,14 +847,27 @@ class MessageListView with ChangeNotifier, _MessageSequence {
668847
void handleMessageEvent(MessageEvent event) {
669848
final message = event.message;
670849
if (!narrow.containsMessage(message) || !_messageVisible(message)) {
850+
assert(event.localMessageId == null || outboxMessages.none((message) =>
851+
message.localMessageId == int.parse(event.localMessageId!, radix: 10)));
671852
return;
672853
}
673854
if (!_fetched) {
674855
// TODO mitigate this fetch/event race: save message to add to list later
675856
return;
676857
}
858+
// We always remove all outbox message items
859+
// to ensure that message items come before them.
860+
_removeOutboxMessageItems();
677861
// TODO insert in middle instead, when appropriate
678862
_addMessage(message);
863+
if (event.localMessageId != null) {
864+
final localMessageId = int.parse(event.localMessageId!);
865+
// [outboxMessages] is epxected to be short, so removing the corresponding
866+
// outbox message and reprocessing them all in linear time is efficient.
867+
outboxMessages.removeWhere(
868+
(message) => message.localMessageId == localMessageId);
869+
}
870+
_reprocessOutboxMessages();
679871
notifyListeners();
680872
}
681873

@@ -707,6 +899,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
707899
// TODO in cases where we do have data to do better, do better.
708900
_reset();
709901
notifyListeners();
902+
_syncOutboxMessages();
710903
fetchInitial();
711904
}
712905

@@ -722,6 +915,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
722915
case PropagateMode.changeLater:
723916
narrow = newNarrow;
724917
_reset();
918+
_syncOutboxMessages();
725919
fetchInitial();
726920
case PropagateMode.changeOne:
727921
}
@@ -796,7 +990,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
796990

797991
/// Notify listeners if the given outbox message is present in this view.
798992
void notifyListenersIfOutboxMessagePresent(int localMessageId) {
799-
// TODO(#1441) implement this
993+
final isAnyPresent =
994+
outboxMessages.any((message) => message.localMessageId == localMessageId);
995+
if (isAnyPresent) {
996+
notifyListeners();
997+
}
800998
}
801999

8021000
/// Called when the app is reassembled during debugging, e.g. for hot reload.

0 commit comments

Comments
 (0)