Skip to content

Handle moved messages for unreads #1311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 15, 2025
90 changes: 75 additions & 15 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -718,16 +718,8 @@ class UpdateMessageEvent extends Event {

// final String? streamName; // ignore

@JsonKey(name: 'stream_id')
final int? origStreamId;
final int? newStreamId;

final PropagateMode? propagateMode;

@JsonKey(name: 'orig_subject')
final TopicName? origTopic;
@JsonKey(name: 'subject')
final TopicName? newTopic;
@JsonKey(readValue: _readMoveData, fromJson: UpdateMessageMoveData.tryParseFromJson, includeToJson: false)
final UpdateMessageMoveData? moveData;

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

Expand All @@ -747,25 +739,93 @@ class UpdateMessageEvent extends Event {
required this.messageIds,
required this.flags,
required this.editTimestamp,
required this.origStreamId,
required this.newStreamId,
required this.propagateMode,
required this.origTopic,
required this.newTopic,
required this.moveData,
required this.origContent,
required this.origRenderedContent,
required this.content,
required this.renderedContent,
required this.isMeMessage,
});

static Map<String, dynamic> _readMoveData(Map<dynamic, dynamic> json, String key) {
// Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
assert(json is Map<String, dynamic>); // value came through `fromJson` with this type
return json as Map<String, dynamic>;
}

factory UpdateMessageEvent.fromJson(Map<String, dynamic> json) =>
_$UpdateMessageEventFromJson(json);

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

/// Data structure representing a message move.
class UpdateMessageMoveData {
final int origStreamId;
final int newStreamId;
final TopicName origTopic;
final TopicName newTopic;
final PropagateMode propagateMode;

UpdateMessageMoveData({
required this.origStreamId,
required this.newStreamId,
required this.origTopic,
required this.newTopic,
required this.propagateMode,
}) : assert(newStreamId != origStreamId || newTopic != origTopic);

/// Try to extract [UpdateMessageMoveData] from the JSON object for an
/// [UpdateMessageEvent].
///
/// Returns `null` if there was no message move.
///
/// Throws an error if the data is malformed.
// When parsing this, 'stream_id', which is also present when there was only
// a content edit, cannot be recovered if this ends up returning `null`.
// This may matter if we ever need 'stream_id' when no message move occurred.
static UpdateMessageMoveData? tryParseFromJson(Map<String, Object?> json) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

This is mostly NFC except that we were logging and ignoring malformed
event data before, and now start to throw errors with the parsing code.

s/with/within/ — i.e., we're now throwing errors from inside the parsing code

final origStreamId = (json['stream_id'] as num?)?.toInt();
final newStreamIdRaw = (json['new_stream_id'] as num?)?.toInt();
final newStreamId = newStreamIdRaw ?? origStreamId;

final origTopic = json['orig_subject'] == null ? null
: TopicName.fromJson(json['orig_subject'] as String);
final newTopicRaw = json['subject'] == null ? null
: TopicName.fromJson(json['subject'] as String);
final newTopic = newTopicRaw ?? origTopic;

final propagateModeString = json['propagate_mode'] as String?;
final propagateMode = propagateModeString == null ? null
: PropagateMode.fromRawString(propagateModeString);

if (newStreamId == origStreamId && newTopic == origTopic) {
if (propagateMode != null) {
throw FormatException(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit in commit message:

(It might also change what particular message gets printed to the
debug log; but that doesn't count as a behavior change. As a part of
that, we add an additional check for propagate mode, to succeed the
block of assertion.)

I see this second sentence as not so closely related to the first one; the first sentence is already true without any caveats related to the propagateMode check, since the only effect of that check is a log message.

Instead let's have a short paragraph at the end, after the "This change is useful" paragraph:

While here, we add a debug log message on unexpected propagateMode,
to further bring this closer to how it will look in the API parsing
code.

'Malformed UpdateMessageEvent: incoherent message-move fields; '
'propagate_mode present but no new channel or topic');
}
return null;
}

return UpdateMessageMoveData(
// The `stream_id` field (aka origStreamId) is documented to be present on moves;
// newStreamId should not be null either because it falls back to origStreamId.
origStreamId: origStreamId!,
newStreamId: newStreamId!,

// The `orig_subject` field (aka origTopic) is documented to be present on moves;
// newTopic should not be null either because it falls back to origTopic.
origTopic: origTopic!,
newTopic: newTopic!,

// The `propagate_mode` field (aka propagateMode) is documented to be present on moves.
propagateMode: propagateMode!,
);
}
}

