Skip to content

Commit c1ef8b2

Browse files
committed
api [nfc]: Extract and parse UpdateMessageMoveData from UpdateMessageEvent
This data structure encapsulates some checks so that we can make all fields non-nullable, with reasonable fallback values. As of writing, we do not use origStreamId (a.k.a.: 'stream_id') when there was no message move, even though it is present if there were content edits This makes dropping 'stream_id' when parsing `moveData` into `null` acceptable for now. Despite the NFC, a subtlety of this change is that `FormatException`'s or null-check errors will be thrown earlier in the polling loop. Because we now parse the move data upfront, instead of dealing with it when handling the message event. Both will end up resulting in the event queue being replaced if such an error occurs. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 925e0c3 commit c1ef8b2

File tree

7 files changed

+141
-83
lines changed

7 files changed

+141
-83
lines changed

lib/api/model/events.dart

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -718,16 +718,8 @@ class UpdateMessageEvent extends Event {
718718

719719
// final String? streamName; // ignore
720720

721-
@JsonKey(name: 'stream_id')
722-
final int? origStreamId;
723-
final int? newStreamId;
724-
725-
final PropagateMode? propagateMode;
726-
727-
@JsonKey(name: 'orig_subject')
728-
final TopicName? origTopic;
729-
@JsonKey(name: 'subject')
730-
final TopicName? newTopic;
721+
@JsonKey(readValue: _readMoveData, fromJson: UpdateMessageMoveData.tryParseFromJson, includeToJson: false)
722+
final UpdateMessageMoveData? moveData;
731723

732724
// final List<TopicLink> topicLinks; // TODO handle
733725

@@ -747,25 +739,88 @@ class UpdateMessageEvent extends Event {
747739
required this.messageIds,
748740
required this.flags,
749741
required this.editTimestamp,
750-
required this.origStreamId,
751-
required this.newStreamId,
752-
required this.propagateMode,
753-
required this.origTopic,
754-
required this.newTopic,
742+
required this.moveData,
755743
required this.origContent,
756744
required this.origRenderedContent,
757745
required this.content,
758746
required this.renderedContent,
759747
required this.isMeMessage,
760748
});
761749

750+
static Map<String, dynamic> _readMoveData(Map<dynamic, dynamic> json, String key) {
751+
// Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
752+
assert(json is Map<String, dynamic>); // value came through `fromJson` with this type
753+
return json as Map<String, dynamic>;
754+
}
755+
762756
factory UpdateMessageEvent.fromJson(Map<String, dynamic> json) =>
763757
_$UpdateMessageEventFromJson(json);
764758

765759
@override
766760
Map<String, dynamic> toJson() => _$UpdateMessageEventToJson(this);
767761
}
768762

