Skip to content

Commit 2c29cdd

Browse files
committed
store: Add RecentDmConversationsView view-model
Toward #119, the list of recent DM conversations. Inspired by how we do this in zulip-mobile, including in the tests.
1 parent 69ba020 commit 2c29cdd

5 files changed

+260
-1
lines changed

lib/model/narrow.dart

+8
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ class DmNarrow extends Narrow implements SendableNarrow {
152152
);
153153
}
154154

155+
/// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations].
156+
factory DmNarrow.ofRecentDmConversation(RecentDmConversation c, {required int selfUserId}) {
157+
return DmNarrow(
158+
allRecipientIds: [...c.userIds, selfUserId]..sort(),
159+
selfUserId: selfUserId,
160+
);
161+
}
162+
155163
/// The user IDs of everyone in the conversation, sorted.
156164
///
157165
/// Each message in the conversation is sent by one of these users
+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/foundation.dart';
3+
4+
import '../api/model/model.dart';
5+
import '../api/model/events.dart';
6+
import 'narrow.dart';
7+
8+
/// A view-model for the recent DM conversations UI.
9+
class RecentDmConversationsView extends ChangeNotifier {
10+
factory RecentDmConversationsView({
11+
required List<RecentDmConversation> initial,
12+
required selfUserId,
13+
}) {
14+
final entries = initial.map((c) {
15+
return MapEntry(
16+
DmNarrow.ofRecentDmConversation(c, selfUserId: selfUserId),
17+
c.maxMessageId,
18+
);
19+
}).toList()..sort((a, b) => -a.value.compareTo(b.value));
20+
return RecentDmConversationsView._(
21+
map: Map.fromEntries(entries),
22+
sorted: QueueList.from(entries.map((e) => e.key)),
23+
selfUserId: selfUserId,
24+
);
25+
}
26+
27+
RecentDmConversationsView._({
28+
required this.map,
29+
required this.sorted,
30+
required this.selfUserId,
31+
});
32+
33+
/// The latest message ID in each conversation.
34+
final Map<DmNarrow, int> map;
35+
36+
/// The [DmNarrow] keys of the map, sorted by latest message descending.
37+
final QueueList<DmNarrow> sorted;
38+
39+
final int selfUserId;
40+
41+
void _insertSorted(DmNarrow key, int msgId) {
42+
final i = sorted.indexWhere((k) => map[k]! < msgId);
43+
// QueueList is a deque, with O(1) to add at start or end.
44+
switch (i) {
45+
case == 0:
46+
sorted.addFirst(key);
47+
case < 0:
48+
sorted.addLast(key);
49+
default:
50+
sorted.insert(i, key);
51+
}
52+
}
53+
54+
/// Handle [MessageEvent], updating [map] and [sorted].
55+
///
56+
/// Can take linear time in general. That sounds inefficient...
57+
/// but it's what the webapp does, so must not be catastrophic. 🤷
58+
/// (In fact the webapp calls `Array#sort`,
59+
/// which takes at *least* linear time, and may be 𝛳(N log N).)
60+
///
61+
/// The point of the event is that we're learning about the message
62+
/// in real time immediately after it was sent --
63+
/// so the overwhelmingly common case is that the message
64+
/// is newer than any existing message we know about. (*)
65+
/// That's therefore the case we optimize for,
66+
/// particularly in the helper _insertSorted.
67+
void handleMessageEvent(MessageEvent event) {
68+
final message = event.message;
69+
if (message is! DmMessage) {
70+
return;
71+
}
72+
final key = DmNarrow.ofMessage(message, selfUserId: selfUserId);
73+
final prev = map[key];
74+
if (prev == null) {
75+
// The conversation is new. Add to both `map` and `sorted`.
76+
map[key] = message.id;
77+
_insertSorted(key, message.id);
78+
} else if (prev >= message.id) {
79+
// The conversation already has a newer message.
80+
// This should be impossible as long as we only listen for messages coming
81+
// through the event system, which sends events in order.
82+
// Anyway, do nothing.
83+
} else {
84+
// The conversation needs to be (a) updated in `map`...
85+
map[key] = message.id;
86+
87+
// ... and (b) possibly moved around in `sorted` to keep the list sorted.
88+
final i = sorted.indexOf(key);
89+
assert(i >= 0, 'key in map should be in sorted');
90+
91+
if (i == 0) {
92+
// The conversation was already the latest, so no reordering needed.
93+
// (This is likely a common case in practice -- happens every time
94+
// the user gets several PMs in a row in the same thread -- so good to
95+
// optimize.)
96+
} else {
97+
// It wasn't the latest. Just handle the general case.
98+
sorted.removeAt(i); // linear time, ouch
99+
_insertSorted(key, message.id);
100+
}
101+
}
102+
notifyListeners();
103+
}
104+
105+
// TODO update from messages loaded in message lists. When doing so,
106+
// review handleMessageEvent so it acknowledges the subtle races that can
107+
// happen when taking data from outside the event system.
108+
}

