From 51616bdfffe90fd63dedc22fac935d2eae52a05e Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 31 Jan 2025 18:06:26 -0500 Subject: [PATCH 1/5] action_sheet [nfc]: Hide resolve/unresolve button for empty topic While this appears to be a user facing change, it's not visible yet, not until TopicName.displayName becomes nullable. This is a part of a series of changes to handle empty topics. Signed-off-by: Zixuan James Li --- lib/widgets/action_sheet.dart | 6 +++++- test/widgets/action_sheet_test.dart | 29 ++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 1d3cbad495..081537cec1 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -300,7 +300,11 @@ void showTopicActionSheet(BuildContext context, { pageContext: pageContext); })); - if (someMessageIdInTopic != null) { + // 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) { optionButtons.add(ResolveUnresolveButton(pageContext: pageContext, topic: topic, someMessageIdInTopic: someMessageIdInTopic)); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index dc611d3ca9..8aeeec4eed 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -353,24 +353,27 @@ void main() { Future showFromAppBar(WidgetTester tester, { ZulipStream? channel, - String topic = someTopic, + TopicName? topic, List? messages, }) async { final effectiveChannel = channel ?? someChannel; + final effectiveTopic = topic ?? TopicName(someTopic); final effectiveMessages = messages ?? [someMessage]; - assert(effectiveMessages.every((m) => m.topic.apiName == topic)); + assert(effectiveMessages.every((m) => m.topic.apiName == effectiveTopic.apiName)); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: effectiveMessages).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: eg.topicNarrow(effectiveChannel.streamId, topic)))); + initNarrow: TopicNarrow(effectiveChannel.streamId, effectiveTopic)))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); final topicRow = find.descendant( of: find.byType(ZulipAppBar), - matching: find.text(topic)); + 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 await tester.pump(const Duration(milliseconds: 250)); @@ -446,6 +449,16 @@ void main() { check(findButtonForLabel('Mark as unresolved')).findsNothing(); }); + testWidgets('show from app bar: resolve/unresolve not offered when topic is empty', (tester) async { + await prepare(); + final message = eg.streamMessage(stream: someChannel, topic: ''); + await showFromAppBar(tester, + topic: TopicName(''), + 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(); await showFromRecipientHeader(tester); @@ -485,7 +498,7 @@ void main() { final message = eg.streamMessage( stream: someChannel, topic: topic, sender: eg.otherUser); await showFromAppBar(tester, - channel: someChannel, topic: topic, messages: [message]); + channel: someChannel, topic: TopicName(topic), messages: [message]); } void checkButtons(List expectedButtonFinders) { @@ -697,7 +710,8 @@ void main() { testWidgets('unresolve: happy path', (tester) async { final message = eg.streamMessage(stream: someChannel, topic: '✔ zulip'); await prepare(topic: '✔ zulip'); - await showFromAppBar(tester, topic: '✔ zulip', messages: [message]); + await showFromAppBar(tester, + topic: TopicName('✔ zulip'), messages: [message]); connection.takeRequests(); connection.prepare(json: UpdateMessageResult().toJson()); await tester.tap(findButtonForLabel('Mark as unresolved')); @@ -710,7 +724,8 @@ void main() { testWidgets('unresolve: weird prefix', (tester) async { final message = eg.streamMessage(stream: someChannel, topic: '✔ ✔ zulip'); await prepare(topic: '✔ ✔ zulip'); - await showFromAppBar(tester, topic: '✔ ✔ zulip', messages: [message]); + await showFromAppBar(tester, + topic: TopicName('✔ ✔ zulip'), messages: [message]); connection.takeRequests(); connection.prepare(json: UpdateMessageResult().toJson()); await tester.tap(findButtonForLabel('Mark as unresolved')); From 47bbfe2aeaa3eccdfc5d1846b7a4d864e11db070 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 21 Feb 2025 18:01:52 -0500 Subject: [PATCH 2/5] compose [nfc]: Handle empty topics for topic-narrow input hint text While this appears to be a user facing change, it's not visible yet, not until TopicName.displayName becomes nullable. This is a part of a series of changes to handle empty topics. A test is skipped because the server does not send empty topics to the client without "empty_topic_name" client capability. Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 3 ++- test/widgets/compose_box_test.dart | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 7f47046d11..756cd43403 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -658,7 +658,8 @@ 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 - '#$streamName > ${topic.displayName}'); + // 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. return zulipLocalizations.composeBoxSelfDmContentHint; diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 52d2d1c851..f53545e64c 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -370,11 +370,20 @@ void main() { }); }); - testWidgets('to TopicNarrow', (tester) async { - await prepare(tester, - narrow: TopicNarrow(channel.streamId, TopicName('topic'))); - checkComposeBoxHintTexts(tester, - contentHintText: 'Message #${channel.name} > topic'); + group('to TopicNarrow', () { + testWidgets('with non-empty topic', (tester) async { + await prepare(tester, + narrow: TopicNarrow(channel.streamId, TopicName('topic'))); + checkComposeBoxHintTexts(tester, + contentHintText: 'Message #${channel.name} > topic'); + }); + + testWidgets('with empty topic', (tester) async { + await prepare(tester, + 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 { From 9d0df653d0fb3d3b0b9d7d8c8ff19db1a8b6da7e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 20 Mar 2025 16:21:19 -0700 Subject: [PATCH 3/5] compose [nfc]: Pull out method for computing topic for hint text This method is trivial for now, but provides a home for logic to be added next. In particular this separates the computation of how the topic should appear in the hint text from what the topic should be in the API, in `destination`. --- lib/widgets/compose_box.dart | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 756cd43403..1577d617b0 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -579,23 +579,31 @@ class _StreamContentInputState extends State<_StreamContentInput> { super.dispose(); } + /// The topic name to use in the hint text. + TopicName _hintTopic() { + return TopicName(widget.controller.topic.textNormalized); + } + @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + final streamName = store.streams[widget.narrow.streamId]?.name ?? zulipLocalizations.unknownChannelName; - final topic = TopicName(widget.controller.topic.textNormalized); + final hintDestination = + // No i18n of this use of "#" and ">" string; those are part of how + // 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 + '#$streamName > ${_hintTopic().displayName}'; + return _ContentInput( narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, topic), + destination: TopicNarrow(widget.narrow.streamId, + TopicName(widget.controller.topic.textNormalized)), controller: widget.controller, - hintText: zulipLocalizations.composeBoxChannelContentHint( - // No i18n of this use of "#" and ">" string; those are part of how - // 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 - '#$streamName > ${topic.displayName}')); + hintText: zulipLocalizations.composeBoxChannelContentHint(hintDestination)); } } From 2f4425fc88acf01753b64c86203cb89e0e4780b0 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 20 Mar 2025 16:10:51 -0700 Subject: [PATCH 4/5] compose: Omit "(no topic)" from hint when not allowed to send there Previously, "Message #stream > (no topic)" would appear as the hint text when the topic input is empty but mandatory. Now, it is shown as "Message #stream" instead, since the "(no topic)" isn't allowed when topics are mandatory. Co-authored-by: Zixuan James Li --- lib/widgets/compose_box.dart | 16 +++++++--- test/widgets/compose_box_test.dart | 51 +++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 1577d617b0..d794a5cc2f 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -579,8 +579,15 @@ class _StreamContentInputState extends State<_StreamContentInput> { super.dispose(); } - /// The topic name to use in the hint text. - TopicName _hintTopic() { + /// The topic name to show in the hint text, or null to show no topic. + TopicName? _hintTopic() { + if (widget.controller.topic.isTopicVacuous) { + if (widget.controller.topic.mandatory) { + // The chosen topic can't be sent to, so don't show it. + return null; + } + } + return TopicName(widget.controller.topic.textNormalized); } @@ -591,12 +598,13 @@ class _StreamContentInputState extends State<_StreamContentInput> { final streamName = store.streams[widget.narrow.streamId]?.name ?? zulipLocalizations.unknownChannelName; - final hintDestination = + final hintTopic = _hintTopic(); + final hintDestination = hintTopic == null // No i18n of this use of "#" and ">" string; those are part of how // 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 - '#$streamName > ${_hintTopic().displayName}'; + ? '#$streamName' : '#$streamName > ${hintTopic.displayName}'; return _ContentInput( narrow: widget.narrow, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index f53545e64c..bca5300fbd 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -326,11 +326,13 @@ void main() { Future prepare(WidgetTester tester, { required Narrow narrow, + bool? mandatoryTopics, }) async { await prepareComposeBox(tester, narrow: narrow, otherUsers: [eg.otherUser, eg.thirdUser], - streams: [channel]); + streams: [channel], + mandatoryTopics: mandatoryTopics); } /// This checks the input's configured hint text without regard to whether @@ -351,17 +353,56 @@ void main() { .decoration.isNotNull().hintText.equals(contentHintText); } - group('to ChannelNarrow', () { + group('to ChannelNarrow, topics not mandatory', () { + final narrow = ChannelNarrow(channel.streamId); + testWidgets('with empty topic', (tester) async { - await prepare(tester, narrow: ChannelNarrow(channel.streamId)); + await prepare(tester, narrow: narrow, mandatoryTopics: false); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name} > (no topic)'); + }); + + testWidgets('with non-empty but vacuous topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: '(no topic)'); + await tester.pump(); checkComposeBoxHintTexts(tester, topicHintText: 'Topic', contentHintText: 'Message #${channel.name} > (no topic)'); }); testWidgets('with non-empty topic', (tester) async { - final narrow = ChannelNarrow(channel.streamId); - await prepare(tester, narrow: narrow); + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: 'new topic'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name} > new topic'); + }); + }); + + group('to ChannelNarrow, mandatory topics', () { + final narrow = ChannelNarrow(channel.streamId); + + testWidgets('with empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with non-empty but vacuous topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + await enterTopic(tester, narrow: narrow, topic: '(no topic)'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with non-empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); await enterTopic(tester, narrow: narrow, topic: 'new topic'); await tester.pump(); checkComposeBoxHintTexts(tester, From 769cc7df405562d5f99a869ed009662e2b5801b6 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 21 Jan 2025 14:19:25 -0500 Subject: [PATCH 5/5] compose: Support sending to empty topic This does not rely on TopicName.displayName being non-nullable or "empty_topic_name" client capability, so it is not an NFC change. The key change that allows sending to empty topic is that `textNormalized` can now be actually empty, instead of being converted to "(no topic)" with `_computeTextNormalized`. --- This is accompanied with a content input hint text change, so that "Message #stream > general chat" appears appropriately when we make TopicName.displayName nullable. --- Previously, "Message #stream > (no topic)" was the hint text for content input as long as the topic input is empty and topics are not mandatory. Showing the default topic does not help create incentive for the user to pick a topic first. So only show it when they intend to leave the topic empty. See discussion: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/general.20chat.20design.20.23F1297/near/2088870 --- This does not aim to implement hint text changes to the topic input, which is always "Topic". We will handle that as a follow-up. Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 45 ++++++++++++- test/widgets/compose_box_test.dart | 103 +++++++++++++++++++++++++---- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d794a5cc2f..7c0c012331 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -157,7 +157,12 @@ class ComposeTopicController extends ComposeController { @override String _computeTextNormalized() { String trimmed = text.trim(); - return trimmed.isEmpty ? kNoTopicTopic : trimmed; + // TODO(server-10): simplify + if (store.zulipFeatureLevel < 334) { + return trimmed.isEmpty ? kNoTopicTopic : trimmed; + } + + return trimmed; } /// Whether [textNormalized] would fail a mandatory-topics check @@ -165,7 +170,20 @@ class ComposeTopicController extends ComposeController { /// /// The term "Vacuous" draws distinction from [String.isEmpty], in the sense /// that certain strings are not empty but also indicate the absence of a topic. - bool get isTopicVacuous => textNormalized == kNoTopicTopic; + /// + /// See also: https://zulip.com/api/send-message#parameter-topic + bool get isTopicVacuous { + if (textNormalized.isEmpty) return true; + + if (textNormalized == kNoTopicTopic) return true; + + // TODO(server-10): simplify + if (store.zulipFeatureLevel >= 334) { + return textNormalized == store.realmEmptyTopicDisplayName; + } + + return false; + } @override List _computeValidationErrors() { @@ -558,10 +576,17 @@ class _StreamContentInputState extends State<_StreamContentInput> { }); } + void _contentFocusChanged() { + setState(() { + // The relevant state lives on widget.controller.contentFocusNode itself. + }); + } + @override void initState() { super.initState(); widget.controller.topic.addListener(_topicChanged); + widget.controller.contentFocusNode.addListener(_contentFocusChanged); } @override @@ -571,11 +596,16 @@ class _StreamContentInputState extends State<_StreamContentInput> { oldWidget.controller.topic.removeListener(_topicChanged); widget.controller.topic.addListener(_topicChanged); } + if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) { + oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged); + widget.controller.contentFocusNode.addListener(_contentFocusChanged); + } } @override void dispose() { widget.controller.topic.removeListener(_topicChanged); + widget.controller.contentFocusNode.removeListener(_contentFocusChanged); super.dispose(); } @@ -586,6 +616,13 @@ 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. + return null; + } } return TopicName(widget.controller.topic.textNormalized); @@ -604,7 +641,9 @@ class _StreamContentInputState extends State<_StreamContentInput> { // 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 - ? '#$streamName' : '#$streamName > ${hintTopic.displayName}'; + ? '#$streamName' + // ignore: dead_null_aware_expression // null topic names soon to be enabled + : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}'; return _ContentInput( narrow: widget.narrow, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index bca5300fbd..e02fd97a15 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -47,6 +47,7 @@ void main() { List otherUsers = const [], List streams = const [], bool? mandatoryTopics, + int? zulipFeatureLevel, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { assert(streams.any((stream) => stream.streamId == streamId), @@ -54,8 +55,10 @@ void main() { } addTearDown(testBinding.reset); selfUser ??= eg.selfUser; - final selfAccount = eg.account(user: selfUser); + zulipFeatureLevel ??= eg.futureZulipFeatureLevel; + final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel, realmMandatoryTopics: mandatoryTopics, )); @@ -327,12 +330,14 @@ void main() { Future prepare(WidgetTester tester, { required Narrow narrow, bool? mandatoryTopics, + int? zulipFeatureLevel, }) async { await prepareComposeBox(tester, narrow: narrow, otherUsers: [eg.otherUser, eg.thirdUser], streams: [channel], - mandatoryTopics: mandatoryTopics); + mandatoryTopics: mandatoryTopics, + zulipFeatureLevel: zulipFeatureLevel); } /// This checks the input's configured hint text without regard to whether @@ -356,16 +361,49 @@ void main() { group('to ChannelNarrow, topics not mandatory', () { final narrow = ChannelNarrow(channel.streamId); - testWidgets('with empty topic', (tester) async { + testWidgets('with empty topic, topic input has focus', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); checkComposeBoxHintTexts(tester, topicHintText: 'Topic', - contentHintText: 'Message #${channel.name} > (no topic)'); + contentHintText: 'Message #${channel.name}'); }); - testWidgets('with non-empty but vacuous topic', (tester) async { + testWidgets('legacy: with empty topic, topic input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false, + zulipFeatureLevel: 333); // TODO(server-10) + await enterTopic(tester, narrow: narrow, topic: ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with non-empty but vacuous topic, topic input has focus', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: false); - await enterTopic(tester, narrow: narrow, topic: '(no topic)'); + await enterTopic(tester, narrow: narrow, + topic: eg.defaultRealmEmptyTopicDisplayName); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('with empty topic, content input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false); + await enterContent(tester, ''); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name} > ' + '${eg.defaultRealmEmptyTopicDisplayName}'); + }, skip: true); // null topic names soon to be enabled + + testWidgets('legacy: with empty topic, content input has focus', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: false, + zulipFeatureLevel: 333); + await enterContent(tester, ''); await tester.pump(); checkComposeBoxHintTexts(tester, topicHintText: 'Topic', @@ -392,15 +430,36 @@ void main() { contentHintText: 'Message #${channel.name}'); }); - testWidgets('with non-empty but vacuous topic', (tester) async { - await prepare(tester, narrow: narrow, mandatoryTopics: true); - await enterTopic(tester, narrow: narrow, topic: '(no topic)'); - await tester.pump(); + testWidgets('legacy: with empty topic', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true, + zulipFeatureLevel: 333); // TODO(server-10) checkComposeBoxHintTexts(tester, topicHintText: 'Topic', contentHintText: 'Message #${channel.name}'); }); + group('with non-empty but vacuous topics', () { + testWidgets('realm_empty_topic_display_name', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + await enterTopic(tester, narrow: narrow, + topic: eg.defaultRealmEmptyTopicDisplayName); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + + testWidgets('"(no topic)"', (tester) async { + await prepare(tester, narrow: narrow, mandatoryTopics: true); + await enterTopic(tester, narrow: narrow, + topic: '(no topic)'); + await tester.pump(); + checkComposeBoxHintTexts(tester, + topicHintText: 'Topic', + contentHintText: 'Message #${channel.name}'); + }); + }); + testWidgets('with non-empty topic', (tester) async { await prepare(tester, narrow: narrow, mandatoryTopics: true); await enterTopic(tester, narrow: narrow, topic: 'new topic'); @@ -703,6 +762,7 @@ void main() { Future setupAndTapSend(WidgetTester tester, { required String topicInputText, required bool mandatoryTopics, + int? zulipFeatureLevel, }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); @@ -711,7 +771,8 @@ void main() { final narrow = ChannelNarrow(channel.streamId); await prepareComposeBox(tester, narrow: narrow, streams: [channel], - mandatoryTopics: mandatoryTopics); + mandatoryTopics: mandatoryTopics, + zulipFeatureLevel: zulipFeatureLevel); await enterTopic(tester, narrow: narrow, topic: topicInputText); await tester.enterText(contentInputFinder, 'test content'); @@ -726,10 +787,21 @@ void main() { expectedMessage: 'Topics are required in this organization.'); } - testWidgets('empty topic -> "(no topic)"', (tester) async { + testWidgets('empty topic -> ""', (tester) async { await setupAndTapSend(tester, topicInputText: '', mandatoryTopics: false); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields['topic'].equals(''); + }); + + testWidgets('legacy: empty topic -> "(no topic)"', (tester) async { + await setupAndTapSend(tester, + topicInputText: '', + mandatoryTopics: false, + zulipFeatureLevel: 333); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') @@ -743,6 +815,13 @@ void main() { checkMessageNotSent(tester); }); + testWidgets('if topics are mandatory, reject `realmEmptyTopicDisplayName`', (tester) async { + await setupAndTapSend(tester, + topicInputText: eg.defaultRealmEmptyTopicDisplayName, + mandatoryTopics: true); + checkMessageNotSent(tester); + }); + testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async { await setupAndTapSend(tester, topicInputText: '(no topic)',