Skip to content

Commit dcaf165

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 442d112 commit dcaf165

File tree

2 files changed

+135
-20
lines changed

2 files changed

+135
-20
lines changed

lib/widgets/compose_box.dart

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,31 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
163163
@override
164164
String _computeTextNormalized() {
165165
String trimmed = text.trim();
166-
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
166+
// TODO(server-10): simplify
167+
if (store.zulipFeatureLevel < 334) {
168+
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
169+
}
170+
171+
return trimmed;
167172
}
168173

169174
/// Whether [textNormalized] would fail a mandatory-topics check
170175
/// (see [TopicValidationError.mandatoryButVacuous]).
171-
bool get _isTopicVacuous => textNormalized == kNoTopicTopic;
176+
bool get _isTopicVacuous {
177+
bool result = textNormalized.isEmpty
178+
// We keep checking for '(no topic)' regardless of the feature level
179+
// because it remains equivalent to an empty topic even when FL >= 334.
180+
// This can change in the future:
181+
// https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/.28realm_.29mandatory_topics.20behavior/near/2062391
182+
|| textNormalized == kNoTopicTopic;
183+
184+
// TODO(server-10): simplify
185+
if (store.zulipFeatureLevel >= 334) {
186+
result |= textNormalized == store.realmEmptyTopicDisplayName;
187+
}
188+
189+
return result;
190+
}
172191

173192
@override
174193
List<TopicValidationError> _computeValidationErrors() {
@@ -561,10 +580,17 @@ class _StreamContentInputState extends State<_StreamContentInput> {
561580
});
562581
}
563582

583+
void _contentFocusChanged() {
584+
setState(() {
585+
// The relevant state lives on widget.controller.contentFocusNode itself.
586+
});
587+
}
588+
564589
@override
565590
void initState() {
566591
super.initState();
567592
widget.controller.topic.addListener(_topicChanged);
593+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
568594
}
569595

570596
@override
@@ -574,21 +600,30 @@ class _StreamContentInputState extends State<_StreamContentInput> {
574600
oldWidget.controller.topic.removeListener(_topicChanged);
575601
widget.controller.topic.addListener(_topicChanged);
576602
}
603+
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
604+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
605+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
606+
}
577607
}
578608

579609
@override
580610
void dispose() {
581611
widget.controller.topic.removeListener(_topicChanged);
612+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
582613
super.dispose();
583614
}
584615

585616
/// The hint text for [_ContentInput] computed from the topic input.
586617
///
587-
/// This is a string referring to the send destination as something like
588-
/// "#stream name" when topics are [ComposeTopicController.mandatory] but the
589-
/// topic input is empty.
618+
/// If topics are [ComposeTopicController.mandatory], the topic name will
619+
/// only be included if the topic input is non-empty, in the form of
620+
/// "#stream name > topic name".
621+
///
622+
/// If topics are not mandatory, the topic name will only be included when the
623+
/// user intentionally leaves the topic input empty.
590624
///
591-
/// Otherwise, the destination is formatted like "#stream name > topic name".
625+
/// Otherwise, this will be a string formatted without a topic name.
626+
/// E.g.: "#stream name".
592627
// No i18n of the use of "#" and ">" strings; those are part of how
593628
// Zulip expresses channels and topics, not any normal English punctuation,
594629
// so don't make sense to translate. See:
@@ -606,14 +641,25 @@ class _StreamContentInputState extends State<_StreamContentInput> {
606641
}
607642

608643
assert(widget.controller.topic._isTopicVacuous);
609-
// Sending to a vacuous topic (see [ComposeTopicController._isTopicVacuous])
610-
// is not possible if topics are mandatory.
611-
if (widget.controller.topic.mandatory) {
644+
if (
645+
// Sending to a vacuous topic (see [ComposeTopicController._isTopicVacuous])
646+
// is not possible if topics are mandatory.
647+
widget.controller.topic.mandatory
648+
// Do not fall back to a vacuous topic unless the user explicitly chooses
649+
// to do so (by skipping topic input and moving focus to content input),
650+
// so that the user is not encouraged to use vacuous topic when they
651+
// have not interacted with the inputs at all.
652+
|| !widget.controller.contentFocusNode.hasFocus
653+
) {
612654
return zulipLocalizations.composeBoxChannelContentHint('#$streamName');
613655
}
614656

657+
final textNormalized = widget.controller.topic.textNormalized;
658+
final vacuousTopicDisplayName =
659+
// TODO(server-10): simplify away conditional
660+
textNormalized.isEmpty ? store.realmEmptyTopicDisplayName : textNormalized;
615661
return zulipLocalizations.composeBoxChannelContentHint(
616-
'#$streamName > $kNoTopicTopic');
662+
'#$streamName > $vacuousTopicDisplayName');
617663
}
618664

619665
@override

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 {
374+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
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 {
367395
await prepare(tester, narrow: narrow, mandatoryTopics: false);
368-
await enterTopic(tester, narrow: narrow, topic: '(no topic)');
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 {
@@ -703,6 +752,7 @@ void main() {
703752
Future<void> setupAndTapSend(WidgetTester tester, {
704753
required String topicInputText,
705754
required bool mandatoryTopics,
755+
int? zulipFeatureLevel,
706756
}) async {
707757
TypingNotifier.debugEnable = false;
708758
addTearDown(TypingNotifier.debugReset);
@@ -711,7 +761,8 @@ void main() {
711761
final narrow = ChannelNarrow(channel.streamId);
712762
await prepareComposeBox(tester,
713763
narrow: narrow, streams: [channel],
714-
mandatoryTopics: mandatoryTopics);
764+
mandatoryTopics: mandatoryTopics,
765+
zulipFeatureLevel: zulipFeatureLevel);
715766

716767
await enterTopic(tester, narrow: narrow, topic: topicInputText);
717768
await tester.enterText(contentInputFinder, 'test content');
@@ -726,10 +777,21 @@ void main() {
726777
expectedMessage: 'Topics are required in this organization.');
727778
}
728779

729-
testWidgets('empty topic -> "(no topic)"', (tester) async {
780+
testWidgets('empty topic -> ""', (tester) async {
730781
await setupAndTapSend(tester,
731782
topicInputText: '',
732783
mandatoryTopics: false);
784+
check(connection.lastRequest).isA<http.Request>()
785+
..method.equals('POST')
786+
..url.path.equals('/api/v1/messages')
787+
..bodyFields['topic'].equals('');
788+
});
789+
790+
testWidgets('legacy: empty topic -> "(no topic)"', (tester) async {
791+
await setupAndTapSend(tester,
792+
topicInputText: '',
793+
mandatoryTopics: false,
794+
zulipFeatureLevel: 333);
733795
check(connection.lastRequest).isA<http.Request>()
734796
..method.equals('POST')
735797
..url.path.equals('/api/v1/messages')
@@ -743,6 +805,13 @@ void main() {
743805
checkMessageNotSent(tester);
744806
});
745807

808+
testWidgets('if topics are mandatory, reject `realmEmptyTopicDisplayName`', (tester) async {
809+
await setupAndTapSend(tester,
810+
topicInputText: eg.defaultRealmEmptyTopicDisplayName,
811+
mandatoryTopics: true);
812+
checkMessageNotSent(tester);
813+
});
814+
746815
testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async {
747816
await setupAndTapSend(tester,
748817
topicInputText: '(no topic)',

0 commit comments

Comments
 (0)