Skip to content

Commit 83ec831

Browse files
committed
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 either "Message #stream > (no topic)" or "Message #stream > general chat" appears appropriately. 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 <[email protected]>
1 parent 2facd04 commit 83ec831

File tree

2 files changed

+139
-21
lines changed

2 files changed

+139
-21
lines changed

lib/widgets/compose_box.dart

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,40 +157,75 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
157157
@override
158158
String _computeTextNormalized() {
159159
String trimmed = text.trim();
160-
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
160+
// TODO(server-10): simplify
161+
if (store.zulipFeatureLevel < 334) {
162+
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
163+
}
164+
165+
return trimmed;
161166
}
162167

163168
/// Whether [textNormalized] would fail a mandatory-topics check
164169
/// (see [mandatory]).
165170
///
166171
/// The term "Vacuous" draws distinction from [String.isEmpty], in the sense
167172
/// that certain strings are not empty but also indicate the absence of a topic.
168-
bool get isTopicVacuous => textNormalized == kNoTopicTopic;
173+
bool get isTopicVacuous {
174+
bool result = textNormalized.isEmpty
175+
// We keep checking for '(no topic)' regardless of the feature level
176+
// because it remains equivalent to an empty topic even when FL >= 334.
177+
// This can change in the future:
178+
// https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/.28realm_.29mandatory_topics.20behavior/near/2062391
179+
|| textNormalized == kNoTopicTopic;
180+
181+
// TODO(server-10): simplify
182+
if (store.zulipFeatureLevel >= 334) {
183+
result |= textNormalized == store.realmEmptyTopicDisplayName;
184+
}
185+
186+
return result;
187+
}
169188

170189
/// The send destination as a string.
171190
///
172-
/// This returns a string formatted like "#stream name" when topics are
173-
/// [mandatory] but the trimmed input is empty.
191+
/// If topics are [mandatory], returns a string formatted with a topic name
192+
/// when the trimmed input is non-empty. E.g.: "#stream name > topic name".
193+
///
194+
/// If topics are not [mandatory], returns a string formatted with a topic
195+
/// name only when the user intentionally leave the trimmed input empty.
174196
///
175-
/// Otherwise, returns a string formatted like "#stream name > topic name".
197+
/// Otherwise, returns a string formatted without a topic name.
198+
/// E.g.: "#stream name".
176199
// No i18n of the use of "#" and ">" strings; those are part of how
177200
// Zulip expresses channels and topics, not any normal English punctuation,
178201
// so don't make sense to translate. See:
179202
// https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585
180-
String getDestinationString({required String streamName}) {
203+
String getDestinationString({
204+
required String streamName,
205+
required bool contentHasFocus,
206+
}) {
181207
final textTrimmed = text.trim();
182208
if (textTrimmed.isNotEmpty) {
183209
return '#$streamName > $textTrimmed';
184210
}
185211

186212
assert(isTopicVacuous);
187-
// Sending to a vacuous topic (see [isTopicVacuous]) is not possible if
188-
// topics are [mandatory].
189-
if (mandatory) {
213+
if (
214+
// Sending to a vacuous topic (see [isTopicVacuous]) is not possible if
215+
// topics are [mandatory].
216+
mandatory
217+
// Do not fall back to a vacuous topic unless the user explicitly chooses
218+
// to do so (by skipping topic input and moving focus to content input),
219+
// because we expect a call to action for the user to pick one first.
220+
|| !contentHasFocus
221+
) {
190222
return '#$streamName';
191223
}
192224

193-
return '#$streamName > $kNoTopicTopic';
225+
final vacuousTopicDisplayName =
226+
// TODO(server-10): simplify away conditional
227+
textNormalized.isEmpty ? store.realmEmptyTopicDisplayName : textNormalized;
228+
return '#$streamName > $vacuousTopicDisplayName';
194229
}
195230

196231
@override
@@ -584,10 +619,17 @@ class _StreamContentInputState extends State<_StreamContentInput> {
584619
});
585620
}
586621

622+
void _contentFocusChanged() {
623+
setState(() {
624+
// The relevant state lives on widget.controller.contentFocusNode itself.
625+
});
626+
}
627+
587628
@override
588629
void initState() {
589630
super.initState();
590631
widget.controller.topic.addListener(_topicChanged);
632+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
591633
}
592634

