Skip to content

Commit 97b4826

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 dd7dd32 commit 97b4826

File tree

2 files changed

+141
-23
lines changed

2 files changed

+141
-23
lines changed

lib/widgets/compose_box.dart

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,41 +163,76 @@ 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
/// The send destination as a string.
174193
///
175-
/// This returns a string formatted like "#stream name" when topics are
176-
/// [mandatory] but the trimmed input is empty.
194+
/// If topics are [mandatory], returns a string formatted with a topic name
195+
/// when the trimmed input is non-empty. E.g.: "#stream name > topic name".
196+
///
197+
/// If topics are not [mandatory], returns a string formatted with a topic
198+
/// name only when the user intentionally leaves the trimmed input empty.
177199
///
178-
/// Otherwise, returns a string formatted like "#stream name > topic name".
200+
/// Otherwise, returns a string formatted without a topic name.
201+
/// E.g.: "#stream name".
179202
// No i18n of the use of "#" and ">" strings; those are part of how
180203
// Zulip expresses channels and topics, not any normal English punctuation,
181204
// so don't make sense to translate. See:
182205
// https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585
183-
String getDestinationString({required String streamName}) {
206+
String getDestinationString({
207+
required String streamName,
208+
required bool contentHasFocus,
209+
}) {
184210
final textTrimmed = text.trim();
185211
if (textTrimmed.isNotEmpty) {
186212
return '#$streamName > $textTrimmed';
187213
}
188214

189215
assert(_isTopicVacuous);
190-
// Sending to a vacuous topic (see [isTopicVacuous]) is not possible if
191-
// topics are [mandatory].
192-
if (mandatory) {
216+
if (
217+
// Sending to a vacuous topic (see [_isTopicVacuous]) is not possible if
218+
// topics are [mandatory].
219+
mandatory
220+
// Do not fall back to a vacuous topic unless the user explicitly chooses
221+
// to do so (by skipping topic input and moving focus to content input),
222+
// so that the user is not encouraged to use vacuous topic when they
223+
// have not interacted with the inputs at all.
224+
|| !contentHasFocus
225+
) {
193226
return '#$streamName';
194227
}
195228

196-
return '#$streamName > $kNoTopicTopic';
229+
final vacuousTopicDisplayName =
230+
// TODO(server-10): simplify away conditional
231+
textNormalized.isEmpty ? store.realmEmptyTopicDisplayName : textNormalized;
232+
return '#$streamName > $vacuousTopicDisplayName';
197233
}
198234

199-
@override
200-
List<TopicValidationError> _computeValidationErrors() {
235+
@override List<TopicValidationError> _computeValidationErrors() {
201236
return [
202237
if (mandatory && _isTopicVacuous)
203238
TopicValidationError.mandatoryButVacuous,
@@ -587,10 +622,17 @@ class _StreamContentInputState extends State<_StreamContentInput> {
587622
});
588623
}
589624

625+
void _contentFocusChanged() {
626+
setState(() {
627+
// The relevant state lives on widget.controller.contentFocusNode itself.
628+
});
629+
}
630+
590631
@override
591632
void initState() {
592633
super.initState();
593634
widget.controller.topic.addListener(_topicChanged);
635+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
594636
}
595637

596638
@override
@@ -600,11 +642,16 @@ class _StreamContentInputState extends State<_StreamContentInput> {
600642
oldWidget.controller.topic.removeListener(_topicChanged);
601643
widget.controller.topic.addListener(_topicChanged);
602644
}
645+
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
646+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
647+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
648+
}
603649
}
604650

605651
@override
606652
void dispose() {
607653
widget.controller.topic.removeListener(_topicChanged);
654+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
608655
super.dispose();
609656
}
610657

@@ -620,7 +667,9 @@ class _StreamContentInputState extends State<_StreamContentInput> {
620667
TopicName(widget.controller.topic.textNormalized)),
621668
controller: widget.controller,
622669
hintText: zulipLocalizations.composeBoxChannelContentHint(
623-
widget.controller.topic.getDestinationString(streamName: streamName)));
670+
widget.controller.topic.getDestinationString(
671+
streamName: streamName,
672+
contentHasFocus: widget.controller.contentFocusNode.hasFocus)));
624673
}
625674
}
626675

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)