Skip to content

Commit 79205c9

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. `handleOutboxMessage`, 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 7d1a97f commit 79205c9

File tree

5 files changed

+505
-29
lines changed

5 files changed

+505
-29
lines changed

lib/model/message_list.dart

+126-11
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,15 @@ 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+
// Usually this should not have that many items, so we do not anticipate
112+
// performance issues with unoptimized O(N) iterations through this list.
113+
final List<OutboxMessage> outboxMessages = [];
114+
115+
/// Whether [messages], [outboxMessages], and [items] represent the results
116+
/// of a fetch.
94117
///
95118
/// This allows the UI to distinguish "still working on fetching messages"
96119
/// from "there are in fact no messages here".
@@ -142,11 +165,12 @@ mixin _MessageSequence {
142165
/// The messages and their siblings in the UI, in order.
143166
///
144167
/// 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.
168+
/// of [messages], followed by each element in [outboxMessages] in order.
169+
/// It may have additional items interspersed before, between, or after the
170+
/// messages.
147171
///
148-
/// This information is completely derived from [messages] and
149-
/// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
172+
/// This information is completely derived from [messages], [outboxMessages]
173+
/// and the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
150174
/// It exists as an optimization, to memoize that computation.
151175
final QueueList<MessageListItem> items = QueueList();
152176

@@ -170,6 +194,7 @@ mixin _MessageSequence {
170194
case MessageListDateSeparatorItem(:var message):
171195
return message.id != null && message.id! <= messageId ? -1 : 1;
172196
case MessageListMessageItem(:var message): return message.id.compareTo(messageId);
197+
case MessageListOutboxMessageItem(): return 1;
173198
}
174199
}
175200

@@ -277,6 +302,7 @@ mixin _MessageSequence {
277302
void _reset() {
278303
generation += 1;
279304
messages.clear();
305+
outboxMessages.clear();
280306
_fetched = false;
281307
_haveOldest = false;
282308
_fetchingOlder = false;
@@ -300,7 +326,8 @@ mixin _MessageSequence {
300326
///
301327
/// Returns whether an item has been appended or not.
302328
///
303-
/// The caller must append a [MessageListMessageBaseItem] after this.
329+
/// The caller must append a [MessageListMessageBaseItem] for [message]
330+
/// after this.
304331
bool _maybeAppendAuxillaryItem(MessageBase message, {
305332
required MessageBase? prevMessage,
306333
}) {
@@ -337,6 +364,40 @@ mixin _MessageSequence {
337364
isLastInBlock: true));
338365
}
339366

367+
/// Append to [items] based on the index-th outbox message.
368+
///
369+
/// All [messages] and previous messages in [outboxMessages] must already have
370+
/// been processed.
371+
void _processOutboxMessage(int index) {
372+
final prevMessage = index == 0 ? messages.lastOrNull : outboxMessages[index - 1];
373+
final message = outboxMessages[index];
374+
375+
final appended = _maybeAppendAuxillaryItem(message, prevMessage: prevMessage);
376+
items.add(MessageListOutboxMessageItem(message,
377+
showSender: appended || prevMessage?.senderId != message.senderId,
378+
isLastInBlock: true));
379+
}
380+
381+
/// Remove items associated with [outboxMessages] from [items].
382+
///
383+
/// This is efficient due to the expected small size of [outboxMessages].
384+
void _removeOutboxMessageItems() {
385+
// This loop relies on the assumption that all [MessageListMessageItem]
386+
// items comes before those associated with outbox messages. If there
387+
// is no [MessageListMessageItem] at all, this will end up removing
388+
// end markers as well.
389+
while (items.isNotEmpty && items.last is! MessageListMessageItem) {
390+
items.removeLast();
391+
}
392+
assert(items.none((e) => e is MessageListOutboxMessageItem));
393+
394+
if (items.isNotEmpty) {
395+
final lastItem = items.last as MessageListMessageItem;
396+
lastItem.isLastInBlock = true;
397+
}
398+
_updateEndMarkers();
399+
}
400+
340401
/// Update [items] to include markers at start and end as appropriate.
341402
void _updateEndMarkers() {
342403
assert(fetched);
@@ -361,12 +422,16 @@ mixin _MessageSequence {
361422
}
362423
}
363424

364-
/// Recompute [items] from scratch, based on [messages], [contents], and flags.
425+
/// Recompute [items] from scratch, based on [messages], [contents],
426+
/// [outboxMessages] and flags.
365427
void _reprocessAll() {
366428
items.clear();
367429
for (var i = 0; i < messages.length; i++) {
368430
_processMessage(i);
369431
}
432+
for (var i = 0; i < outboxMessages.length; i++) {
433+
_processOutboxMessage(i);
434+
}
370435
_updateEndMarkers();
371436
}
372437
}
@@ -527,7 +592,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
527592
// TODO(#80): fetch from anchor firstUnread, instead of newest
528593
// TODO(#82): fetch from a given message ID as anchor
529594
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
530-
assert(messages.isEmpty && contents.isEmpty);
595+
assert(messages.isEmpty && contents.isEmpty && outboxMessages.isEmpty);
531596
// TODO schedule all this in another isolate
532597
final generation = this.generation;
533598
final result = await getMessages(store.connection,
@@ -545,6 +610,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
545610
_addMessage(message);
546611
}
547612
}
613+
for (final outboxMessage in store.outboxMessages.values) {
614+
_maybeAddOutboxMessage(outboxMessage);
615+
}
548616
_fetched = true;
549617
_haveOldest = result.foundOldest;
550618
_updateEndMarkers();
@@ -651,15 +719,43 @@ class MessageListView with ChangeNotifier, _MessageSequence {
651719
}
652720
}
653721

