Skip to content

Commit c275187

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`, similar to `handleMessage`, also look at `fetched` before adding the outbox message. However, there is no data race to prevent — we can totally not clear `outboxMessages` in `_reset`, and do the initial fetch from `MessageListView.init`, so that outbox messages do not rely on the fetched state at all. I decided against it since it is easy to check `fetched`, and making `fetchInitial` reprocess `outboxMessages` from a clean state (which is inexpensive) helps ensuring message list invariants.
1 parent c19721e commit c275187

File tree

6 files changed

+749
-45
lines changed

6 files changed

+749
-45
lines changed

lib/model/message_list.dart

Lines changed: 156 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

@@ -261,6 +287,23 @@ mixin _MessageSequence {
261287
return true;
262288
}
263289

290+
/// Remove all outbox messages that satisfy [test].
291+
///
292+
/// Returns true if any outbox messages were removed, false otherwise.
293+
bool _removeOutboxMessagesWhere(bool Function(OutboxMessage) test) {
294+
final count = outboxMessages.length;
295+
outboxMessages.removeWhere(test);
296+
if (outboxMessages.length == count) {
297+
return false;
298+
}
299+
_removeOutboxMessageItems();
300+
for (int i = 0; i < outboxMessages.length; i++) {
301+
_processOutboxMessage(i);
302+
}
303+
_updateEndMarkers();
304+
return true;
305+
}
306+
264307
void _insertAllMessages(int index, Iterable<Message> toInsert) {
265308
// TODO parse/process messages in smaller batches, to not drop frames.
266309
// On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages.
@@ -278,6 +321,7 @@ mixin _MessageSequence {
278321
void _reset() {
279322
generation += 1;
280323
messages.clear();
324+
outboxMessages.clear();
281325
_fetched = false;
282326
_haveOldest = false;
283327
_fetchingOlder = false;
@@ -301,7 +345,8 @@ mixin _MessageSequence {
301345
///
302346
/// Returns whether an item has been appended or not.
303347
///
304-
/// The caller must append a [MessageListMessageBaseItem] after this.
348+
/// The caller must append a [MessageListMessageBaseItem] for [message]
349+
/// after this.
305350
bool _maybeAppendAuxillaryItem(MessageBase message, {
306351
required MessageBase? prevMessage,
307352
}) {
@@ -338,6 +383,40 @@ mixin _MessageSequence {
338383
isLastInBlock: true));
339384
}
340385

386+
/// Append to [items] based on the index-th outbox message.
387+
///
388+
/// All [messages] and previous messages in [outboxMessages] must already have
389+
/// been processed.
390+
void _processOutboxMessage(int index) {
391+
final prevMessage = index == 0 ? messages.lastOrNull : outboxMessages[index - 1];
392+
final message = outboxMessages[index];
393+
394+
final appended = _maybeAppendAuxillaryItem(message, prevMessage: prevMessage);
395+
items.add(MessageListOutboxMessageItem(message,
396+
showSender: appended || prevMessage?.senderId != message.senderId,
397+
isLastInBlock: true));
398+
}
399+
400+
/// Remove items associated with [outboxMessages] from [items].
401+
///
402+
/// This is efficient due to the expected small size of [outboxMessages].
403+
void _removeOutboxMessageItems() {
404+
// This loop relies on the assumption that all [MessageListMessageItem]
405+
// items comes before those associated with outbox messages. If there
406+
// is no [MessageListMessageItem] at all, this will end up removing
407+
// end markers as well.
408+
while (items.isNotEmpty && items.last is! MessageListMessageItem) {
409+
items.removeLast();
410+
}
411+
assert(items.none((e) => e is MessageListOutboxMessageItem));
412+
413+
if (items.isNotEmpty) {
414+
final lastItem = items.last as MessageListMessageItem;
415+
lastItem.isLastInBlock = true;
416+
}
417+
_updateEndMarkers();
418+
}
419+
341420
/// Update [items] to include markers at start and end as appropriate.
342421
void _updateEndMarkers() {
343422
assert(fetched);
@@ -362,12 +441,16 @@ mixin _MessageSequence {
362441
}
363442
}
364443

365-
/// Recompute [items] from scratch, based on [messages], [contents], and flags.
444+
/// Recompute [items] from scratch, based on [messages], [contents],
445+
/// [outboxMessages] and flags.
366446
void _reprocessAll() {
367447
items.clear();
368448
for (var i = 0; i < messages.length; i++) {
369449
_processMessage(i);
370450
}
451+
for (var i = 0; i < outboxMessages.length; i++) {
452+
_processOutboxMessage(i);
453+
}
371454
_updateEndMarkers();
372455
}
373456
}
@@ -497,7 +580,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
497580
// TODO(#80): fetch from anchor firstUnread, instead of newest
498581
// TODO(#82): fetch from a given message ID as anchor
499582
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
500-
assert(messages.isEmpty && contents.isEmpty);
583+
assert(messages.isEmpty && contents.isEmpty && outboxMessages.isEmpty);
501584
// TODO schedule all this in another isolate
502585
final generation = this.generation;
503586
final result = await getMessages(store.connection,
@@ -515,6 +598,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
515598
_addMessage(message);
516599
}
517600
}
601+
for (final outboxMessage in store.outboxMessages.values) {
602+
_maybeAddOutboxMessage(outboxMessage);
603+
}
518604
_fetched = true;
519605
_haveOldest = result.foundOldest;
520606
_updateEndMarkers();
@@ -621,15 +707,43 @@ class MessageListView with ChangeNotifier, _MessageSequence {
621707
}
622708
}
623709

710+
/// Add [outboxMessage] if it belongs to the view.
711+
///
712+
/// Returns true if the message was added, false otherwise.
713+
bool _maybeAddOutboxMessage(OutboxMessage outboxMessage) {
714+
assert(outboxMessages.none(
715+
(message) => message.localMessageId == outboxMessage.localMessageId));
716+
if (!outboxMessage.hidden
717+
&& narrow.containsMessage(outboxMessage)
718+
&& _messageVisible(outboxMessage)) {
719+
outboxMessages.add(outboxMessage);
720+
_processOutboxMessage(outboxMessages.length - 1);
721+
return true;
722+
}
723+
return false;
724+
}
725+
624726
void addOutboxMessage(OutboxMessage outboxMessage) {
625-
// TODO(#1441) implement this
727+
if (!fetched) return;
728+
if (_maybeAddOutboxMessage(outboxMessage)) {
729+
notifyListeners();
730+
}
626731
}
627732

628733
/// Remove the [outboxMessage] from the view.
629734
///
630735
/// This is a no-op if the message is not found.
631736
void removeOutboxMessage(OutboxMessage outboxMessage) {
632-
// TODO(#1441) implement this
737+
final removed = outboxMessages.remove(outboxMessage);
738+
if (!removed) {
739+
return;
740+
}
741+
742+
_removeOutboxMessageItems();
743+
for (int i = 0; i < outboxMessages.length; i++) {
744+
_processOutboxMessage(i);
745+
}
746+
notifyListeners();
633747
}
634748

635749
void handleUserTopicEvent(UserTopicEvent event) {
@@ -638,10 +752,17 @@ class MessageListView with ChangeNotifier, _MessageSequence {
638752
return;
639753

640754
case VisibilityEffect.muted:
641-
if (_removeMessagesWhere((message) =>
642-
(message is StreamMessage
643-
&& message.streamId == event.streamId
644-
&& message.topic == event.topicName))) {
755+
bool removed = _removeOutboxMessagesWhere((message) =>
756+
message is StreamOutboxMessage
757+
&& message.conversation.streamId == event.streamId
758+
&& message.conversation.topic == event.topicName);
759+
760+
removed |= _removeMessagesWhere((message) =>
761+
message is StreamMessage
762+
&& message.streamId == event.streamId
763+
&& message.topic == event.topicName);
764+
765+
if (removed) {
645766
notifyListeners();
646767
}
647768

@@ -667,14 +788,29 @@ class MessageListView with ChangeNotifier, _MessageSequence {
667788
void handleMessageEvent(MessageEvent event) {
668789
final message = event.message;
669790
if (!narrow.containsMessage(message) || !_messageVisible(message)) {
791+
assert(event.localMessageId == null || outboxMessages.none((message) =>
792+
message.localMessageId == int.parse(event.localMessageId!, radix: 10)));
670793
return;
671794
}
672795
if (!_fetched) {
673796
// TODO mitigate this fetch/event race: save message to add to list later
674797
return;
675798
}
799+
// We always remove all outbox message items
800+
// to ensure that message items come before them.
801+
_removeOutboxMessageItems();
676802
// TODO insert in middle instead, when appropriate
677803
_addMessage(message);
804+
if (event.localMessageId != null) {
805+
final localMessageId = int.parse(event.localMessageId!);
806+
// [outboxMessages] is epxected to be short, so removing the corresponding
807+
// outbox message and reprocessing them all in linear time is efficient.
808+
outboxMessages.removeWhere(
809+
(message) => message.localMessageId == localMessageId);
810+
}
811+
for (int i = 0; i < outboxMessages.length; i++) {
812+
_processOutboxMessage(i);
813+
}
678814
notifyListeners();
679815
}
680816

@@ -795,7 +931,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
795931

796932
/// Notify listeners if the given outbox message is present in this view.
797933
void notifyListenersIfOutboxMessagePresent(int localMessageId) {
798-
// TODO(#1441) implement this
934+
final isAnyPresent =
935+
outboxMessages.any((message) => message.localMessageId == localMessageId);
936+
if (isAnyPresent) {
937+
notifyListeners();
938+
}
799939
}
800940

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

lib/widgets/message_list.dart

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,11 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
686686
key: ValueKey(data.message.id),
687687
header: header,
688688
item: data);
689+
case MessageListOutboxMessageItem():
690+
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
691+
return MessageItem(
692+
header: header,
693+
item: data);
689694
}
690695
}
691696
}
@@ -979,6 +984,7 @@ class MessageItem extends StatelessWidget {
979984
child: Column(children: [
980985
switch (item) {
981986
MessageListMessageItem() => MessageWithPossibleSender(item: item),
987+
MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item),
982988
},
983989
]));
984990
if (item case MessageListMessageItem(:final message)) {
@@ -1332,7 +1338,7 @@ final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US');
13321338
class _SenderRow extends StatelessWidget {
13331339
const _SenderRow({required this.message, required this.showTimestamp});
13341340

1335-
final Message message;
1341+
final MessageBase message;
13361342
final bool showTimestamp;
13371343

13381344
@override
@@ -1362,7 +1368,9 @@ class _SenderRow extends StatelessWidget {
13621368
userId: message.senderId),
13631369
const SizedBox(width: 8),
13641370
Flexible(
1365-
child: Text(message.senderFullName, // TODO(#716): use `store.senderDisplayName`
1371+
child: Text(message is Message
1372+
? store.senderDisplayName(message as Message)
1373+
: store.userDisplayName(message.senderId),
13661374
style: TextStyle(
13671375
fontSize: 18,
13681376
height: (22 / 18),
@@ -1461,3 +1469,30 @@ class MessageWithPossibleSender extends StatelessWidget {
14611469
])));
14621470
}
14631471
}
1472+
1473+
/// A placeholder for Zulip message sent by the self-user.
1474+
///
1475+
/// See also [OutboxMessage].
1476+
class OutboxMessageWithPossibleSender extends StatelessWidget {
1477+
const OutboxMessageWithPossibleSender({super.key, required this.item});
1478+
1479+
final MessageListOutboxMessageItem item;
1480+
1481+
@override
1482+
Widget build(BuildContext context) {
1483+
final message = item.message;
1484+
return Padding(
1485+
padding: const EdgeInsets.symmetric(vertical: 4),
1486+
child: Column(children: [
1487+
if (item.showSender) _SenderRow(message: message, showTimestamp: false),
1488+
Padding(
1489+
padding: const EdgeInsets.symmetric(horizontal: 16),
1490+
// This is adapated from [MessageContent].
1491+
// TODO(#576): Offer InheritedMessage ancestor once we are ready
1492+
// to support local echoing images and lightbox.
1493+
child: DefaultTextStyle(
1494+
style: ContentTheme.of(context).textStylePlainParagraph,
1495+
child: BlockContentList(nodes: item.content.nodes))),
1496+
]));
1497+
}
1498+
}

test/api/model/model_checks.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ extension StreamConversationChecks on Subject<StreamConversation> {
3434
Subject<String?> get displayRecipient => has((x) => x.displayRecipient, 'displayRecipient');
3535
}
3636

37+
extension DmConversationChecks on Subject<DmConversation> {
38+
Subject<List<int>> get allRecipientIds => has((x) => x.allRecipientIds, 'allRecipientIds');
39+
}
40+
3741
extension MessageBaseChecks<T extends Conversation> on Subject<MessageBase<T>> {
3842
Subject<int?> get id => has((e) => e.id, 'id');
3943
Subject<int> get senderId => has((e) => e.senderId, 'senderId');

0 commit comments

Comments
 (0)