Skip to content

Commit 769cc7d

Browse files
PIG208gnprice
authored andcommitted
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 <[email protected]>
1 parent 2f4425f commit 769cc7d

File tree

2 files changed

+133
-15
lines changed

2 files changed

+133
-15
lines changed

lib/widgets/compose_box.dart

+42-3
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,33 @@ 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+
///
174+
/// See also: https://zulip.com/api/send-message#parameter-topic
175+
bool get isTopicVacuous {
176+
if (textNormalized.isEmpty) return true;
177+
178+
if (textNormalized == kNoTopicTopic) return true;
179+
180+
// TODO(server-10): simplify
181+
if (store.zulipFeatureLevel >= 334) {
182+
return textNormalized == store.realmEmptyTopicDisplayName;
183+
}
184+
185+
return false;
186+
}
169187

170188
@override
171189
List<TopicValidationError> _computeValidationErrors() {
@@ -558,10 +576,17 @@ class _StreamContentInputState extends State<_StreamContentInput> {
558576
});
559577
}
560578

579+
void _contentFocusChanged() {
580+
setState(() {
581+
// The relevant state lives on widget.controller.contentFocusNode itself.
582+
});
583+
}
584+
561585
@override
562586
void initState() {
563587
super.initState();
564588
widget.controller.topic.addListener(_topicChanged);
589+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
565590
}
566591

567592
@override
@@ -571,11 +596,16 @@ class _StreamContentInputState extends State<_StreamContentInput> {
571596
oldWidget.controller.topic.removeListener(_topicChanged);
572597
widget.controller.topic.addListener(_topicChanged);
573598
}
599+
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
600+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
601+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
602+
}
574603
}
575604

576605
@override
577606
void dispose() {
578607
widget.controller.topic.removeListener(_topicChanged);
608+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
579609
super.dispose();
580610
}
581611

@@ -586,6 +616,13 @@ class _StreamContentInputState extends State<_StreamContentInput> {
586616
// The chosen topic can't be sent to, so don't show it.
587617
return null;
588618
}
619+
if (!widget.controller.contentFocusNode.hasFocus) {
620+
// Do not fall back to a vacuous topic unless the user explicitly chooses
621+
// to do so (by skipping topic input and moving focus to content input),
622+
// so that the user is not encouraged to use vacuous topic when they
623+
// have not interacted with the inputs at all.
624+
return null;
625+
}
589626
}
590627

