Skip to content

Commit b4344c7

Browse files
committed
compose: Change topic input hint text
This is similar to web's behavior. When topics are not mandatory: - an alternative hint text "Enter a topic (skip for “general chat”)" is shown when the topic input has focus; - an opaque placeholder text (e.g.: "general chat") is shown if the user skipped to content input; Because the topic input is always shown in a message list page channel narrow (assuming permission to send messages), this also adds an initial state: - a short hint text, "Topic", is shown if the user hasn't interacted with topic or content inputs at all, or when the user unfocused topic input without moving focus to content input. This only changes the topic input's hint text. See CZO discussion for design details: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/general.20chat.20design.20.23F1297/near/2106736
1 parent 25eace6 commit b4344c7

13 files changed

+268
-10
lines changed

assets/l10n/app_en.arb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,13 @@
379379
"@composeBoxTopicHintText": {
380380
"description": "Hint text for topic input widget in compose box."
381381
},
382+
"composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)",
383+
"@composeBoxEnterTopicOrSkipHintText": {
384+
"description": "Hint text for topic input widget in compose box when topics are optional.",
385+
"placeholders": {
386+
"defaultTopicName": {"type": "String", "example": "general chat"}
387+
}
388+
},
382389
"composeBoxUploadingFilename": "Uploading {filename}…",
383390
"@composeBoxUploadingFilename": {
384391
"description": "Placeholder in compose box showing the specified file is currently uploading.",

lib/generated/l10n/zulip_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,12 @@ abstract class ZulipLocalizations {
622622
/// **'Topic'**
623623
String get composeBoxTopicHintText;
624624

625+
/// Hint text for topic input widget in compose box when topics are optional.
626+
///
627+
/// In en, this message translates to:
628+
/// **'Enter a topic (skip for “{defaultTopicName}”)'**
629+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName);
630+
625631
/// Placeholder in compose box showing the specified file is currently uploading.
626632
///
627633
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
316316
@override
317317
String get composeBoxTopicHintText => 'Topic';
318318

319+
@override
320+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
321+
return 'Enter a topic (skip for “$defaultTopicName”)';
322+
}
323+
319324
@override
320325
String composeBoxUploadingFilename(String filename) {
321326
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_en.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
316316
@override
317317
String get composeBoxTopicHintText => 'Topic';
318318

319+
@override
320+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
321+
return 'Enter a topic (skip for “$defaultTopicName”)';
322+
}
323+
319324
@override
320325
String composeBoxUploadingFilename(String filename) {
321326
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_ja.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
316316
@override
317317
String get composeBoxTopicHintText => 'Topic';
318318

319+
@override
320+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
321+
return 'Enter a topic (skip for “$defaultTopicName”)';
322+
}
323+
319324
@override
320325
String composeBoxUploadingFilename(String filename) {
321326
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_nb.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
316316
@override
317317
String get composeBoxTopicHintText => 'Topic';
318318

319+
@override
320+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
321+
return 'Enter a topic (skip for “$defaultTopicName”)';
322+
}
323+
319324
@override
320325
String composeBoxUploadingFilename(String filename) {
321326
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_pl.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
323323
@override
324324
String get composeBoxTopicHintText => 'Wątek';
325325

326+
@override
327+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
328+
return 'Enter a topic (skip for “$defaultTopicName”)';
329+
}
330+
326331
@override
327332
String composeBoxUploadingFilename(String filename) {
328333
return 'Przekazywanie $filename…';

lib/generated/l10n/zulip_localizations_ru.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
324324
@override
325325
String get composeBoxTopicHintText => 'Тема';
326326

327+
@override
328+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
329+
return 'Enter a topic (skip for “$defaultTopicName”)';
330+
}
331+
327332
@override
328333
String composeBoxUploadingFilename(String filename) {
329334
return 'Загрузка $filename…';

lib/generated/l10n/zulip_localizations_sk.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
316316
@override
317317
String get composeBoxTopicHintText => 'Topic';
318318

319+
@override
320+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
321+
return 'Enter a topic (skip for “$defaultTopicName”)';
322+
}
323+
319324
@override
320325
String composeBoxUploadingFilename(String filename) {
321326
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_uk.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,11 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
325325
@override
326326
String get composeBoxTopicHintText => 'Тема';
327327

328+
@override
329+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
330+
return 'Enter a topic (skip for “$defaultTopicName”)';
331+
}
332+
328333
@override
329334
String composeBoxUploadingFilename(String filename) {
330335
return 'Завантаження $filename…';

lib/widgets/compose_box.dart

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -681,16 +681,115 @@ class _TopicInput extends StatefulWidget {
681681
}
682682

683683
class _TopicInputState extends State<_TopicInput> {
684+
void _topicOrContentFocusChanged() {
685+
setState(() {
686+
final status = widget.controller.topicInteractionStatus;
687+
if (widget.controller.topicFocusNode.hasFocus) {
688+
// topic input gains focus
689+
status.value = ComposeTopicInteractionStatus.isEditing;
690+
} else if (widget.controller.contentFocusNode.hasFocus) {
691+
// content input gains focus
692+
status.value = ComposeTopicInteractionStatus.hasChosen;
693+
} else {
694+
// neither input has focus, the new value of topicInteractionStatus
695+
// depends on its previous value
696+
if (status.value == ComposeTopicInteractionStatus.isEditing) {
697+
// topic input loses focus
698+
status.value = ComposeTopicInteractionStatus.notEditingNotChosen;
699+
} else {
700+
// content input loses focus; stay in hasChosen
701+
assert(status.value == ComposeTopicInteractionStatus.hasChosen);
702+
}
703+
}
704+
});
705+
}
706+
707+
void _topicInteractionStatusChanged() {
708+
setState(() {
709+
// The actual state lives in widget.controller.topicInteractionStatus
710+
});
711+
}
712+
713+
@override
714+
void initState() {
715+
super.initState();
716+
widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged);
717+
widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged);
718+
widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged);
719+
}
720+
721+
@override
722+
void didUpdateWidget(covariant _TopicInput oldWidget) {
723+
super.didUpdateWidget(oldWidget);
724+
if (oldWidget.controller != widget.controller) {
725+
oldWidget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged);
726+
widget.controller.topicFocusNode.addListener(_topicOrContentFocusChanged);
727+
oldWidget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged);
728+
widget.controller.contentFocusNode.addListener(_topicOrContentFocusChanged);
729+
oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged);
730+
widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged);
731+
}
732+
}
733+
734+
@override
735+
void dispose() {
736+
widget.controller.topicFocusNode.removeListener(_topicOrContentFocusChanged);
737+
widget.controller.contentFocusNode.removeListener(_topicOrContentFocusChanged);
738+
widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged);
739+
super.dispose();
740+
}
741+
684742
@override
685743
Widget build(BuildContext context) {
686744
final zulipLocalizations = ZulipLocalizations.of(context);
687745
final designVariables = DesignVariables.of(context);
688-
TextStyle topicTextStyle = TextStyle(
746+
final store = PerAccountStoreWidget.of(context);
747+
748+
final topicTextStyle = TextStyle(
689749
fontSize: 20,
690750
height: 22 / 20,
691751
color: designVariables.textInput.withFadedAlpha(0.9),
692752
).merge(weightVariableTextStyle(context, wght: 600));
693753

754+
// TODO(server-10) simplify away
755+
final emptyTopicsSupported = store.zulipFeatureLevel >= 334;
756+
757+
final String hintText;
758+
TextStyle hintStyle = topicTextStyle.copyWith(
759+
color: designVariables.textInput.withFadedAlpha(0.5));
760+
761+
if (store.realmMandatoryTopics) {
762+
// Something short and not distracting.
763+
hintText = zulipLocalizations.composeBoxTopicHintText;
764+
} else {
765+
switch (widget.controller.topicInteractionStatus.value) {
766+
case ComposeTopicInteractionStatus.notEditingNotChosen:
767+
// Something short and not distracting.
768+
hintText = zulipLocalizations.composeBoxTopicHintText;
769+
case ComposeTopicInteractionStatus.isEditing:
770+
// The user is actively interacting with the input. Since topics are
771+
// not mandatory, show a long hint text mentioning that they can be
772+
// left empty.
773+
hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
774+
emptyTopicsSupported
775+
? store.realmEmptyTopicDisplayName
776+
: kNoTopicTopic);
777+
case ComposeTopicInteractionStatus.hasChosen:
778+
// The topic has likely been chosen. Since topics are not mandatory,
779+
// show the default topic display name as if the user has entered that
780+
// when they left the input empty.
781+
if (emptyTopicsSupported) {
782+
hintText = store.realmEmptyTopicDisplayName;
783+
hintStyle = topicTextStyle.copyWith(fontStyle: FontStyle.italic);
784+
} else {
785+
hintText = kNoTopicTopic;
786+
hintStyle = topicTextStyle;
787+
}
788+
}
789+
}
790+
791+
final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle);
792+
694793
return TopicAutocomplete(
695794
streamId: widget.streamId,
696795
controller: widget.controller.topic,
@@ -706,10 +805,7 @@ class _TopicInputState extends State<_TopicInput> {
706805
focusNode: widget.controller.topicFocusNode,
707806
textInputAction: TextInputAction.next,
708807
style: topicTextStyle,
709-
decoration: InputDecoration(
710-
hintText: zulipLocalizations.composeBoxTopicHintText,
711-
hintStyle: topicTextStyle.copyWith(
712-
color: designVariables.textInput.withFadedAlpha(0.5))))));
808+
decoration: decoration)));
713809
}
714810
}
715811