/// A Zulip event of type `delete_message`: https://zulip.com/api/get-events#delete_message
@JsonSerializable(fieldRename: FieldRename.snake)
class DeleteMessageEvent extends Event {
Expand Down
27 changes: 3 additions & 24 deletions lib/api/model/events.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -954,4 +954,15 @@ enum PropagateMode {
changeAll;

String toJson() => _$PropagateModeEnumMap[this]!;

/// Get a [PropagateMode] from a raw string. Throws if the string is
/// unrecognized.
///
/// Example:
/// 'change_one' -> PropagateMode.changeOne
static PropagateMode fromRawString(String raw) => _byRawString[raw]!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use "api" for prefix

model [nfc]: Add PropagateMode.fromRawString


// _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake`
static final _byRawString = _$PropagateModeEnumMap
.map((key, value) => MapEntry(value, key));
}
56 changes: 9 additions & 47 deletions lib/model/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,48 +169,17 @@ class MessageStoreImpl with MessageStore {
}

void _handleUpdateMessageEventMove(UpdateMessageEvent event) {
// The interaction between the fields of these events are a bit tricky.
// For reference, see: https://zulip.com/api/get-events#update_message

final origStreamId = event.origStreamId;
final newStreamId = event.newStreamId; // null if topic-only move
final origTopic = event.origTopic;
final newTopic = event.newTopic;
final propagateMode = event.propagateMode;

if (origTopic == null) {
final messageMove = event.moveData;
if (messageMove == null) {
// There was no move.
assert(() {
if (newStreamId != null && origStreamId != null
&& newStreamId != origStreamId) {
// This should be impossible; `orig_subject` (aka origTopic) is
// documented to be present when either the stream or topic changed.
debugLog('Malformed UpdateMessageEvent: stream move but no origTopic'); // TODO(log)
}
return true;
}());
return;
}

if (newStreamId == null && newTopic == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

message: Preface message move handling with more comprehensive null-checks

Does this really make the null-checks more comprehensive? The old null-checks look pretty comprehensive to me — there's the one on this line, and two more below it.

In particular, is there a case where this version rejects an event which the old code would have acted on?

It seems to me like the main point of this commit is the way it moves the two fallbacks like ?? origTopic up to the top of this logic, in order to make it closer to how it'll be after we move all the "find if and how the messages were moved" logic out from here into the API parsing code.

// If neither the channel nor topic name changed, nothing moved.
// In that case `orig_subject` (aka origTopic) should have been null.
Comment on lines -195 to -197
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you try splitting this commit:
7337514 api: Extract and parse UpdateMessageMoveData from UpdateMessageEvent

into one commit that introduces the new logic for interpreting these, but keeps it here in this method, and then a commit that introduces UpdateMessageMoveData and moves the logic there? I think that'd be helpful for reading the changes.

In particular it'd be helpful if that second commit is NFC for debug builds — i.e. if its only functional change is that in some conditions where the old code would assert, the new code will throw while parsing.

assert(debugLog('Malformed UpdateMessageEvent: move but no newStreamId or newTopic')); // TODO(log)
return;
}
if (origStreamId == null) {
// The `stream_id` field (aka origStreamId) is documented to be present on moves.
assert(debugLog('Malformed UpdateMessageEvent: move but no origStreamId')); // TODO(log)
return;
}
if (propagateMode == null) {
// The `propagate_mode` field (aka propagateMode) is documented to be present on moves.
assert(debugLog('Malformed UpdateMessageEvent: move but no propagateMode')); // TODO(log)
return;
}
final UpdateMessageMoveData(
:origStreamId, :newStreamId, :origTopic, :newTopic) = messageMove;

final wasResolveOrUnresolve = (newStreamId == null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

message [nfc]: Remove redundant parentheses

This change is trivial and self-explanatory, so it can just get squashed into the substantive change that's touchinig the same code.

&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic!));
final wasResolveOrUnresolve = newStreamId == origStreamId
&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic);

for (final messageId in event.messageIds) {
final message = messages[messageId];
Expand All @@ -221,14 +190,14 @@ class MessageStoreImpl with MessageStore {
continue;
}

if (newStreamId != null) {
if (newStreamId != origStreamId) {
message.streamId = newStreamId;
// See [StreamMessage.displayRecipient] on why the invalidation is
// needed.
message.displayRecipient = null;
}

if (newTopic != null) {
if (newTopic != origTopic) {
message.topic = newTopic;
}

Expand All @@ -239,14 +208,7 @@ class MessageStoreImpl with MessageStore {
}

for (final view in _messageListViews) {
view.messagesMoved(
origStreamId: origStreamId,
newStreamId: newStreamId ?? origStreamId,
origTopic: origTopic,
newTopic: newTopic ?? origTopic,
messageIds: event.messageIds,
propagateMode: propagateMode,
);
view.messagesMoved(messageMove: messageMove, messageIds: event.messageIds);
}
}

Expand Down
9 changes: 4 additions & 5 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -720,13 +720,12 @@ class MessageListView with ChangeNotifier, _MessageSequence {
}

void messagesMoved({
required int origStreamId,
required int newStreamId,
required TopicName origTopic,
required TopicName newTopic,
required UpdateMessageMoveData messageMove,
required List<int> messageIds,
required PropagateMode propagateMode,
}) {
final UpdateMessageMoveData(
:origStreamId, :newStreamId, :origTopic, :newTopic, :propagateMode,
) = messageMove;
switch (narrow) {
case DmNarrow():
// DMs can't be moved (nor created by moves),
Expand Down
Loading