763+
/// Data structure representing a message move.
764+
class UpdateMessageMoveData {
765+
final int origStreamId;
766+
final int newStreamId;
767+
768+
final PropagateMode propagateMode;
769+
770+
final TopicName origTopic;
771+
final TopicName newTopic;
772+
773+
UpdateMessageMoveData({
774+
required this.origStreamId,
775+
required this.newStreamId,
776+
required this.propagateMode,
777+
required this.origTopic,
778+
required this.newTopic,
779+
}) : assert(origStreamId != newStreamId || origTopic != newTopic);
780+
781+
/// Try to extract [UpdateMessageMoveData] from the JSON object for an
782+
/// [UpdateMessageEvent].
783+
///
784+
/// Returns `null` if there was no message move.
785+
///
786+
/// Throws an error if the data is malformed.
787+
// When parsing this, 'stream_id', which is also present when there was only
788+
// a content edit, cannot be recovered if this ends up returning `null`.
789+
// This may matter if we ever need 'stream_id' when no message move occurred.
790+
static UpdateMessageMoveData? tryParseFromJson(Map<String, Object?> json) {
791+
final origStreamId = (json['stream_id'] as num?)?.toInt();
792+
final newStreamIdRaw = (json['new_stream_id'] as num?)?.toInt();
793+
final newStreamId = newStreamIdRaw ?? origStreamId;
794+
795+
final propagateModeString = json['propagate_mode'] as String?;
796+
final propagateMode = propagateModeString == null ? null
797+
: PropagateMode.fromRawString(propagateModeString);
798+
799+
final origTopic = json['orig_subject'] == null ? null
800+
: TopicName.fromJson(json['orig_subject'] as String);
801+
final newTopicRaw = json['subject'] == null ? null
802+
: TopicName.fromJson(json['subject'] as String);
803+
final newTopic = newTopicRaw ?? origTopic;
804+
805+
if (origTopic == newTopic && origStreamId == newStreamId) {
806+
if (propagateMode != null) {
807+
throw FormatException(
808+
'UpdateMessageEvent: incoherent message-move fields; '
809+
'propagate_mode present but no new channel or topic');
810+
}
811+
return null;
812+
}
813+
814+
return UpdateMessageMoveData(
815+
origStreamId: origStreamId!,
816+
newStreamId: newStreamId!,
817+
propagateMode: propagateMode!,
818+
origTopic: origTopic!,
819+
newTopic: newTopic!,
820+
);
821+
}
822+
}
823+
769824
/// A Zulip event of type `delete_message`: https://zulip.com/api/get-events#delete_message
770825
@JsonSerializable(fieldRename: FieldRename.snake)
771826
class DeleteMessageEvent extends Event {

lib/api/model/events.g.dart

Lines changed: 3 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/model/message.dart

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -169,27 +169,17 @@ class MessageStoreImpl with MessageStore {
169169
}
170170

171171
void _handleUpdateMessageEventMove(UpdateMessageEvent event) {
172-
// The interaction between the fields of these events are a bit tricky.
173-
// For reference, see: https://zulip.com/api/get-events#update_message
174-
175-
final origStreamId = event.origStreamId;
176-
final newStreamId = event.newStreamId ?? origStreamId;
177-
final origTopic = event.origTopic;
178-
final newTopic = event.newTopic ?? origTopic;
179-
final propagateMode = event.propagateMode;
180-
181-
if (origTopic == newTopic && origStreamId == newStreamId) {
182-
if (propagateMode != null) {
183-
throw FormatException(
184-
'UpdateMessageEvent: incoherent message-move fields; '
185-
'propagate_mode present but no new channel or topic');
186-
}
172+
final messageMove = event.moveData;
173+
if (messageMove == null) {
187174
// There was no move.
188175
return;
189176
}
190177

178+
final UpdateMessageMoveData(
179+
:origStreamId, :newStreamId, :origTopic, :newTopic) = messageMove;
180+
191181
final wasResolveOrUnresolve = origStreamId == newStreamId
192-
&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic!, newTopic!);
182+
&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic);
193183

194184
for (final messageId in event.messageIds) {
195185
final message = messages[messageId];
@@ -201,7 +191,7 @@ class MessageStoreImpl with MessageStore {
201191
}
202192

203193
if (origStreamId != newStreamId) {
204-
message.streamId = newStreamId!;
194+
message.streamId = newStreamId;
205195
// See [StreamMessage.displayRecipient] on why the invalidation is
206196
// needed.
207197
message.displayRecipient = null;
@@ -218,14 +208,7 @@ class MessageStoreImpl with MessageStore {
218208
}
219209

220210
for (final view in _messageListViews) {
221-
view.messagesMoved(
222-
origStreamId: origStreamId!,
223-
newStreamId: newStreamId!,
224-
origTopic: origTopic!,
225-
newTopic: newTopic!,
226-
messageIds: event.messageIds,
227-
propagateMode: propagateMode!,
228-
);
211+
view.messagesMoved(messageMove: messageMove, messageIds: event.messageIds);
229212
}
230213
}
231214

lib/model/message_list.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -720,13 +720,12 @@ class MessageListView with ChangeNotifier, _MessageSequence {
720720
}
721721

722722
void messagesMoved({
723-
required int origStreamId,
724-
required int newStreamId,
725-
required TopicName origTopic,
726-
required TopicName newTopic,
723+
required UpdateMessageMoveData messageMove,
727724
required List<int> messageIds,
728-
required PropagateMode propagateMode,
729725
}) {
726+
final UpdateMessageMoveData(
727+
:origStreamId, :newStreamId, :origTopic, :newTopic, :propagateMode,
728+
) = messageMove;
730729
switch (narrow) {
731730
case DmNarrow():
732731
// DMs can't be moved (nor created by moves),

test/api/model/events_checks.dart

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,22 @@ extension UpdateMessageEventChecks on Subject<UpdateMessageEvent> {
4848
Subject<List<int>> get messageIds => has((e) => e.messageIds, 'messageIds');
4949
Subject<List<MessageFlag>> get flags => has((e) => e.flags, 'flags');
5050
Subject<int?> get editTimestamp => has((e) => e.editTimestamp, 'editTimestamp');
51-
Subject<int?> get origStreamId => has((e) => e.origStreamId, 'origStreamId');
52-
Subject<int?> get newStreamId => has((e) => e.newStreamId, 'newStreamId');
53-
Subject<PropagateMode?> get propagateMode => has((e) => e.propagateMode, 'propagateMode');
54-
Subject<TopicName?> get origTopic => has((e) => e.origTopic, 'origTopic');
55-
Subject<TopicName?> get newTopic => has((e) => e.newTopic, 'newTopic');
51+
Subject<UpdateMessageMoveData?> get moveData => has((e) => e.moveData, 'moveData');
5652
Subject<String?> get origContent => has((e) => e.origContent, 'origContent');
5753
Subject<String?> get origRenderedContent => has((e) => e.origRenderedContent, 'origRenderedContent');
5854
Subject<String?> get content => has((e) => e.content, 'content');
5955
Subject<String?> get renderedContent => has((e) => e.renderedContent, 'renderedContent');
6056
Subject<bool?> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
6157
}
6258

59+
extension UpdateMessageMoveDataChecks on Subject<UpdateMessageMoveData> {
60+
Subject<int> get origStreamId => has((e) => e.origStreamId, 'origStreamId');
61+
Subject<int> get newStreamId => has((e) => e.newStreamId, 'newStreamId');
62+
Subject<PropagateMode> get propagateMode => has((e) => e.propagateMode, 'propagateMode');
63+
Subject<TopicName> get origTopic => has((e) => e.origTopic, 'origTopic');
64+
Subject<TopicName> get newTopic => has((e) => e.newTopic, 'newTopic');
65+
}
66+
6367
extension DeleteMessageEventChecks on Subject<DeleteMessageEvent> {
6468
Subject<MessageType?> get messageType => has((e) => e.messageType, 'messageType');
6569
}

test/api/model/events_test.dart

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,26 @@ void main() {
106106
'propagate_mode': 'change_all',
107107
};
108108

109+
test('smoke moveData', () {
110+
check(Event.fromJson({ ...baseMoveJson,
111+
'stream_id': 1,
112+
'new_stream_id': 2,
113+
'orig_subject': 'foo',
114+
'subject': 'bar',
115+
'propagate_mode': 'change_all',
116+
})).isA<UpdateMessageEvent>().moveData.isNotNull()
117+
..origStreamId.equals(1)
118+
..newStreamId.equals(2)
119+
..origTopic.equals(const TopicName('foo'))
120+
..newTopic.equals(const TopicName('bar'))
121+
..propagateMode.equals(PropagateMode.changeAll);
122+
});
123+
109124
test('stream_id -> origStreamId', () {
110125
check(Event.fromJson({ ...baseMoveJson,
111126
'stream_id': 1,
112127
'new_stream_id': 2,
113-
})).isA<UpdateMessageEvent>()
128+
})).isA<UpdateMessageEvent>().moveData.isNotNull()
114129
..origStreamId.equals(1)
115130
..newStreamId.equals(2);
116131
});
@@ -119,10 +134,32 @@ void main() {
119134
check(Event.fromJson({ ...baseMoveJson,
120135
'orig_subject': 'foo',
121136
'subject': 'bar',
122-
})).isA<UpdateMessageEvent>()
137+
})).isA<UpdateMessageEvent>().moveData.isNotNull()
123138
..origTopic.equals(const TopicName('foo'))
124139
..newTopic.equals(const TopicName('bar'));
125140
});
141+
142+
test('new channel, same topic: fill in newTopic', () {
143+
check(Event.fromJson({ ...baseMoveJson,
144+
'orig_subject': 'foo',
145+
'subject': null,
146+
'stream_id': 1,
147+
'new_stream_id': 2,
148+
})).isA<UpdateMessageEvent>().moveData.isNotNull()
149+
..origTopic.equals(const TopicName('foo'))
150+
..newTopic.equals(const TopicName('foo'));
151+
});
152+
153+
test('same channel, new topic; fill in newStreamId', () {
154+
check(Event.fromJson({ ...baseMoveJson,
155+
'orig_subject': 'foo',
156+
'subject': 'bar',
157+
'stream_id': 1,
158+
'new_stream_id': null,
159+
})).isA<UpdateMessageEvent>().moveData.isNotNull()
160+
..origStreamId.equals(1)
161+
..newStreamId.equals(1);
162+
});
126163
});
127164

