Skip to content
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

Handle moved messages for unreads #1311

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open

Conversation

PIG208
Copy link
Member

@PIG208 PIG208 commented Jan 27, 2025

Work toward #901

I prioritized updating unreads data for moved messages because it is more visible after supporting the resolve/unresolve topic actions. Updating recent senders data will be a follow-up to this.

@PIG208 PIG208 force-pushed the pr-unreads branch 5 times, most recently from 683c713 to dad5aa0 Compare January 30, 2025 23:23
@PIG208 PIG208 marked this pull request as ready for review January 30, 2025 23:24
@PIG208 PIG208 added the maintainer review PR ready for review by Zulip maintainers label Jan 30, 2025
@PIG208 PIG208 requested a review from chrisbobbe January 30, 2025 23:24
@chrisbobbe
Copy link
Collaborator

chrisbobbe commented Feb 8, 2025

Actually @gnprice, I'd appreciate any thoughts you have from a skim of the first three commits:

model [nfc]: Add PropagateMode.fromRawString
api [nfc]: Extract and parse UpdateMessageMoveData from UpdateMessageEvent
api: Parse UpdateMessageMoveData with inter-related constraints

before I start my review, if that's OK; I think you designed/implemented similar logic in zulip-mobile.

@gnprice
Copy link
Member

gnprice commented Feb 19, 2025

Didn't catch the GitHub notification for this earlier, sorry — thanks for the ping in chat today.

