From db46fcf3f05791011662e2a72a1f87c9fb710657 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 26 Oct 2023 15:23:16 +0100 Subject: [PATCH 1/3] api [nfc]: Factor out logic that supports serializing ApiNarrowDm Also pull out narrow.toJson tests. --- lib/api/model/narrow.dart | 16 +++++++ lib/api/route/messages.dart | 9 +--- test/api/route/messages_test.dart | 74 ++++++++++++++++++------------- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 438e0939f8..8907c4de76 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -1,5 +1,19 @@ typedef ApiNarrow = List; +/// Resolve any [ApiNarrowDm] elements appropriately. +/// +/// This encapsulates a server-feature check. +ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) { + if (!narrow.any((element) => element is ApiNarrowDm)) { + return narrow; + } + final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) + return narrow.map((element) => switch (element) { + ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), + _ => element, + }).toList(); +} + /// An element in the list representing a narrow in the Zulip API. /// /// Docs: @@ -51,6 +65,8 @@ class ApiNarrowTopic extends ApiNarrowElement { /// An instance directly of this class must not be serialized with [jsonEncode], /// and more generally its [operator] getter must not be called. /// Instead, call [resolve] and use the object it returns. +/// +/// If part of [ApiNarrow] use [resolveDmElements]. class ApiNarrowDm extends ApiNarrowElement { @override String get operator { assert(false, diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 21638ee44c..75d6f47f14 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -90,15 +90,8 @@ Future getMessages(ApiConnection connection, { bool? applyMarkdown, // bool? useFirstUnreadAnchor // omitted because deprecated }) { - if (narrow.any((element) => element is ApiNarrowDm)) { - final supportsOperatorDm = connection.zulipFeatureLevel! >= 177; // TODO(server-7) - narrow = narrow.map((element) => switch (element) { - ApiNarrowDm() => element.resolve(legacy: !supportsOperatorDm), - _ => element, - }).toList(); - } return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { - 'narrow': narrow, + 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), 'anchor': switch (anchor) { NumericAnchor(:var messageId) => messageId, AnchorCode.newest => RawParameter('newest'), diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 5670bc3118..c5284939c3 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -172,6 +172,34 @@ void main() { }); }); + test('Narrow.toJson', () { + return FakeApiConnection.with_((connection) async { + void checkNarrow(ApiNarrow narrow, String expected) { + narrow = resolveDmElements(narrow, connection.zulipFeatureLevel!); + check(jsonEncode(narrow)).equals(expected); + } + + checkNarrow(const AllMessagesNarrow().apiEncode(), jsonEncode([])); + checkNarrow(const StreamNarrow(12).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + ])); + checkNarrow(const TopicNarrow(12, 'stuff').apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); + + checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ + {'operator': 'dm', 'operand': [123, 234]}, + ])); + + connection.zulipFeatureLevel = 176; + checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ + {'operator': 'pm-with', 'operand': [123, 234]}, + ])); + connection.zulipFeatureLevel = eg.futureZulipFeatureLevel; + }); + }); + group('getMessages', () { Future checkGetMessages( FakeApiConnection connection, { @@ -215,38 +243,20 @@ void main() { }); }); - test('narrow', () { - return FakeApiConnection.with_((connection) async { - Future checkNarrow(ApiNarrow narrow, String expected) async { - connection.prepare(json: fakeResult.toJson()); - await checkGetMessages(connection, - narrow: narrow, - anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, - expected: { - 'narrow': expected, - 'anchor': 'newest', - 'num_before': '10', - 'num_after': '20', - }); - } - - await checkNarrow(const AllMessagesNarrow().apiEncode(), jsonEncode([])); - await checkNarrow(const StreamNarrow(12).apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, - ])); - await checkNarrow(const TopicNarrow(12, 'stuff').apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, - {'operator': 'topic', 'operand': 'stuff'}, - ])); - - await checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ - {'operator': 'dm', 'operand': [123, 234]}, - ])); - connection.zulipFeatureLevel = 176; - await checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ - {'operator': 'pm-with', 'operand': [123, 234]}, - ])); - connection.zulipFeatureLevel = eg.futureZulipFeatureLevel; + test('narrow uses resolveDmElements to encode', () { + return FakeApiConnection.with_(zulipFeatureLevel: 176, (connection) async { + connection.prepare(json: fakeResult.toJson()); + await checkGetMessages(connection, + narrow: [ApiNarrowDm([123, 234])], + anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + expected: { + 'narrow': jsonEncode([ + {'operator': 'pm-with', 'operand': [123, 234]}, + ]), + 'anchor': 'newest', + 'num_before': '10', + 'num_after': '20', + }); }); }); From dfb1a9ea669821c5cb6ea8d15f644c3d92c71fdb Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 26 Oct 2023 20:28:23 +0100 Subject: [PATCH 2/3] api [nfc]: Pull out a method Anchor.toJson --- lib/api/route/messages.dart | 20 +++++++++----- lib/api/route/messages.g.dart | 6 +++++ test/api/route/messages_test.dart | 45 +++++++++++++++++-------------- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 75d6f47f14..d707b28ccb 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -92,12 +92,7 @@ Future getMessages(ApiConnection connection, { }) { return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), - 'anchor': switch (anchor) { - NumericAnchor(:var messageId) => messageId, - AnchorCode.newest => RawParameter('newest'), - AnchorCode.oldest => RawParameter('oldest'), - AnchorCode.firstUnread => RawParameter('first_unread'), - }, + 'anchor': RawParameter(anchor.toJson()), if (includeAnchor != null) 'include_anchor': includeAnchor, 'num_before': numBefore, 'num_after': numAfter, @@ -112,17 +107,28 @@ Future getMessages(ApiConnection connection, { sealed class Anchor { /// This const constructor allows subclasses to have const constructors. const Anchor(); + + String toJson(); } /// An anchor value for [getMessages] other than a specific message ID. /// /// https://zulip.com/api/get-messages#parameter-anchor -enum AnchorCode implements Anchor { newest, oldest, firstUnread } +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum AnchorCode implements Anchor { + newest, oldest, firstUnread; + + @override + String toJson() => _$AnchorCodeEnumMap[this]!; +} /// A specific message ID, used as an anchor in [getMessages]. class NumericAnchor extends Anchor { const NumericAnchor(this.messageId); final int messageId; + + @override + String toJson() => messageId.toString(); } @JsonSerializable(fieldRename: FieldRename.snake) diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 3eea6dc1e1..724327135d 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -59,3 +59,9 @@ Map _$UploadFileResultToJson(UploadFileResult instance) => { 'uri': instance.uri, }; + +const _$AnchorCodeEnumMap = { + AnchorCode.newest: 'newest', + AnchorCode.oldest: 'oldest', + AnchorCode.firstUnread: 'first_unread', +}; diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index c5284939c3..b5553db0ce 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -200,6 +200,19 @@ void main() { }); }); + test('Anchor.toJson', () { + void checkAnchor(Anchor anchor, String expected) { + check(anchor.toJson()).equals(expected); + } + + checkAnchor(AnchorCode.newest, 'newest'); + checkAnchor(AnchorCode.oldest, 'oldest'); + checkAnchor(AnchorCode.firstUnread, 'first_unread'); + checkAnchor(const NumericAnchor(1), '1'); + checkAnchor(const NumericAnchor(999999999), '999999999'); + checkAnchor(const NumericAnchor(10000000000000000), '10000000000000000'); + }); + group('getMessages', () { Future checkGetMessages( FakeApiConnection connection, { @@ -260,27 +273,19 @@ void main() { }); }); - test('anchor', () { + test('numeric anchor', () { return FakeApiConnection.with_((connection) async { - Future checkAnchor(Anchor anchor, String expected) async { - connection.prepare(json: fakeResult.toJson()); - await checkGetMessages(connection, - narrow: const AllMessagesNarrow().apiEncode(), - anchor: anchor, numBefore: 10, numAfter: 20, - expected: { - 'narrow': jsonEncode([]), - 'anchor': expected, - 'num_before': '10', - 'num_after': '20', - }); - } - - await checkAnchor(AnchorCode.newest, 'newest'); - await checkAnchor(AnchorCode.oldest, 'oldest'); - await checkAnchor(AnchorCode.firstUnread, 'first_unread'); - await checkAnchor(const NumericAnchor(1), '1'); - await checkAnchor(const NumericAnchor(999999999), '999999999'); - await checkAnchor(const NumericAnchor(10000000000000000), '10000000000000000'); + connection.prepare(json: fakeResult.toJson()); + await checkGetMessages(connection, + narrow: const AllMessagesNarrow().apiEncode(), + anchor: const NumericAnchor(42), + numBefore: 10, numAfter: 20, + expected: { + 'narrow': jsonEncode([]), + 'anchor': '42', + 'num_before': '10', + 'num_after': '20', + }); }); }); }); From dfcc6ca080cc62987be0666a1a5561cb77685788 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Mon, 30 Oct 2023 23:06:28 +0000 Subject: [PATCH 3/3] api: Add updateMessageFlags and updateMessageFlagsForNarrow routes --- lib/api/model/model.dart | 2 +- lib/api/route/messages.dart | 85 +++++++++++++++++++++ lib/api/route/messages.g.dart | 40 ++++++++++ test/api/route/messages_test.dart | 120 ++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 86e70ef965..9c83e6cdb4 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -430,7 +430,7 @@ sealed class Message { Map toJson(); } -/// As in [Message.flags]. +/// https://zulip.com/api/update-message-flags#available-flags @JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) enum MessageFlag { read, diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index d707b28ccb..b01d2c3bb7 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -288,3 +288,88 @@ Future removeReaction(ApiConnection connection, { 'reaction_type': RawParameter(reactionType.toJson()), }); } + +/// https://zulip.com/api/update-message-flags +Future updateMessageFlags(ApiConnection connection, { + required List messages, + required UpdateMessageFlagsOp op, + required MessageFlag flag, +}) { + return connection.post('updateMessageFlags', UpdateMessageFlagsResult.fromJson, 'messages/flags', { + 'messages': messages, + 'op': RawParameter(op.toJson()), + 'flag': RawParameter(flag.toJson()), + }); +} + +/// An `op` value for [updateMessageFlags] and [updateMessageFlagsForNarrow]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum UpdateMessageFlagsOp { + add, + remove; + + String toJson() => _$UpdateMessageFlagsOpEnumMap[this]!; +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdateMessageFlagsResult { + final List messages; + + UpdateMessageFlagsResult({ + required this.messages, + }); + + factory UpdateMessageFlagsResult.fromJson(Map json) => + _$UpdateMessageFlagsResultFromJson(json); + + Map toJson() => _$UpdateMessageFlagsResultToJson(this); +} + +/// https://zulip.com/api/update-message-flags-for-narrow +/// +/// This binding only supports feature levels 155+. +// TODO(server-6) remove FL 155+ mention in doc, and the related `assert` +Future updateMessageFlagsForNarrow(ApiConnection connection, { + required Anchor anchor, + bool? includeAnchor, + required int numBefore, + required int numAfter, + required ApiNarrow narrow, + required UpdateMessageFlagsOp op, + required MessageFlag flag, +}) { + assert(connection.zulipFeatureLevel! >= 155); + return connection.post('updateMessageFlagsForNarrow', UpdateMessageFlagsForNarrowResult.fromJson, 'messages/flags/narrow', { + 'anchor': RawParameter(anchor.toJson()), + if (includeAnchor != null) 'include_anchor': includeAnchor, + 'num_before': numBefore, + 'num_after': numAfter, + 'narrow': resolveDmElements(narrow, connection.zulipFeatureLevel!), + 'op': RawParameter(op.toJson()), + 'flag': RawParameter(flag.toJson()), + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UpdateMessageFlagsForNarrowResult { + final int processedCount; + final int updatedCount; + final int? firstProcessedId; + final int? lastProcessedId; + final bool foundOldest; + final bool foundNewest; + + UpdateMessageFlagsForNarrowResult({ + required this.processedCount, + required this.updatedCount, + required this.firstProcessedId, + required this.lastProcessedId, + required this.foundOldest, + required this.foundNewest, + }); + + factory UpdateMessageFlagsForNarrowResult.fromJson(Map json) => + _$UpdateMessageFlagsForNarrowResultFromJson(json); + + Map toJson() => _$UpdateMessageFlagsForNarrowResultToJson(this); +} diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index 724327135d..94ffafd939 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -60,8 +60,48 @@ Map _$UploadFileResultToJson(UploadFileResult instance) => 'uri': instance.uri, }; +UpdateMessageFlagsResult _$UpdateMessageFlagsResultFromJson( + Map json) => + UpdateMessageFlagsResult( + messages: + (json['messages'] as List).map((e) => e as int).toList(), + ); + +Map _$UpdateMessageFlagsResultToJson( + UpdateMessageFlagsResult instance) => + { + 'messages': instance.messages, + }; + +UpdateMessageFlagsForNarrowResult _$UpdateMessageFlagsForNarrowResultFromJson( + Map json) => + UpdateMessageFlagsForNarrowResult( + processedCount: json['processed_count'] as int, + updatedCount: json['updated_count'] as int, + firstProcessedId: json['first_processed_id'] as int?, + lastProcessedId: json['last_processed_id'] as int?, + foundOldest: json['found_oldest'] as bool, + foundNewest: json['found_newest'] as bool, + ); + +Map _$UpdateMessageFlagsForNarrowResultToJson( + UpdateMessageFlagsForNarrowResult instance) => + { + 'processed_count': instance.processedCount, + 'updated_count': instance.updatedCount, + 'first_processed_id': instance.firstProcessedId, + 'last_processed_id': instance.lastProcessedId, + 'found_oldest': instance.foundOldest, + 'found_newest': instance.foundNewest, + }; + const _$AnchorCodeEnumMap = { AnchorCode.newest: 'newest', AnchorCode.oldest: 'oldest', AnchorCode.firstUnread: 'first_unread', }; + +const _$UpdateMessageFlagsOpEnumMap = { + UpdateMessageFlagsOp.add: 'add', + UpdateMessageFlagsOp.remove: 'remove', +}; diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index b5553db0ce..8127842a3e 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -451,4 +451,124 @@ void main() { }); }); }); + + group('updateMessageFlags', () { + Future checkUpdateMessageFlags( + FakeApiConnection connection, { + required List messages, + required UpdateMessageFlagsOp op, + required MessageFlag flag, + required Map expected, + }) async { + final result = await updateMessageFlags(connection, + messages: messages, op: op, flag: flag); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') + ..bodyFields.deepEquals(expected); + return result; + } + + test('smoke', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: + UpdateMessageFlagsResult(messages: [1, 2]).toJson()); + await checkUpdateMessageFlags(connection, + messages: [1, 2, 3], + op: UpdateMessageFlagsOp.add, flag: MessageFlag.read, + expected: { + 'messages': jsonEncode([1, 2, 3]), + 'op': 'add', + 'flag': 'read', + }); + }); + }); + }); + + group('updateMessageFlagsForNarrow', () { + Future checkUpdateMessageFlagsForNarrow( + FakeApiConnection connection, { + required Anchor anchor, + required int numBefore, + required int numAfter, + required ApiNarrow narrow, + required UpdateMessageFlagsOp op, + required MessageFlag flag, + required Map expected, + }) async { + final result = await updateMessageFlagsForNarrow(connection, + anchor: anchor, numBefore: numBefore, numAfter: numAfter, + narrow: narrow, op: op, flag: flag); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals(expected); + return result; + } + + UpdateMessageFlagsForNarrowResult mkResult({required bool foundOldest}) => + UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: foundOldest, foundNewest: true); + + test('smoke', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: mkResult(foundOldest: true).toJson()); + await checkUpdateMessageFlagsForNarrow(connection, + anchor: AnchorCode.oldest, + numBefore: 0, numAfter: 20, + narrow: const AllMessagesNarrow().apiEncode(), + op: UpdateMessageFlagsOp.add, flag: MessageFlag.read, + expected: { + 'anchor': 'oldest', + 'num_before': '0', + 'num_after': '20', + 'narrow': jsonEncode([]), + 'op': 'add', + 'flag': 'read', + }); + }); + }); + + test('narrow uses resolveDmElements to encode', () { + return FakeApiConnection.with_(zulipFeatureLevel: 176, (connection) async { + connection.prepare(json: mkResult(foundOldest: true).toJson()); + await checkUpdateMessageFlagsForNarrow(connection, + anchor: AnchorCode.oldest, + numBefore: 0, numAfter: 20, + narrow: [ApiNarrowDm([123, 234])], + op: UpdateMessageFlagsOp.add, flag: MessageFlag.read, + expected: { + 'anchor': 'oldest', + 'num_before': '0', + 'num_after': '20', + 'narrow': jsonEncode([ + {'operator': 'pm-with', 'operand': [123, 234]}, + ]), + 'op': 'add', + 'flag': 'read', + }); + }); + }); + + test('numeric anchor', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: mkResult(foundOldest: false).toJson()); + await checkUpdateMessageFlagsForNarrow(connection, + anchor: const NumericAnchor(42), + numBefore: 0, numAfter: 20, + narrow: const AllMessagesNarrow().apiEncode(), + op: UpdateMessageFlagsOp.add, flag: MessageFlag.read, + expected: { + 'anchor': '42', + 'num_before': '0', + 'num_after': '20', + 'narrow': jsonEncode([]), + 'op': 'add', + 'flag': 'read', + }); + }); + }); + }); }