@@ -1382,17 +1478,67 @@ sealed class ComposeBoxController {
13821478
}
13831479
}
13841480

1481+
/// Represent how a user has interacted with topic and content inputs.
1482+
///
1483+
/// State-transition diagram:
1484+
///
1485+
/// ```
1486+
/// (default)
1487+
/// Topic input │ Content input
1488+
/// lost focus. ▼ gained focus.
1489+
/// ┌────────────► notEditingNotChosen ────────────┐
1490+
/// │ │ │
1491+
/// │ Topic input │ │
1492+
/// │ gained focus. │ │
1493+
/// │ ◄─────────────────────────┘ ▼
1494+
/// isEditing ◄───────────────────────────── hasChosen
1495+
/// │ Focus moved from ▲ │ ▲
1496+
/// │ content to topic. │ │ │
1497+
/// │ │ │ │
1498+
/// └──────────────────────────────────────┘ └─────┘
1499+
/// Focus moved from Content input loses focus
1500+
/// topic to content. without topic input gaining it.
1501+
/// ```
1502+
///
1503+
/// This state machine offers the following invariants:
1504+
/// - When topic input has focus, the status must be [isEditing].
1505+
/// - When content input has focus, the status must be [hasChosen].
1506+
/// - When neither input has focus, and content input was the last
1507+
/// input among the two to be focused, the status must be [hasChosen].
1508+
/// - Otherwise, the status must be [notEditingNotChosen].
1509+
enum ComposeTopicInteractionStatus {
1510+
/// The topic has likely not been chosen if left empty,
1511+
/// and is not being actively edited.
1512+
///
1513+
/// When in this status neither the topic input nor the content input has focus.
1514+
notEditingNotChosen,
1515+
1516+
/// The topic is being actively edited.
1517+
///
1518+
/// When in this status, the topic input must have focus.
1519+
isEditing,
1520+
1521+
/// The topic has likely been chosen, even if it is left empty.
1522+
///
1523+
/// When in this status, the topic input must have no focus;
1524+
/// the content input might have focus.
1525+
hasChosen,
1526+
}
1527+
13851528
class StreamComposeBoxController extends ComposeBoxController {
13861529
StreamComposeBoxController({required PerAccountStore store})
13871530
: topic = ComposeTopicController(store: store);
13881531

13891532
final ComposeTopicController topic;
13901533
final topicFocusNode = FocusNode();
1534+
final ValueNotifier<ComposeTopicInteractionStatus> topicInteractionStatus =
1535+
ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen);
13911536

13921537
@override
13931538
void dispose() {
13941539
topic.dispose();
13951540
topicFocusNode.dispose();
1541+
topicInteractionStatus.dispose();
13961542
super.dispose();
13971543
}
13981544
}

test/flutter_checks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ extension TextStyleChecks on Subject<TextStyle> {
8383
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
8484
Subject<Color?> get color => has((t) => t.color, 'color');
8585
Subject<double?> get fontSize => has((t) => t.fontSize, 'fontSize');
86+
Subject<FontStyle?> get fontStyle => has((t) => t.fontStyle, 'fontStyle');
8687
Subject<FontWeight?> get fontWeight => has((t) => t.fontWeight, 'fontWeight');
8788
Subject<double?> get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing');
8889
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');
@@ -228,6 +229,7 @@ extension ThemeDataChecks on Subject<ThemeData> {
228229

229230
extension InputDecorationChecks on Subject<InputDecoration> {
230231
Subject<String?> get hintText => has((x) => x.hintText, 'hintText');
232+
Subject<TextStyle?> get hintStyle => has((x) => x.hintStyle, 'hintStyle');
231233
}
232234

233235
extension TextFieldChecks on Subject<TextField> {

0 commit comments

Comments
 (0)