lib/model/store.dart

+8-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import '../log.dart';
1616
import 'autocomplete.dart';
1717
import 'database.dart';
1818
import 'message_list.dart';
19+
import 'recent_dm_conversations.dart';
1920

2021
export 'package:drift/drift.dart' show Value;
2122
export 'database.dart' show Account, AccountsCompanion;
@@ -158,7 +159,9 @@ class PerAccountStore extends ChangeNotifier {
158159
streams = Map.fromEntries(initialSnapshot.streams.map(
159160
(stream) => MapEntry(stream.streamId, stream))),
160161
subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map(
161-
(subscription) => MapEntry(subscription.streamId, subscription)));
162+
(subscription) => MapEntry(subscription.streamId, subscription))),
163+
recentDmConversationsView = RecentDmConversationsView(
164+
initial: initialSnapshot.recentPrivateConversations, selfUserId: account.userId);
162165

163166
final Account account;
164167
final ApiConnection connection; // TODO(#135): update zulipFeatureLevel with events
@@ -178,6 +181,9 @@ class PerAccountStore extends ChangeNotifier {
178181

179182
// TODO lots more data. When adding, be sure to update handleEvent too.
180183

184+
// TODO call [RecentDmConversationsView.dispose] in [dispose]
185+
final RecentDmConversationsView recentDmConversationsView;
186+
181187
final Set<MessageListView> _messageListViews = {};
182188

183189
void registerMessageList(MessageListView view) {
@@ -260,6 +266,7 @@ class PerAccountStore extends ChangeNotifier {
260266
notifyListeners();
261267
} else if (event is MessageEvent) {
262268
assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}"));
269+
recentDmConversationsView.handleMessageEvent(event);
263270
for (final view in _messageListViews) {
264271
view.maybeAddMessage(event.message);
265272
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:collection/collection.dart';
3+
import 'package:zulip/model/narrow.dart';
4+
import 'package:zulip/model/recent_dm_conversations.dart';
5+
6+
extension RecentDmConversationsViewChecks on Subject<RecentDmConversationsView> {
7+
Subject<Map<DmNarrow, int>> get map => has((v) => v.map, 'map');
8+
Subject<QueueList<DmNarrow>> get sorted => has((v) => v.sorted, 'sorted');
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/api/model/events.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/model/narrow.dart';
6+
import 'package:zulip/model/recent_dm_conversations.dart';
7+
8+
import '../example_data.dart' as eg;
9+
import 'recent_dm_conversations_checks.dart';
10+
11+
void main() {
12+
group('RecentDmConversationsView', () {
13+
/// Get a [DmNarrow] from a list of recipient IDs excluding self.
14+
DmNarrow key(userIds) {
15+
return DmNarrow(
16+
allRecipientIds: [eg.selfUser.userId, ...userIds]..sort(),
17+
selfUserId: eg.selfUser.userId,
18+
);
19+
}
20+
21+
test('construct from initial data', () {
22+
check(RecentDmConversationsView(selfUserId: eg.selfUser.userId,
23+
initial: []))
24+
..map.isEmpty()
25+
..sorted.isEmpty();
26+
27+
check(RecentDmConversationsView(selfUserId: eg.selfUser.userId,
28+
initial: [
29+
RecentDmConversation(userIds: [], maxMessageId: 200),
30+
RecentDmConversation(userIds: [1], maxMessageId: 100),
31+
RecentDmConversation(userIds: [2, 1], maxMessageId: 300), // userIds out of order
32+
]))
33+
..map.deepEquals({
34+
key([1, 2]): 300,
35+
key([]): 200,
36+
key([1]): 100,
37+
})
38+
..sorted.deepEquals([key([1, 2]), key([]), key([1])]);
39+
});
40+
41+
group('message event (new message)', () {
42+
setupView() {
43+
return RecentDmConversationsView(selfUserId: eg.selfUser.userId,
44+
initial: [
45+
RecentDmConversation(userIds: [1], maxMessageId: 200),
46+
RecentDmConversation(userIds: [1, 2], maxMessageId: 100),
47+
]);
48+
}
49+
50+
test('(check base state)', () {
51+
// This is here mostly for checked documentation of what's in
52+
// baseState, to help in reading the other test cases.
53+
check(setupView())
54+
..map.deepEquals({
55+
key([1]): 200,
56+
key([1, 2]): 100,
57+
})
58+
..sorted.deepEquals([key([1]), key([1, 2])]);
59+
});
60+
61+
test('stream message -> do nothing', () {
62+
final expected = setupView();
63+
check(setupView()
64+
..handleMessageEvent(MessageEvent(id: 1, message: eg.streamMessage())))
65+
..map.deepEquals(expected.map)
66+
..sorted.deepEquals(expected.sorted);
67+
});
68+
69+
test('new conversation, newest message', () {
70+
final message = eg.dmMessage(id: 300, from: eg.selfUser, to: [eg.user(userId: 2)]);
71+
check(setupView()
72+
..handleMessageEvent(MessageEvent(id: 1, message: message)))
73+
..map.deepEquals({
74+
key([2]): 300,
75+
key([1]): 200,
76+
key([1, 2]): 100,
77+
})
78+
..sorted.deepEquals([key([2]), key([1]), key([1, 2])]);
79+
});
80+
81+
test('new conversation, not newest message', () {
82+
final message = eg.dmMessage(id: 150, from: eg.selfUser, to: [eg.user(userId: 2)]);
83+
check(setupView()
84+
..handleMessageEvent(MessageEvent(id: 1, message: message)))
85+
..map.deepEquals({
86+
key([1]): 200,
87+
key([2]): 150,
88+
key([1, 2]): 100,
89+
})
90+
..sorted.deepEquals([key([1]), key([2]), key([1, 2])]);
91+
});
92+
93+
test('existing conversation, newest message', () {
94+
final message = eg.dmMessage(id: 300, from: eg.selfUser,
95+
to: [eg.user(userId: 1), eg.user(userId: 2)]);
96+
check(setupView()
97+
..handleMessageEvent(MessageEvent(id: 1, message: message)))
98+
..map.deepEquals({
99+
key([1, 2]): 300,
100+
key([1]): 200,
101+
})
102+
..sorted.deepEquals([key([1, 2]), key([1])]);
103+
});
104+
105+
test('existing newest conversation, newest message', () {
106+
final message = eg.dmMessage(id: 300, from: eg.selfUser, to: [eg.user(userId: 1)]);
107+
check(setupView()
108+
..handleMessageEvent(MessageEvent(id: 1, message: message)))
109+
..map.deepEquals({
110+
key([1]): 300,
111+
key([1, 2]): 100,
112+
})
113+
..sorted.deepEquals([key([1]), key([1, 2])]);
114+
});
115+
116+
test('existing conversation, not newest in conversation', () {
117+
final message = eg.dmMessage(id: 99, from: eg.selfUser,
118+
to: [eg.user(userId: 1), eg.user(userId: 2)]);
119+
final expected = setupView();
120+
check(setupView()
121+
..handleMessageEvent(MessageEvent(id: 1, message: message)))
122+
..map.deepEquals(expected.map)
123+
..sorted.deepEquals(expected.sorted);
124+
});
125+
});
126+
});
127+
}

0 commit comments

Comments
 (0)