diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 1adc44196f..5882122baa 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -85,6 +85,8 @@ class InitialSnapshot { final Uri? serverEmojiDataUrl; // TODO(server-6) + final String? realmEmptyTopicDisplayName; // TODO(server-10) + @JsonKey(readValue: _readUsersIsActiveFallbackTrue) final List realmUsers; @JsonKey(readValue: _readUsersIsActiveFallbackFalse) @@ -138,6 +140,7 @@ class InitialSnapshot { required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, required this.serverEmojiDataUrl, + required this.realmEmptyTopicDisplayName, required this.realmUsers, required this.realmNonActiveUsers, required this.crossRealmBots, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 7ff9755812..79cfbe5557 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -85,6 +85,7 @@ InitialSnapshot _$InitialSnapshotFromJson( json['server_emoji_data_url'] == null ? null : Uri.parse(json['server_emoji_data_url'] as String), + realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?, realmUsers: (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') as List) @@ -135,6 +136,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, 'server_emoji_data_url': instance.serverEmojiDataUrl?.toString(), + 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, 'realm_users': instance.realmUsers, 'realm_non_active_users': instance.realmNonActiveUsers, 'cross_realm_bots': instance.crossRealmBots, diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index cf3b484564..073255bc9d 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -921,7 +921,7 @@ class TopicAutocompleteView extends AutocompleteView= 334, since topics cannot + /// be empty otherwise. + // TODO(server-10) simplify this + String get realmEmptyTopicDisplayName { + assert(_realmEmptyTopicDisplayName != null); // TODO(log) + return _realmEmptyTopicDisplayName ?? 'general chat'; + } + final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting + final Map realmDefaultExternalAccounts; List customProfileFields; /// For docs, please see [InitialSnapshot.emailAddressVisibility]. diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index e34946a31f..a31369c3d9 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -415,12 +415,23 @@ class TopicAutocomplete extends AutocompleteField { } void setTopic(TopicName newTopic) { - value = TextEditingValue(text: newTopic.displayName); + // ignore: dead_null_aware_expression // null topic names soon to be enabled + value = TextEditingValue(text: newTopic.displayName ?? ''); } } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 480437c912..81133cde20 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -532,12 +532,15 @@ class _TopicItem extends StatelessWidget { style: TextStyle( fontSize: 17, height: (20 / 17), + // ignore: unnecessary_null_comparison // null topic names soon to be enabled + fontStyle: topic.displayName == null ? FontStyle.italic : null, // TODO(design) check if this is the right variable color: designVariables.labelMenuButton, ), maxLines: 2, overflow: TextOverflow.ellipsis, - topic.displayName))), + // ignore: dead_null_aware_expression // null topic names soon to be enabled + topic.displayName ?? store.realmEmptyTopicDisplayName))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), // TODO(design) copies the "@" marker color; is there a better color? diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 2cc9e2963e..b8557162e9 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -339,8 +339,11 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(topic.displayName, style: const TextStyle( + // ignore: dead_null_aware_expression // null topic names soon to be enabled + Flexible(child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, style: TextStyle( fontSize: 13, + // ignore: unnecessary_null_comparison // null topic names soon to be enabled + fontStyle: topic.displayName == null ? FontStyle.italic : null, ).merge(weightVariableTextStyle(context)))), if (icon != null) Padding( @@ -1092,11 +1095,15 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - child: Text(topic.displayName, + // ignore: dead_null_aware_expression // null topic names soon to be enabled + child: Text(topic.displayName ?? store.realmEmptyTopicDisplayName, // TODO: Give a way to see the whole topic (maybe a // long-press interaction?) overflow: TextOverflow.ellipsis, - style: recipientHeaderTextStyle(context))), + style: recipientHeaderTextStyle(context, + // ignore: unnecessary_null_comparison // null topic names soon to be enabled + fontStyle: topic.displayName == null ? FontStyle.italic : null, + ))), const SizedBox(width: 4), Icon(size: 14, color: designVariables.title.withFadedAlpha(0.5), // A null [Icon.icon] makes a blank space. @@ -1191,12 +1198,13 @@ class DmRecipientHeader extends StatelessWidget { } } -TextStyle recipientHeaderTextStyle(BuildContext context) { +TextStyle recipientHeaderTextStyle(BuildContext context, {FontStyle? fontStyle}) { return TextStyle( color: DesignVariables.of(context).title, fontSize: 16, letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 16), height: (18 / 16), + fontStyle: fontStyle, ).merge(weightVariableTextStyle(context, wght: 600)); } diff --git a/test/example_data.dart b/test/example_data.dart index 04d6723b7a..635667eb6c 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -881,6 +881,8 @@ TestGlobalStore globalStore({List accounts = const []}) { } const _globalStore = globalStore; +const String defaultRealmEmptyTopicDisplayName = 'test general chat'; + InitialSnapshot initialSnapshot({ String? queueId, int? lastEventId, @@ -906,6 +908,7 @@ InitialSnapshot initialSnapshot({ Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, Uri? serverEmojiDataUrl, + String? realmEmptyTopicDisplayName, List? realmUsers, List? realmNonActiveUsers, List? crossRealmBots, @@ -943,6 +946,7 @@ InitialSnapshot initialSnapshot({ maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl ?? realmUrl.replace(path: '/static/emoji.json'), + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName ?? defaultRealmEmptyTopicDisplayName, realmUsers: realmUsers ?? [], realmNonActiveUsers: realmNonActiveUsers ?? [], crossRealmBots: crossRealmBots ?? [], diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index e916151426..16b92d98d4 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -1027,8 +1027,9 @@ void main() { }); group('TopicAutocompleteQuery.testTopic', () { + final store = eg.store(); void doCheck(String rawQuery, String topic, bool expected) { - final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic)); + final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic), store); expected ? check(result).isTrue() : check(result).isFalse(); } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 2e0ad50156..484c3b2454 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -99,9 +99,11 @@ Future setupToComposeInput(WidgetTester tester, { /// Returns a [Finder] for the topic input's [TextField]. Future setupToTopicInput(WidgetTester tester, { required List topics, + String? realmEmptyTopicDisplayName, }) async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + realmEmptyTopicDisplayName: realmEmptyTopicDisplayName)); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); final connection = store.connection as FakeApiConnection; @@ -392,16 +394,11 @@ void main() { }); group('TopicAutocomplete', () { - void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) { - check(find.text(topic.name.displayName).evaluate().length).equals(expected ? 1 : 0); - } - testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async { final topic1 = eg.getStreamTopicsEntry(maxId: 1, name: 'Topic one'); final topic2 = eg.getStreamTopicsEntry(maxId: 2, name: 'Topic two'); final topic3 = eg.getStreamTopicsEntry(maxId: 3, name: 'Topic three'); final topicInputFinder = await setupToTopicInput(tester, topics: [topic1, topic2, topic3]); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); // Options are filtered correctly for query // TODO(#226): Remove this extra edit when this bug is fixed. @@ -410,24 +407,24 @@ void main() { await tester.pumpAndSettle(); // "topic three" and "topic two" appear, but not "topic one" - checkTopicShown(topic1, store, expected: false); - checkTopicShown(topic2, store, expected: true); - checkTopicShown(topic3, store, expected: true); + check(find.text('Topic one' )).findsNothing(); + check(find.text('Topic two' )).findsOne(); + check(find.text('Topic three')).findsOne(); // Finishing autocomplete updates topic box; causes options to disappear await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) .equals(topic3.name.displayName); - checkTopicShown(topic1, store, expected: false); - checkTopicShown(topic2, store, expected: false); - checkTopicShown(topic3, store, expected: true); // shown in `_TopicInput` once + check(find.text('Topic one' )).findsNothing(); + check(find.text('Topic two' )).findsNothing(); + check(find.text('Topic three')).findsOne(); // shown in `_TopicInput` once // Then a new autocomplete intent brings up options again await tester.enterText(topicInputFinder, 'Topic'); await tester.enterText(topicInputFinder, 'Topic T'); await tester.pumpAndSettle(); - checkTopicShown(topic2, store, expected: true); + check(find.text('Topic two')).findsOne(); }); testWidgets('text selection is reset on choosing an option', (tester) async { @@ -464,5 +461,47 @@ void main() { await tester.pump(Duration.zero); }); + + testWidgets('display realmEmptyTopicDisplayName for empty topic', (tester) async { + final topic = eg.getStreamTopicsEntry(name: ''); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic], + realmEmptyTopicDisplayName: 'some display name'); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(topicInputFinder, ' '); + await tester.enterText(topicInputFinder, ''); + await tester.pumpAndSettle(); + + check(find.text('some display name')).findsOne(); + }, skip: true); // null topic names soon to be enabled + + testWidgets('match realmEmptyTopicDisplayName in autocomplete', (tester) async { + final topic = eg.getStreamTopicsEntry(name: ''); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic], + realmEmptyTopicDisplayName: 'general chat'); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(topicInputFinder, 'general ch'); + await tester.enterText(topicInputFinder, 'general cha'); + await tester.pumpAndSettle(); + + check(find.text('general chat')).findsOne(); + }, skip: true); // null topic names soon to be enabled + + testWidgets('autocomplete to realmEmptyTopicDisplayName sets topic to empty string', (tester) async { + final topic = eg.getStreamTopicsEntry(name: ''); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic], + realmEmptyTopicDisplayName: 'general chat'); + final controller = tester.widget(topicInputFinder).controller!; + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(topicInputFinder, 'general ch'); + await tester.enterText(topicInputFinder, 'general cha'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('general chat')); + await tester.pump(Duration.zero); + check(controller.value).text.equals(''); + }, skip: true); // null topic names soon to be enabled }); } diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 3fa3713d5d..7b2be9176e 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -307,6 +307,15 @@ void main() { }); }); + testWidgets('empty topic', (tester) async { + final channel = eg.stream(); + await setupPage(tester, + streams: [channel], + subscriptions: [(eg.subscription(channel))], + unreadMessages: [eg.streamMessage(stream: channel, topic: '')]); + check(find.text(eg.defaultRealmEmptyTopicDisplayName)).findsOne(); + }, skip: true); // null topic names soon to be enabled + group('topic visibility', () { final channel = eg.stream(); const topic = 'topic'; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 7107e936ca..bd54412a46 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -194,6 +194,16 @@ void main() { group('app bar', () { // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + testWidgets('handle empty topics', (tester) async { + final channel = eg.stream(); + await setupMessageListPage(tester, + narrow: eg.topicNarrow(channel.streamId, ''), + streams: [channel], + messageCount: 1); + checkAppBarChannelTopic( + channel.name, eg.defaultRealmEmptyTopicDisplayName); + }, skip: true); // null topic names soon to be enabled + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -934,6 +944,26 @@ void main() { check(findInMessageList('topic name')).length.equals(1); }); + final messageEmptyTopic = eg.streamMessage(stream: stream, topic: ''); + + testWidgets('show general chat for empty topics with channel name', (tester) async { + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + messages: [messageEmptyTopic], subscriptions: [eg.subscription(stream)]); + await tester.pump(); + check(findInMessageList('stream name')).single; + check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; + }, skip: true); // null topic names soon to be enabled + + testWidgets('show general chat for empty topics without channel name', (tester) async { + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(messageEmptyTopic), + messages: [messageEmptyTopic]); + await tester.pump(); + check(findInMessageList('stream name')).isEmpty(); + check(findInMessageList(eg.defaultRealmEmptyTopicDisplayName)).single; + }, skip: true); // null topic names soon to be enabled + testWidgets('show topic visibility icon when followed', (tester) async { await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(),