722+
/// Add [outboxMessage] if it belongs to the view.
723+
///
724+
/// Returns true if the message was added, false otherwise.
725+
bool _maybeAddOutboxMessage(OutboxMessage outboxMessage) {
726+
assert(outboxMessages.none(
727+
(message) => message.localMessageId == outboxMessage.localMessageId));
728+
if (!outboxMessage.hidden
729+
&& narrow.containsMessage(outboxMessage)
730+
&& _messageVisible(outboxMessage)) {
731+
outboxMessages.add(outboxMessage);
732+
_processOutboxMessage(outboxMessages.length - 1);
733+
return true;
734+
}
735+
return false;
736+
}
737+
654738
void handleOutboxMessage(OutboxMessage outboxMessage) {
655-
// TODO: implement this
739+
if (!fetched) return;
740+
if (_maybeAddOutboxMessage(outboxMessage)) {
741+
notifyListeners();
742+
}
656743
}
657744

658745
/// Remove the [outboxMessage] from the view.
659746
///
660747
/// This is a no-op if the message is not found.
661748
void removeOutboxMessageIfExists(OutboxMessage outboxMessage) {
662-
// TODO: implement this
749+
final removed = outboxMessages.remove(outboxMessage);
750+
if (!removed) {
751+
return;
752+
}
753+
754+
_removeOutboxMessageItems();
755+
for (int i = 0; i < outboxMessages.length; i++) {
756+
_processOutboxMessage(i);
757+
}
758+
notifyListeners();
663759
}
664760