593635
@override
@@ -597,11 +639,16 @@ class _StreamContentInputState extends State<_StreamContentInput> {
597639
oldWidget.controller.topic.removeListener(_topicChanged);
598640
widget.controller.topic.addListener(_topicChanged);
599641
}
642+
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
643+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
644+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
645+
}
600646
}
601647

602648
@override
603649
void dispose() {
604650
widget.controller.topic.removeListener(_topicChanged);
651+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
605652
super.dispose();
606653
}
607654

@@ -617,7 +664,9 @@ class _StreamContentInputState extends State<_StreamContentInput> {
617664
TopicName(widget.controller.topic.textNormalized)),
618665
controller: widget.controller,
619666
hintText: zulipLocalizations.composeBoxChannelContentHint(
620-
widget.controller.topic.getDestinationString(streamName: streamName)));
667+
widget.controller.topic.getDestinationString(
668+
streamName: streamName,
669+
contentHasFocus: widget.controller.contentFocusNode.hasFocus)));
621670
}
622671
}
623672

test/widgets/compose_box_test.dart

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,18 @@ void main() {
4747
List<User> otherUsers = const [],
4848
List<ZulipStream> streams = const [],
4949
bool? mandatoryTopics,
50+
int? zulipFeatureLevel,
5051
}) async {
5152
if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) {
5253
assert(streams.any((stream) => stream.streamId == streamId),
5354
'Add a channel with "streamId" the same as of $narrow.streamId to the store.');
5455
}
5556
addTearDown(testBinding.reset);
5657
selfUser ??= eg.selfUser;
57-
final selfAccount = eg.account(user: selfUser);
58+
zulipFeatureLevel ??= eg.futureZulipFeatureLevel;
59+
final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel);
5860
await testBinding.globalStore.add(selfAccount, eg.initialSnapshot(
61+
zulipFeatureLevel: zulipFeatureLevel,
5962
realmMandatoryTopics: mandatoryTopics,
6063
));
6164

@@ -327,12 +330,14 @@ void main() {
327330
Future<void> prepare(WidgetTester tester, {
328331
required Narrow narrow,
329332
bool? mandatoryTopics,
333+
int? zulipFeatureLevel,
330334
}) async {
331335
await prepareComposeBox(tester,
332336
narrow: narrow,
333337
otherUsers: [eg.otherUser, eg.thirdUser],
334338
streams: [channel],
335-
mandatoryTopics: mandatoryTopics);
339+
mandatoryTopics: mandatoryTopics,
340+
zulipFeatureLevel: zulipFeatureLevel);
336341
}
337342

