diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index fd806a4ff1..e1e8d92bbd 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -40,6 +40,12 @@ sealed class Event { case 'message': return MessageEvent.fromJson(json); case 'update_message': return UpdateMessageEvent.fromJson(json); case 'delete_message': return DeleteMessageEvent.fromJson(json); + case 'update_message_flags': + switch (json['op'] as String) { + case 'add': return UpdateMessageFlagsAddEvent.fromJson(json); + case 'remove': return UpdateMessageFlagsRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'reaction': return ReactionEvent.fromJson(json); case 'heartbeat': return HeartbeatEvent.fromJson(json); // TODO add many more event types @@ -439,13 +445,122 @@ class DeleteMessageEvent extends Event { Map toJson() => _$DeleteMessageEventToJson(this); } -/// As in [DeleteMessageEvent.messageType]. +/// As in [DeleteMessageEvent.messageType] +/// or [UpdateMessageFlagsMessageDetail.type]. @JsonEnum(fieldRename: FieldRename.snake) enum MessageType { stream, private; } +/// A Zulip event of type `update_message_flags`. +/// +/// For the corresponding API docs, see subclasses. +sealed class UpdateMessageFlagsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'update_message_flags'; + + String get op; + + @JsonKey(unknownEnumValue: MessageFlag.unknown) + final MessageFlag flag; + final List messages; + + UpdateMessageFlagsEvent({ + required super.id, + required this.flag, + required this.messages, + }); +} + +/// An [UpdateMessageFlagsEvent] with op `add`: https://zulip.com/api/get-events#update_message_flags-add +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdateMessageFlagsAddEvent extends UpdateMessageFlagsEvent { + @override + String get op => 'add'; + + final bool all; + + UpdateMessageFlagsAddEvent({ + required super.id, + required super.flag, + required super.messages, + required this.all, + }); + + factory UpdateMessageFlagsAddEvent.fromJson(Map json) => + _$UpdateMessageFlagsAddEventFromJson(json); + + @override + Map toJson() => _$UpdateMessageFlagsAddEventToJson(this); +} + +/// An [UpdateMessageFlagsEvent] with op `remove`: https://zulip.com/api/get-events#update_message_flags-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdateMessageFlagsRemoveEvent extends UpdateMessageFlagsEvent { + @override + String get op => 'remove'; + + // final bool all; // deprecated, ignore + final Map? messageDetails; + + UpdateMessageFlagsRemoveEvent({ + required super.id, + required super.flag, + required super.messages, + required this.messageDetails, + }); + + factory UpdateMessageFlagsRemoveEvent.fromJson(Map json) { + final result = _$UpdateMessageFlagsRemoveEventFromJson(json); + // Crunchy-shell validation + if ( + result.flag == MessageFlag.read + && true // (we assume `event_types` has `message` and `update_message_flags`) + ) { + result.messageDetails as Map; + } + return result; + } + + @override + Map toJson() => _$UpdateMessageFlagsRemoveEventToJson(this); +} + +/// As in [UpdateMessageFlagsRemoveEvent.messageDetails]. +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdateMessageFlagsMessageDetail { + final MessageType type; + final bool? mentioned; + final List? userIds; + final int? streamId; + final String? topic; + + UpdateMessageFlagsMessageDetail({ + required this.type, + required this.mentioned, + required this.userIds, + required this.streamId, + required this.topic, + }); + + factory UpdateMessageFlagsMessageDetail.fromJson(Map json) { + final result = _$UpdateMessageFlagsMessageDetailFromJson(json); + // Crunchy-shell validation + switch (result.type) { + case MessageType.stream: + result.streamId as int; + result.topic as String; + case MessageType.private: + result.userIds as List; + } + return result; + } + + Map toJson() => _$UpdateMessageFlagsMessageDetailToJson(this); +} + /// A Zulip event of type `reaction`, with op `add` or `remove`. /// /// See: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index b84f6991db..8424ac3315 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -275,6 +275,75 @@ const _$MessageTypeEnumMap = { MessageType.private: 'private', }; +UpdateMessageFlagsAddEvent _$UpdateMessageFlagsAddEventFromJson( + Map json) => + UpdateMessageFlagsAddEvent( + id: json['id'] as int, + flag: $enumDecode(_$MessageFlagEnumMap, json['flag'], + unknownValue: MessageFlag.unknown), + messages: + (json['messages'] as List).map((e) => e as int).toList(), + all: json['all'] as bool, + ); + +Map _$UpdateMessageFlagsAddEventToJson( + UpdateMessageFlagsAddEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'flag': instance.flag, + 'messages': instance.messages, + 'all': instance.all, + }; + +UpdateMessageFlagsRemoveEvent _$UpdateMessageFlagsRemoveEventFromJson( + Map json) => + UpdateMessageFlagsRemoveEvent( + id: json['id'] as int, + flag: $enumDecode(_$MessageFlagEnumMap, json['flag'], + unknownValue: MessageFlag.unknown), + messages: + (json['messages'] as List).map((e) => e as int).toList(), + messageDetails: (json['message_details'] as Map?)?.map( + (k, e) => MapEntry( + int.parse(k), + UpdateMessageFlagsMessageDetail.fromJson( + e as Map)), + ), + ); + +Map _$UpdateMessageFlagsRemoveEventToJson( + UpdateMessageFlagsRemoveEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'flag': instance.flag, + 'messages': instance.messages, + 'message_details': + instance.messageDetails?.map((k, e) => MapEntry(k.toString(), e)), + }; + +UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson( + Map json) => + UpdateMessageFlagsMessageDetail( + type: $enumDecode(_$MessageTypeEnumMap, json['type']), + mentioned: json['mentioned'] as bool?, + userIds: + (json['user_ids'] as List?)?.map((e) => e as int).toList(), + streamId: json['stream_id'] as int?, + topic: json['topic'] as String?, + ); + +Map _$UpdateMessageFlagsMessageDetailToJson( + UpdateMessageFlagsMessageDetail instance) => + { + 'type': _$MessageTypeEnumMap[instance.type]!, + 'mentioned': instance.mentioned, + 'user_ids': instance.userIds, + 'stream_id': instance.streamId, + 'topic': instance.topic, + }; + ReactionEvent _$ReactionEventFromJson(Map json) => ReactionEvent( id: json['id'] as int, diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index a08de48185..1b93fc99c7 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -420,6 +420,35 @@ class MessageListView with ChangeNotifier, _MessageSequence { notifyListeners(); } + void maybeUpdateMessageFlags(UpdateMessageFlagsEvent event) { + final isAdd = switch (event) { + UpdateMessageFlagsAddEvent() => true, + UpdateMessageFlagsRemoveEvent() => false, + }; + + bool didUpdateAny = false; + if (isAdd && (event as UpdateMessageFlagsAddEvent).all) { + for (final message in messages) { + message.flags.add(event.flag); + didUpdateAny = true; + } + } else { + for (final messageId in event.messages) { + final index = _findMessageWithId(messageId); + if (index != -1) { + final message = messages[index]; + isAdd ? message.flags.add(event.flag) : message.flags.remove(event.flag); + didUpdateAny = true; + } + } + } + if (!didUpdateAny) { + return; + } + + notifyListeners(); + } + void maybeUpdateMessageReactions(ReactionEvent event) { final index = _findMessageWithId(event.messageId); if (index == -1) { diff --git a/lib/model/store.dart b/lib/model/store.dart index 630b6dce00..8860d815f3 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -309,6 +309,11 @@ class PerAccountStore extends ChangeNotifier { } else if (event is DeleteMessageEvent) { assert(debugLog("server event: delete_message ${event.messageIds}")); // TODO handle + } else if (event is UpdateMessageFlagsEvent) { + assert(debugLog("server event: update_message_flags/${event.op} ${event.flag.toJson()}")); + for (final view in _messageListViews) { + view.maybeUpdateMessageFlags(event); + } } else if (event is ReactionEvent) { assert(debugLog("server event: reaction/${event.op}")); for (final view in _messageListViews) { diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 7e0a404ca6..337fa99b94 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -10,19 +10,6 @@ import 'events_checks.dart'; import 'model_checks.dart'; void main() { - test('message: move flags into message object', () { - final message = eg.streamMessage(); - MessageEvent mkEvent(List flags) => Event.fromJson({ - 'type': 'message', - 'id': 1, - 'message': (deepToJson(message) as Map)..remove('flags'), - 'flags': flags.map((f) => f.toJson()).toList(), - }) as MessageEvent; - check(mkEvent(message.flags)).message.jsonEquals(message); - check(mkEvent([])).message.flags.deepEquals([]); - check(mkEvent([MessageFlag.read])).message.flags.deepEquals([MessageFlag.read]); - }); - test('user_settings: all known settings have event handling', () { final dataClassFieldNames = UserSettings.debugKnownNames; final enumNames = UserSettingName.values.map((n) => n.name); @@ -42,4 +29,35 @@ void main() { ' on the pattern of the existing cases.' ).isEmpty(); }); + + test('message: move flags into message object', () { + final message = eg.streamMessage(); + MessageEvent mkEvent(List flags) => Event.fromJson({ + 'type': 'message', + 'id': 1, + 'message': (deepToJson(message) as Map)..remove('flags'), + 'flags': flags.map((f) => f.toJson()).toList(), + }) as MessageEvent; + check(mkEvent(message.flags)).message.jsonEquals(message); + check(mkEvent([])).message.flags.deepEquals([]); + check(mkEvent([MessageFlag.read])).message.flags.deepEquals([MessageFlag.read]); + }); + + test('update_message_flags/remove: require messageDetails in mark-as-unread', () { + final baseJson = { + 'id': 1, + 'type': 'update_message_flags', + 'op': 'remove', + 'flag': 'starred', + 'messages': [123], + 'all': false, + }; + check(() => UpdateMessageFlagsRemoveEvent.fromJson(baseJson)).returnsNormally(); + check(() => UpdateMessageFlagsRemoveEvent.fromJson({ + ...baseJson, 'flag': 'read', + })).throws(); + check(() => UpdateMessageFlagsRemoveEvent.fromJson({ + ...baseJson, 'message_details': {'123': {'type': 'private', 'mentioned': false, 'user_ids': [2]}}, + })).returnsNormally(); + }); } diff --git a/test/example_data.dart b/test/example_data.dart index cc6472a3a2..7f8206c735 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -155,7 +155,7 @@ StreamMessage streamMessage({ int? lastEditTimestamp, List? reactions, int? timestamp, - List? flags, + List? flags, }) { final effectiveStream = stream ?? _stream(); // The use of JSON here is convenient in order to delegate parts of the data @@ -191,7 +191,7 @@ DmMessage dmMessage({ String? contentMarkdown, int? lastEditTimestamp, int? timestamp, - List? flags, + List? flags, }) { assert(!to.any((user) => user.userId == from.userId)); return DmMessage.fromJson({ diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index de2288a441..d93af2258d 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -439,6 +439,137 @@ void main() async { }); }); + group('maybeUpdateMessageFlags', () { + UpdateMessageFlagsAddEvent mkAddEvent( + MessageFlag flag, + List messageIds, { + bool all = false, + }) { + return UpdateMessageFlagsAddEvent( + id: 1, + flag: flag, + messages: messageIds, + all: all, + ); + } + + UpdateMessageFlagsRemoveEvent mkRemoveEvent(MessageFlag flag, List messages) { + final messageDetails = Map.fromEntries(messages.map((message) { + final mentioned = message.flags.contains(MessageFlag.mentioned) + || message.flags.contains(MessageFlag.wildcardMentioned); + return MapEntry( + message.id, + switch (message) { + StreamMessage() => UpdateMessageFlagsMessageDetail( + type: MessageType.stream, + mentioned: mentioned, + streamId: message.streamId, + topic: message.subject, + userIds: null, + ), + DmMessage() => UpdateMessageFlagsMessageDetail( + type: MessageType.private, + mentioned: mentioned, + streamId: null, + topic: null, + userIds: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId).otherRecipientIds, + ), + }); + })); + + return UpdateMessageFlagsRemoveEvent( + id: 1, + flag: flag, + messages: messages.map((m) => m.id).toList(), + messageDetails: messageDetails, + ); + } + + group('add flag', () { + test('not in list', () async { + prepare(); + final message = eg.streamMessage(id: 1, flags: []); + await prepareMessages(foundOldest: true, messages: [message]); + model.maybeUpdateMessageFlags(mkAddEvent(MessageFlag.read, [2])); + checkNotNotified(); + check(model).messages.single.flags.deepEquals([]); + }); + + test('affected message, unaffected message, absent message', () async { + prepare(); + final message1 = eg.streamMessage(id: 1, flags: []); + final message2 = eg.streamMessage(id: 2, flags: []); + await prepareMessages(foundOldest: true, messages: [message1, message2]); + model.maybeUpdateMessageFlags(mkAddEvent(MessageFlag.read, [message2.id, 3])); + checkNotifiedOnce(); + check(model).messages + ..[0].flags.deepEquals([]) + ..[1].flags.deepEquals([MessageFlag.read]); + }); + + test('all: true, list non-empty', () async { + prepare(); + final message1 = eg.streamMessage(id: 1, flags: []); + final message2 = eg.streamMessage(id: 2, flags: []); + await prepareMessages(foundOldest: true, messages: [message1, message2]); + model.maybeUpdateMessageFlags(mkAddEvent(MessageFlag.read, [], all: true)); + checkNotifiedOnce(); + check(model).messages + ..[0].flags.deepEquals([MessageFlag.read]) + ..[1].flags.deepEquals([MessageFlag.read]); + }); + + test('all: true, list empty', () async { + prepare(); + await prepareMessages(foundOldest: true, messages: []); + model.maybeUpdateMessageFlags(mkAddEvent(MessageFlag.read, [], all: true)); + checkNotNotified(); + }); + + test('other flags not clobbered', () async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + prepare(); + await prepareMessages(foundOldest: true, messages: [message]); + model.maybeUpdateMessageFlags(mkAddEvent(MessageFlag.read, [message.id])); + checkNotifiedOnce(); + check(model).messages.single.flags.deepEquals([MessageFlag.starred, MessageFlag.read]); + }); + }); + + group('remove flag', () { + test('not in list', () async { + prepare(); + final message = eg.streamMessage(id: 1, flags: [MessageFlag.read]); + await prepareMessages(foundOldest: true, messages: [message]); + model.maybeUpdateMessageFlags(mkAddEvent(MessageFlag.read, [2])); + checkNotNotified(); + check(model).messages.single.flags.deepEquals([MessageFlag.read]); + }); + + test('affected message, unaffected message, absent message', () async { + prepare(); + final message1 = eg.streamMessage(id: 1, flags: [MessageFlag.read]); + final message2 = eg.streamMessage(id: 2, flags: [MessageFlag.read]); + final message3 = eg.streamMessage(id: 3, flags: [MessageFlag.read]); + await prepareMessages(foundOldest: true, messages: [message1, message2]); + model.maybeUpdateMessageFlags(mkRemoveEvent(MessageFlag.read, [message2, message3])); + checkNotifiedOnce(); + check(model).messages + ..[0].flags.deepEquals([MessageFlag.read]) + ..[1].flags.deepEquals([]); + }); + + test('other flags not affected', () async { + final message = eg.streamMessage(flags: [MessageFlag.starred, MessageFlag.read]); + prepare(); + await prepareMessages(foundOldest: true, messages: [message]); + model.maybeUpdateMessageFlags(mkRemoveEvent(MessageFlag.read, [message])); + checkNotifiedOnce(); + check(model).messages.single.flags.deepEquals([MessageFlag.starred]); + }); + }); + }); + test('reassemble', () async { final stream = eg.stream(); prepare(narrow: StreamNarrow(stream.streamId)); diff --git a/test/stdlib_checks.dart b/test/stdlib_checks.dart index 2829c92ec5..87b3026288 100644 --- a/test/stdlib_checks.dart +++ b/test/stdlib_checks.dart @@ -9,6 +9,10 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:http/http.dart' as http; +extension ListChecks on Subject> { + Subject operator [](int index) => has((l) => l[index], '[$index]'); +} + extension NullableMapChecks on Subject?> { void deepEquals(Map? expected) { if (expected == null) {