-
Notifications
You must be signed in to change notification settings - Fork 272
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
base: main
Are you sure you want to change the base?
Conversation
683c713
to
dad5aa0
Compare
Actually @gnprice, I'd appreciate any thoughts you have from a skim of the first three commits: model [nfc]: Add PropagateMode.fromRawString before I start my review, if that's OK; I think you designed/implemented similar logic in zulip-mobile. |
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:
|
(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.) |
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>
Thanks for the review! The PR has been updated. |
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>
There was a problem hiding this 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.
await store.handleEvent(eg.updateMessageEventMoveFrom( | ||
origMessages: otherChannelMovedMessages, | ||
newStreamId: otherStream.streamId, | ||
newStreamId: thirdStream.streamId, | ||
)); |
There was a problem hiding this comment.
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;
}());
There was a problem hiding this comment.
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/api/model/events_test.dart
Outdated
}; | ||
|
||
test('stream_id -> origStreamId', () { | ||
test('stream_id -> origStreamId, subject = orig_subject', () { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lib/api/model/events.dart
Outdated
static Object? _readMoveData(Map<Object?, Object?> json, String key) { | ||
// Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`. | ||
return json as Object?; | ||
} |
There was a problem hiding this comment.
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?
/// Data structure representing a message move. | ||
class UpdateMessageMoveData { |
There was a problem hiding this comment.
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.
lib/api/model/events.dart
Outdated
if (propagateMode == null) { | ||
// There was no move. |
There was a problem hiding this comment.
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 considerpropagate_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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lib/model/unreads.dart
Outdated
if (topics == null) return; | ||
if (topics == null) return QueueList(); |
There was a problem hiding this comment.
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).
lib/model/unreads.dart
Outdated
bool _handleMessageMove(UpdateMessageEvent event) { | ||
final messageMove = event.moveData; | ||
if (messageMove == null) { | ||
// No moved messages or malformed event. |
There was a problem hiding this comment.
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?
lib/model/unreads.dart
Outdated
final topics = streams[messageMove.origStreamId]; | ||
if (topics == null || topics[messageMove.origTopic] == null) { | ||
// No known unreads affected by move; nothing to do. | ||
return false; | ||
} |
There was a problem hiding this comment.
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; | ||
} | ||
|
There was a problem hiding this comment.
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.
// TODO(#901) handle moved messages | ||
madeAnyUpdate |= _handleMessageMove(event); |
There was a problem hiding this comment.
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. 🎉
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh oops! Indeed!
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 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 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>
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 // 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 |
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 |
There was a problem hiding this 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.
lib/api/model/events.dart
Outdated
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: inconsistent indentation
lib/api/model/events.dart
Outdated
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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;
}
lib/model/message.dart
Outdated
// The interaction between the fields of these events are a bit tricky. | ||
// For reference, see: https://zulip.com/api/get-events#update_message |
There was a problem hiding this comment.
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.
lib/api/model/events.dart
Outdated
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'); | ||
} |
There was a problem hiding this comment.
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!,
);
}
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; | ||
} |
There was a problem hiding this comment.
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;
}
There was a problem hiding this comment.
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.
test/model/unreads_test.dart
Outdated
await channelStore.addStream(newChannel); | ||
fillWithMessages(unreadMessages); |
There was a problem hiding this comment.
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', () { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>
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.