Skip to content

Commit 7646cb4

Browse files
committed
do not merge; api: Indicate support for handling empty topics
Look for `allow_empty_topic_name` and `empty_topic_name` under "Feature level 334" in the API Changelog to verify the affected routes: https://zulip.com/api/changelog To keep the API bindings thin, instead of setting `allow_empty_topic_name` for the callers, we require the callers to pass the appropriate values instead. Instead of making this parameter a `bool` that defaults to `false` (and have the bindings remove the field when it's `false`), we type it as `bool?` and only drop it when it is `null`. This is also for making the API binding thin. Fixes: zulip#1250
1 parent cca33c4 commit 7646cb4

10 files changed

+129
-7
lines changed

lib/api/route/channels.dart

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ part 'channels.g.dart';
77
/// https://zulip.com/api/get-stream-topics
88
Future<GetStreamTopicsResult> getStreamTopics(ApiConnection connection, {
99
required int streamId,
10+
bool? allowEmptyTopicName,
1011
}) {
11-
return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {});
12+
assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null');
13+
return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {
14+
if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName,
15+
});
1216
}
1317

1418
@JsonSerializable(fieldRename: FieldRename.snake)

lib/api/route/events.dart

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
1818
'user_avatar_url_field_optional': false, // TODO(#254): turn on
1919
'stream_typing_notifications': true,
2020
'user_settings_object': true,
21+
'empty_topic_name': true,
2122
},
2223
});
2324
}

lib/api/route/messages.dart