The general strategy in those commits looks good. The high-level comments I have are:

  • Here in the API parsing code, when the response is malformed, instead of returning null, let's just throw — for example if something shouldn't be null, then just the ! operator is enough.

    That's what already happens for all kinds of other ways it can be malformed, like if json['stream_id'] is a string value (instead of a number or absent/null). Such exceptions get turned into MalformedServerResponseException by the generic code in ApiConnection.send, and in the case of events those get handled appropriately by the long-poll loop. (They cause a reload of the store, because if there's an event we weren't able to handle then we may no longer have accurate state.)

  • Let's have an invariant that we don't wind up with an UpdateMessageMoveData that doesn't represent a move: i.e. where the old and new channel ID are equal and the old and new topic are equal. In our code that updates data structures we might rely on that assumption (for example so that we're not mutating the same data structure twice concurrently as both the source and target of a move).

    That can be enforced by both an assert on the constructor, and the try-parse method either throwing or returning null before getting to the constructor in that case. (Compare the zulip-mobile logic quoted below.)

  • This logic about in what circumstances the various "orig" and "new" fields might be null, or are required to be present, feels pretty unsatisfying. Even though it's meant to accurately reflect guarantees that are in the API docs (https://zulip.com/api/get-events#update_message), and probably it does, it doesn't feel solid.

    In particular, facts like this one feel like quirks:

    orig_subject (aka origTopic) is documented to be present when either the stream or topic changed.

    and are asymmetric between the topic and the channel fields. Indeed this sort of fact has changed in the past (origStreamId aka stream_id became more widely present in Zulip Server 5), and just last year we had to edit the logic because the docs were wrong (020bfcb) about a fact of this kind which we had relied on.

    This logic in the zulip-mobile version feels more robust, because it's based on natural facts about the semantics the event is about:

    if (new_topic === orig_topic && new_stream_id === orig_stream_id) {
      // Stream and topic didn't change.
      return null;

    Perhaps better yet, though: we could just look to see if propagate_mode is present. (The zulip-mobile version didn't consider propagate_mode as part of this logic.) That's something that is necessary if there was a move, and clearly meaningless if there wasn't — so it seems like a very crisp and reliable indicator of whether there was a move. (And the API docs agree that it, too, is present just if there was a move.)

@gnprice
Copy link
Member

gnprice commented Feb 19, 2025

(So probably the right next steps are: @PIG208 go ahead and act on the feedback above, and then after that revision @chrisbobbe will do maintainer review.)

PIG208 added a commit to PIG208/zulip-flutter that referenced this pull request Feb 20, 2025
This drops the separate if-statements in favor of null-checks and
tries to rely on the natural semantics of the API.

See Greg's comment on this:
  zulip#1311 (comment)

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
@PIG208
Copy link
Member Author

PIG208 commented Feb 20, 2025

Thanks for the review! The PR has been updated.

PIG208 added a commit to PIG208/zulip-flutter that referenced this pull request Feb 20, 2025
This drops the separate if-statements in favor of null-checks and
tries to rely on the natural semantics of the API.

This drops an expcetion that would have been thrown when propagate mode
is not present but the topic name/stream id changes.

See Greg's comment on this:
  zulip#1311 (comment)

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks, I'm excited for more correctness in the Unreads model! Small comments below.

Comment on lines 785 to 836
await store.handleEvent(eg.updateMessageEventMoveFrom(
origMessages: otherChannelMovedMessages,
newStreamId: otherStream.streamId,
newStreamId: thirdStream.streamId,
));
Copy link
Collaborator

Choose a reason for hiding this comment

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

What do you think about adding an assert to eg.updateMessageEventMoveFrom, to catch invalid input?:

  final origStreamId = origMessage.streamId;
  final origTopic = origMessage.topic;
  assert(() {
    final streamChanged = newStreamId != null && newStreamId != origStreamId;
    final topicChanged = newTopic != null && newTopic != origTopic;
    return streamChanged || topicChanged;
  }());

Copy link
Member Author

Choose a reason for hiding this comment

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

It would be helpful before we pull out UpdateMessageMoveData, which actually helped me discover this issue in the first place. Having the assertions and check in one place (UpdateMessageMoveData) appears to be easier to maintain.

};

test('stream_id -> origStreamId', () {
test('stream_id -> origStreamId, subject = orig_subject', () {
Copy link
Collaborator

Choose a reason for hiding this comment

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

events test: Make test data more realistic

Could please you explain this commit a bit more? I don't understand the changes to the test descriptions. Their current descriptions are "A -> B" pairs, where A is input and B is expected output. I don't understand what the added pair "subject = orig_subject" means, and it doesn't follow that pattern. If I look at 'orig_subject' and 'subject' in the input, I see they're not equal; I guess that's not what the "=" is for, then.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's meant to represent that the topic does not change in this move. Changing it to a short phrase should help make it clear.

Copy link
Member

Choose a reason for hiding this comment

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

This new check seems like it'd be clearest as a new separate test case. I'm not sure how it relates to the existing test, or really what it's meant to check.

Comment on lines 796 to 817
static Object? _readMoveData(Map<Object?, Object?> json, String key) {
// Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
return json as Object?;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

At this point we know json is a Map<String, dynamic>, because it was passed as the argument to UpdateMessageEvent.fromJson, which has that type. I think it would be neater to have _readMoveData encapsulate this, having Map<String, dynamic> for its return type instead of Object?.

For readValue, we're asked to implement a function that takes any Map, which is why we can't say Map<String, dynamic> json for _readMoveData. But that's fine; we can still have an assert:

--- lib/api/model/events.dart
+++ lib/api/model/events.dart
@@ -723,8 +723,7 @@ class UpdateMessageMoveData {
   /// [UpdateMessageEvent].
   ///
   /// Throws an error if the data is malformed.
-  factory UpdateMessageMoveData.fromJson(Object? json) {
-    json as Map<String, Object?>;
+  factory UpdateMessageMoveData.fromJson(Map<String, dynamic> json) {
     final origStreamId = (json['stream_id'] as num?)?.toInt();
     final newStreamId = (json['new_stream_id'] as num?)?.toInt();
     final propagateModeString = json['propagate_mode'] as String?;
@@ -793,9 +792,10 @@ class UpdateMessageEvent extends Event {
     required this.isMeMessage,
   });
 
-  static Object? _readMoveData(Map<Object?, Object?> json, String key) {
+  static Map<String, dynamic> _readMoveData(Map<dynamic, dynamic> json, String key) {
     // Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
-    return json as Object?;
+    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) =>

I know that the assert is redundant with the json as Map<String, dynamic>. My thought is to be clear that this is an invariant we own in our code; it's not about what we expect from the server (which is the case for most casts here, like json['propagate_mode'] as String?).

What do you think?

Comment on lines +704 to +705
/// Data structure representing a message move.
class UpdateMessageMoveData {
Copy link
Collaborator

Choose a reason for hiding this comment

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

api [nfc]: Extract and parse UpdateMessageMoveData from UpdateMessageEvent

At this commit the UpdateMessageMoveData dartdoc isn't accurate, because we still instantiate the class when there was no move, or when the fields have the correct types but are incoherent about whether there was a move (e.g. propagate_mode present but the stream/topic fields all absent).

How about:

/// Data structure holding the fields about a message move.

(or similar), until the "representing a message move" becomes accurate in a later commit.

Comment on lines 737 to 746
if (propagateMode == null) {
// There was no move.
Copy link
Collaborator

Choose a reason for hiding this comment

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

api: Simplify move data parsing

This drops the separate if-statements in favor of null-checks and
tries to rely on the natural semantics of the API.

This drops an expcetion that would have been thrown when propagate mode
is not present but the topic name/stream id changes.

See Greg's comment on this:
  https://github.com/zulip/zulip-flutter/pull/1311#issuecomment-2667327271

Signed-off-by: Zixuan James Li <zixuan@zulip.com>

This new implementation follows the documented API, right? From that linked comment from Greg:

Perhaps better yet, though: we could just look to see if propagate_mode is present. (The zulip-mobile version didn't consider propagate_mode as part of this logic.) That's something that is necessary if there was a move, and clearly meaningless if there wasn't — so it seems like a very crisp and reliable indicator of whether there was a move. (And the API docs agree that it, too, is present just if there was a move.)

So let's explain it more confidently than "tries to rely on the natural semantics of the API". 🙂 Can just briefly say why we're not directly transcribing zulip-mobile code, but that this way is valid and more crisp, and link to Greg's comment.

Also how about squashing this commit into the previous:

api: Parse UpdateMessageMoveData with inter-related constraints

and perhaps even squash that into the one before:

api [nfc]: Extract and parse UpdateMessageMoveData from UpdateMessageEvent

Copy link
Collaborator

Choose a reason for hiding this comment

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

For this part:

This drops an expcetion that would have been thrown when propagate mode
is not present but the topic name/stream id changes.

That would be easy to add back in, right, in the if (propagateMode == null)? Just that we'd throw if any of the four stream / topic fields are present.

Copy link
Member

Choose a reason for hiding this comment

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

That would be easy to add back in, right, in the if (propagateMode == null)? Just that we'd throw if any of the four stream / topic fields are present.

When there isn't a move, some of those four fields may still be present and it's complicated what exact subset is expected. (See "feel like quirks" and "changed in the past" in this comment: #1311 (comment)) So such a check would have to be more complicated than that.

Copy link
Member Author

Choose a reason for hiding this comment

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

We can have a check like:

// pseudo representation of the types of the fields
TopicName? origTopic = ...;
int? origStreamId = ...;
final newTopic ??= origTopic;
final newStreamId ??= origStreamId;

if (propagateMode == null) {
  if (origTopic != newTopic || origStreamId != newStreamId) {
    throw FormatException('move but no propagateMode'); // or "maybe move but no propagateMode"
  }
  // There was no move.
  return null;
}

This still throws if we have origTopic == null or origStreamId == null with a non-null newTopic or newStreamId, but that's malformed data anyway.

It is the negation of origStreamId == newStreamId && origTopic == newTopic, which is used later in this method.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, that could be fine if it doesn't involve making the other logic any more complicated to support it.

if (topics == null) return;
if (topics == null) return QueueList();
Copy link
Collaborator

Choose a reason for hiding this comment

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

The only thing we do with this is an .isEmpty check, right? How about returning null instead of creating a new QueueList (seems more efficient).

bool _handleMessageMove(UpdateMessageEvent event) {
final messageMove = event.moveData;
if (messageMove == null) {
// No moved messages or malformed event.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the "or malformed event" part is from an old revision—we throw if it's malformed, right?

Comment on lines 313 to 319
final topics = streams[messageMove.origStreamId];
if (topics == null || topics[messageMove.origTopic] == null) {
// No known unreads affected by move; nothing to do.
return false;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is redundant with the early returns at the top of _removeAllInStreamTopic, right?

// No moved messages or malformed event.
return false;
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Could say

    final UpdateMessageMoveData(
      :origStreamId, :newStreamId, :origTopic, :newTopic) = messageMove;

to avoid repetition of messageMove. several times.

Comment on lines -299 to +308
// TODO(#901) handle moved messages
madeAnyUpdate |= _handleMessageMove(event);
Copy link
Collaborator

Choose a reason for hiding this comment

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

unreads: Handle updates when there are moved messages

The commit message can get a "fixes" line. 🎉

Copy link
Member Author

Choose a reason for hiding this comment

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

We will get there soon! #901 also requires updates to recent senders data model.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh oops! Indeed!

PIG208 added a commit to PIG208/zulip-flutter that referenced this pull request Feb 24, 2025
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.

This also allows us to drop the `assert`'s and "TODO(log)"'s, because
the stacktrace we need can be retrieved after throwing these
`FormatException`'s.

This is similar to zulip-mobile code for parsing move data. The main
difference is that we check the value of `propagate_mode`, which is
documented to be present on message moves.  With this single indicator,
the logic is crisp for ruling out non-move message update events.
See Greg's comment on this:
  zulip#1311 (comment)

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
PIG208 added a commit to PIG208/zulip-flutter that referenced this pull request Feb 24, 2025
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.

This also allows us to drop the `assert`'s and "TODO(log)"'s, because
the stacktrace we need can be retrieved after throwing these
`FormatException`'s.

This is similar to zulip-mobile code for parsing move data. The main
difference is that we check the value of `propagate_mode`, which is
documented to be present on message moves.  With this single indicator,
the logic is crisp for ruling out non-move message update events.
See Greg's comment on this:
  zulip#1311 (comment)

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
PIG208 added a commit to PIG208/zulip-flutter that referenced this pull request Feb 25, 2025
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.

This also allows us to drop the `assert`'s and "TODO(log)"'s, because
the stacktrace we need can be retrieved after throwing these
`FormatException`'s.

This is similar to zulip-mobile code for parsing move data. The main
difference is that we check the value of `propagate_mode`, which is
documented to be present on message moves.  With this single indicator,
the logic is crisp for ruling out non-move message update events.
See Greg's comment on this:
  zulip#1311 (comment)

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
@chrisbobbe
Copy link
Collaborator

It turns out there's a move-message edge case that we'll need to handle, either here or as a followup issue.

It's this TODO in Unreads.handleUpdateMessageEvent:

    // We assume this event can't signal a change in a message's 'read' flag.
    // TODO can it actually though, when it's about messages being moved into an
    //   unsubscribed stream?
    //   https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639957

The conclusion from an API-design discussion is that clients need to check if the messages were moved into a channel that is unsubscribed, and if so, drop them from unreads, since we won't be getting a separate event about that. (This is currently undocumented on update_message but should be soon.)

@PIG208
Copy link
Member Author

PIG208 commented Feb 25, 2025

Just read the discussion. Yeah, it seems straightforward enough to include in the main implementation commit. I removed the TODO and kept a link referring to that discussion at where this is handled.

From my understanding, the isRead flag gets stale and the claim that " We assume this event can't signal a change in a message's 'read' flag." holds, so there is nothing else to do at the TODO site; we handle this in _handleMessageMove.

@PIG208 PIG208 requested a review from chrisbobbe February 25, 2025 03:48
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks! Small comments below, and I'll follow up on CZO where you pointed out that the move-messages event isn't arriving for a move to an unsubscribed channel, in your testing.

Comment on lines 735 to 740
final propagateMode = propagateModeString == null ? null
: PropagateMode.fromRawString(propagateModeString);
final origTopic = json['orig_subject'] == null ? null
: TopicName.fromJson(json['orig_subject'] as String);
final newTopic = json['subject'] == null ? origTopic
: TopicName.fromJson(json['subject'] as String);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: inconsistent indentation

required this.origContent,
required this.origRenderedContent,
required this.content,
required this.renderedContent,
required this.isMeMessage,
});

static Map<String, Object?> _readMoveData(Map<Object?, Object?> json, String key) {
// Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
assert(json is Map<String, Object?>); // value came through `fromJson` with this type
Copy link
Collaborator

Choose a reason for hiding this comment

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

fromJson actually says Map<String, dynamic>, as I mentioned in #1311 (comment) . I'm not sure if the distinction matters, but I won't block on changing it; I'd appreciate @gnprice's thoughts if he sees this and has an opinion.

Copy link
Member Author

@PIG208 PIG208 Feb 26, 2025

Choose a reason for hiding this comment

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

I noticed that in the generated code, while the return type of _readMoveData is already Map<String, Object?>, the generator still uses a type cast in response to tryParseFromJson accepting Map<String, Object?>:

      moveData: UpdateMessageMoveData.tryParseFromJson(
          UpdateMessageEvent._readMoveData(json, 'move_data')
              as Map<String, Object?>),

So we can actually skip the type cast in the readValue callback. And then we can just have:

  static Object? _readMoveData(Map<dynamic, dynamic> json, String key) {
    // Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
    return json;
  }

Comment on lines 172 to 173
// The interaction between the fields of these events are a bit tricky.
// For reference, see: https://zulip.com/api/get-events#update_message
Copy link
Collaborator

Choose a reason for hiding this comment

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

This trickiness is now encapsulated in event.moveData, so this comment doesn't apply here and it can be removed.

Comment on lines 742 to 754
if (propagateMode == null) {
if (origTopic != newTopic || origStreamId != newStreamId) {
throw FormatException('move but no propagateMode');
}
// There was no move.
return null;
}

if (origStreamId == newStreamId && origTopic == newTopic) {
// If neither the channel nor topic name changed, nothing moved.
// In that case `propagate_mode` (aka propagateMode) should have been null.
throw FormatException('move but unchanged newStreamId and newTopic');
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looking at the cases where we throw, in this code, I think the error messages and code comment aren't quite accurate about our level of knowledge. In those cases, we know that the event's fields are incoherent, but I think we don't know enough to say if a move or non-move was intended. How about something like this?:

    final propagateModePresent = propagateMode != null;
    final newChannelOrTopic = origStreamId != newStreamId || origTopic != newTopic;
    switch ((propagateModePresent, newChannelOrTopic)) {
      case (true, false):
        throw FormatException(
          'UpdateMessageEvent: incoherent message-move fields; '
          'propagate_mode present but no new channel or topic');
      case (false, true):
        throw FormatException(
          'UpdateMessageEvent: incoherent message-move fields; '
          'propagate_mode absent but new channel or topic');
      case (false, false):
        return null; // No move.
      case (true, true):
        return UpdateMessageMoveData(
          origStreamId: origStreamId!,
          newStreamId: newStreamId!,
          propagateMode: propagateMode!,
          origTopic: origTopic!,
          newTopic: newTopic!,
        );
    }

Comment on lines +318 to +335
if (!channelStore.subscriptions.containsKey(newStreamId)) {
// Unreads moved to an unsubscribed channel; just drop them.
// See also:
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/2101926
return true;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Quoting your comment above, #1311 (comment) :

Just read the discussion [link]. Yeah, it seems straightforward enough to include in the main implementation commit. I removed the TODO and kept a link referring to that discussion at where this is handled.

From my understanding, the isRead flag gets stale and the claim that " We assume this event can't signal a change in a message's 'read' flag." holds, so there is nothing else to do at the TODO site; we handle this in _handleMessageMove.

Ah I think my code comment—

    // We assume this event can't signal a change in a message's 'read' flag.
    // TODO can it actually though, when it's about messages being moved into an
    //   unsubscribed stream?
    //   https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639957
    final bool isRead = event.flags.contains(MessageFlag.read);

—isn't quite accurate. The event can signal a change in a message's 'read' flag; it does in this case, because the API says it does (or anyway it will once the API doc is updated). The flag did change, on the server, and this event is the only thing that tells us about it ('signals' it). It's just that the event's 'read' flag isn't part of that signal.

So I agree we should remove the TODO. But let's reword the remaining part so that it's accurate, maybe like this:

-    // We assume this event can't signal a change in a message's 'read' flag.
+    // We expect the event's 'read' flag to be boring,
+    // matching the message's local unread state.

I think that expectation happens to hold in the moved-to-unsubscribed-channel case. But we don't need to care if it does, right—the API is clear that we should just drop the unreads—so how about adding another early return before the isUnreadLocally != isUnreadEvent check:

      final newChannelId = event.moveData?.newStreamId;
      if (newChannelId != null && !channelStore.subscriptions.containsKey(newChannelId)) {
        // When unread messages are moved to an unsubscribed channel, the server
        // marks them as read without sending a mark-as-read event. Clients are
        // asked to special-case this by marking them as read, which we do in
        // _handleMessageMove. That contract is clear enough and doesn't involve
        // this event's 'read' flag, so don't bother logging about the flag;
        // its behavior seems like an implementation detail that could change.
        return true;
      }

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, I see that the move event isn't actually arriving in this case, in your testing: https://chat.zulip.org/#narrow/channel/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/2103863

I'll join that discussion.

Comment on lines 567 to 591
await channelStore.addStream(newChannel);
fillWithMessages(unreadMessages);
Copy link
Collaborator

Choose a reason for hiding this comment

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

A quick assert that the channel is unsubscribed could help reassure the reader that the test is set up correctly.

@@ -469,6 +469,189 @@ void main() {
}
}
});

group('moves', () {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We could also test a move to a same-named topic in a different channel; what do you think?

Copy link
Member Author

Choose a reason for hiding this comment

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

"to another subscribed channel" and "to unsubscribed channel" under the "move unread messages" subgroup do test this scenario. Do you mean testing a move to a different channel and a different topic name?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you mean testing a move to a different channel and a different topic name?

Oh, oops: yes, I assume it would be helpful to test both cases.

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
The streamId of the moved messages does not change as intended, and the
topic remains the same; this is not a valid message move.

We will later introduce a data structure that captures inconsistencies
of this kind.

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
Both orig_subject and stream_id are documented to be present on message
moves even if the message is not moved to a new topic/channel,
respectively; propagate_mode is always present on message moves.

See also API documentation:
  https://zulip.com/api/get-events#update_message

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
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.

This also allows us to drop the `assert`'s and "TODO(log)"'s, because
the stacktrace we need can be retrieved after throwing these
`FormatException`'s.

This is similar to zulip-mobile code for parsing move data. The main
difference is that we check the value of `propagate_mode`, which is
documented to be present on message moves.  With this single indicator,
the logic is crisp for ruling out non-move message update events.
See Greg's comment on this:
  zulip#1311 (comment)

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
This does not correctly catch the case when
`origTopic='topic'`, and
`newTopic=origStreamId=newStreamId=null`.

Instead, remove it and rely on the assertions from
UpdateMessageMoveData's constructor.

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
Signed-off-by: Zixuan James Li <zixuan@zulip.com>
Signed-off-by: Zixuan James Li <zixuan@zulip.com>
Signed-off-by: Zixuan James Li <zixuan@zulip.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
maintainer review PR ready for review by Zulip maintainers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants