diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..d2d7b53033 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -379,6 +379,13 @@ "@composeBoxTopicHintText": { "description": "Hint text for topic input widget in compose box." }, + "composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)", + "@composeBoxEnterTopicOrSkipHintText": { + "description": "Hint text for topic input widget in compose box when topics are optional.", + "placeholders": { + "defaultTopicName": {"type": "String", "example": "general chat"} + } + }, "composeBoxUploadingFilename": "Uploading {filename}…", "@composeBoxUploadingFilename": { "description": "Placeholder in compose box showing the specified file is currently uploading.", diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 90769e815b..e0f2065e62 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -581,11 +581,7 @@ extension type const TopicName(String _value) { /// The string this topic is displayed as to the user in our UI. /// /// At the moment this always equals [apiName]. - /// In the future this will become null for the "general chat" topic (#1250), - /// so that UI code can identify when it needs to represent the topic - /// specially in the way prescribed for "general chat". - // TODO(#1250) carry out that plan - String get displayName => _value; + String? get displayName => _value.isEmpty ? null : _value; /// The key to use for "same topic as" comparisons. String canonicalize() => apiName.toLowerCase(); diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index bfa46f5ab8..ebc78c476b 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -7,8 +7,12 @@ part 'channels.g.dart'; /// https://zulip.com/api/get-stream-topics Future getStreamTopics(ApiConnection connection, { required int streamId, + bool? allowEmptyTopicName, }) { - return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', {}); + assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null'); + return connection.get('getStreamTopics', GetStreamTopicsResult.fromJson, 'users/me/$streamId/topics', { + if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName, + }); } @JsonSerializable(fieldRename: FieldRename.snake) diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index bbd7be5a0a..bd14521c74 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -18,6 +18,7 @@ Future registerQueue(ApiConnection connection) { 'user_avatar_url_field_optional': false, // TODO(#254): turn on 'stream_typing_notifications': true, 'user_settings_object': true, + 'empty_topic_name': true, }, }); } diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 6a42158b75..7766d612e2 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -16,9 +16,11 @@ part 'messages.g.dart'; Future getMessageCompat(ApiConnection connection, { required int messageId, bool? applyMarkdown, + bool? allowEmptyTopicName, }) async { final useLegacyApi = connection.zulipFeatureLevel! < 120; if (useLegacyApi) { + assert(allowEmptyTopicName == null); final response = await getMessages(connection, narrow: [ApiNarrowMessageId(messageId)], anchor: NumericAnchor(messageId), @@ -37,6 +39,7 @@ Future getMessageCompat(ApiConnection connection, { final response = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); return response.message; } on ZulipApiException catch (e) { @@ -57,10 +60,13 @@ Future getMessageCompat(ApiConnection connection, { Future getMessage(ApiConnection connection, { required int messageId, bool? applyMarkdown, + bool? allowEmptyTopicName, }) { + assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null'); assert(connection.zulipFeatureLevel! >= 120); return connection.get('getMessage', GetMessageResult.fromJson, 'messages/$messageId', { if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName, }); } @@ -89,8 +95,10 @@ Future getMessages(ApiConnection connection, { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + bool? allowEmptyTopicName, // bool? useFirstUnreadAnchor // omitted because deprecated }) { + assert(allowEmptyTopicName != false, '`allowEmptyTopicName` should only be true or null'); return connection.get('getMessages', GetMessagesResult.fromJson, 'messages', { 'narrow': resolveApiNarrowForServer(narrow, connection.zulipFeatureLevel!), 'anchor': RawParameter(anchor.toJson()), @@ -99,6 +107,7 @@ Future getMessages(ApiConnection connection, { 'num_after': numAfter, if (clientGravatar != null) 'client_gravatar': clientGravatar, if (applyMarkdown != null) 'apply_markdown': applyMarkdown, + if (allowEmptyTopicName != null) 'allow_empty_topic_name': allowEmptyTopicName, }); } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index e326703e4b..6181c7b39b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -622,6 +622,12 @@ abstract class ZulipLocalizations { /// **'Topic'** String get composeBoxTopicHintText; + /// Hint text for topic input widget in compose box when topics are optional. + /// + /// In en, this message translates to: + /// **'Enter a topic (skip for “{defaultTopicName}”)'** + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName); + /// Placeholder in compose box showing the specified file is currently uploading. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 8d36fa6bd0..f26b9d017c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a74a2e1eaf..bfb14645d5 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c11a3fae23..35088555e2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d23bd323fd..ae79d99ea9 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e1a6bd45f4..15f61bd882 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -323,6 +323,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Wątek'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Przekazywanie $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 78b68e812a..e4248179e2 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -324,6 +324,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Загрузка $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 193ac26d8e..baded578ba 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -316,6 +316,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Topic'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Uploading $filename…'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index c1898631fd..50b1029dd7 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -325,6 +325,11 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxTopicHintText => 'Тема'; + @override + String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) { + return 'Enter a topic (skip for “$defaultTopicName”)'; + } + @override String composeBoxUploadingFilename(String filename) { return 'Завантаження $filename…'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 073255bc9d..8eb46b06f0 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -904,7 +904,11 @@ class TopicAutocompleteView extends AutocompleteView _fetch() async { assert(!_isFetching); _isFetching = true; - final result = await getStreamTopics(store.connection, streamId: streamId); + final result = await getStreamTopics(store.connection, streamId: streamId, + allowEmptyTopicName: + // TODO(server-10): simplify this condition away + store.zulipFeatureLevel >= 334 ? true : null, + ); _topics = result.topics.map((e) => e.name); _isFetching = false; return _startSearch(); @@ -942,13 +946,11 @@ class TopicAutocompleteQuery extends AutocompleteQuery { bool testTopic(TopicName topic, PerAccountStore store) { // TODO(#881): Sort by match relevance, like web does. - // ignore: unnecessary_null_comparison // null topic names soon to be enabled if (topic.displayName == null) { return store.realmEmptyTopicDisplayName.toLowerCase() .contains(raw.toLowerCase()); } return topic.displayName != raw - // ignore: unnecessary_non_null_assertion // null topic names soon to be enabled && topic.displayName!.toLowerCase().contains(raw.toLowerCase()); } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 58a0e1bb95..86750f43fb 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -510,6 +510,9 @@ class MessageListView with ChangeNotifier, _MessageSequence { anchor: AnchorCode.newest, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: + // TODO(server-10): simplify this condition away + store.zulipFeatureLevel >= 334 ? true : null, ); if (this.generation > generation) return; _adjustNarrowForTopicPermalink(result.messages.firstOrNull); @@ -582,6 +585,9 @@ class MessageListView with ChangeNotifier, _MessageSequence { includeAnchor: false, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: + // TODO(server-10): simplify this condition away + store.zulipFeatureLevel >= 334 ? true : null, ); } catch (e) { hasFetchError = true; diff --git a/lib/model/store.dart b/lib/model/store.dart index 939120113e..6e4a4b08bb 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -577,6 +577,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor /// be empty otherwise. // TODO(server-10) simplify this String get realmEmptyTopicDisplayName { + assert(zulipFeatureLevel >= 334); assert(_realmEmptyTopicDisplayName != null); // TODO(log) return _realmEmptyTopicDisplayName ?? 'general chat'; } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 88114d48bb..6aa7239a0c 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -304,9 +304,7 @@ void showTopicActionSheet(BuildContext context, { // TODO: check for other cases that may disallow this action (e.g.: time // limit for editing topics). - if (someMessageIdInTopic != null - // ignore: unnecessary_null_comparison // null topic names soon to be enabled - && topic.displayName != null) { + if (someMessageIdInTopic != null && topic.displayName != null) { optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, topic: topic, someMessageIdInTopic: someMessageIdInTopic)); diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index ad4557be08..540f223f98 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -257,9 +257,12 @@ abstract final class ZulipAction { // On final failure or success, auto-dismiss the snackbar. final zulipLocalizations = ZulipLocalizations.of(context); try { + final store = PerAccountStoreWidget.of(context); fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection, messageId: messageId, applyMarkdown: false, + // TODO(server-10): simplify this condition away + allowEmptyTopicName: store.zulipFeatureLevel >= 334 ? true : null, ); if (fetchedMessage == null) { errorMessage = zulipLocalizations.errorMessageDoesNotSeemToExist; diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a31369c3d9..a1956295eb 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -416,13 +416,11 @@ class TopicAutocomplete extends AutocompleteField { } void setTopic(TopicName newTopic) { - // ignore: dead_null_aware_expression // null topic names soon to be enabled value = TextEditingValue(text: newTopic.displayName ?? ''); } } @@ -596,11 +595,18 @@ class _StreamContentInputState extends State<_StreamContentInput> { }); } + void _topicInteractionStatusChanged() { + setState(() { + // The relevant state lives on widget.controller.topicInteractionStatus itself. + }); + } + @override void initState() { super.initState(); widget.controller.topic.addListener(_topicChanged); widget.controller.contentFocusNode.addListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); } @override @@ -614,12 +620,17 @@ class _StreamContentInputState extends State<_StreamContentInput> { oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged); widget.controller.contentFocusNode.addListener(_contentFocusChanged); } + if (widget.controller.topicInteractionStatus != oldWidget.controller.topicInteractionStatus) { + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } } @override void dispose() { widget.controller.topic.removeListener(_topicChanged); widget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); super.dispose(); } @@ -630,11 +641,11 @@ class _StreamContentInputState extends State<_StreamContentInput> { // The chosen topic can't be sent to, so don't show it. return null; } - if (!widget.controller.contentFocusNode.hasFocus) { - // Do not fall back to a vacuous topic unless the user explicitly chooses - // to do so (by skipping topic input and moving focus to content input), - // so that the user is not encouraged to use vacuous topic when they - // have not interacted with the inputs at all. + if (widget.controller.topicInteractionStatus.value != + ComposeTopicInteractionStatus.hasChosen) { + // Do not fall back to a vacuous topic unless the user explicitly + // chooses to do so, so that the user is not encouraged to use vacuous + // topic before they have interacted with the inputs at all. return null; } } @@ -656,7 +667,6 @@ class _StreamContentInputState extends State<_StreamContentInput> { // so don't make sense to translate. See: // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 ? '#$streamName' - // ignore: dead_null_aware_expression // null topic names soon to be enabled : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}'; return _TypingNotifier( @@ -670,41 +680,140 @@ class _StreamContentInputState extends State<_StreamContentInput> { } } -class _TopicInput extends StatelessWidget { +class _TopicInput extends StatefulWidget { const _TopicInput({required this.streamId, required this.controller}); final int streamId; final StreamComposeBoxController controller; + @override + State<_TopicInput> createState() => _TopicInputState(); +} + +class _TopicInputState extends State<_TopicInput> { + void _topicFocusChanged() { + setState(() { + if (widget.controller.topicFocusNode.hasFocus) { + widget.controller.topicInteractionStatus.value = + ComposeTopicInteractionStatus.isEditing; + } else if (!widget.controller.contentFocusNode.hasFocus) { + widget.controller.topicInteractionStatus.value = + ComposeTopicInteractionStatus.notEditingNotChosen; + } + }); + } + + void _contentFocusChanged() { + setState(() { + if (widget.controller.contentFocusNode.hasFocus) { + widget.controller.topicInteractionStatus.value = + ComposeTopicInteractionStatus.hasChosen; + } + }); + } + + void _topicInteractionStatusChanged() { + setState(() { + // The actual state lives in widget.controller.topicInteractionStatus + }); + } + + @override + void initState() { + super.initState(); + widget.controller.topicFocusNode.addListener(_topicFocusChanged); + widget.controller.contentFocusNode.addListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + + @override + void didUpdateWidget(covariant _TopicInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.topicFocusNode.removeListener(_topicFocusChanged); + widget.controller.topicFocusNode.addListener(_topicFocusChanged); + oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.contentFocusNode.addListener(_contentFocusChanged); + oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged); + } + } + + @override + void dispose() { + widget.controller.topicFocusNode.removeListener(_topicFocusChanged); + widget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); - TextStyle topicTextStyle = TextStyle( + final store = PerAccountStoreWidget.of(context); + + final topicTextStyle = TextStyle( fontSize: 20, height: 22 / 20, color: designVariables.textInput.withFadedAlpha(0.9), ).merge(weightVariableTextStyle(context, wght: 600)); + // TODO(server-10) simplify away + final emptyTopicsSupported = store.zulipFeatureLevel >= 334; + + final String hintText; + TextStyle hintStyle = topicTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5)); + + if (store.realmMandatoryTopics) { + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + } else { + switch (widget.controller.topicInteractionStatus.value) { + case ComposeTopicInteractionStatus.notEditingNotChosen: + // Something short and not distracting. + hintText = zulipLocalizations.composeBoxTopicHintText; + case ComposeTopicInteractionStatus.isEditing: + // The user is actively interacting with the input. Since topics are + // not mandatory, show a long hint text mentioning that they can be + // left empty. + hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText( + emptyTopicsSupported + ? store.realmEmptyTopicDisplayName + : kNoTopicTopic); + case ComposeTopicInteractionStatus.hasChosen: + // The topic has likely been chosen. Since topics are not mandatory, + // show the default topic display name as if the user has entered that + // when they left the input empty. + if (emptyTopicsSupported) { + hintText = store.realmEmptyTopicDisplayName; + hintStyle = topicTextStyle.copyWith(fontStyle: FontStyle.italic); + } else { + hintText = kNoTopicTopic; + hintStyle = topicTextStyle; + } + } + } + + final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle); + return TopicAutocomplete( - streamId: streamId, - controller: controller.topic, - focusNode: controller.topicFocusNode, - contentFocusNode: controller.contentFocusNode, + streamId: widget.streamId, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, + contentFocusNode: widget.controller.contentFocusNode, fieldViewBuilder: (context) => Container( padding: const EdgeInsets.only(top: 10, bottom: 9), decoration: BoxDecoration(border: Border(bottom: BorderSide( width: 1, color: designVariables.foreground.withFadedAlpha(0.2)))), child: TextField( - controller: controller.topic, - focusNode: controller.topicFocusNode, + controller: widget.controller.topic, + focusNode: widget.controller.topicFocusNode, textInputAction: TextInputAction.next, style: topicTextStyle, - decoration: InputDecoration( - hintText: zulipLocalizations.composeBoxTopicHintText, - hintStyle: topicTextStyle.copyWith( - color: designVariables.textInput.withFadedAlpha(0.5)))))); + decoration: decoration))); } } @@ -729,7 +838,6 @@ class _FixedDestinationContentInput extends StatelessWidget { // Zulip expresses channels and topics, not any normal English punctuation, // so don't make sense to translate. See: // https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585 - // ignore: dead_null_aware_expression // null topic names soon to be enabled '#$streamName > ${topic.displayName ?? store.realmEmptyTopicDisplayName}'); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. @@ -1377,17 +1485,67 @@ sealed class ComposeBoxController { } } +/// Represent how a user has interacted with topic and content inputs. +/// +/// State-transition diagram: +/// +/// ``` +/// (default) +/// Topic input │ Content input +/// lost focus. ▼ gained focus. +/// ┌────────────► notEditingNotChosen ────────────┐ +/// │ │ │ +/// │ Topic input │ │ +/// │ gained focus. │ │ +/// │ ◄─────────────────────────┘ ▼ +/// isEditing ◄───────────────────────────── hasChosen +/// │ Focus moved from ▲ │ ▲ +/// │ content to topic. │ │ │ +/// │ │ │ │ +/// └──────────────────────────────────────┘ └─────┘ +/// Focus moved from Content input loses focus +/// topic to content. without topic input gaining it. +/// ``` +/// +/// This state machine offers the following invariants: +/// - When topic input has focus, the status must be [isEditing]. +/// - When content input has focus, the status must be [hasChosen]. +/// - When neither input has focus, and content input was the last +/// input among the two to be focused, the status must be [hasChosen]. +/// - Otherwise, the status must be [notEditingNotChosen]. +enum ComposeTopicInteractionStatus { + /// The topic has likely not been chosen if left empty, + /// and is not being actively edited. + /// + /// When in this status neither the topic input nor the content input has focus. + notEditingNotChosen, + + /// The topic is being actively edited. + /// + /// When in this status, the topic input must have focus. + isEditing, + + /// The topic has likely been chosen, even if it is left empty. + /// + /// When in this status, the topic input must have no focus; + /// the content input might have focus. + hasChosen, +} + class StreamComposeBoxController extends ComposeBoxController { StreamComposeBoxController({required PerAccountStore store}) : topic = ComposeTopicController(store: store); final ComposeTopicController topic; final topicFocusNode = FocusNode(); + final ValueNotifier topicInteractionStatus = + ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen); @override void dispose() { topic.dispose(); topicFocusNode.dispose(); + topicInteractionStatus.dispose(); super.dispose(); } } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index a8c0c12b59..0f6a5c75a1 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -546,14 +546,12 @@ 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, - // 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), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 90a0762d34..de5540ba1f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -338,10 +338,8 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - // 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) @@ -1158,13 +1156,11 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - // 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, - // ignore: unnecessary_null_comparison // null topic names soon to be enabled fontStyle: topic.displayName == null ? FontStyle.italic : null, ))), const SizedBox(width: 4), diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 8791c1b9d9..1a17f70f60 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -26,7 +26,7 @@ extension ZulipStreamChecks on Subject { extension TopicNameChecks on Subject { Subject get apiName => has((x) => x.apiName, 'apiName'); - Subject get displayName => has((x) => x.displayName, 'displayName'); + Subject get displayName => has((x) => x.displayName, 'displayName'); } extension StreamConversationChecks on Subject { diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 416fca4f3b..2f4d5702d8 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -20,10 +20,12 @@ void main() { required bool expectLegacy, required int messageId, bool? applyMarkdown, + bool? allowEmptyTopicName, }) async { final result = await getMessageCompat(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); if (expectLegacy) { check(connection.lastRequest).isA() @@ -43,6 +45,8 @@ void main() { ..url.path.equals('/api/v1/messages/$messageId') ..url.queryParameters.deepEquals({ if (applyMarkdown != null) 'apply_markdown': applyMarkdown.toString(), + if (allowEmptyTopicName != null) + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } return result; @@ -57,6 +61,7 @@ void main() { expectLegacy: false, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNotNull().jsonEquals(message); }); @@ -71,6 +76,7 @@ void main() { expectLegacy: false, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: true, ); check(result).isNull(); }); @@ -92,6 +98,7 @@ void main() { expectLegacy: true, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: null, ); check(result).isNotNull().jsonEquals(message); }); @@ -113,6 +120,7 @@ void main() { expectLegacy: true, messageId: message.id, applyMarkdown: true, + allowEmptyTopicName: null, ); check(result).isNull(); }); @@ -124,11 +132,13 @@ void main() { FakeApiConnection connection, { required int messageId, bool? applyMarkdown, + bool? allowEmptyTopicName, required Map expected, }) async { final result = await getMessage(connection, messageId: messageId, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -159,6 +169,16 @@ void main() { }); }); + test('allow empty topic name', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: fakeResult.toJson()); + await checkGetMessage(connection, + messageId: 1, + allowEmptyTopicName: true, + expected: {'allow_empty_topic_name': 'true'}); + }); + }); + test('Throws assertion error when FL <120', () { return FakeApiConnection.with_(zulipFeatureLevel: 119, (connection) async { connection.prepare(json: fakeResult.toJson()); @@ -255,12 +275,14 @@ void main() { required int numAfter, bool? clientGravatar, bool? applyMarkdown, + bool? allowEmptyTopicName, required Map expected, }) async { final result = await getMessages(connection, narrow: narrow, anchor: anchor, includeAnchor: includeAnchor, numBefore: numBefore, numAfter: numAfter, clientGravatar: clientGravatar, applyMarkdown: applyMarkdown, + allowEmptyTopicName: allowEmptyTopicName, ); check(connection.lastRequest).isA() ..method.equals('GET') @@ -279,11 +301,13 @@ void main() { await checkGetMessages(connection, narrow: const CombinedFeedNarrow().apiEncode(), anchor: AnchorCode.newest, numBefore: 10, numAfter: 20, + allowEmptyTopicName: true, expected: { 'narrow': jsonEncode([]), 'anchor': 'newest', 'num_before': '10', 'num_after': '20', + 'allow_empty_topic_name': 'true', }); }); }); diff --git a/test/example_data.dart b/test/example_data.dart index fc3acfc5a4..9b4c49d58d 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -77,7 +77,7 @@ final Uri realmUrl = Uri.parse('https://chat.example/'); Uri get _realmUrl => realmUrl; const String recentZulipVersion = '9.0'; -const int recentZulipFeatureLevel = 278; +const int recentZulipFeatureLevel = 334; const int futureZulipFeatureLevel = 9999; const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1; diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index df2777aac6..295dcde7b9 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -83,6 +83,7 @@ extension TextStyleChecks on Subject { Subject get inherit => has((t) => t.inherit, 'inherit'); Subject get color => has((t) => t.color, 'color'); Subject get fontSize => has((t) => t.fontSize, 'fontSize'); + Subject get fontStyle => has((t) => t.fontStyle, 'fontStyle'); Subject get fontWeight => has((t) => t.fontWeight, 'fontWeight'); Subject get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing'); Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); @@ -228,6 +229,7 @@ extension ThemeDataChecks on Subject { extension InputDecorationChecks on Subject { Subject get hintText => has((x) => x.hintText, 'hintText'); + Subject get hintStyle => has((x) => x.hintStyle, 'hintStyle'); } extension TextFieldChecks on Subject { diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 16b92d98d4..9d9fe090e0 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -19,6 +20,7 @@ import 'package:zulip/widgets/compose_box.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../fake_async.dart'; +import '../stdlib_checks.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; @@ -1026,6 +1028,38 @@ void main() { check(done).isTrue(); }); + test('TopicAutocompleteView getStreamTopics request', () async { + final store = eg.store(); + final connection = store.connection as FakeApiConnection; + + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: '')], + ).toJson()); + TopicAutocompleteView.init(store: store, streamId: 1000, + query: TopicAutocompleteQuery('foo')); + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/1000/topics') + ..url.queryParameters['allow_empty_topic_name'].equals('true'); + }); + + test('legacy: TopicAutocompleteView getStreamTopics request', () async { + final account = eg.account(user: eg.selfUser, zulipFeatureLevel: 333); + final store = eg.store(account: account, initialSnapshot: eg.initialSnapshot( + zulipFeatureLevel: 333)); + final connection = store.connection as FakeApiConnection; + + connection.prepare(json: GetStreamTopicsResult( + topics: [eg.getStreamTopicsEntry(name: 'foo')], + ).toJson()); + TopicAutocompleteView.init(store: store, streamId: 1000, + query: TopicAutocompleteQuery('foo')); + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/users/me/1000/topics') + ..url.queryParameters.isEmpty(); + }); + group('TopicAutocompleteQuery.testTopic', () { final store = eg.store(); void doCheck(String rawQuery, String topic, bool expected) { diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index f92bdfc096..24d1b6fb1a 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -82,6 +82,7 @@ void main() { bool? includeAnchor, required int numBefore, required int numAfter, + bool? allowEmptyTopicName, }) { check(connection.lastRequest).isA() ..method.equals('GET') @@ -92,6 +93,8 @@ void main() { if (includeAnchor != null) 'include_anchor': includeAnchor.toString(), 'num_before': numBefore.toString(), 'num_after': numAfter.toString(), + if (allowEmptyTopicName != null) + 'allow_empty_topic_name': allowEmptyTopicName.toString(), }); } @@ -126,6 +129,7 @@ void main() { anchor: 'newest', numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); } @@ -238,6 +242,7 @@ void main() { includeAnchor: false, numBefore: kMessageListFetchBatchSize, numAfter: 0, + allowEmptyTopicName: true, ); }); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8aeeec4eed..764f90f83d 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -52,12 +52,16 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + int? zulipFeatureLevel, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final selfAccount = eg.account(user: eg.selfUser, + zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel)); + store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUsers([ eg.selfUser, eg.user(userId: message.senderId), @@ -73,7 +77,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, child: MessageListPage(initNarrow: narrow))); // global store, per-account store, and message list get loaded @@ -372,7 +376,6 @@ void main() { final topicRow = find.descendant( of: find.byType(ZulipAppBar), matching: find.text( - // ignore: dead_null_aware_expression // null topic names soon to be enabled effectiveTopic.displayName ?? eg.defaultRealmEmptyTopicDisplayName)); await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation @@ -393,7 +396,7 @@ void main() { await tester.longPress(find.descendant( of: find.byType(RecipientHeader), - matching: find.text(effectiveMessage.topic.displayName))); + matching: find.text(effectiveMessage.topic.displayName!))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); } @@ -457,7 +460,7 @@ void main() { messages: [message]); check(findButtonForLabel('Mark as resolved')).findsNothing(); check(findButtonForLabel('Mark as unresolved')).findsNothing(); - }, skip: true); // null topic names soon to be enabled + }); testWidgets('show from recipient header', (tester) async { await prepare(); @@ -1156,6 +1159,32 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); check(findQuoteAndReplyButton(tester)).isNull(); }); + + testWidgets('handle empty topic', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + check(connection.lastRequest).isA() + .url.queryParameters['allow_empty_topic_name'].equals('true'); + await tester.pump(Duration.zero); + }); + + testWidgets('legacy: handle empty topic', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message), + zulipFeatureLevel: 333); + + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + check(connection.lastRequest).isA() + .url.queryParameters + .not((it) => it.containsKey('allow_empty_topic_name')); + await tester.pump(Duration.zero); + }); }); group('MarkAsUnread', () { diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 484c3b2454..b4ff007a8d 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -415,7 +415,7 @@ void main() { await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) - .equals(topic3.name.displayName); + .equals(topic3.name.displayName!); check(find.text('Topic one' )).findsNothing(); check(find.text('Topic two' )).findsNothing(); check(find.text('Topic three')).findsOne(); // shown in `_TopicInput` once @@ -473,7 +473,7 @@ void main() { 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: ''); @@ -486,7 +486,7 @@ void main() { 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: ''); @@ -502,6 +502,6 @@ void main() { 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/compose_box_test.dart b/test/widgets/compose_box_test.dart index 65740a8d1e..f979c687de 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -372,7 +372,8 @@ void main() { await enterTopic(tester, narrow: narrow, topic: ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', contentHintText: 'Message #${channel.name}'); }); @@ -382,7 +383,7 @@ void main() { await enterTopic(tester, narrow: narrow, topic: ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic (skip for “(no topic)”)', contentHintText: 'Message #${channel.name}'); }); @@ -391,6 +392,40 @@ void main() { await enterTopic(tester, narrow: narrow, topic: eg.defaultRealmEmptyTopicDisplayName); await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, topic input has focus, then content input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }); + + testWidgets('with empty topic, topic input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); checkComposeBoxHintTexts(tester, topicHintText: 'Topic', contentHintText: 'Message #${channel.name}'); @@ -401,10 +436,12 @@ void main() { await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: eg.defaultRealmEmptyTopicDisplayName, contentHintText: 'Message #${channel.name} > ' '${eg.defaultRealmEmptyTopicDisplayName}'); - }, skip: true); // null topic names soon to be enabled + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.equals(FontStyle.italic); + }); testWidgets('legacy: with empty topic, content input has focus', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: false, @@ -412,8 +449,44 @@ void main() { await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: '(no topic)', contentHintText: 'Message #${channel.name} > (no topic)'); + check(tester.widget(topicInputFinder)).decoration.isNotNull() + .hintStyle.isNotNull().fontStyle.isNull(); + }); + + testWidgets('with empty topic, content input has focus, then topic input gains focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, content input has focus, then loses it', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + + FocusManager.instance.primaryFocus!.unfocus(); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: eg.defaultRealmEmptyTopicDisplayName, + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); }); testWidgets('with non-empty topic', (tester) async { @@ -421,7 +494,8 @@ void main() { await enterTopic(tester, narrow: narrow, topic: 'new topic'); await tester.pump(); checkComposeBoxHintTexts(tester, - topicHintText: 'Topic', + topicHintText: 'Enter a topic ' + '(skip for “${eg.defaultRealmEmptyTopicDisplayName}”)', contentHintText: 'Message #${channel.name} > new topic'); }); }); @@ -489,7 +563,7 @@ void main() { narrow: TopicNarrow(channel.streamId, TopicName(''))); checkComposeBoxHintTexts(tester, contentHintText: 'Message #${channel.name} > ${eg.defaultRealmEmptyTopicDisplayName}'); - }, skip: true); // null topic names soon to be enabled + }); }); testWidgets('to DmNarrow with self', (tester) async { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 9fa5de1bf3..c91b70cb44 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -314,7 +314,7 @@ void main() { 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(); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 047a036cb5..f4de7b54ae 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -204,7 +204,7 @@ void main() { 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 = >[]; @@ -331,6 +331,7 @@ void main() { 'anchor': AnchorCode.newest.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': '0', + 'allow_empty_topic_name': 'true', }); }); @@ -363,6 +364,7 @@ void main() { 'anchor': AnchorCode.newest.toJson(), 'num_before': kMessageListFetchBatchSize.toString(), 'num_after': '0', + 'allow_empty_topic_name': 'true', }); }); }); @@ -1015,7 +1017,7 @@ void main() { 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, @@ -1024,7 +1026,7 @@ void main() { 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,