Skip to content

Commit 2835293

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 ccf1058 commit 2835293

File tree

2 files changed

+134
-20
lines changed

2 files changed

+134
-20
lines changed

lib/widgets/compose_box.dart

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,38 +157,71 @@ 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 [textNormalized] is vacuous (see [isTopicVacuous]).
191+
/// This returns a string formatted like "#stream name > topic name"
192+
/// when [textNormalized] is not vacuous (see [isTopicVacuous]), or when
193+
/// the topic is not [mandatory] and the user has chosen to leave it vacuous.
174194
///
175-
/// Otherwise, returns a string formatted like "#stream name > topic name".
195+
/// Otherwise, returns a string formatted without a topic name.
196+
/// E.g.: "#stream name".
176197
// No i18n of the use of "#" and ">" strings; those are part of how
177198
// Zulip expresses channels and topics, not any normal English punctuation,
178199
// so don't make sense to translate. See:
179200
// https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585
180-
String getDestinationString({required String streamName}) {
201+
String getDestinationString({
202+
required String streamName,
203+
required bool contentHasFocus,
204+
}) {
181205
if (!isTopicVacuous) {
182206
return '#$streamName > $textNormalized';
183207
}
184208

185-
// Sending to a vacuous topic (see [isTopicVacuous]) is not possible if
186-
// topics are [mandatory].
187-
if (mandatory) {
209+
if (
210+
// Sending to a vacuous topic (see [isTopicVacuous]) is not possible if
211+
// topics are [mandatory].
212+
mandatory
213+
// Do not fall back to a vacuous topic unless the user explicitly chooses
214+
// to do so (by skipping topic input and moving focus to content input),
215+
// because we expect a call to action for the user to pick one first.
216+
|| !contentHasFocus
217+
) {
188218
return '#$streamName';
189219
}
190220

191-
return '#$streamName > $kNoTopicTopic';
221+
final vacuousTopicDisplayName =
222+
// TODO(server-10): simplify away conditional
223+
textNormalized.isEmpty ? store.realmEmptyTopicDisplayName : textNormalized;
224+
return '#$streamName > $vacuousTopicDisplayName';
192225
}
193226

194227
@override
@@ -582,10 +615,17 @@ class _StreamContentInputState extends State<_StreamContentInput> {
582615
});
583616
}
584617

618+
void _contentFocusChanged() {
619+
setState(() {
620+
// The relevant state lives on widget.controller.contentFocusNode itself.
621+
});
622+
}
623+
585624
@override
586625
void initState() {
587626
super.initState();
588627
widget.controller.topic.addListener(_topicChanged);
628+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
589629
}
590630

591631
@override
@@ -595,11 +635,16 @@ class _StreamContentInputState extends State<_StreamContentInput> {
595635
oldWidget.controller.topic.removeListener(_topicChanged);
596636
widget.controller.topic.addListener(_topicChanged);
597637
}
638+
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
639+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
640+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
641+
}
598642
}
599643

600644
@override
601645
void dispose() {
602646
widget.controller.topic.removeListener(_topicChanged);
647+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
603648
super.dispose();
604649
}
605650

@@ -615,7 +660,9 @@ class _StreamContentInputState extends State<_StreamContentInput> {
615660
TopicName(widget.controller.topic.textNormalized)),
616661
controller: widget.controller,
617662
hintText: zulipLocalizations.composeBoxChannelContentHint(
618-
widget.controller.topic.getDestinationString(streamName: streamName)));
663+
widget.controller.topic.getDestinationString(
664+
streamName: streamName,
665+
contentHasFocus: widget.controller.contentFocusNode.hasFocus)));
619666
}
620667
}
621668

test/widgets/compose_box_test.dart

Lines changed: 76 additions & 9 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,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('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+
});
382+
383+
testWidgets('legacy: with empty topic, topic input has focus', (tester) async {
384+
await prepare(tester, narrow: narrow, mandatoryTopics: false,
385+
zulipFeatureLevel: 333);
386+
await enterTopic(tester, narrow: narrow, topic: '');
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+
});
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',
@@ -394,13 +432,22 @@ void main() {
394432

395433
testWidgets('with non-empty but vacuous topic', (tester) async {
396434
await prepare(tester, narrow: narrow, mandatoryTopics: true);
397-
await enterTopic(tester, narrow: narrow, topic: '(no topic)');
435+
await enterTopic(tester, narrow: narrow,
436+
topic: eg.defaultRealmEmptyTopicDisplayName);
398437
await tester.pump();
399438
checkComposeBoxHintTexts(tester,
400439
topicHintText: 'Topic',
401440
contentHintText: 'Message #${channel.name}');
402441
});
403442

443+
testWidgets('legacy: with empty topic', (tester) async {
444+
await prepare(tester, narrow: narrow, mandatoryTopics: true,
445+
zulipFeatureLevel: 333);
446+
checkComposeBoxHintTexts(tester,
447+
topicHintText: 'Topic',
448+
contentHintText: 'Message #${channel.name}');
449+
});
450+
404451
testWidgets('with non-empty topic', (tester) async {
405452
await prepare(tester, narrow: narrow, mandatoryTopics: true);
406453
await enterTopic(tester, narrow: narrow, topic: 'new topic');
@@ -705,6 +752,7 @@ void main() {
705752
Future<void> setupAndTapSend(WidgetTester tester, {
706753
required String topicInputText,
707754
required bool mandatoryTopics,
755+
int? zulipFeatureLevel,
708756
}) async {
709757
TypingNotifier.debugEnable = false;
710758
addTearDown(TypingNotifier.debugReset);
@@ -713,7 +761,8 @@ void main() {
713761
final narrow = ChannelNarrow(channel.streamId);
714762
await prepareComposeBox(tester,
715763
narrow: narrow, streams: [channel],
716-
mandatoryTopics: mandatoryTopics);
764+
mandatoryTopics: mandatoryTopics,
765+
zulipFeatureLevel: zulipFeatureLevel);
717766

718767
await enterTopic(tester, narrow: narrow, topic: topicInputText);
719768
await tester.enterText(contentInputFinder, 'test content');
@@ -728,10 +777,21 @@ void main() {
728777
expectedMessage: 'Topics are required in this organization.');
729778
}
730779

731-
testWidgets('empty topic -> "(no topic)"', (tester) async {
780+
testWidgets('empty topic -> empty topic', (tester) async {
732781
await setupAndTapSend(tester,
733782
topicInputText: '',
734783
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+
}, skip: true); // null topic names soon to be enabled
789+
790+
testWidgets('legacy: empty topic -> "(no topic)"', (tester) async {
791+
await setupAndTapSend(tester,
792+
topicInputText: '',
793+
mandatoryTopics: false,
794+
zulipFeatureLevel: 333);
735795
check(connection.lastRequest).isA<http.Request>()
736796
..method.equals('POST')
737797
..url.path.equals('/api/v1/messages')
@@ -745,6 +805,13 @@ void main() {
745805
checkMessageNotSent(tester);
746806
});
747807

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+
}, skip: true); // null topic names soon to be enabled
814+
748815
testWidgets('if topics are mandatory, reject "(no topic)"', (tester) async {
749816
await setupAndTapSend(tester,
750817
topicInputText: '(no topic)',

0 commit comments

Comments
 (0)