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
87 changes: 72 additions & 15 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,70 @@ class MessageEvent extends Event {
}
}

/// Data structure representing a message move.
class UpdateMessageMoveData {
Comment on lines +704 to +705
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.

final int origStreamId;
final int newStreamId;

final PropagateMode propagateMode;

final TopicName origTopic;
final TopicName newTopic;

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

/// 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) {
final origStreamId = (json['stream_id'] as num?)?.toInt();
final newStreamId = (json['new_stream_id'] as num?)?.toInt() ?? origStreamId;
final propagateModeString = json['propagate_mode'] as String?;
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);

final newChannelOrTopic = origStreamId != newStreamId || origTopic != newTopic;
switch ((propagateMode != null, 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!,
);
}
}

Object? toJson() => null;
}

/// A Zulip event of type `update_message`: https://zulip.com/api/get-events#update_message
@JsonSerializable(fieldRename: FieldRename.snake)
class UpdateMessageEvent extends Event {
Expand All @@ -718,16 +782,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)
final UpdateMessageMoveData? moveData;

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

Expand All @@ -747,18 +803,19 @@ 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 Object? _readMoveData(Map<dynamic, dynamic> json, String key) {
// Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`.
return json;
}

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

Expand Down
25 changes: 4 additions & 21 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]!;

// _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake`
static final _byRawString = _$PropagateModeEnumMap
.map((key, value) => MapEntry(value, key));
}
65 changes: 16 additions & 49 deletions lib/model/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,49 +169,12 @@ 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) {
// 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;
}());
final messageMove = event.moveData;
if (messageMove == null) {
// There is no message move.
return;
}

if (newStreamId == null && newTopic == null) {
// If neither the channel nor topic name changed, nothing moved.
// In that case `orig_subject` (aka origTopic) should have been null.
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 wasResolveOrUnresolve = (newStreamId == null
&& MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic!));

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

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

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

final wasResolveOrUnresolve =
messageMove.origStreamId == messageMove.newStreamId
&& MessageEditState.topicMoveWasResolveOrUnresolve(
messageMove.origTopic, messageMove.newTopic);
if (!wasResolveOrUnresolve
&& message.editState == MessageEditState.none) {
message.editState = MessageEditState.moved;
Expand All @@ -240,12 +207,12 @@ class MessageStoreImpl with MessageStore {

for (final view in _messageListViews) {
view.messagesMoved(
origStreamId: origStreamId,
newStreamId: newStreamId ?? origStreamId,
origTopic: origTopic,
newTopic: newTopic ?? origTopic,
origStreamId: messageMove.origStreamId,
newStreamId: messageMove.newStreamId,
origTopic: messageMove.origTopic,
newTopic: messageMove.newTopic,
messageIds: event.messageIds,
propagateMode: propagateMode,
propagateMode: messageMove.propagateMode,
);
}
}
Expand Down
63 changes: 54 additions & 9 deletions lib/model/unreads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,8 @@ class Unreads extends ChangeNotifier {
(f) => f == MessageFlag.mentioned || f == MessageFlag.wildcardMentioned,
);

// 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
// We expect the event's 'read' flag to be boring,
// matching the message's local unread state.
final bool isRead = event.flags.contains(MessageFlag.read);
assert(() {
final isUnreadLocally = isUnread(messageId);
Expand All @@ -272,6 +270,17 @@ class Unreads extends ChangeNotifier {
// We were going to check something but can't; shrug.
if (isUnreadLocally == null) return true;

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;
}

if (isUnreadLocally != isUnreadInEvent) {
// If this happens, then either:
// - the server and client have been out of sync about the message's
Expand All @@ -296,13 +305,39 @@ class Unreads extends ChangeNotifier {
madeAnyUpdate |= mentions.add(messageId);
}

// TODO(#901) handle moved messages
madeAnyUpdate |= _handleMessageMove(event);
Comment on lines -299 to +308
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!


if (madeAnyUpdate) {
notifyListeners();
}
}

bool _handleMessageMove(UpdateMessageEvent event) {
if (event.moveData == null) {
// No moved messages.
return false;
}
final UpdateMessageMoveData(
:origStreamId, :newStreamId, :origTopic, :newTopic) = event.moveData!;

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.

final messageToMoveIds = _removeAllInStreamTopic(
event.messageIds.toSet(), origStreamId, origTopic);
if (messageToMoveIds == null || messageToMoveIds.isEmpty) {
// No known unreads affected by move; nothing to do.
return false;
}

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;
}
Comment on lines +330 to +335
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.


_addAllInStreamTopic(messageToMoveIds..sort(), newStreamId, newTopic);
return true;
}

void handleDeleteMessageEvent(DeleteMessageEvent event) {
mentions.removeAll(event.messageIds);
final messageIdsSet = Set.of(event.messageIds);
Expand Down Expand Up @@ -455,6 +490,8 @@ class Unreads extends ChangeNotifier {

// [messageIds] must be sorted ascending and without duplicates.
void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, TopicName topic) {
assert(messageIds.isNotEmpty);
assert(isSortedWithoutDuplicates(messageIds));
final topics = streams[streamId] ??= {};
topics.update(topic,
ifAbsent: () => messageIds,
Expand Down Expand Up @@ -488,20 +525,28 @@ class Unreads extends ChangeNotifier {
}
}

void _removeAllInStreamTopic(Set<int> incomingMessageIds, int streamId, TopicName topic) {
QueueList<int>? _removeAllInStreamTopic(Set<int> incomingMessageIds, int streamId, TopicName topic) {
final topics = streams[streamId];
if (topics == null) return;
if (topics == null) return null;
final messageIds = topics[topic];
if (messageIds == null) return;
if (messageIds == null) return null;

// ([QueueList] doesn't have a `removeAll`)
messageIds.removeWhere((id) => incomingMessageIds.contains(id));
final removedMessageIds = QueueList<int>();
messageIds.removeWhere((id) {
if (incomingMessageIds.contains(id)) {
removedMessageIds.add(id);
return true;
}
return false;
});
if (messageIds.isEmpty) {
topics.remove(topic);
if (topics.isEmpty) {
streams.remove(streamId);
}
}
return removedMessageIds;
}

// TODO use efficient model lookups
Expand Down
Loading