338343
/// This checks the input's configured hint text without regard to whether
@@ -356,16 +361,50 @@ void main() {
356361
group('to ChannelNarrow, topics not mandatory', () {
357362
final narrow = ChannelNarrow(channel.streamId);
358363

359-
testWidgets('with empty topic', (tester) async {
364+
testWidgets('with empty topic, topic input has focus', (tester) async {
360365
await prepare(tester, narrow: narrow, mandatoryTopics: false);
366+
await enterTopic(tester, narrow: narrow, topic: '');
367+
await tester.pump();
361368
checkComposeBoxHintTexts(tester,
362369
topicHintText: 'Topic',
363-
contentHintText: 'Message #${channel.name} > (no topic)');
370+
contentHintText: 'Message #${channel.name}');
364371
});
365372

366-
testWidgets('with non-empty but vacuous topic', (tester) async {
373+
testWidgets('with non-empty but vacuous topic, topic input has focus', (tester) async {
367374
await prepare(tester, narrow: narrow, mandatoryTopics: false);
368-
await enterTopic(tester, narrow: narrow, topic: '(no topic)');
375+
await enterTopic(tester, narrow: narrow,
376+
topic: eg.defaultRealmEmptyTopicDisplayName);
377+
await tester.pump();
378+
checkComposeBoxHintTexts(tester,
379+
topicHintText: 'Topic',
380+
contentHintText: 'Message #${channel.name} > '
381+
'${eg.defaultRealmEmptyTopicDisplayName}');
382+
});
383+
384+
testWidgets('legacy: with empty topic, topic input has focus', (tester) async {
385+
await prepare(tester, narrow: narrow, mandatoryTopics: false,
386+
zulipFeatureLevel: 333);
387+
await enterTopic(tester, narrow: narrow, topic: '');
388+
await tester.pump();
389+
checkComposeBoxHintTexts(tester,
390+
topicHintText: 'Topic',
391+
contentHintText: 'Message #${channel.name}');
392+
});
393+
394+
testWidgets('with empty topic, content input has focus', (tester) async {
395+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
396+
await enterContent(tester, '');
397+
await tester.pump();
398+
checkComposeBoxHintTexts(tester,
399+
topicHintText: 'Topic',
400+
contentHintText: 'Message #${channel.name} > '
401+
'${eg.defaultRealmEmptyTopicDisplayName}');
402+
});
403+
404+
testWidgets('legacy: with empty topic, content input has focus', (tester) async {
405+
await prepare(tester, narrow: narrow, mandatoryTopics: false,
406+
zulipFeatureLevel: 333);
407+
await enterContent(tester, '');
369408
await tester.pump();
370409
checkComposeBoxHintTexts(tester,
371410
topicHintText: 'Topic',
@@ -394,11 +433,21 @@ void main() {
394433

395434
testWidgets('with non-empty but vacuous topic', (tester) async {
396435
await prepare(tester, narrow: narrow, mandatoryTopics: true);
397-
await enterTopic(tester, narrow: narrow, topic: '(no topic)');
436+
await enterTopic(tester, narrow: narrow,
437+
topic: eg.defaultRealmEmptyTopicDisplayName);
398438
await tester.pump();
399439
checkComposeBoxHintTexts(tester,
400440
topicHintText: 'Topic',
401-
contentHintText: 'Message #${channel.name} > (no topic)');
441+
contentHintText: 'Message #${channel.name} > '
442+
'${eg.defaultRealmEmptyTopicDisplayName}');
443+
});
444+
445+
testWidgets('legacy: with empty topic', (tester) async {
446+
await prepare(tester, narrow: narrow, mandatoryTopics: true,
447+
zulipFeatureLevel: 333);
448+
checkComposeBoxHintTexts(tester,
449+
topicHintText: 'Topic',
450+
contentHintText: 'Message #${channel.name}');
402451
});
403452

404453
testWidgets('with non-empty topic', (tester) async {
@@ -705,6 +754,7 @@ void main() {
705754
Future<void> setupAndTapSend(WidgetTester tester, {
706755
required String topicInputText,
707756
required bool mandatoryTopics,
757+
int? zulipFeatureLevel,
708758
}) async {
709759
TypingNotifier.debugEnable = false;
710760
addTearDown(TypingNotifier.debugReset);
@@ -713,7 +763,8 @@ void main() {
713763
final narrow = ChannelNarrow(channel.streamId);
714764
await prepareComposeBox(tester,
715765
narrow: narrow, streams: [channel],
716-
mandatoryTopics: mandatoryTopics);
766+
mandatoryTopics: mandatoryTopics,
767+
zulipFeatureLevel: zulipFeatureLevel);
717768

718769
await enterTopic(tester, narrow: narrow, topic: topicInputText);
719770
await tester.enterText(contentInputFinder, 'test content');
@@ -728,10 +779,21 @@ void main() {
728779
expectedMessage: 'Topics are required in this organization.');
729780
}
730781

731-
testWidgets('empty topic -> "(no topic)"', (tester) async {
782+
testWidgets('empty topic -> empty topic', (tester) async {
732783
await setupAndTapSend(tester,
733784
topicInputText: '',
734785
mandatoryTopics: false);
786+
check(connection.lastRequest).isA<http.Request>()
787+
..method.equals('POST')
788+
..url.path.equals('/api/v1/messages')
789+
..bodyFields['topic'].equals('');
790+
}, skip: true); // null topic names soon to be enabled
791+
792+
testWidgets('legacy: empty topic -> "(no topic)"', (tester) async {
793+
await setupAndTapSend(tester,
794+
topicInputText: '',
795+
mandatoryTopics: false,
796+
zulipFeatureLevel: 333);
735797
check(connection.lastRequest).isA<http.Request>()
736798
..method.equals('POST')
737799
..url.path.equals('/api/v1/messages')
@@ -745,6 +807,13 @@ void main() {
745807
checkMessageNotSent(tester);
746808
});
747809

810+
testWidgets('if topics are mandatory, reject `realmEmptyTopicDisplayName`', (tester) async {
811+
await setupAndTapSend(tester,
812+
topicInputText: eg.defaultRealmEmptyTopicDisplayName,
813+
mandatoryTopics: true);
814+
checkMessageNotSent(tester);
815+
}, skip: true); // null topic names soon to be enabled
816+
748817
testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async {
749818
await setupAndTapSend(tester,
750819
topicInputText: '(no topic)',

0 commit comments

Comments
 (0)