Skip to content

msglist: Support data model for edited/moved marker #750

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 3 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@

// This much more focused automatic fix is helpful, though.
"files.trimTrailingWhitespace": true,

// Suppress the warnings that appear for TODOs throughout the codebase.
"dart.showTodos": false,
}
87 changes: 87 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,9 @@ sealed class Message {
final String contentType;

// final List<MessageEditHistory> editHistory; // TODO handle
@JsonKey(readValue: MessageEditState._readFromMessage, fromJson: Message._messageEditStateFromJson)
MessageEditState editState;

final int id;
bool isMeMessage;
int? lastEditTimestamp;
Expand All @@ -490,6 +493,12 @@ sealed class Message {
@JsonKey(name: 'match_subject')
final String? matchTopic;

static MessageEditState _messageEditStateFromJson(dynamic json) {
// This is a no-op so that [MessageEditState._readFromMessage]
// can return the enum value directly.
return json as MessageEditState;
}

static Reactions? _reactionsFromJson(dynamic json) {
final list = (json as List<dynamic>);
return list.isNotEmpty ? Reactions.fromJson(list) : null;
Expand All @@ -508,6 +517,7 @@ sealed class Message {
required this.client,
required this.content,
required this.contentType,
required this.editState,
required this.id,
required this.isMeMessage,
required this.lastEditTimestamp,
Expand Down Expand Up @@ -573,6 +583,7 @@ class StreamMessage extends Message {
required super.client,
required super.content,
required super.contentType,
required super.editState,
required super.id,
required super.isMeMessage,
required super.lastEditTimestamp,
Expand Down Expand Up @@ -675,6 +686,7 @@ class DmMessage extends Message {
required super.client,
required super.content,
required super.contentType,
required super.editState,
required super.id,
required super.isMeMessage,
required super.lastEditTimestamp,
Expand All @@ -698,3 +710,78 @@ class DmMessage extends Message {
@override
Map<String, dynamic> toJson() => _$DmMessageToJson(this);
}

enum MessageEditState {
none,
edited,
moved;

// Adapted from the shared code:
// https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts
// The canonical resolved-topic prefix.
static const String _resolvedTopicPrefix = '✔ ';

/// Whether the given topic move reflected either a "resolve topic"
/// or "unresolve topic" operation.
///
/// The Zulip "resolved topics" feature is implemented by renaming the topic;
/// but for purposes of [Message.editState], we want to ignore such renames.
/// This method identifies topic moves that should be ignored in that context.
static bool topicMoveWasResolveOrUnresolve(String topic, String prevTopic) {
if (topic.startsWith(_resolvedTopicPrefix)
&& topic.substring(_resolvedTopicPrefix.length) == prevTopic) {
return true;
}

if (prevTopic.startsWith(_resolvedTopicPrefix)
&& prevTopic.substring(_resolvedTopicPrefix.length) == topic) {
return true;
}

return false;
}

static MessageEditState _readFromMessage(Map<dynamic, dynamic> json, String key) {
// Adapted from `analyze_edit_history` in the web app:
// https://github.com/zulip/zulip/blob/c31cebbf68a93927d41e9947427c2dd4d46503e3/web/src/message_list_view.js#L68-L118
final editHistory = json['edit_history'] as List<dynamic>?;
final lastEditTimestamp = json['last_edit_timestamp'] as int?;
if (editHistory == null) {
return (lastEditTimestamp != null)
? MessageEditState.edited
: MessageEditState.none;
}

// Edit history should never be empty whenever it is present
assert(editHistory.isNotEmpty);

bool hasMoved = false;
for (final entry in editHistory) {
if (entry['prev_content'] != null) {
return MessageEditState.edited;
}

if (entry['prev_stream'] != null) {
hasMoved = true;
continue;
}

// TODO(server-5) prev_subject was the old name of prev_topic on pre-5.0 servers
final prevTopic = (entry['prev_topic'] ?? entry['prev_subject']) as String?;
final topic = entry['topic'] as String?;
if (prevTopic != null) {
// TODO(server-5) pre-5.0 servers do not have the 'topic' field
if (topic == null) {
hasMoved = true;
} else {
hasMoved |= !topicMoveWasResolveOrUnresolve(topic, prevTopic);
}
}
}

if (hasMoved) return MessageEditState.moved;

// This can happen when a topic is resolved but nothing else has been edited
return MessageEditState.none;
}
}
12 changes: 12 additions & 0 deletions lib/api/model/model.g.dart

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

25 changes: 23 additions & 2 deletions lib/model/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ class MessageStoreImpl with MessageStore {
if (message == null) return;

message.flags = event.flags;
if (event.origContent != null) {
// The message is guaranteed to be edited.
// See also: https://zulip.com/api/get-events#update_message
message.editState = MessageEditState.edited;
}
if (event.renderedContent != null) {
assert(message.contentType == 'text/html',
"Message contentType was ${message.contentType}; expected text/html.");
Expand Down Expand Up @@ -168,10 +173,26 @@ class MessageStoreImpl with MessageStore {
return;
}

// final newStreamId = event.newStreamId; // null if topic-only move
// final newTopic = event.newTopic!;
final newTopic = event.newTopic!;
final newChannelId = event.newStreamId; // null if topic-only move

if (newChannelId == null
&& MessageEditState.topicMoveWasResolveOrUnresolve(event.origTopic!, newTopic)) {
// The topic was only resolved/unresolved.
// No change to the messages' editState.
return;
}

// TODO(#150): Handle message moves. The views' recipient headers
// may need updating, and consequently showSender too.
// Currently only editState gets updated.
for (final messageId in event.messageIds) {
final message = messages[messageId];
if (message == null) continue;
// Do not override the edited marker if the message has also been moved.
if (message.editState == MessageEditState.edited) continue;
message.editState = MessageEditState.moved;
}
}

void handleDeleteMessageEvent(DeleteMessageEvent event) {
Expand Down
1 change: 1 addition & 0 deletions test/api/model/model_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ extension MessageChecks on Subject<Message> {
Subject<int> get id => has((e) => e.id, 'id');
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
Subject<int?> get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp');
Subject<MessageEditState> get editState => has((e) => e.editState, 'editState');
Subject<Reactions?> get reactions => has((e) => e.reactions, 'reactions');
Subject<int> get recipientId => has((e) => e.recipientId, 'recipientId');
Subject<String> get senderEmail => has((e) => e.senderEmail, 'senderEmail');
Expand Down
109 changes: 109 additions & 0 deletions test/api/model/model_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ void main() {
);
check(m2).flags.deepEquals([MessageFlag.read, MessageFlag.unknown]);
});

// Code relevant to messageEditState is tested separately in the
// MessageEditState group.
});

group('DmMessage', () {
Expand Down Expand Up @@ -212,4 +215,110 @@ void main() {
.deepEquals([2, 3, 11]);
});
});

group('MessageEditState', () {
Map<String, dynamic> baseJson() => deepToJson(eg.streamMessage()) as Map<String, dynamic>;

group('Edit history is absent', () {
test('Message with no evidence of an edit history -> none', () {
check(Message.fromJson(baseJson()..['edit_history'] = null))
.editState.equals(MessageEditState.none);
});

test('Message without edit history has last edit timestamp -> edited', () {
check(Message.fromJson(baseJson()
..['edit_history'] = null
..['last_edit_timestamp'] = 1678139636))
.editState.equals(MessageEditState.edited);
});
});

void checkEditState(MessageEditState editState, List<Map<String, dynamic>> editHistory){
check(Message.fromJson(baseJson()..['edit_history'] = editHistory))
.editState.equals(editState);
}

group('edit history exists', () {
test('Moved message has last edit timestamp but no actual edits -> moved', () {
check(Message.fromJson(baseJson()
..['edit_history'] = [{'prev_stream': 5, 'stream': 7}]
..['last_edit_timestamp'] = 1678139636))
.editState.equals(MessageEditState.moved);
});

test('Channel change only -> moved', () {
checkEditState(MessageEditState.moved,
[{'prev_stream': 5, 'stream': 7}]);
});

test('Topic name change only -> moved', () {
checkEditState(MessageEditState.moved,
[{'prev_topic': 'old_topic', 'topic': 'new_topic'}]);
});

test('Both topic and content changed -> edited', () {
checkEditState(MessageEditState.edited, [
{'prev_topic': 'old_topic', 'topic': 'new_topic'},
{'prev_content': 'old_content'},
]);
checkEditState(MessageEditState.edited, [
{'prev_content': 'old_content'},
{'prev_topic': 'old_topic', 'topic': 'new_topic'},
]);
});

test('Both topic and content changed in a single edit -> edited', () {
checkEditState(MessageEditState.edited,
[{'prev_topic': 'old_topic', 'topic': 'new_topic', 'prev_content': 'old_content'}]);
});

test('Content change only -> edited', () {
checkEditState(MessageEditState.edited,
[{'prev_content': 'old_content'}]);
});

test("'prev_topic' present without the 'topic' field -> moved", () {
checkEditState(MessageEditState.moved,
[{'prev_topic': 'old_topic'}]);
});

test("'prev_subject' present from a pre-5.0 server -> moved", () {
checkEditState(MessageEditState.moved,
[{'prev_subject': 'old_topic'}]);
});
});

group('topic resolved in edit history', () {
test('Topic was only resolved -> none', () {
checkEditState(MessageEditState.none,
[{'prev_topic': 'old_topic', 'topic': '✔ old_topic'}]);
});

test('Topic was resolved but the content changed in the history -> edited', () {
checkEditState(MessageEditState.edited, [
{'prev_topic': 'old_topic', 'topic': '✔ old_topic'},
{'prev_content': 'old_content'},
]);
});

test('Topic was resolved but it also moved in the history -> moved', () {
checkEditState(MessageEditState.moved, [
{'prev_topic': 'old_topic', 'topic': 'new_topic'},
{'prev_topic': '✔ old_topic', 'topic': 'old_topic'},
]);
});

test('Topic was moved but it also was resolved in the history -> moved', () {
checkEditState(MessageEditState.moved, [
{'prev_topic': '✔ old_topic', 'topic': 'old_topic'},
{'prev_topic': 'old_topic', 'topic': 'new_topic'},
]);
});

test('Unresolving topic with a weird prefix -> moved', () {
checkEditState(MessageEditState.moved,
[{'prev_topic': '✔ ✔old_topic', 'topic': 'old_topic'}]);
});
});
});
}
32 changes: 32 additions & 0 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,38 @@ UpdateMessageEvent updateMessageEditEvent(
);
}

UpdateMessageEvent updateMessageMoveEvent(
List<Message> messages, {
int? newStreamId,
String? origTopic,
String? newTopic,
String? origContent,
String? newContent,
}) {
assert(messages.isNotEmpty);
final origMessage = messages[0];
final messageId = origMessage.id;
return UpdateMessageEvent(
id: 0,
userId: selfUser.userId,
renderingOnly: false,
messageId: messageId,
messageIds: messages.map((message) => message.id).toList(),
flags: origMessage.flags,
editTimestamp: 1234567890, // TODO generate timestamp
origStreamId: origMessage is StreamMessage ? origMessage.streamId : null,
newStreamId: newStreamId,
propagateMode: null,
origTopic: origTopic,
newTopic: newTopic,
origContent: origContent,
origRenderedContent: origContent,
content: newContent,
renderedContent: newContent,
isMeMessage: false,
);
}

UpdateMessageFlagsRemoveEvent updateMessageFlagsRemoveEvent(
MessageFlag flag,
Iterable<Message> messages, {
Expand Down
Loading
Loading