+9
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ part 'messages.g.dart';
1616
Future<Message?> getMessageCompat(ApiConnection connection, {
1717
required int messageId,
1818
bool? applyMarkdown,
19+
bool? allowEmptyTopicName,
1920
}) async {
2021
final useLegacyApi = connection.zulipFeatureLevel! < 120;
2122
if (useLegacyApi) {
23+
assert(allowEmptyTopicName == null);
2224
final response = await getMessages(connection,
2325
narrow: [ApiNarrowMessageId(messageId)],
2426
anchor: NumericAnchor(messageId),
@@ -37,6 +39,7 @@ Future<Message?> getMessageCompat(ApiConnection connection, {
3739
final response = await getMessage(connection,
3840
messageId: messageId,
3941
applyMarkdown: applyMarkdown,
42+
allowEmptyTopicName: allowEmptyTopicName,
4043
);
4144
return response.message;
4245
} on ZulipApiException catch (e) {
@@ -57,10 +60,13 @@ Future<Message?> getMessageCompat(ApiConnection connection, {
5760
Future<GetMessageResult> getMessage(ApiConnection connection, {
5861
required int messageId,
5962
bool? applyMarkdown,
63+
bool? allowEmptyTopicName,
6064
}) {
65+
assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null');
6166
assert(connection.zulipFeatureLevel! >= 120);
6267
return connection.get('getMessage', GetMessageResult.fromJson, 'messages/$messageId', {
6368
if (applyMarkdown != null) 'apply_markdown': applyMarkdown,
69+
if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName,
6470
});
6571
}
6672

@@ -88,8 +94,10 @@ Future<GetMessagesResult> getMessages(ApiConnection connection, {
8894
required int numAfter,
8995
bool? clientGravatar,
9096
bool? applyMarkdown,
97+
bool? allowEmptyTopicName,
9198
// bool? useFirstUnreadAnchor // omitted because deprecated
9299
}) {
100+
assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null');
93101
return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', {
94102
'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!),
95103
'anchor': RawParameter(anchor.toJson()),
@@ -98,6 +106,7 @@ Future<GetMessagesResult> getMessages(ApiConnection connection, {
98106
'num_after': numAfter,
99107
if (clientGravatar != null) 'client_gravatar': clientGravatar,
100108
if (applyMarkdown != null) 'apply_markdown': applyMarkdown,
109+
if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName,
101110
});
102111
}
103112

lib/model/autocomplete.dart

+5-1
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,11 @@ class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, Top
904904
Future<void> _fetch() async {
905905
assert(!_isFetching);
906906
_isFetching = true;
907-
final result = await getStreamTopics(store.connection, streamId: streamId);
907+
final result = await getStreamTopics(store.connection, streamId: streamId,
908+
allowEmptyTopicName:
909+
// TODO(server-10): simplify this condition away
910+
store.zulipFeatureLevel >= 334 ? true : null,
911+
);
908912
_topics = result.topics.map((e) => e.name);
909913
_isFetching = false;
910914
return _startSearch();

lib/model/message_list.dart

+6
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
509509
anchor: AnchorCode.newest,
510510
numBefore: kMessageListFetchBatchSize,
511511
numAfter: 0,
512+
allowEmptyTopicName:
513+
// TODO(server-10): simplify this condition away
514+
store.zulipFeatureLevel >= 334 ? true : null,
512515
);
513516
if (this.generation > generation) return;
514517
_adjustNarrowForTopicPermalink(result.messages.firstOrNull);
@@ -581,6 +584,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
581584
includeAnchor: false,
582585
numBefore: kMessageListFetchBatchSize,
583586
numAfter: 0,
587+
allowEmptyTopicName:
588+
// TODO(server-10): simplify this condition away
589+
store.zulipFeatureLevel >= 334 ? true : null,
584590
);
585591
} catch (e) {
586592
hasFetchError = true;

lib/widgets/action_sheet.dart

+4-1
Original file line numberDiff line numberDiff line change
@@ -777,9 +777,12 @@ Future<String?> fetchRawContentWithFeedback({
777777
// On final failure or success, auto-dismiss the snackbar.
778778
final zulipLocalizations = ZulipLocalizations.of(context);
779779
try {
780-
fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection,
780+
final store = PerAccountStoreWidget.of(context);
781+
fetchedMessage = await getMessageCompat(store.connection,
781782
messageId: messageId,
782783
applyMarkdown: false,
784+
// TODO(server-10): simplify this condition away
785+
allowEmptyTopicName: store.zulipFeatureLevel >= 334 ? true : null,
783786
);
784787
if (fetchedMessage == null) {
785788
errorMessage = zulipLocalizations.errorMessageDoesNotSeemToExist;

test/api/route/messages_test.dart

+24
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ void main() {
2020
required bool expectLegacy,
2121
required int messageId,
2222
bool? applyMarkdown,
23+
bool? allowEmptyTopicName,
2324
}) async {
2425
final result = await getMessageCompat(connection,
2526
messageId: messageId,
2627
applyMarkdown: applyMarkdown,
28+
allowEmptyTopicName: allowEmptyTopicName,
2729
);
2830
if (expectLegacy) {
2931
check(connection.lastRequest).isA<http.Request>()
@@ -43,6 +45,8 @@ void main() {
4345
..url.path.equals('/api/v1/messages/$messageId')
4446
..url.queryParameters.deepEquals({
4547
if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(),
48+
if (allowEmptyTopicName != null)
49+
'allow_empty_topic_name': allowEmptyTopicName.toString(),
4650
});
4751
}
4852
return result;
@@ -57,6 +61,7 @@ void main() {
5761
expectLegacy: false,
5862
messageId: message.id,
5963
applyMarkdown: true,
64+
allowEmptyTopicName: true,
6065
);
6166
check(result).isNotNull().jsonEquals(message);
6267
});
@@ -71,6 +76,7 @@ void main() {
7176
expectLegacy: false,
7277
messageId: message.id,
7378
applyMarkdown: true,
79+
allowEmptyTopicName: true,
7480
);
7581
check(result).isNull();
7682
});
@@ -92,6 +98,7 @@ void main() {
9298
expectLegacy: true,
9399
messageId: message.id,
94100
applyMarkdown: true,
101+
allowEmptyTopicName: null,
95102
);
96103
check(result).isNotNull().jsonEquals(message);
97104
});
@@ -113,6 +120,7 @@ void main() {
113120
expectLegacy: true,
114121
messageId: message.id,
115122
applyMarkdown: true,
123+
allowEmptyTopicName: null,
116124
);
117125
check(result).isNull();
118126
});
@@ -124,11 +132,13 @@ void main() {
124132
FakeApiConnection connection, {
125133
required int messageId,
126134
bool? applyMarkdown,
135+
bool? allowEmptyTopicName,
127136
required Map<String, String> expected,
128137
}) async {
129138
final result = await getMessage(connection,
130139
messageId: messageId,
131140
applyMarkdown: applyMarkdown,
141+
allowEmptyTopicName: allowEmptyTopicName,
132142
);
133143
check(connection.lastRequest).isA<http.Request>()
134144
..method.equals('GET')
@@ -159,6 +169,16 @@ void main() {
159169
});
160170
});
161171

172+
test('allow empty topic name', () {
173+
return FakeApiConnection.with_((connection) async {
174+
connection.prepare(json: fakeResult.toJson());
175+
await checkGetMessage(connection,
176+
messageId: 1,
177+
allowEmptyTopicName: true,
178+
expected: {'allow_empty_topic_name': 'true'});
179+
});
180+
});
181+
162182
test('Throws assertion error when FL <120', () {
163183
return FakeApiConnection.with_(zulipFeatureLevel: 119, (connection) async {
164184
connection.prepare(json: fakeResult.toJson());
@@ -255,12 +275,14 @@ void main() {
255275
required int numAfter,
256276
bool? clientGravatar,
257277
bool? applyMarkdown,
278+
bool? allowEmptyTopicName,
258279
required Map<String, String> expected,
259280
}) async {
260281
final result = await getMessages(connection,
261282
narrow: narrow, anchor: anchor, includeAnchor: includeAnchor,
262283
numBefore: numBefore, numAfter: numAfter,
263284
clientGravatar: clientGravatar, applyMarkdown: applyMarkdown,
285+
allowEmptyTopicName: allowEmptyTopicName,
264286
);
265287
check(connection.lastRequest).isA<http.Request>()
266288
..method.equals('GET')
@@ -279,11 +301,13 @@ void main() {
279301
await checkGetMessages(connection,
280302
narrow: const CombinedFeedNarrow().apiEncode(),
281303
anchor: AnchorCode.newest, numBefore: 10, numAfter: 20,
304+
allowEmptyTopicName: true,
282305
expected: {
283306
'narrow': jsonEncode([]),
284307
'anchor': 'newest',
285308
'num_before': '10',
286309
'num_after': '20',
310+
'allow_empty_topic_name': 'true',
287311
});
288312
});
289313
});

test/model/autocomplete_test.dart

+34
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33

44
import 'package:checks/checks.dart';
55
import 'package:flutter/widgets.dart';
6+
import 'package:http/http.dart' as http;
67
import 'package:test/scaffolding.dart';
78
import 'package:zulip/api/model/initial_snapshot.dart';
89
import 'package:zulip/api/model/model.dart';
@@ -19,6 +20,7 @@ import 'package:zulip/widgets/compose_box.dart';
1920
import '../api/fake_api.dart';
2021
import '../example_data.dart' as eg;
2122
import '../fake_async.dart';
23+
import '../stdlib_checks.dart';
2224
import 'test_store.dart';
2325
import 'autocomplete_checks.dart';
2426

@@ -1026,6 +1028,38 @@ void main() {
10261028
check(done).isTrue();
10271029
});
10281030

1031+
test('TopicAutocompleteView allow empty topic name on modern servers', () async {
1032+
final account = eg.account(user: eg.selfUser, zulipFeatureLevel: 334);
1033+
final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot(
1034+
zulipFeatureLevel: 334));
1035+
final connection = store.connection as FakeApiConnection;
1036+
1037+
connection.prepare(json: GetStreamTopicsResult(
1038+
topics: [eg.getStreamTopicsEntry(name: '')],
1039+
).toJson());
1040+
TopicAutocompleteView.init(store: store, streamId: 1000,
1041+
query: TopicAutocompleteQuery('foo'));
1042+
check(connection.lastRequest).isA<http.Request>()
1043+
..url.path.equals('/api/v1/users/me/1000/topics')
1044+
..url.queryParameters['allow_empty_topic_name'].equals('true');
1045+
});
1046+
1047+
test('TopicAutocompleteView disallow empty topic name on legacy servers', () async {
1048+
final account = eg.account(user: eg.selfUser, zulipFeatureLevel: 333);
1049+
final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot(
1050+
zulipFeatureLevel: 333));
1051+
final connection = store.connection as FakeApiConnection;
1052+
1053+
connection.prepare(json: GetStreamTopicsResult(
1054+
topics: [eg.getStreamTopicsEntry(name: 'foo')],
1055+
).toJson());
1056+
TopicAutocompleteView.init(store: store, streamId: 1000,
1057+
query: TopicAutocompleteQuery('foo'));
1058+
check(connection.lastRequest).isA<http.Request>()
1059+
..url.path.equals('/api/v1/users/me/1000/topics')
1060+
..url.queryParameters.isEmpty();
1061+
});
1062+
10291063
group('TopicAutocompleteQuery.testTopic', () {
10301064
final store = eg.store();
10311065
void doCheck(String rawQuery, String topic, bool expected) {

test/model/message_list_test.dart

+9-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ void main() {
4949
Future<void> prepare({Narrow narrow = const CombinedFeedNarrow()}) async {
5050
final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId);
5151
subscription = eg.subscription(stream);
52-
store = eg.store();
52+
final account = eg.account(user: eg.selfUser,
53+
zulipFeatureLevel: eg.futureZulipFeatureLevel);
54+
store = eg.store(account: account, initialSnapshot: eg.initialSnapshot(
55+
zulipFeatureLevel: eg.futureZulipFeatureLevel));
5356
await store.addStream(stream);
5457
await store.addSubscription(subscription);
5558
connection = store.connection as FakeApiConnection;
@@ -82,6 +85,7 @@ void main() {
8285
bool? includeAnchor,
8386
required int numBefore,
8487
required int numAfter,
88+
bool? allowEmptyTopicName,
8589
}) {
8690
check(connection.lastRequest).isA<http.Request>()
8791
..method.equals('GET')
@@ -92,6 +96,8 @@ void main() {
9296
if (includeAnchor != null) 'include_anchor': includeAnchor.toString(),
9397
'num_before': numBefore.toString(),
9498
'num_after': numAfter.toString(),
99+
if (allowEmptyTopicName != null)
100+
'allow_empty_topic_name': allowEmptyTopicName.toString(),
95101
});
96102
}
97103

@@ -126,6 +132,7 @@ void main() {
126132
anchor: 'newest',
127133
numBefore: kMessageListFetchBatchSize,
128134
numAfter: 0,
135+
allowEmptyTopicName: true,
129136
);
130137
}
131138

@@ -238,6 +245,7 @@ void main() {
238245
includeAnchor: false,
239246
numBefore: kMessageListFetchBatchSize,
240247
numAfter: 0,
248+
allowEmptyTopicName: true,
241249
);
242250
});
243251

