Skip to content

Commit 11938c7

Browse files
gnpricechrisbobbe
authored andcommitted
msglist: Share recipient headers across messages
Fixes: #174
1 parent cb784e8 commit 11938c7

File tree

3 files changed

+256
-22
lines changed

3 files changed

+256
-22
lines changed

lib/model/message_list.dart

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ class MessageListRecipientHeaderItem extends MessageListItem {
2828
/// A message to show in the message list.
2929
class MessageListMessageItem extends MessageListItem {
3030
final Message message;
31-
final ZulipContent content;
32-
final bool isLastInBlock;
31+
ZulipContent content;
32+
bool isLastInBlock;
3333

3434
MessageListMessageItem(this.message, this.content, {required this.isLastInBlock});
3535
}
@@ -127,7 +127,7 @@ mixin _MessageSequence {
127127
assert(itemIndex > -1
128128
&& items[itemIndex] is MessageListMessageItem
129129
&& identical((items[itemIndex] as MessageListMessageItem).message, message));
130-
items[itemIndex] = MessageListMessageItem(isLastInBlock: true, message, content);
130+
(items[itemIndex] as MessageListMessageItem).content = content;
131131
}
132132

133133
/// Append [message] to [messages], and update derived data accordingly.
@@ -170,13 +170,20 @@ mixin _MessageSequence {
170170
/// This message must already have been parsed and reflected in [contents].
171171
void _processMessage(int index) {
172172
// This will get more complicated to handle the ways that messages interact
173-
// with the display of neighboring messages: sender headings #175,
174-
// recipient headings #174, and date separators #173.
175-
items.add(MessageListRecipientHeaderItem(messages[index]));
176-
items.add(MessageListMessageItem(
177-
isLastInBlock: true,
178-
messages[index], contents[index],
179-
));
173+
// with the display of neighboring messages: sender headings #175
174+
// and date separators #173.
175+
final message = messages[index];
176+
final content = contents[index];
177+
if (index > 0 && canShareRecipientHeader(messages[index - 1], message)) {
178+
assert(items.last is MessageListMessageItem);
179+
final prevMessageItem = items.last as MessageListMessageItem;
180+
assert(identical(prevMessageItem.message, messages[index - 1]));
181+
assert(prevMessageItem.isLastInBlock);
182+
prevMessageItem.isLastInBlock = false;
183+
} else {
184+
items.add(MessageListRecipientHeaderItem(message));
185+
}
186+
items.add(MessageListMessageItem(message, content, isLastInBlock: true));
180187
}
181188

182189
/// Update [items] to include markers at start and end as appropriate.
@@ -210,6 +217,53 @@ mixin _MessageSequence {
210217
}
211218
}
212219

220+
@visibleForTesting
221+
bool canShareRecipientHeader(Message prevMessage, Message message) {
222+
if (prevMessage is StreamMessage && message is StreamMessage) {
223+
if (prevMessage.streamId != message.streamId) return false;
224+
if (prevMessage.subject != message.subject) return false;
225+
} else if (prevMessage is DmMessage && message is DmMessage) {
226+
if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) {
227+
return false;
228+
}
229+
} else {
230+
return false;
231+
}
232+
233+
// switch ((prevMessage, message)) {
234+
// case (StreamMessage(), StreamMessage()):
235+
// // TODO(dart-3): this doesn't type-narrow prevMessage and message
236+
// case (DmMessage(), DmMessage()):
237+
// // …
238+
// default:
239+
// return false;
240+
// }
241+
242+
// TODO memoize [DateTime]s... also use memoized for showing date/time in msglist
243+
final prevTime = DateTime.fromMillisecondsSinceEpoch(prevMessage.timestamp * 1000);
244+
final time = DateTime.fromMillisecondsSinceEpoch(message.timestamp * 1000);
245+
if (!_sameDay(prevTime, time)) return false;
246+
247+
return true;
248+
}
249+
250+
// Intended for [Message.allRecipientIds]. Assumes efficient `length`.
251+
bool _equalIdSequences(Iterable<int> xs, Iterable<int> ys) {
252+
if (xs.length != ys.length) return false;
253+
final xs_ = xs.iterator; final ys_ = ys.iterator;
254+
while (xs_.moveNext() && ys_.moveNext()) {
255+
if (xs_.current != ys_.current) return false;
256+
}
257+
return true;
258+
}
259+
260+
bool _sameDay(DateTime date1, DateTime date2) {
261+
if (date1.year != date2.year) return false;
262+
if (date1.month != date2.month) return false;
263+
if (date1.day != date2.day) return false;
264+
return true;
265+
}
266+
213267
/// A view-model for a message list.
214268
///
215269
/// The owner of one of these objects must call [dispose] when the object
@@ -343,6 +397,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
343397
/// were changed, and ignores any changes to its stream or topic.
344398
///
345399
/// TODO(#150): Handle message moves.
400+
// NB that when handling message moves (#150), recipient headers may need updating.
346401
void maybeUpdateMessage(UpdateMessageEvent event) {
347402
final idx = _findMessageWithId(event.messageId);
348403
if (idx == -1) {

test/model/message_list_test.dart

Lines changed: 185 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,183 @@ void main() async {
461461
check(model).messages.length.equals(31);
462462
check(model.contents[0]).equalsNode(correctContent);
463463
});
464+
465+
test('recipient headers are maintained consistently', () async {
466+
// This tests the code that maintains the invariant that recipient headers
467+
// are present just where [canShareRecipientHeader] requires them.
468+
// In [checkInvariants] we check the current state against that invariant,
469+
// so here we just need to exercise that code through all the relevant cases.
470+
// Each [checkNotifiedOnce] call ensures there's been a [checkInvariants] call
471+
// (in the listener that increments [notifiedCount]).
472+
//
473+
// A separate unit test covers [canShareRecipientHeader] itself.
474+
// So this test just needs messages that can share, and messages that can't,
475+
// and doesn't need to exercise the different reasons that messages can't.
476+
477+
const timestamp = 1693602618;
478+
final stream = eg.stream();
479+
Message streamMessage(int id) =>
480+
eg.streamMessage(id: id, stream: stream, topic: 'foo', timestamp: timestamp);
481+
Message dmMessage(int id) =>
482+
eg.dmMessage(id: id, from: eg.selfUser, to: [], timestamp: timestamp);
483+
484+
// First, test fetchInitial, where some headers are needed and others not.
485+
prepare();
486+
connection.prepare(json: newestResult(
487+
foundOldest: false,
488+
messages: [streamMessage(10), streamMessage(11), dmMessage(12)],
489+
).toJson());
490+
await model.fetchInitial();
491+
checkNotifiedOnce();
492+
493+
// Then fetchOlder, where a header is needed in between…
494+
connection.prepare(json: olderResult(
495+
anchor: model.messages[0].id,
496+
foundOldest: false,
497+
messages: [streamMessage(7), streamMessage(8), dmMessage(9)],
498+
).toJson());
499+
await model.fetchOlder();
500+
checkNotified(count: 2);
501+
502+
// … and fetchOlder where there's no header in between.
503+
connection.prepare(json: olderResult(
504+
anchor: model.messages[0].id,
505+
foundOldest: false,
506+
messages: [streamMessage(6)],
507+
).toJson());
508+
await model.fetchOlder();
509+
checkNotified(count: 2);
510+
511+
// Then test maybeAddMessage, where a new header is needed…
512+
model.maybeAddMessage(streamMessage(13));
513+
checkNotifiedOnce();
514+
515+
// … and where it's not.
516+
model.maybeAddMessage(streamMessage(14));
517+
checkNotifiedOnce();
518+
519+
// Then test maybeUpdateMessage, where a header is and remains needed…
520+
UpdateMessageEvent updateEvent(Message message) => UpdateMessageEvent(
521+
id: 1, messageId: message.id, messageIds: [message.id],
522+
flags: message.flags,
523+
renderedContent: '${message.content}<p>edited</p>',
524+
);
525+
model.maybeUpdateMessage(updateEvent(model.messages.first));
526+
checkNotifiedOnce();
527+
model.maybeUpdateMessage(updateEvent(model.messages[model.messages.length - 2]));
528+
checkNotifiedOnce();
529+
530+
// … and where it's not.
531+
model.maybeUpdateMessage(updateEvent(model.messages.last));
532+
checkNotifiedOnce();
533+
534+
// Then test reassemble.
535+
model.reassemble();
536+
checkNotifiedOnce();
537+
538+
// Have a new fetchOlder reach the oldest, so that a history-start marker appears…
539+
connection.prepare(json: olderResult(
540+
anchor: model.messages[0].id,
541+
foundOldest: true,
542+
messages: [streamMessage(5)],
543+
).toJson());
544+
await model.fetchOlder();
545+
checkNotified(count: 2);
546+
547+
// … and then test reassemble again.
548+
model.reassemble();
549+
checkNotifiedOnce();
550+
});
551+
552+
group('canShareRecipientHeader', () {
553+
test('stream messages vs DMs, no share', () {
554+
final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
555+
final streamMessage = eg.streamMessage(timestamp: dmMessage.timestamp);
556+
check(canShareRecipientHeader(streamMessage, dmMessage)).isFalse();
557+
check(canShareRecipientHeader(dmMessage, streamMessage)).isFalse();
558+
});
559+
560+
test('stream messages of same day share just if same stream/topic', () {
561+
final stream0 = eg.stream(streamId: 123);
562+
final stream1 = eg.stream(streamId: 234);
563+
final messageAB = eg.streamMessage(stream: stream0, topic: 'foo');
564+
final messageXB = eg.streamMessage(stream: stream1, topic: 'foo', timestamp: messageAB.timestamp);
565+
final messageAX = eg.streamMessage(stream: stream0, topic: 'bar', timestamp: messageAB.timestamp);
566+
check(canShareRecipientHeader(messageAB, messageAB)).isTrue();
567+
check(canShareRecipientHeader(messageAB, messageXB)).isFalse();
568+
check(canShareRecipientHeader(messageXB, messageAB)).isFalse();
569+
check(canShareRecipientHeader(messageAB, messageAX)).isFalse();
570+
check(canShareRecipientHeader(messageAX, messageAB)).isFalse();
571+
check(canShareRecipientHeader(messageAX, messageXB)).isFalse();
572+
check(canShareRecipientHeader(messageXB, messageAX)).isFalse();
573+
});
574+
575+
test('DMs of same day share just if same recipients', () {
576+
final message0 = eg.dmMessage(from: eg.selfUser, to: []);
577+
final message01 = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser], timestamp: message0.timestamp);
578+
final message10 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], timestamp: message0.timestamp);
579+
final message02 = eg.dmMessage(from: eg.selfUser, to: [eg.thirdUser], timestamp: message0.timestamp);
580+
final message20 = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], timestamp: message0.timestamp);
581+
final message012 = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser, eg.thirdUser], timestamp: message0.timestamp);
582+
final message102 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], timestamp: message0.timestamp);
583+
final message201 = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser], timestamp: message0.timestamp);
584+
final groups = [[message0], [message01, message10],
585+
[message02, message20], [message012, message102, message201]];
586+
for (int i0 = 0; i0 < groups.length; i0++) {
587+
for (int i1 = 0; i1 < groups.length; i1++) {
588+
for (int j0 = 0; j0 < groups[i0].length; j0++) {
589+
for (int j1 = 0; j1 < groups[i1].length; j1++) {
590+
final message0 = groups[i0][j0];
591+
final message1 = groups[i1][j1];
592+
check(
593+
because: 'recipients ${message0.allRecipientIds} vs ${message1.allRecipientIds}',
594+
canShareRecipientHeader(message0, message1),
595+
).equals(i0 == i1);
596+
}
597+
}
598+
}
599+
}
600+
});
601+
602+
test('messages to same recipient share just if same day', () {
603+
// These timestamps will differ depending on the timezone of the
604+
// environment where the tests are run, in order to give the same results
605+
// in the code under test which is also based on the ambient timezone.
606+
// TODO(dart): It'd be great if tests could control the ambient timezone,
607+
// so as to exercise cases like where local time falls back across midnight.
608+
int timestampFromLocalTime(String date) => DateTime.parse(date).millisecondsSinceEpoch ~/ 1000;
609+
610+
const t111a = '2021-01-01 00:00:00';
611+
const t111b = '2021-01-01 12:00:00';
612+
const t111c = '2021-01-01 23:59:58';
613+
const t111d = '2021-01-01 23:59:59';
614+
const t112a = '2021-01-02 00:00:00';
615+
const t112b = '2021-01-02 00:00:01';
616+
const t121 = '2021-02-01 00:00:00';
617+
const t211 = '2022-01-01 00:00:00';
618+
final groups = [[t111a, t111b, t111c, t111d], [t112a, t112b], [t121], [t211]];
619+
620+
final stream = eg.stream();
621+
for (int i0 = 0; i0 < groups.length; i0++) {
622+
for (int i1 = i0; i1 < groups.length; i1++) {
623+
for (int j0 = 0; j0 < groups[i0].length; j0++) {
624+
for (int j1 = (i0 == i1) ? j0 : 0; j1 < groups[i1].length; j1++) {
625+
final time0 = groups[i0][j0];
626+
final time1 = groups[i1][j1];
627+
check(because: 'times $time0, $time1', canShareRecipientHeader(
628+
eg.streamMessage(stream: stream, topic: 'foo', timestamp: timestampFromLocalTime(time0)),
629+
eg.streamMessage(stream: stream, topic: 'foo', timestamp: timestampFromLocalTime(time1)),
630+
)).equals(i0 == i1);
631+
check(because: 'times $time0, $time1', canShareRecipientHeader(
632+
eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time0)),
633+
eg.dmMessage(from: eg.selfUser, to: [], timestamp: timestampFromLocalTime(time1)),
634+
)).equals(i0 == i1);
635+
}
636+
}
637+
}
638+
}
639+
});
640+
});
464641
}
465642

