Skip to content

Commit c463d5d

Browse files
committed
msglist: Handle updated events in MessageListView (zulip#118).
Processes an UpdateMessageEvent and hands it off to the MessageListView to update, if the message is visible in the MessageListView. This completes the changes required for issue zulip#118.
1 parent 2465701 commit c463d5d

File tree

6 files changed

+309
-7
lines changed

6 files changed

+309
-7
lines changed

lib/api/model/model.dart

+4-4
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,13 @@ class Subscription {
250250
sealed class Message {
251251
final String? avatarUrl;
252252
final String client;
253-
final String content;
253+
String content;
254254
final String contentType;
255255

256256
// final List<MessageEditHistory> editHistory; // TODO handle
257257
final int id;
258-
final bool isMeMessage;
259-
final int? lastEditTimestamp;
258+
bool isMeMessage;
259+
int? lastEditTimestamp;
260260

261261
// final List<Reaction> reactions; // TODO handle
262262
final int recipientId;
@@ -271,7 +271,7 @@ sealed class Message {
271271

272272
// final List<TopicLink> topicLinks; // TODO handle
273273
// final string type; // handled by runtime type of object
274-
final List<String> flags; // TODO enum
274+
List<String> flags; // TODO enum
275275
final String? matchContent;
276276
final String? matchSubject;
277277

lib/model/message_list.dart

+71
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import 'package:collection/collection.dart';
12
import 'package:flutter/foundation.dart';
23

4+
import '../api/model/events.dart';
35
import '../api/model/model.dart';
46
import '../api/route/messages.dart';
57
import 'content.dart';
@@ -86,6 +88,75 @@ class MessageListView extends ChangeNotifier {
8688
notifyListeners();
8789
}
8890

91+
_applyChangesToMessage(UpdateMessageEvent event, Message message) {
92+
// In earlier server versions, omitting the userId indicates that this is a
93+
// rendering-only update. That means this change was initiated by the server,
94+
// not the user.
95+
96+
// TODO(server-5): Cut this fallback; rely on renderingOnly from FL 114
97+
final isRenderingOnly = event.renderingOnly ?? (event.userId == null);
98+
99+
if (event.editTimestamp != null && !isRenderingOnly) {
100+
// Only update the timestamp if this was a user-led update,
101+
// not a server-only update
102+
message.lastEditTimestamp = event.editTimestamp;
103+
}
104+
105+
message.flags = event.flags;
106+
107+
if (event.renderedContent != null) {
108+
assert(message.contentType == 'text/html', "Expected message to have contentType 'text/html'. Instead, got ${message.contentType}");
109+
message.content = event.renderedContent!;
110+
}
111+
112+
if (event.isMeMessage != null) {
113+
message.isMeMessage = event.isMeMessage!;
114+
}
115+
116+
}
117+
118+
// This is almost directly copied from package:collection/src/algorithms.dart
119+
// The way that package was set up doesn't allow us to search
120+
// for a message ID among a bunch of message objects - this is a quick
121+
// modification of that method to work here for us.
122+
@visibleForTesting
123+
int findMessageWithId(int messageId) {
124+
var min = 0;
125+
var max = messages.length;
126+
while (min < max) {
127+
var mid = min + ((max - min) >> 1);
128+
final message = messages[mid];
129+
var comp = message.id.compareTo(messageId);
130+
if (comp == 0) return mid;
131+
if (comp < 0) {
132+
min = mid + 1;
133+
} else {
134+
max = mid;
135+
}
136+
}
137+
return -1;
138+
}
139+
140+
/// Update the message the given event applies to, if present in this view.
141+
///
142+
/// This method only handles the case where the message's contents
143+
/// were changed, and ignores any changes to its stream or topic.
144+
///
145+
/// TODO(#150): Handle message moves.
146+
void maybeUpdateMessage(UpdateMessageEvent event) {
147+
final idx = findMessageWithId(event.messageId);
148+
149+
if (idx == -1) {
150+
return;
151+
}
152+
153+
final message = messages[idx];
154+
_applyChangesToMessage(event, message);
155+
156+
contents[idx] = parseContent(message.content);
157+
notifyListeners();
158+
}
159+
89160
/// Called when the app is reassembled during debugging, e.g. for hot reload.
90161
///
91162
/// This will redo from scratch any computations we can, such as parsing

lib/model/store.dart

+3-1
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ class PerAccountStore extends ChangeNotifier {
272272
}
273273
} else if (event is UpdateMessageEvent) {
274274
assert(debugLog("server event: update_message ${event.messageId}"));
275-
// TODO handle
275+
for (final view in _messageListViews) {
276+
view.maybeUpdateMessage(event);
277+
}
276278
} else if (event is DeleteMessageEvent) {
277279
assert(debugLog("server event: delete_message ${event.messageIds}"));
278280
// TODO handle

test/api/model/model_checks.dart

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ extension MessageChecks on Subject<Message> {
88
toJson.deepEquals(expected.toJson());
99
}
1010

11+
Subject<String> get content => has((e) => e.content, 'content');
12+
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
13+
Subject<int?> get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp');
1114
Subject<List<String>> get flags => has((e) => e.flags, 'flags');
1215

1316
// TODO accessors for other fields

test/example_data.dart

+4-2
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ StreamMessage streamMessage({
127127
String? topic,
128128
String? content,
129129
String? contentMarkdown,
130+
List<String>? flags,
130131
}) {
131132
final effectiveStream = stream ?? _stream();
132133
// The use of JSON here is convenient in order to delegate parts of the data
@@ -140,7 +141,7 @@ StreamMessage streamMessage({
140141
..._messagePropertiesFromContent(content, contentMarkdown),
141142
'display_recipient': effectiveStream.name,
142143
'stream_id': effectiveStream.streamId,
143-
'flags': [],
144+
'flags': flags ?? [],
144145
'id': id ?? 1234567, // TODO generate example IDs
145146
'subject': topic ?? 'example topic',
146147
'timestamp': 1678139636,
@@ -158,6 +159,7 @@ DmMessage dmMessage({
158159
required List<User> to,
159160
String? content,
160161
String? contentMarkdown,
162+
List<String>? flags,
161163
}) {
162164
assert(!to.any((user) => user.userId == from.userId));
163165
return DmMessage.fromJson({
@@ -168,7 +170,7 @@ DmMessage dmMessage({
168170
.map((u) => {'id': u.userId, 'email': u.email, 'full_name': u.fullName})
169171
.toList(growable: false),
170172

171-
'flags': [],
173+
'flags': flags ?? [],
172174
'id': id ?? 1234567, // TODO generate example IDs
173175
'subject': '',
174176
'timestamp': 1678139636,

test/model/message_list_test.dart

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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/api/route/messages.dart';
6+
import 'package:zulip/model/message_list.dart';
7+
import 'package:zulip/model/narrow.dart';
8+
import 'package:zulip/model/store.dart';
9+
10+
import '../api/fake_api.dart';
11+
import '../api/model/model_checks.dart';
12+
import '../model/binding.dart';
13+
import '../model/test_store.dart';
14+
import '../example_data.dart' as eg;
15+
16+
const int userId = 1;
17+
18+
Future<PerAccountStore> setupStore(ZulipStream stream) async {
19+
addTearDown(TestZulipBinding.instance.reset);
20+
21+
await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot());
22+
23+
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id);
24+
store.addUser(eg.user(userId: userId));
25+
store.addStream(stream);
26+
27+
return store;
28+
}
29+
30+
Future<MessageListView> messageListViewWithMessages(List<Message> messages, PerAccountStore store, Narrow narrow) async {
31+
final messageList = MessageListView.init(store: store, narrow: narrow);
32+
33+
final connection = store.connection as FakeApiConnection;
34+
35+
connection.prepare(json: GetMessagesResult(
36+
anchor: messages.first.id,
37+
foundNewest: true,
38+
foundOldest: true,
39+
foundAnchor: true,
40+
historyLimited: false,
41+
messages: messages,
42+
).toJson());
43+
44+
await messageList.fetch();
45+
46+
check(messageList.messages.length).equals(messages.length);
47+
48+
return messageList;
49+
}
50+
51+
void main() async {
52+
TestZulipBinding.ensureInitialized();
53+
54+
final stream = eg.stream();
55+
final narrow = StreamNarrow(stream.streamId);
56+
57+
group('update message tests', () {
58+
59+
test('find message in message list returns index of message', () async {
60+
final store = await setupStore(stream);
61+
62+
final m1 = eg.streamMessage(id: 2, stream: stream);
63+
final m2 = eg.streamMessage(id: 4, stream: stream);
64+
final m3 = eg.streamMessage(id: 6, stream: stream);
65+
66+
final messageList = await messageListViewWithMessages([m1, m2, m3], store, narrow);
67+
// The implementation of this uses a binary search, so let's test it
68+
// a bit more exhaustively.
69+
70+
check(messageList.findMessageWithId(1)).equals(-1);
71+
check(messageList.findMessageWithId(2)).equals(0);
72+
check(messageList.findMessageWithId(3)).equals(-1);
73+
check(messageList.findMessageWithId(4)).equals(1);
74+
check(messageList.findMessageWithId(5)).equals(-1);
75+
check(messageList.findMessageWithId(6)).equals(2);
76+
check(messageList.findMessageWithId(7)).equals(-1);
77+
78+
// Invalid IDs
79+
check(messageList.findMessageWithId(-8409)).equals(-1);
80+
check(messageList.findMessageWithId(0)).equals(-1);
81+
});
82+
83+
test('update events are correctly applied to message when it is in the stream', () async {
84+
final store = await setupStore(stream);
85+
86+
const oldContent = "<p>Hello, world</p>";
87+
const newContent = "<p>Hello, edited</p>";
88+
const newTimestamp = 99999;
89+
90+
List<String> oldFlags = [];
91+
List<String> newFlags = ["starred"];
92+
93+
final originalMessage = eg.streamMessage(id: 243, stream: stream, content: oldContent, flags: oldFlags);
94+
final messageList = await messageListViewWithMessages([originalMessage], store, narrow);
95+
96+
final updateEvent = UpdateMessageEvent(
97+
id: 1,
98+
messageId: originalMessage.id,
99+
messageIds: [originalMessage.id],
100+
flags: newFlags,
101+
renderedContent: newContent,
102+
editTimestamp: newTimestamp,
103+
isMeMessage: true,
104+
userId: userId,
105+
renderingOnly: false,
106+
);
107+
108+
final message = messageList.messages.single;
109+
check(message)
110+
..content.equals(oldContent)
111+
..flags.deepEquals(oldFlags)
112+
..isMeMessage.isFalse();
113+
114+
var listenersNotified = false;
115+
116+
messageList.addListener(() { listenersNotified = true; });
117+
messageList.maybeUpdateMessage(updateEvent);
118+
119+
check(listenersNotified).isTrue();
120+
121+
check(message)
122+
..identicalTo(messageList.messages.single)
123+
..content.equals(newContent)
124+
..lastEditTimestamp.equals(newTimestamp)
125+
..flags.equals(newFlags)
126+
..isMeMessage.isTrue();
127+
});
128+
129+
test('update event is ignored when message is not in the message list', () async {
130+
final store = await setupStore(stream);
131+
132+
const oldContent = "<p>Hello, world</p>";
133+
const newContent = "<p>Hello, edited</p>";
134+
const newTimestamp = 99999;
135+
136+
final originalMessage = eg.streamMessage(id: 243, stream: stream, content: oldContent);
137+
final messageList = await messageListViewWithMessages([originalMessage], store, narrow);
138+
139+
final updateEvent = UpdateMessageEvent(
140+
id: 1,
141+
messageId: originalMessage.id + 1,
142+
messageIds: [originalMessage.id + 1],
143+
flags: originalMessage.flags,
144+
renderedContent: newContent,
145+
editTimestamp: newTimestamp,
146+
userId: userId,
147+
renderingOnly: false,
148+
);
149+
150+
final message = messageList.messages.single;
151+
check(message).content.equals(oldContent);
152+
153+
var listenersNotified = false;
154+
155+
messageList.addListener(() { listenersNotified = true; });
156+
messageList.maybeUpdateMessage(updateEvent);
157+
158+
check(listenersNotified).isFalse();
159+
check(message).content.equals(oldContent);
160+
});
161+
162+
test('rendering-only update does not change timestamp', () async {
163+
final store = await setupStore(stream);
164+
165+
const oldContent = "<p>Hello, world</p>";
166+
const oldTimestamp = 78492;
167+
const newContent = "<p>Hello, world</p> <div>Some link preview</div>";
168+
const newTimestamp = 99999;
169+
170+
final originalMessage = eg.streamMessage(id: 972, stream: stream, content: oldContent);
171+
originalMessage.lastEditTimestamp = oldTimestamp;
172+
173+
final messageList = await messageListViewWithMessages([originalMessage], store, narrow);
174+
175+
final updateEvent = UpdateMessageEvent(
176+
id: 1,
177+
messageId: originalMessage.id,
178+
messageIds: [originalMessage.id],
179+
flags: originalMessage.flags,
180+
renderedContent: newContent,
181+
editTimestamp: newTimestamp,
182+
renderingOnly: true,
183+
userId: null,
184+
);
185+
186+
final message = messageList.messages[0];
187+
messageList.maybeUpdateMessage(updateEvent);
188+
check(message)
189+
..content.equals(newContent)
190+
..lastEditTimestamp.equals(oldTimestamp);
191+
});
192+
193+
// TODO(server-5): Cut this test; rely on renderingOnly from FL 114
194+
test('rendering-only update does not change timestamp (for old server versions)', () async {
195+
final store = await setupStore(stream);
196+
197+
const oldContent = "<p>Hello, world</p>";
198+
const oldTimestamp = 78492;
199+
const newContent = "<p>Hello, world</p> <div>Some link preview</div>";
200+
const newTimestamp = 99999;
201+
202+
final originalMessage = eg.streamMessage(id: 972, stream: stream, content: oldContent);
203+
originalMessage.lastEditTimestamp = oldTimestamp;
204+
205+
final messageList = await messageListViewWithMessages([originalMessage], store, narrow);
206+
207+
final updateEvent = UpdateMessageEvent(
208+
id: 1,
209+
messageId: originalMessage.id,
210+
messageIds: [originalMessage.id],
211+
flags: originalMessage.flags,
212+
renderedContent: newContent,
213+
editTimestamp: newTimestamp,
214+
userId: null,
215+
);
216+
217+
final message = messageList.messages.single;
218+
messageList.maybeUpdateMessage(updateEvent);
219+
check(message)
220+
..content.equals(newContent)
221+
..lastEditTimestamp.equals(oldTimestamp);
222+
});
223+
});
224+
}

0 commit comments

Comments
 (0)