128165
test('delete_message: require streamId and topic for stream messages', () {

test/example_data.dart

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -653,11 +653,7 @@ UpdateMessageEvent updateMessageEditEvent(
653653
messageIds: [messageId],
654654
flags: flags ?? origMessage.flags,
655655
editTimestamp: editTimestamp ?? 1234567890, // TODO generate timestamp
656-
origStreamId: origMessage is StreamMessage ? origMessage.streamId : null,
657-
newStreamId: null,
658-
propagateMode: null,
659-
origTopic: null,
660-
newTopic: null,
656+
moveData: null,
661657
origContent: 'some probably-mismatched old Markdown',
662658
origRenderedContent: origMessage.content,
663659
content: 'some probably-mismatched new Markdown',
@@ -690,11 +686,13 @@ UpdateMessageEvent _updateMessageMoveEvent(
690686
messageIds: messageIds,
691687
flags: flags,
692688
editTimestamp: 1234567890, // TODO generate timestamp
693-
origStreamId: origStreamId,
694-
newStreamId: newStreamId,
695-
propagateMode: propagateMode,
696-
origTopic: origTopic,
697-
newTopic: newTopic,
689+
moveData: UpdateMessageMoveData(
690+
origStreamId: origStreamId,
691+
newStreamId: newStreamId ?? origStreamId,
692+
propagateMode: propagateMode,
693+
origTopic: origTopic,
694+
newTopic: newTopic ?? origTopic,
695+
),
698696
origContent: origContent,
699697
origRenderedContent: origContent,
700698
content: newContent,

0 commit comments

Comments
 (0)