Skip to content

Commit 2f35e8d

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 3b49e3e commit 2f35e8d

File tree

6 files changed

+533
-38
lines changed

6 files changed

+533
-38
lines changed

lib/model/message.dart

+9-12
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,9 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
321321
}
322322
_outboxMessageDebounceTimers.remove(localMessageId)?.cancel();
323323
outboxMessage.unhide();
324-
// TODO: uncomment this once message list support is added:
325-
// for (final view in _messageListViews) {
326-
// view.handleOutboxMessage(outboxMessage);
327-
// }
324+
for (final view in _messageListViews) {
325+
view.handleOutboxMessage(outboxMessage);
326+
}
328327
}
329328

330329
/// Update the state of the [OutboxMessage] with the given [localMessageId],
@@ -346,10 +345,9 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
346345
if (outboxMessage.hidden) {
347346
return;
348347
}
349-
// TODO: uncomment this once message list support is added:
350-
// for (final view in _messageListViews) {
351-
// view.notifyListenersIfOutboxMessagePresent(localMessageId);
352-
// }
348+
for (final view in _messageListViews) {
349+
view.notifyListenersIfOutboxMessagePresent(localMessageId);
350+
}
353351
}
354352

355353

@@ -362,10 +360,9 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore {
362360
assert(false, 'Removing unknown outbox message with localMessageId: $localMessageId');
363361
return;
364362
}
365-
// TODO: uncomment this once message list support is added:
366-
// for (final view in _messageListViews) {
367-
// view.removeOutboxMessageIfExists(removed);
368-
// }
363+
for (final view in _messageListViews) {
364+
view.removeOutboxMessageIfExists(removed);
365+
}
369366
}
370367

371368
@override

lib/model/message_list.dart

+140-8
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../api/route/messages.dart';
1010
import 'algorithms.dart';
1111
import 'channel.dart';
1212
import 'content.dart';
13+
import 'message.dart';
1314
import 'narrow.dart';
1415
import 'store.dart';
1516

