Skip to content

Commit f9a4f56

Browse files
committed
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: #1250 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 96b534f commit f9a4f56

11 files changed

+127
-5
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

@@ -89,8 +95,10 @@ Future<GetMessagesResult> getMessages(ApiConnection connection, {
8995
required int numAfter,
9096
bool? clientGravatar,
9197
bool? applyMarkdown,
98+
bool? allowEmptyTopicName,
9299
// bool? useFirstUnreadAnchor // omitted because deprecated
93100
}) {
101+
assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null');
94102
return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', {
95103
'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!),
96104
'anchor': RawParameter(anchor.toJson()),
@@ -99,6 +107,7 @@ Future<GetMessagesResult> getMessages(ApiConnection connection, {
99107
'num_after': numAfter,
100108
if (clientGravatar != null) 'client_gravatar': clientGravatar,
101109
if (applyMarkdown != null) 'apply_markdown': applyMarkdown,
110+
if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName,
102111
});
103112
}
104113

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
@@ -510,6 +510,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
510510
anchor: AnchorCode.newest,
511511
numBefore: kMessageListFetchBatchSize,
512512
numAfter: 0,
513+
allowEmptyTopicName:
514+
// TODO(server-10): simplify this condition away
515+
store.zulipFeatureLevel >= 334 ? true : null,
513516
);
514517
if (this.generation > generation) return;
515518
_adjustNarrowForTopicPermalink(result.messages.firstOrNull);
@@ -582,6 +585,9 @@ class MessageListView with ChangeNotifier, _MessageSequence {
582585
includeAnchor: false,
583586
numBefore: kMessageListFetchBatchSize,
584587
numAfter: 0,
588+
allowEmptyTopicName:
589+
// TODO(server-10): simplify this condition away
590+
store.zulipFeatureLevel >= 334 ? true : null,
585591
);
586592
} catch (e) {
587593
hasFetchError = true;

lib/widgets/actions.dart

+3
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,12 @@ abstract final class ZulipAction {
257257
// On final failure or success, auto-dismiss the snackbar.
258258
final zulipLocalizations = ZulipLocalizations.of(context);
259259
try {
260+
final store = PerAccountStoreWidget.of(context);
260261
fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection,
261262
messageId: messageId,
262263
applyMarkdown: false,
264+
// TODO(server-10): simplify this condition away
265+
allowEmptyTopicName: store.zulipFeatureLevel >= 334 ? true : null,
263266
);
264267
if (fetchedMessage == null) {
265268
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 getStreamTopics request', () async {
1032+
final store = eg.store();
1033+
final connection = store.connection as FakeApiConnection;
1034+
1035+
connection.prepare(json: GetStreamTopicsResult(
1036+
topics: [eg.getStreamTopicsEntry(name: '')],
1037+
).toJson());
1038+
TopicAutocompleteView.init(store: store, streamId: 1000,
1039+
query: TopicAutocompleteQuery('foo'));
1040+
check(connection.lastRequest).isA<http.Request>()
1041+
..method.equals('GET')
1042+
..url.path.equals('/api/v1/users/me/1000/topics')
1043+
..url.queryParameters['allow_empty_topic_name'].equals('true');
1044+
});
1045+
1046+
test('legacy: TopicAutocompleteView getStreamTopics request', () async {
1047+
final account = eg.account(user: eg.selfUser, zulipFeatureLevel: 333);
1048+
final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot(
1049+
zulipFeatureLevel: 333));
1050+
final connection = store.connection as FakeApiConnection;
1051+
1052+
connection.prepare(json: GetStreamTopicsResult(
1053+
topics: [eg.getStreamTopicsEntry(name: 'foo')],
1054+
).toJson());
1055+
TopicAutocompleteView.init(store: store, streamId: 1000,
1056+
query: TopicAutocompleteQuery('foo'));
1057+
check(connection.lastRequest).isA<http.Request>()
1058+
..method.equals('GET')
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

+5
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ void main() {
8282
bool? includeAnchor,
8383
required int numBefore,
8484
required int numAfter,
85+
bool? allowEmptyTopicName,
8586
}) {
8687
check(connection.lastRequest).isA<http.Request>()
8788
..method.equals('GET')
@@ -92,6 +93,8 @@ void main() {
9293
if (includeAnchor != null) 'include_anchor': includeAnchor.toString(),
9394
'num_before': numBefore.toString(),
9495
'num_after': numAfter.toString(),
96+
if (allowEmptyTopicName != null)
97+
'allow_empty_topic_name': allowEmptyTopicName.toString(),
9598
});
9699
}
97100

@@ -126,6 +129,7 @@ void main() {
126129
anchor: 'newest',
127130
numBefore: kMessageListFetchBatchSize,
128131
numAfter: 0,
132+
allowEmptyTopicName: true,
129133
);
130134
}
131135

@@ -238,6 +242,7 @@ void main() {
238242
includeAnchor: false,
239243
numBefore: kMessageListFetchBatchSize,
240244
numAfter: 0,
245+
allowEmptyTopicName: true,
241246
);
242247
});
243248

test/widgets/action_sheet_test.dart

+33-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 selfAccount = eg.account(user: eg.selfUser,
61+
zulipFeatureLevel: zulipFeatureLevel);
62+
await testBinding.globalStore.add(selfAccount, eg.initialSnapshot(
63+
zulipFeatureLevel: zulipFeatureLevel));
64+
store = await testBinding.globalStore.perAccount(selfAccount.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: selfAccount.id,
7781
child: MessageListPage(initNarrow: narrow)));
7882

7983
// global store, per-account store, and message list get loaded
@@ -1155,6 +1159,32 @@ 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,
1166+
message: message, narrow: TopicNarrow.ofMessage(message));
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,
1178+
message: message, narrow: TopicNarrow.ofMessage(message),
1179+
zulipFeatureLevel: 333);
1180+
1181+
prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world');
1182+
await tapQuoteAndReplyButton(tester);
1183+
check(connection.lastRequest).isA<http.Request>()
1184+
.url.queryParameters
1185+
.not((it) => it.containsKey('allow_empty_topic_name'));
1186+
await tester.pump(Duration.zero);
1187+
});
11581188
});
11591189

11601190
group('MarkAsUnread', () {

test/widgets/message_list_test.dart

+2
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ void main() {
331331
'anchor': AnchorCode.newest.toJson(),
332332
'num_before': kMessageListFetchBatchSize.toString(),
333333
'num_after': '0',
334+
'allow_empty_topic_name': 'true',
334335
});
335336
});
336337

@@ -363,6 +364,7 @@ void main() {
363364
'anchor': AnchorCode.newest.toJson(),
364365
'num_before': kMessageListFetchBatchSize.toString(),
365366
'num_after': '0',
367+
'allow_empty_topic_name': 'true',
366368
});
367369
});
368370
});

0 commit comments

Comments
 (0)