466643
void checkInvariants(MessageListView model) {
@@ -484,9 +661,6 @@ void checkInvariants(MessageListView model) {
484661
.equalsNode(parseContent(model.messages[i].content));
485662
}
486663

487-
check(model).items.length.equals(
488-
((model.haveOldest || model.fetchingOlder) ? 1 : 0)
489-
+ 2 * model.messages.length);
490664
int i = 0;
491665
if (model.haveOldest) {
492666
check(model.items[i++]).isA<MessageListHistoryStartItem>();
@@ -495,13 +669,18 @@ void checkInvariants(MessageListView model) {
495669
check(model.items[i++]).isA<MessageListLoadingItem>();
496670
}
497671
for (int j = 0; j < model.messages.length; j++) {
498-
check(model.items[i++]).isA<MessageListRecipientHeaderItem>()
499-
.message.identicalTo(model.messages[j]);
672+
if (j == 0
673+
|| !canShareRecipientHeader(model.messages[j-1], model.messages[j])) {
674+
check(model.items[i++]).isA<MessageListRecipientHeaderItem>()
675+
.message.identicalTo(model.messages[j]);
676+
}
500677
check(model.items[i++]).isA<MessageListMessageItem>()
501678
..message.identicalTo(model.messages[j])
502679
..content.identicalTo(model.contents[j])
503-
..isLastInBlock.isTrue();
680+
..isLastInBlock.equals(
681+
i == model.items.length || model.items[i] is! MessageListMessageItem);
504682
}
683+
check(model.items).length.equals(i);
505684
}
506685

507686
extension MessageListRecipientHeaderItemChecks on Subject<MessageListRecipientHeaderItem> {

test/widgets/message_list_test.dart

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ void main() {
7070
testWidgets('basic', (tester) async {
7171
await setupMessageListPage(tester, foundOldest: false,
7272
messages: List.generate(100, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser)));
73-
check(itemCount(tester)).equals(200);
73+
check(itemCount(tester)).equals(101);
7474

7575
// Fling-scroll upward...
7676
await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000);
@@ -83,13 +83,13 @@ void main() {
8383
await tester.pump(Duration.zero); // Allow a frame for the response to arrive.
8484

8585
// Now we have more messages.
86-
check(itemCount(tester)).equals(400);
86+
check(itemCount(tester)).equals(201);
8787
});
8888

8989
testWidgets('observe double-fetch glitch', (tester) async {
9090
await setupMessageListPage(tester, foundOldest: false,
9191
messages: List.generate(100, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser)));
92-
check(itemCount(tester)).equals(200);
92+
check(itemCount(tester)).equals(101);
9393

9494
// Fling-scroll upward...
9595
await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000);
@@ -101,18 +101,18 @@ void main() {
101101
for (int i = 0; i < 30; i++) {
102102
// Find the point in the fling where the fetch starts.
103103
await tester.pump(const Duration(milliseconds: 100));
104-
if (itemCount(tester)! > 200) break; // The loading indicator appeared.
104+
if (itemCount(tester)! > 101) break; // The loading indicator appeared.
105105
}
106106
await tester.pump(Duration.zero); // Allow a frame for the response to arrive.
107-
check(itemCount(tester)).equals(400);
107+
check(itemCount(tester)).equals(201);
108108

109109
// On the next frame, we promptly fetch *another* batch.
110110
// This is a glitch and it'd be nicer if we didn't.
111111
connection.prepare(json: olderResult(anchor: 850, foundOldest: false,
112112
messages: List.generate(100, (i) => eg.streamMessage(id: 750 + i, sender: eg.selfUser))).toJson());
113113
await tester.pump(const Duration(milliseconds: 1));
114114
await tester.pump(Duration.zero);
115-
check(itemCount(tester)).equals(600);
115+
check(itemCount(tester)).equals(301);
116116
}, skip: true); // TODO this still reproduces manually, still needs debugging,
117117
// but has become harder to reproduce in a test.
118118
});

0 commit comments

Comments
 (0)