665761
void handleUserTopicEvent(UserTopicEvent event) {
@@ -697,14 +793,29 @@ class MessageListView with ChangeNotifier, _MessageSequence {
697793
void handleMessageEvent(MessageEvent event) {
698794
final message = event.message;
699795
if (!narrow.containsMessage(message) || !_messageVisible(message)) {
796+
assert(event.localMessageId == null || outboxMessages.none((message) =>
797+
message.localMessageId == int.parse(event.localMessageId!, radix: 10)));
700798
return;
701799
}
702800
if (!_fetched) {
703801
// TODO mitigate this fetch/event race: save message to add to list later
704802
return;
705803
}
804+
// We always remove all outbox message items
805+
// to ensure that message items come before them.
806+
_removeOutboxMessageItems();
706807
// TODO insert in middle instead, when appropriate
707808
_addMessage(message);
809+
if (event.localMessageId != null) {
810+
final localMessageId = int.parse(event.localMessageId!);
811+
// [outboxMessages] is epxected to be short, so removing the corresponding
812+
// outbox message and reprocessing them all in linear time is efficient.
813+
outboxMessages.removeWhere(
814+
(message) => message.localMessageId == localMessageId);
815+
}
816+
for (int i = 0; i < outboxMessages.length; i++) {
817+
_processOutboxMessage(i);
818+
}
708819
notifyListeners();
709820
}
710821

@@ -825,7 +936,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
825936

826937
/// Notify listeners if the given outbox message is present in this view.
827938
void notifyListenersIfOutboxMessagePresent(int localMessageId) {
828-
// TODO: implement this
939+
final isAnyPresent =
940+
outboxMessages.any((message) => message.localMessageId == localMessageId);
941+
if (isAnyPresent) {
942+
notifyListeners();
943+
}
829944
}
830945

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

lib/widgets/message_list.dart

+38-2
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,12 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
687687
header: header,
688688
trailingWhitespace: i == 1 ? 8 : 11,
689689
item: data);
690+
case MessageListOutboxMessageItem():
691+
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
692+
return MessageItem(
693+
header: header,
694+
trailingWhitespace: 11,
695+
item: data);
690696
}
691697
}
692698
}
@@ -983,6 +989,7 @@ class MessageItem extends StatelessWidget {
983989
child: Column(children: [
984990
switch (item) {
985991
MessageListMessageItem() => MessageWithPossibleSender(item: item),
992+
MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item),
986993
},
987994
if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!),
988995
]));
@@ -1337,7 +1344,7 @@ final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US');
13371344
class _SenderRow extends StatelessWidget {
13381345
const _SenderRow({required this.message});
13391346

1340-
final Message message;
1347+
final MessageBase message;
13411348

13421349
@override
13431350
Widget build(BuildContext context) {
@@ -1366,7 +1373,9 @@ class _SenderRow extends StatelessWidget {
13661373
userId: message.senderId),
13671374
const SizedBox(width: 8),
13681375
Flexible(
1369-
child: Text(message.senderFullName, // TODO(#716): use `store.senderDisplayName`
1376+
child: Text(message is Message
1377+
? store.senderDisplayName(message as Message)
1378+
: store.userDisplayName(message.senderId),
13701379
style: TextStyle(
13711380
fontSize: 18,
13721381
height: (22 / 18),
@@ -1463,3 +1472,30 @@ class MessageWithPossibleSender extends StatelessWidget {
14631472
])));
14641473
}
14651474
}
1475+
1476+
/// A placeholder for Zulip message sent by the self-user.
1477+
///
1478+
/// See also [OutboxMessage].
1479+
class OutboxMessageWithPossibleSender extends StatelessWidget {
1480+
const OutboxMessageWithPossibleSender({super.key, required this.item});
1481+
1482+
final MessageListOutboxMessageItem item;
1483+
1484+
@override
1485+
Widget build(BuildContext context) {
1486+
final message = item.message;
1487+
return Padding(
1488+
padding: const EdgeInsets.symmetric(vertical: 4),
1489+
child: Column(children: [
1490+
if (item.showSender) _SenderRow(message: message),
1491+
Padding(
1492+
padding: const EdgeInsets.symmetric(horizontal: 16),
1493+
// This is adapated from [MessageContent].
1494+
// TODO(#576): Offer InheritedMessage ancestor once we are ready
1495+
// to support local echoing images and lightbox.
1496+
child: DefaultTextStyle(
1497+
style: ContentTheme.of(context).textStylePlainParagraph,
1498+
child: BlockContentList(nodes: item.content.nodes))),
1499+
]));
1500+
}
1501+
}

0 commit comments

Comments
 (0)