diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 8faabba6f0..3f9ebf2bc8 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -14,24 +14,29 @@ typedef ApiNarrow = List; /// reasonably be omitted will be omitted. ApiNarrow resolveApiNarrowForServer(ApiNarrow narrow, int zulipFeatureLevel) { final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7) + final supportsOperatorChannel = zulipFeatureLevel >= 250; // TODO(server-9) final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9) bool hasDmElement = false; + bool hasChannelElement = false; bool hasWithElement = false; for (final element in narrow) { switch (element) { - case ApiNarrowDm(): hasDmElement = true; - case ApiNarrowWith(): hasWithElement = true; + case ApiNarrowChannel(): hasChannelElement = true; + case ApiNarrowDm(): hasDmElement = true; + case ApiNarrowWith(): hasWithElement = true; default: } } - if (!(hasDmElement || (hasWithElement && !supportsOperatorWith))) { + if (!(hasChannelElement || hasDmElement || (hasWithElement && !supportsOperatorWith))) { return narrow; } final result = []; for (final element in narrow) { switch (element) { + case ApiNarrowChannel(): + result.add(element.resolve(legacy: !supportsOperatorChannel)); case ApiNarrowDm(): result.add(element.resolve(legacy: !supportsOperatorDm)); case ApiNarrowWith() when !supportsOperatorWith: @@ -93,17 +98,51 @@ sealed class ApiNarrowElement { }; } -class ApiNarrowStream extends ApiNarrowElement { - @override String get operator => 'stream'; +class ApiNarrowChannel extends ApiNarrowElement { + @override String get operator { + assert(false, + "The [operator] getter was called on a plain [ApiNarrowChannel]. " + "Before passing to [jsonEncode] or otherwise getting [operator], " + "the [ApiNarrowChannel] must be replaced by the result of [ApiNarrowChannel.resolve]." + ); + return "channel"; + } @override final int operand; - ApiNarrowStream(this.operand, {super.negated}); + ApiNarrowChannel(this.operand, {super.negated}); - factory ApiNarrowStream.fromJson(Map json) => ApiNarrowStream( - json['operand'] as int, - negated: json['negated'] as bool? ?? false, - ); + factory ApiNarrowChannel.fromJson(Map json) { + var operand = (json['operand'] as int); + var negated = json['negated'] as bool? ?? false; + return json['operator'] == 'stream' + ? ApiNarrowStream._(operand, negated: negated) + : ApiNarrowChannelModern._(operand, negated: negated); + } + + /// This element resolved, as either an [ApiNarrowChannelModern] or an [ApiNarrowStream]. + ApiNarrowChannel resolve({required bool legacy}) { + return legacy ? ApiNarrowStream._(operand, negated: negated) + : ApiNarrowChannelModern._(operand, negated: negated); + } +} + +/// An [ApiNarrowElement] with the 'channel' operator (and not the legacy 'stream'). +/// +/// To construct one of these, use [ApiNarrowChannel.resolve]. +class ApiNarrowChannelModern extends ApiNarrowChannel { + @override String get operator => 'channel'; + + ApiNarrowChannelModern._(super.operand, {super.negated}); +} + +/// An [ApiNarrowElement] with the legacy 'stream' operator. +/// +/// To construct one of these, use [ApiNarrowChannel.resolve]. +class ApiNarrowStream extends ApiNarrowChannel { + @override String get operator => 'stream'; + + ApiNarrowStream._(super.operand, {super.negated}); } class ApiNarrowTopic extends ApiNarrowElement { diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index 92d6db1687..58bcd58b71 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -71,11 +71,14 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { fragment.write('${element.operator}/'); switch (element) { + case ApiNarrowChannelModern(): case ApiNarrowStream(): final streamId = element.operand; final name = store.streams[streamId]?.name ?? 'unknown'; final slugifiedName = _encodeHashComponent(name.replaceAll(' ', '-')); fragment.write('$streamId-$slugifiedName'); + case ApiNarrowChannel(): + assert(false, 'ApiNarrowChannel should have been resolved'); case ApiNarrowTopic(): fragment.write(_encodeHashComponent(element.operand.apiName)); case ApiNarrowDmModern(): @@ -182,7 +185,7 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor assert(segments.isNotEmpty); assert(segments.length.isEven); - ApiNarrowStream? streamElement; + ApiNarrowChannel? channelElement; ApiNarrowTopic? topicElement; ApiNarrowDm? dmElement; ApiNarrowWith? withElement; @@ -196,10 +199,10 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor switch (operator) { case _NarrowOperator.stream: case _NarrowOperator.channel: - if (streamElement != null) return null; + if (channelElement != null) return null; final streamId = _parseStreamOperand(operand, store); if (streamId == null) return null; - streamElement = ApiNarrowStream(streamId, negated: negated); + channelElement = ApiNarrowChannel(streamId, negated: negated); case _NarrowOperator.topic: case _NarrowOperator.subject: @@ -238,7 +241,7 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor final Narrow? narrow; if (isElementOperands.isNotEmpty) { - if (streamElement != null || topicElement != null || dmElement != null || withElement != null) { + if (channelElement != null || topicElement != null || dmElement != null || withElement != null) { return null; } if (isElementOperands.length > 1) return null; @@ -257,10 +260,10 @@ NarrowLink? _interpretNarrowSegments(List segments, PerAccountStore stor return null; } } else if (dmElement != null) { - if (streamElement != null || topicElement != null || withElement != null) return null; + if (channelElement != null || topicElement != null || withElement != null) return null; narrow = DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId); - } else if (streamElement != null) { - final streamId = streamElement.operand; + } else if (channelElement != null) { + final streamId = channelElement.operand; if (topicElement != null) { narrow = TopicNarrow(streamId, topicElement.operand, with_: withElement?.operand); } else { diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index ff4ccfbbc0..863867a195 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -80,7 +80,7 @@ class ChannelNarrow extends Narrow { } @override - ApiNarrow apiEncode() => [ApiNarrowStream(streamId)]; + ApiNarrow apiEncode() => [ApiNarrowChannel(streamId)]; @override String toString() => 'ChannelNarrow($streamId)'; @@ -117,7 +117,7 @@ class TopicNarrow extends Narrow implements SendableNarrow { @override ApiNarrow apiEncode() => [ - ApiNarrowStream(streamId), + ApiNarrowChannel(streamId), ApiNarrowTopic(topic), if (with_ != null) ApiNarrowWith(with_!), ]; diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index a862b512b6..4de2bd1de7 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -214,14 +214,14 @@ void main() { checkNarrow(const CombinedFeedNarrow().apiEncode(), jsonEncode([])); checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, + {'operator': 'channel', 'operand': 12}, ])); checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, + {'operator': 'channel', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, + {'operator': 'channel', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, {'operator': 'with', 'operand': 1}, ])); @@ -235,7 +235,7 @@ void main() { connection.zulipFeatureLevel = 270; checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ - {'operator': 'stream', 'operand': 12}, + {'operator': 'channel', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); checkNarrow([ApiNarrowDm([123, 234])], jsonEncode([ @@ -245,6 +245,19 @@ void main() { {'operator': 'dm', 'operand': [123, 234]}, ])); + connection.zulipFeatureLevel = 249; + checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + ])); + checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); + checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ + {'operator': 'stream', 'operand': 12}, + {'operator': 'topic', 'operand': 'stuff'}, + ])); + connection.zulipFeatureLevel = 176; checkNarrow(eg.topicNarrow(12, 'stuff', with_: 1).apiEncode(), jsonEncode([ {'operator': 'stream', 'operand': 12}, diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 23a2fe8971..2fc1fea37f 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -351,11 +351,11 @@ hello check(quoteAndReplyPlaceholder( GlobalLocalizations.zulipLocalizations, store, message: message)).equals(''' -@_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/stream/1-test-here/topic/some.20topic/near/${message.id}): *(loading message ${message.id})* +@_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/channel/1-test-here/topic/some.20topic/near/${message.id}): *(loading message ${message.id})* '''); check(quoteAndReply(store, message: message, rawContent: 'Hello world!')).equals(''' -@_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/stream/1-test-here/topic/some.20topic/near/${message.id}): +@_**Full Name|123** [said](${eg.selfAccount.realmUrl}#narrow/channel/1-test-here/topic/some.20topic/near/${message.id}): ```quote Hello world! ``` diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index 824def8cc2..f5ff1a76c9 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -60,15 +60,16 @@ void main() { .equals(store.realmUrl.resolve('#narrow/is/starred/near/1')); }); - test('ChannelNarrow / TopicNarrow', () { + group('ChannelNarrow / TopicNarrow', () { void checkNarrow(String expectedFragment, { required int streamId, required String name, String? topic, int? nearMessageId, + int? zulipFeatureLevel = eg.futureZulipFeatureLevel, }) async { assert(expectedFragment.startsWith('#'), 'wrong-looking expectedFragment'); - final store = eg.store(); + final store = eg.store()..connection.zulipFeatureLevel = zulipFeatureLevel; await store.addStream(eg.stream(streamId: streamId, name: name)); final narrow = topic == null ? ChannelNarrow(streamId) @@ -77,22 +78,54 @@ void main() { .equals(store.realmUrl.resolve(expectedFragment)); } - checkNarrow(streamId: 1, name: 'announce', '#narrow/stream/1-announce'); - checkNarrow(streamId: 378, name: 'api design', '#narrow/stream/378-api-design'); - checkNarrow(streamId: 391, name: 'Outreachy', '#narrow/stream/391-Outreachy'); - checkNarrow(streamId: 415, name: 'chat.zulip.org', '#narrow/stream/415-chat.2Ezulip.2Eorg'); - checkNarrow(streamId: 419, name: 'français', '#narrow/stream/419-fran.C3.A7ais'); - checkNarrow(streamId: 403, name: 'Hshs[™~}(.', '#narrow/stream/403-Hshs.5B.E2.84.A2~.7D.28.2E'); - checkNarrow(streamId: 60, name: 'twitter', nearMessageId: 1570686, '#narrow/stream/60-twitter/near/1570686'); - - checkNarrow(streamId: 48, name: 'mobile', topic: 'Welcome screen UI', - '#narrow/stream/48-mobile/topic/Welcome.20screen.20UI'); - checkNarrow(streamId: 243, name: 'mobile-team', topic: 'Podfile.lock clash #F92', - '#narrow/stream/243-mobile-team/topic/Podfile.2Elock.20clash.20.23F92'); - checkNarrow(streamId: 377, name: 'translation/zh_tw', topic: '翻譯 "stream"', - '#narrow/stream/377-translation.2Fzh_tw/topic/.E7.BF.BB.E8.AD.AF.20.22stream.22'); - checkNarrow(streamId: 42, name: 'Outreachy 2016-2017', topic: '2017-18 Stream?', nearMessageId: 302690, - '#narrow/stream/42-Outreachy-2016-2017/topic/2017-18.20Stream.3F/near/302690'); + test('modern including "channel" operator', () { + checkNarrow(streamId: 1, name: 'announce', '#narrow/channel/1-announce'); + checkNarrow(streamId: 378, name: 'api design', '#narrow/channel/378-api-design'); + checkNarrow(streamId: 391, name: 'Outreachy', '#narrow/channel/391-Outreachy'); + checkNarrow(streamId: 415, name: 'chat.zulip.org', '#narrow/channel/415-chat.2Ezulip.2Eorg'); + checkNarrow(streamId: 419, name: 'français', '#narrow/channel/419-fran.C3.A7ais'); + checkNarrow(streamId: 403, name: 'Hshs[™~}(.', '#narrow/channel/403-Hshs.5B.E2.84.A2~.7D.28.2E'); + checkNarrow(streamId: 60, name: 'twitter', nearMessageId: 1570686, '#narrow/channel/60-twitter/near/1570686'); + + checkNarrow(streamId: 48, name: 'mobile', topic: 'Welcome screen UI', + '#narrow/channel/48-mobile/topic/Welcome.20screen.20UI'); + checkNarrow(streamId: 243, name: 'mobile-team', topic: 'Podfile.lock clash #F92', + '#narrow/channel/243-mobile-team/topic/Podfile.2Elock.20clash.20.23F92'); + checkNarrow(streamId: 377, name: 'translation/zh_tw', topic: '翻譯 "stream"', + '#narrow/channel/377-translation.2Fzh_tw/topic/.E7.BF.BB.E8.AD.AF.20.22stream.22'); + checkNarrow(streamId: 42, name: 'Outreachy 2016-2017', topic: '2017-18 Stream?', nearMessageId: 302690, + '#narrow/channel/42-Outreachy-2016-2017/topic/2017-18.20Stream.3F/near/302690'); + }); + + test('legacy including "stream" operator', () { + checkNarrow(streamId: 1, name: 'announce', zulipFeatureLevel: 249, + '#narrow/stream/1-announce'); + checkNarrow(streamId: 378, name: 'api design', zulipFeatureLevel: 249, + '#narrow/stream/378-api-design'); + checkNarrow(streamId: 391, name: 'Outreachy', zulipFeatureLevel: 249, + '#narrow/stream/391-Outreachy'); + checkNarrow(streamId: 415, name: 'chat.zulip.org', zulipFeatureLevel: 249, + '#narrow/stream/415-chat.2Ezulip.2Eorg'); + checkNarrow(streamId: 419, name: 'français', zulipFeatureLevel: 249, + '#narrow/stream/419-fran.C3.A7ais'); + checkNarrow(streamId: 403, name: 'Hshs[™~}(.', zulipFeatureLevel: 249, + '#narrow/stream/403-Hshs.5B.E2.84.A2~.7D.28.2E'); + checkNarrow(streamId: 60, name: 'twitter', nearMessageId: 1570686, zulipFeatureLevel: 249, + '#narrow/stream/60-twitter/near/1570686'); + + checkNarrow(streamId: 48, name: 'mobile', topic: 'Welcome screen UI', + zulipFeatureLevel: 249, + '#narrow/stream/48-mobile/topic/Welcome.20screen.20UI'); + checkNarrow(streamId: 243, name: 'mobile-team', topic: 'Podfile.lock clash #F92', + zulipFeatureLevel: 249, + '#narrow/stream/243-mobile-team/topic/Podfile.2Elock.20clash.20.23F92'); + checkNarrow(streamId: 377, name: 'translation/zh_tw', topic: '翻譯 "stream"', + zulipFeatureLevel: 249, + '#narrow/stream/377-translation.2Fzh_tw/topic/.E7.BF.BB.E8.AD.AF.20.22stream.22'); + checkNarrow(streamId: 42, name: 'Outreachy 2016-2017', topic: '2017-18 Stream?', nearMessageId: 302690, + zulipFeatureLevel: 249, + '#narrow/stream/42-Outreachy-2016-2017/topic/2017-18.20Stream.3F/near/302690'); + }); }); test('DmNarrow', () { diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index dbe2e6d8eb..7134b06f58 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -156,7 +156,7 @@ void main() { ..method.equals('GET') ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ - 'narrow': jsonEncode(narrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!)), 'anchor': anchor, if (includeAnchor != null) 'include_anchor': includeAnchor.toString(), 'num_before': numBefore.toString(), diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 41e0462233..1519b43c99 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -347,7 +347,7 @@ void main() { 'num_before': '0', 'num_after': '1000', 'narrow': jsonEncode([ - {'operator': 'stream', 'operand': channelId}, + {'operator': 'channel', 'operand': channelId}, {'operator': 'is', 'operand': 'unread'}, ]), 'op': 'add', @@ -1011,7 +1011,9 @@ void main() { check(connection.lastRequest).isA() ..url.path.equals('/api/v1/messages/flags/narrow') ..bodyFields['narrow'].equals(jsonEncode([ - ...eg.topicNarrow(someChannel.streamId, someTopic).apiEncode(), + ...resolveApiNarrowForServer( + eg.topicNarrow(someChannel.streamId, someTopic).apiEncode(), + connection.zulipFeatureLevel!), ApiNarrowIs(IsOperand.unread), ])) ..bodyFields['op'].equals('add') @@ -1607,7 +1609,9 @@ void main() { 'include_anchor': 'true', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), + 'narrow': jsonEncode(resolveApiNarrowForServer( + TopicNarrow.ofMessage(message).apiEncode(), + connection.zulipFeatureLevel!)), 'op': 'remove', 'flag': 'read', }); @@ -1652,7 +1656,9 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages/flags/narrow') ..bodyFields['narrow'].equals( - jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode())); + jsonEncode(resolveApiNarrowForServer( + eg.topicNarrow(newStream.streamId, newTopic).apiEncode(), + connection.zulipFeatureLevel!))); }); testWidgets('shows error when fails', (tester) async { diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 6810092f86..2aa7432280 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -74,7 +74,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', }); @@ -155,7 +155,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', }); @@ -180,7 +180,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', }); @@ -199,7 +199,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', }); @@ -223,7 +223,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 14d60d5700..84996ea1a1 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -491,7 +491,7 @@ void main() { ..method.equals('GET') ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ - 'narrow': jsonEncode(narrow.apiEncode()), + 'narrow': jsonEncode(resolveApiNarrowForServer(narrow.apiEncode(), connection.zulipFeatureLevel!)), 'anchor': AnchorCode.firstUnread.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': kMessageListFetchBatchSize.toString(), @@ -524,7 +524,7 @@ void main() { ..method.equals('GET') ..url.path.equals('/api/v1/messages') ..url.queryParameters.deepEquals({ - 'narrow': jsonEncode(narrow.apiEncode()), + 'narrow': jsonEncode(resolveApiNarrowForServer(narrow.apiEncode(), connection.zulipFeatureLevel!)), 'anchor': AnchorCode.firstUnread.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': kMessageListFetchBatchSize.toString(), @@ -1080,7 +1080,7 @@ void main() { 'include_anchor': 'false', 'num_before': '0', 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), + 'narrow': jsonEncode(resolveApiNarrowForServer(apiNarrow, connection.zulipFeatureLevel!)), 'op': 'add', 'flag': 'read', });