test/widgets/action_sheet_test.dart

+32-3
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,16 @@ late FakeApiConnection connection;
5252
Future<void> setupToMessageActionSheet(WidgetTester tester, {
5353
required Message message,
5454
required Narrow narrow,
55+
int? zulipFeatureLevel,
5556
}) async {
5657
addTearDown(testBinding.reset);
5758
assert(narrow.containsMessage(message));
5859

59-
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
60-
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
60+
final account = eg.account(user: eg.selfUser,
61+
zulipFeatureLevel: zulipFeatureLevel);
62+
await testBinding.globalStore.add(account, eg.initialSnapshot(
63+
zulipFeatureLevel: zulipFeatureLevel));
64+
store = await testBinding.globalStore.perAccount(account.id);
6165
await store.addUsers([
6266
eg.selfUser,
6367
eg.user(userId: message.senderId),
@@ -73,7 +77,7 @@ Future<void> setupToMessageActionSheet(WidgetTester tester, {
7377

7478
connection.prepare(json: eg.newestGetMessagesResult(
7579
foundOldest: true, messages: [message]).toJson());
76-
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
80+
await tester.pumpWidget(TestZulipApp(accountId: account.id,
7781
child: MessageListPage(initNarrow: narrow)));
7882

7983
// global store, per-account store, and message list get loaded
@@ -1155,6 +1159,31 @@ void main() {
11551159
await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow());
11561160
check(findQuoteAndReplyButton(tester)).isNull();
11571161
});
1162+
1163+
testWidgets('handle empty topic', (tester) async {
1164+
final message = eg.streamMessage();
1165+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message),
1166+
zulipFeatureLevel: 334);
1167+
1168+
prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world');
1169+
await tapQuoteAndReplyButton(tester);
1170+
check(connection.lastRequest).isA<http.Request>()
1171+
.url.queryParameters['allow_empty_topic_name'].equals('true');
1172+
await tester.pump(Duration.zero);
1173+
});
1174+
1175+
testWidgets('legacy: handle empty topic', (tester) async {
1176+
final message = eg.streamMessage();
1177+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message),
1178+
zulipFeatureLevel: 333);
1179+
1180+
prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world');
1181+
await tapQuoteAndReplyButton(tester);
1182+
check(connection.lastRequest).isA<http.Request>()
1183+
.url.queryParameters
1184+
.not((it) => it.containsKey('allow_empty_topic_name'));
1185+
await tester.pump(Duration.zero);
1186+
});
11581187
});
11591188

11601189
group('MarkAsUnread', () {

0 commit comments

Comments
 (0)