591628
return TopicName(widget.controller.topic.textNormalized);
@@ -604,7 +641,9 @@ class _StreamContentInputState extends State<_StreamContentInput> {
604641
// Zulip expresses channels and topics, not any normal English punctuation,
605642
// so don't make sense to translate. See:
606643
// https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585
607-
? '#$streamName' : '#$streamName > ${hintTopic.displayName}';
644+
? '#$streamName'
645+
// ignore: dead_null_aware_expression // null topic names soon to be enabled
646+
: '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}';
608647

609648
return _ContentInput(
610649
narrow: widget.narrow,

test/widgets/compose_box_test.dart

+91-12
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,49 @@ 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('legacy: with empty topic, topic input has focus', (tester) async {
374+
await prepare(tester, narrow: narrow, mandatoryTopics: false,
375+
zulipFeatureLevel: 333); // TODO(server-10)
376+
await enterTopic(tester, narrow: narrow, topic: '');
377+
await tester.pump();
378+
checkComposeBoxHintTexts(tester,
379+
topicHintText: 'Topic',
380+
contentHintText: 'Message #${channel.name}');
381+
});
382+
383+
testWidgets('with non-empty but vacuous topic, topic input has focus', (tester) async {
367384
await prepare(tester, narrow: narrow, mandatoryTopics: false);
368-
await enterTopic(tester, narrow: narrow, topic: '(no topic)');
385+
await enterTopic(tester, narrow: narrow,
386+
topic: eg.defaultRealmEmptyTopicDisplayName);
387+
await tester.pump();
388+
checkComposeBoxHintTexts(tester,
389+
topicHintText: 'Topic',
390+
contentHintText: 'Message #${channel.name}');
391+
});
392+
393+
testWidgets('with empty topic, content input has focus', (tester) async {
394+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
395+
await enterContent(tester, '');
396+
await tester.pump();
397+
checkComposeBoxHintTexts(tester,
398+
topicHintText: 'Topic',
399+
contentHintText: 'Message #${channel.name} > '
400+
'${eg.defaultRealmEmptyTopicDisplayName}');
401+
}, skip: true); // null topic names soon to be enabled
402+
403+
testWidgets('legacy: with empty topic, content input has focus', (tester) async {
404+
await prepare(tester, narrow: narrow, mandatoryTopics: false,
405+
zulipFeatureLevel: 333);
406+
await enterContent(tester, '');
369407
await tester.pump();
370408
checkComposeBoxHintTexts(tester,
371409
topicHintText: 'Topic',
@@ -392,15 +430,36 @@ void main() {
392430
contentHintText: 'Message #${channel.name}');
393431
});
394432

395-
testWidgets('with non-empty but vacuous topic', (tester) async {
396-
await prepare(tester, narrow: narrow, mandatoryTopics: true);
397-
await enterTopic(tester, narrow: narrow, topic: '(no topic)');
398-
await tester.pump();
433+
testWidgets('legacy: with empty topic', (tester) async {
434+
await prepare(tester, narrow: narrow, mandatoryTopics: true,
435+
zulipFeatureLevel: 333); // TODO(server-10)
399436
checkComposeBoxHintTexts(tester,
400437
topicHintText: 'Topic',
401438
contentHintText: 'Message #${channel.name}');
402439
});
403440

441+
group('with non-empty but vacuous topics', () {
442+
testWidgets('realm_empty_topic_display_name', (tester) async {
443+
await prepare(tester, narrow: narrow, mandatoryTopics: true);
444+
await enterTopic(tester, narrow: narrow,
445+
topic: eg.defaultRealmEmptyTopicDisplayName);
446+
await tester.pump();
447+
checkComposeBoxHintTexts(tester,
448+
topicHintText: 'Topic',
449+
contentHintText: 'Message #${channel.name}');
450+
});
451+
452+
testWidgets('"(no topic)"', (tester) async {
453+
await prepare(tester, narrow: narrow, mandatoryTopics: true);
454+
await enterTopic(tester, narrow: narrow,
455+
topic: '(no topic)');
456+
await tester.pump();
457+
checkComposeBoxHintTexts(tester,
458+
topicHintText: 'Topic',
459+
contentHintText: 'Message #${channel.name}');
460+
});
461+
});
462+
404463
testWidgets('with non-empty topic', (tester) async {
405464
await prepare(tester, narrow: narrow, mandatoryTopics: true);
406465
await enterTopic(tester, narrow: narrow, topic: 'new topic');
@@ -703,6 +762,7 @@ void main() {
703762
Future<void> setupAndTapSend(WidgetTester tester, {
704763
required String topicInputText,
705764
required bool mandatoryTopics,
765+
int? zulipFeatureLevel,
706766
}) async {
707767
TypingNotifier.debugEnable = false;
708768
addTearDown(TypingNotifier.debugReset);
@@ -711,7 +771,8 @@ void main() {
711771
final narrow = ChannelNarrow(channel.streamId);
712772
await prepareComposeBox(tester,
713773
narrow: narrow, streams: [channel],
714-
mandatoryTopics: mandatoryTopics);
774+
mandatoryTopics: mandatoryTopics,
775+
zulipFeatureLevel: zulipFeatureLevel);
715776

716777
await enterTopic(tester, narrow: narrow, topic: topicInputText);
717778
await tester.enterText(contentInputFinder, 'test content');
@@ -726,10 +787,21 @@ void main() {
726787
expectedMessage: 'Topics are required in this organization.');
727788
}
728789

729-
testWidgets('empty topic -> "(no topic)"', (tester) async {
790+
testWidgets('empty topic -> ""', (tester) async {
730791
await setupAndTapSend(tester,
731792
topicInputText: '',
732793
mandatoryTopics: false);
794+
check(connection.lastRequest).isA<http.Request>()
795+
..method.equals('POST')
796+
..url.path.equals('/api/v1/messages')
797+
..bodyFields['topic'].equals('');
798+
});
799+
800+
testWidgets('legacy: empty topic -> "(no topic)"', (tester) async {
801+
await setupAndTapSend(tester,
802+
topicInputText: '',
803+
mandatoryTopics: false,
804+
zulipFeatureLevel: 333);
733805
check(connection.lastRequest).isA<http.Request>()
734806
..method.equals('POST')
735807
..url.path.equals('/api/v1/messages')
@@ -743,6 +815,13 @@ void main() {
743815
checkMessageNotSent(tester);
744816
});
745817

818+
testWidgets('if topics are mandatory, reject `realmEmptyTopicDisplayName`', (tester) async {
819+
await setupAndTapSend(tester,
820+
topicInputText: eg.defaultRealmEmptyTopicDisplayName,
821+
mandatoryTopics: true);
822+
checkMessageNotSent(tester);
823+
});
824+
746825
testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async {
747826
await setupAndTapSend(tester,
748827
topicInputText: '(no topic)',

0 commit comments

Comments
 (0)