@@ -62,6 +63,21 @@ class MessageListMessageItem extends MessageListMessageBaseItem {
6263
});
6364
}
6465

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+
6581
/// Indicates the app is loading more messages at the top.
6682
// TODO(#80): or loading at the bottom, by adding a [MessageListDirection.newer]
6783
class MessageListLoadingItem extends MessageListItem {
@@ -89,7 +105,15 @@ mixin _MessageSequence {
89105
/// See also [contents] and [items].
90106
final List<Message> messages = [];
91107

92-
/// 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.
93117
///
94118
/// This allows the UI to distinguish "still working on fetching messages"
95119
/// from "there are in fact no messages here".
@@ -141,11 +165,12 @@ mixin _MessageSequence {
141165
/// The messages and their siblings in the UI, in order.
142166
///
143167
/// This has a [MessageListMessageItem] corresponding to each element
144-
/// of [messages], in order. It may have additional items interspersed
145-
/// 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.
146171
///
147-
/// This information is completely derived from [messages] and
148-
/// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
172+
/// This information is completely derived from [messages], [outboxMessages]
173+
/// and the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
149174
/// It exists as an optimization, to memoize that computation.
150175
final QueueList<MessageListItem> items = QueueList();
151176

@@ -170,6 +195,7 @@ mixin _MessageSequence {
170195
if (message.id == null) return 1;
171196
return message.id! <= messageId ? -1 : 1;
172197
case MessageListMessageItem(:var message): return message.id.compareTo(messageId);
198+
case MessageListOutboxMessageItem(): return 1;
173199
}
174200
}
175201

@@ -277,6 +303,7 @@ mixin _MessageSequence {
277303
void _reset() {
278304
generation += 1;
279305
messages.clear();
306+
outboxMessages.clear();
280307
_fetched = false;
281308
_haveOldest = false;
282309
_fetchingOlder = false;
@@ -300,7 +327,8 @@ mixin _MessageSequence {
300327
///
301328
/// Returns whether an item has been appended or not.
302329
///
303-
/// The caller must append a [MessageListMessageBaseItem] after this.
330+
/// The caller must append a [MessageListMessageBaseItem] for [message]
331+
/// after this.
304332
bool _maybeAppendAuxillaryItem(MessageBase message, {
305333
required MessageBase? prevMessage,
306334
}) {
@@ -337,6 +365,40 @@ mixin _MessageSequence {
337365
isLastInBlock: true));
338366
}
339367

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

364-
/// Recompute [items] from scratch, based on [messages], [contents], and flags.
426+
/// Recompute [items] from scratch, based on [messages], [contents],
427+
/// [outboxMessages] and flags.
365428
void _reprocessAll() {
366429
items.clear();
367430
for (var i = 0; i < messages.length; i++) {
368431
_processMessage(i);
369432
}
433+
for (var i = 0; i < outboxMessages.length; i++) {
434+
_processOutboxMessage(i);
435+
}
370436
_updateEndMarkers();
371437
}
372438
}
@@ -527,7 +593,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
527593
// TODO(#80): fetch from anchor firstUnread, instead of newest
528594
// TODO(#82): fetch from a given message ID as anchor
529595
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
530-
assert(messages.isEmpty && contents.isEmpty);
596+
assert(messages.isEmpty && contents.isEmpty && outboxMessages.isEmpty);
531597
// TODO schedule all this in another isolate
532598
final generation = this.generation;
533599
final result = await getMessages(store.connection,
@@ -545,6 +611,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
545611
_addMessage(message);
546612
}
547613
}
614+
for (final outboxMessage in store.outboxMessages.values) {
615+
_maybeAddOutboxMessage(outboxMessage);
616+
}
548617
_fetched = true;
549618
_haveOldest = result.foundOldest;
550619
_updateEndMarkers();
@@ -651,6 +720,45 @@ class MessageListView with ChangeNotifier, _MessageSequence {
651720
}
652721
}
653722

723+
/// Add [outboxMessage] if it belongs to the view.
724+
///
725+
/// Returns true if the message was added, false otherwise.
726+
bool _maybeAddOutboxMessage(OutboxMessage outboxMessage) {
727+
assert(outboxMessages.none(
728+
(message) => message.localMessageId == outboxMessage.localMessageId));
729+
if (!outboxMessage.hidden
730+
&& narrow.containsMessage(outboxMessage)
731+
&& _messageVisible(outboxMessage)) {
732+
outboxMessages.add(outboxMessage);
733+
_processOutboxMessage(outboxMessages.length - 1);
734+
return true;
735+
}
736+
return false;
737+
}
738+
739+
void handleOutboxMessage(OutboxMessage outboxMessage) {
740+
if (!fetched) return;
741+
if (_maybeAddOutboxMessage(outboxMessage)) {
742+
notifyListeners();
743+
}
744+
}
745+
746+
/// Remove the [outboxMessage] from the view.
747+
///
748+
/// This is a no-op if the message is not found.
749+
void removeOutboxMessageIfExists(OutboxMessage outboxMessage) {
750+
final removed = outboxMessages.remove(outboxMessage);
751+
if (!removed) {
752+
return;
753+
}
754+
755+
_removeOutboxMessageItems();
756+
for (int i = 0; i < outboxMessages.length; i++) {
757+
_processOutboxMessage(i);
758+
}
759+
notifyListeners();
760+
}
761+
654762
void handleUserTopicEvent(UserTopicEvent event) {
655763
switch (_canAffectVisibility(event)) {
656764
case VisibilityEffect.none:
@@ -686,14 +794,29 @@ class MessageListView with ChangeNotifier, _MessageSequence {
686794
void handleMessageEvent(MessageEvent event) {
687795
final message = event.message;
688796
if (!narrow.containsMessage(message) || !_messageVisible(message)) {
797+
assert(event.localMessageId == null || outboxMessages.none((message) =>
798+
message.localMessageId == int.parse(event.localMessageId!, radix: 10)));
689799
return;
690800
}
691801
if (!_fetched) {
692802
// TODO mitigate this fetch/event race: save message to add to list later
693803
return;
694804
}
805+
// We always remove all outbox message items
806+
// to ensure that message items come before them.
807+
_removeOutboxMessageItems();
695808
// TODO insert in middle instead, when appropriate
696809
_addMessage(message);
810+
if (event.localMessageId != null) {
811+
final localMessageId = int.parse(event.localMessageId!);
812+
// [outboxMessages] is epxected to be short, so removing the corresponding
813+
// outbox message and reprocessing them all in linear time is efficient.
814+
outboxMessages.removeWhere(
815+
(message) => message.localMessageId == localMessageId);
816+
}
817+
for (int i = 0; i < outboxMessages.length; i++) {
818+
_processOutboxMessage(i);
819+
}
697820
notifyListeners();
698821
}
699822

@@ -812,6 +935,15 @@ class MessageListView with ChangeNotifier, _MessageSequence {
812935
}
813936
}
814937

938+
/// Notify listeners if the given outbox message is present in this view.
939+
void notifyListenersIfOutboxMessagePresent(int localMessageId) {
940+
final isAnyPresent =
941+
outboxMessages.any((message) => message.localMessageId == localMessageId);
942+
if (isAnyPresent) {
943+
notifyListeners();
944+
}
945+
}
946+
815947
/// Called when the app is reassembled during debugging, e.g. for hot reload.
816948
///
817949
/// This will redo from scratch any computations we can, such as parsing

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
}
@@ -982,6 +988,7 @@ class MessageItem extends StatelessWidget {
982988
child: Column(children: [
983989
switch (item) {
984990
MessageListMessageItem() => MessageWithPossibleSender(item: item),
991+
MessageListOutboxMessageItem() => OutboxMessageWithPossibleSender(item: item),
985992
},
986993
if (trailingWhitespace != null && item.isLastInBlock) SizedBox(height: trailingWhitespace!),
987994
]));
@@ -1335,7 +1342,7 @@ final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US');
13351342
class _SenderRow extends StatelessWidget {
13361343
const _SenderRow({required this.message});
13371344

1338-
final Message message;
1345+
final MessageBase message;
13391346

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

0 commit comments

Comments
 (0)