Skip to content

general chat #3: support alternative styling for topic input on edits/focus/blur #1365

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

PIG208
Copy link
Member

@PIG208 PIG208 commented Feb 19, 2025

The last PR of the sequence.

Previous ones:

Fixes: #1250

Screenshots:

focus mandatory non-mandatory non-mandatory (legacy)
no focus mandatory-no-focus non-mandatory-no-focus non-mandatory-no-focus-legacy
topic focused mandatory-topic-focused non-mandatory-topic-focused non-mandatory-topic-focused-legacy
content focused mandatory-content-focused non-mandatory-content-focused non-mandatory-content-focused-legacy

@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch 2 times, most recently from fb97e82 to 635b377 Compare February 28, 2025 01:37
@PIG208 PIG208 marked this pull request as ready for review February 28, 2025 01:41
@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch 7 times, most recently from 8029ffb to 2efb7f0 Compare March 4, 2025 04:27
@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch from 2efb7f0 to 6307134 Compare March 24, 2025 22:55
Comment on lines 749 to 797
String hintText = zulipLocalizations.composeBoxTopicHintText;
TextStyle hintStyle = topicTextStyle.copyWith(
color: designVariables.textInput.withFadedAlpha(0.5));
final defaultTopicDisplayName = _defaultTopicDisplayName();
if (defaultTopicDisplayName != null) {
if (widget.controller.topicFocusNode.hasFocus) {
// The user is actively interacting with the input.
// Show a long and engaging hint text.
hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
defaultTopicDisplayName);
} else if (widget.controller.hasChosenTopic.value) {
// The topic has been chosen. Show the default topic display name in
// the input as if the user has entered that when they left it empty.
hintText = defaultTopicDisplayName;
hintStyle = topicTextStyle.copyWith(
// TODO(server-10) simplify
fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null);
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, with switch:

final defaultTopicDisplayName = store.zulipFeatureLevel >= 334
  ? store.realmEmptyTopicDisplayName : kNoTopicTopic;

final decoration = switch ((
  store.realmMandatoryTopics,
  widget.controller.hasChosenTopic.value,
  widget.controller.topicFocusNode.hasFocus,
)) {
  (false, true, _) => InputDecoration(
    hintText: defaultTopicDisplayName,
    hintStyle: topicTextStyle.copyWith(
      fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null)),
  (false, false, true) => InputDecoration(
    hintText: zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
      defaultTopicDisplayName),
    hintStyle: hintStyle),
  (_, _, _) => InputDecoration(
    hintText: zulipLocalizations.composeBoxTopicHintText,
    hintStyle: hintStyle),
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that switch would be easier to understand as an if/else chain — I feel like as the reader I'd be basically transforming it into that in my head in order to understand what it's saying.

@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch from 6307134 to 0697ccb Compare March 24, 2025 23:23
@PIG208 PIG208 requested a review from chrisbobbe March 24, 2025 23:26
@PIG208 PIG208 added the maintainer review PR ready for review by Zulip maintainers label Mar 24, 2025
@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch from 0697ccb to a50d8ae Compare March 26, 2025 21:38
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Small comments below.

Comment on lines 1421 to 1426
/// Whether the user has made up their mind choosing a topic.
///
/// Empirically, this should be set to `false` whenever the user focuses on
/// the topic input, and set to `true` whenever the user focuses on the
/// content input.
ValueNotifier<bool> hasChosenTopic = ValueNotifier(false);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about "Whether the user seems to have made up their mind choosing a topic." or similar? It's impossible to know for sure, right :) they could come back and change it again before sending, or they could send accidentally before making up their mind.

Also I don't understand what "empirically" means here. The sentence sounds like we're an observer making notes from what we've seen of the app's behavior, rather than the ones deciding what the behavior is and causing it. 🙂

void _contentFocusChanged() {
setState(() {
// The relevant state lives on widget.controller.contentFocusNode itself.
});
if (widget.controller.contentFocusNode.hasFocus){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: spacing

@@ -576,16 +576,26 @@ class _StreamContentInputState extends State<_StreamContentInput> {
});
}

void _hasChosenTopicChanged() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose: Track user interaction with inputs statefully

What's the behavior change in this commit?

Comment on lines 762 to 789
(_, _, _) => InputDecoration(
// In all other cases, show a short hint text for less distraction.
hintText: zulipLocalizations.composeBoxTopicHintText,
hintStyle: hintStyle),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we assert that cases (_, false, false) and (_, true, true) never happen? If those states can happen, they probably won't persist for more than a frame—but that would be a frame where the text would be flickering to something different, and it would look janky.

Also would it be helpful to mention realmMandatoryTopics in some of these comments?

I wonder if an if/else might be a better fit for this logic than a switch.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think (_ false, false) can represent the initial state when the user open a page without interacting with any of the inputs, so it is definitely possible.

(_, true, true) seems possible as a really intermediate state, but it is hard to tell without staring at the conditions for some time.

These three cases might be better represented with an enum:

  • none when the user hasn't interacted with the inputs;
  • isEditing when the topic input has focus;
  • hasEdited when the content input has focus/topic input lost focus previously.

We can tweak the behavior a bit so none -> isEditing, none -> hasEdited, and isEditing <-> hasEdited are the only possible state transitions. This eliminates the possible flickering when more than one transition can happen between frames (namely the case when topic input is unfocused and content input gains focus, which can be represented as the isEditing -> hasEdited transition).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, if we implement the states but keeping the current behavior, it still seems to be conceptually subject to the flickering issue when the topic input loses focus and content input gains focus, if the changes to the focus node do not happen in the same frame.

However, it doesn't seem to be the case when testing this:

  void _topicFocusChanged() {
    print('topic changed: $buildCount');
    setState(() {
      if (widget.controller.topicFocusNode.hasFocus) {
        widget.controller.topicEditStatus.value = ComposeTopicEditStatus.isEditing;
      } else if (!widget.controller.contentFocusNode.hasFocus) {
        widget.controller.topicEditStatus.value = ComposeTopicEditStatus.notInteracted;
      }
    });
  }

  void _contentFocusChanged() {
    print('content changed: $buildCount');
    setState(() {
      if (widget.controller.contentFocusNode.hasFocus) {
        widget.controller.topicEditStatus.value = ComposeTopicEditStatus.hasChosen;
      }
    });
  }

Log message when the focus is shifted from topic to content input:

I/flutter (13922): topic changed: 2
I/flutter (13922): content changed: 2

The else if (!widget.controller.contentFocusNode.hasFocus) check should then be sufficient to prevent the flickering issue.

@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch from a50d8ae to 3043984 Compare April 14, 2025 23:26
@PIG208
Copy link
Member Author

PIG208 commented Apr 14, 2025

Thanks for the review! I have updated the PR. This revision rewrites the feature using enums and a state machine.

@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch 2 times, most recently from 9692e2a to 808d8d7 Compare April 15, 2025 19:49
@chrisbobbe
Copy link
Collaborator

Thanks! This seems to have gathered a rebase conflict; could you resolve that please?

@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch from 808d8d7 to dd4ebf0 Compare April 16, 2025 17:58
@PIG208 PIG208 requested a review from chrisbobbe April 17, 2025 00:45
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Here's a review of the first two commits:

90770e1 compose: Unskip test; handle TopicName with empty displayName for now
ec6622f compose [nfc]: Convert _TopicInput to a StatefulWidget

and I've read part of the third:

5132980 compose: Change topic input hint text

Comment on lines 658 to 661
// ignore: dead_null_aware_expression // null topic names soon to be enabled
: '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}';
// null topic names soon to be enabled
: '#$streamName > '
'${hintTopic.displayName.isEmpty ? store.realmEmptyTopicDisplayName
: hintTopic.displayName}';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose: Unskip test; handle TopicName with empty displayName for now

This logic was introduced in 769cc7df, which relied on the fact that
TopicName.displayName is `null` when the topic is empty.  Normally it is
fine to assume that the displayName is non-empty with the server flags,
but in this case the TopicName is constructed by our code.

I'm having trouble understanding the commit message. I'm having trouble with this part:

the fact that TopicName.displayName is null when the topic is empty

because that wasn't a fact at 769cc7d, and it's still not a fact in main. The TopicName.displayName field's type is String, not String?, so the value can't be null.

I'm also having trouble with the second sentence ("Normally…"). Is the empty string a valid value for TopicName.displayName or not? I suspect it isn't, and our code shouldn't be constructing a TopicName that way.

It looks like the only thing our code does with that TopicName is to get its displayName. How about skipping the trip through TopicName and just working only with strings? (This would mean the commit-message summary line needs updating.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I notice the code uses the store.realmEmptyTopicDisplayName getter. That getter has this in its dartdoc:

/// This should only be accessed when FL >= 334 […]

but it takes some nonlocal reasoning to confirm that this access indeed only happens when FL >= 334. How about at least adding an assert on the zulipFeatureLevel in that getter, so we can be more confident that we won't regress on that part of its contract?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. the value of TopicName usually comes from the server, and before we start passing the "empty_topic_names" client capability, displayName being empty is unexpected. I think it should be fine to work with strings here.

Not sure if we want to switch back to TopicName once TopicName.displayName becomes nullable. In UI code, we usually handle empty topic names by checking if TopicName.displayName is null, which should also make grepping easier.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TopicName is for requests we make (or might make) to the server, as much as it is for values we get from the server. That's the reason for its apiName getter:

  /// The string this topic is identified by in the Zulip API.
  ///
  /// This should be used in constructing HTTP requests to the server,
  /// but rarely for other purposes.  See [displayName] and [canonicalize].

and is why it appears in a number of places in the compose-box code.

As of the tip of the branch, I think it's helpful that this hintTopic local is a TopicName, because that allows the logic for falling back to "general chat" to look the same as the corresponding logic across the rest of the codebase:

      : '#$streamName > ${hintTopic.displayName ?? store.realmEmptyTopicDisplayName}';

Looking at references to realmEmptyTopicDisplayName, one sees that almost all of them are gated behind a condition of some TopicName.displayName being null, and the majority are in exactly this form with a ??.

I haven't looked closely at the individual commits in this branch to have a suggestion on the clearest way to break the changes down into steps, though.

@@ -671,12 +671,17 @@ class _StreamContentInputState extends State<_StreamContentInput> {
}
}

class _TopicInput extends StatelessWidget {
class _TopicInput extends StatefulWidget {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose [nfc]: Convert _TopicInput to a StatefulWidget

We will shortly start adding listeners for this.

What is "this" in the commit message? I think this commit message doesn't need a body, but as-is, it sounds like it's saying we want something to listen to the newly stateful widget itself, and I don't know what that means.

@@ -379,6 +379,13 @@
"@composeBoxTopicHintText": {
"description": "Hint text for topic input widget in compose box."
},
"composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 intial
state:

- a short hint text, "Topic", is shown if the user hasn't
  interacted with topic or content inputs at all.

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

Commit-message nits:

  • 'Enter a topic (skip for “general chat”)'
  • spelling of 'initial'


@override
void didUpdateWidget(covariant _TopicInput oldWidget) {
super.didUpdateWidget(oldWidget);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since widget.controller keeps a reference to the old store (widget.controller.topic.store), we should remove that reference when there's a new store; _TopicInputState should use PerAccountStoreAwareStateMixin.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what to include in the onNewStore method. _TopicInputState doesn't have listeners on widget.controller.topic. Its only reference to widget.controller.topic is in the build method, where it passes the widget.controller.topic instance as-is.

widget.controller comes from and is owned by _ComposeBoxState. It does implement a onNewStore method that swaps out widget.controller.topic.store with the new store. Maybe that should be sufficient?

Comment on lines 1469 to 1493
/// Represent how a user has edited the topic, by tracking their previous
/// interactions with topic and content inputs.
///
/// State-transition diagram:
///
/// ```
/// content input
/// gains focus
/// none ─────────────► hasChosen
/// ▲ │ │ ▲
/// │ └────────────────┤ │
/// │ topic input │ │ content input
/// │ gains focus │ │ gains focus
/// │ ▼ │
/// └────────────────── isEditing
/// topic input loses focus
/// and content input has no focus
/// ```
///
/// This state machine offers the following invariants:
/// - When topic input has focus, the edit status must be [isEditing].
/// - When content input has focus, the edit status must be [hasChosen].
/// - Otherwise, the edit status can be either of [none] or [hasChosen],
/// but never [isEditing].
enum ComposeTopicEditStatus {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "how a user has edited the topic" isn't the best description for what this represents. If I use this to try to straightforwardly describe each state, the results are misleading:

  • "The user has edited the topic in the 'none' way"—or perhaps "The user has not edited the topic"…but looking at the diagram, that's definitely not what this state means.

Or awkward/confusing:

  • "The user has edited the topic in the 'has chosen' way."
  • "The user has edited the topic in the 'is editing' way."

Or from another angle: it's equally possible to be in any of the states after any given sequence of edits (if an edit means changing the text in the input).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the "topic input gains focus" label meant to apply equally to the hasChosen -> isEditing transition? It's positioned so it looks like it's just meant to apply to none -> isEditing.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// - Otherwise, the edit status can be either of [none] or [hasChosen],
///   but never [isEditing].

"Otherwise" means neither the topic input nor the content input has focus, right? Looking at the diagram, I don't see how the state could be hasChosen in that "otherwise" case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. How about maybe ComposeTopicInteractionStatus? I'm also not happy with the none name. We can be a bit more literal about what that means. Updated the diagram:

/// ```
///                       (default)
///    Topic input            │          Content input
///    lost focus.            ▼          gained focus.
///   ┌────────────► notEditingNotChosen ────────────┐
///   │                                 │            │
///   │         Topic input             │            │
///   │         gained focus.           │            │
///   │       ◄─────────────────────────┘            ▼
/// isEditing ◄───────────────────────────── hasChosen
///   │         Focus moved from                     ▲
///   │         content to topic.                    │
///   │                                              │
///   └──────────────────────────────────────────────┘
///    Focus moved from
///    topic to content.
/// ```

The state can be hasChosen even after content input has lost focus. Will revise that to make it a bit clearer.

@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch 2 times, most recently from 1f01088 to 987e199 Compare April 19, 2025 01:08
@PIG208
Copy link
Member Author

PIG208 commented Apr 19, 2025

Thanks for the review! Pushed an update.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I've now read through all the commits. Small comments below.

@@ -379,6 +379,13 @@
"@composeBoxTopicHintText": {
"description": "Hint text for topic input widget in compose box."
},
"composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose: Change topic input hint text

When you rebase, this commit will need a flutter run to update the new Ukrainian translation file that exists in main.

}) async {
addTearDown(testBinding.reset);
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Since this is meant to be the self-account, I'd name it selfAccount instead of just account.

Comment on lines 452 to 453
testWidgets('show from app bar: resolve/unresolve not offered when topic is empty', (tester) async {
await prepare();
await prepare(zulipFeatureLevel: 334);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would eg.recentZulipFeatureLevel (bumped so it's actually recent and >=334) be just as effective here and in the other places?

Comment on lines 1501 to 1515
/// This state machine offers the following invariants:
/// - When topic input has focus, the status must be [isEditing].
/// - When content input has focus, the status must be [hasChosen].
/// - When neither inputs has focus, and content input was the last
/// input among the two to be focused, the status must be [hasChosen].
/// - Otherwise, the status must be [notEditingNotChosen].
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible for the content input to lose focus without the focus moving to the topic input, but that transition doesn't have an arrow in the diagram. If we add one (which I think would be an arrow from hasChosen to itself, in a loop shape), that should help clarify this invariant:

/// - When neither inputs has focus, and content input was the last
///   input among the two to be focused, the status must be [hasChosen].

(also nit: "neither input has focus")

Comment on lines 760 to 790
final hintStyle = topicTextStyle.copyWith(
color: designVariables.textInput.withFadedAlpha(0.5));
final defaultTopicDisplayName = store.zulipFeatureLevel >= 334
? store.realmEmptyTopicDisplayName : kNoTopicTopic;

final decoration = switch ((
store.realmMandatoryTopics,
widget.controller.topicInteractionStatus.value,
)) {
(false, ComposeTopicInteractionStatus.hasChosen) => InputDecoration(
// The topic has likely been chosen. Since topics are not mandaotry,
// show the default topic display name as if the user has entered that
// when they left the input empty.
hintText: defaultTopicDisplayName,
hintStyle: topicTextStyle.copyWith(
fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null)),

(false, ComposeTopicInteractionStatus.isEditing) => InputDecoration(
// The user is actively interacting with the input. Since topics are
// not mandatory, show a long hint text mentioning that they can be
// left empty.
hintText: zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
defaultTopicDisplayName),
hintStyle: hintStyle),

(false, ComposeTopicInteractionStatus.notEditingNotChosen) ||
(true, _) => InputDecoration(
// Otherwise, show a short hint text for less distraction.
hintText: zulipLocalizations.composeBoxTopicHintText,
hintStyle: hintStyle),
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a draft that takes some complexity out of the switch and removes some repetition (like hintStyle: hintStyle). It's more lines of code, but I think easier to read from top to bottom:

    final topicTextStyle = TextStyle(
      fontSize: 20,
      height: 22 / 20,
      color: designVariables.textInput.withFadedAlpha(0.9),
    ).merge(weightVariableTextStyle(context, wght: 600));

    // TODO(server-10) simplify away
    final emptyTopicsSupported = store.zulipFeatureLevel >= 334;

    late final String hintText;
    TextStyle hintStyle = topicTextStyle.copyWith(
      color: designVariables.textInput.withFadedAlpha(0.5));

    if (store.realmMandatoryTopics) {
      // Something short and not distracting.
      hintText = zulipLocalizations.composeBoxTopicHintText;
    } else {
      switch (widget.controller.topicInteractionStatus.value) {
        case ComposeTopicInteractionStatus.notEditingNotChosen:
          // Something short and not distracting.
          hintText = zulipLocalizations.composeBoxTopicHintText;
        case ComposeTopicInteractionStatus.isEditing:
          // The user is actively interacting with the input.  Since topics are
          // not mandatory, show a long hint text mentioning that they can be
          // left empty.
          hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
            emptyTopicsSupported
              ? store.realmEmptyTopicDisplayName
              : kNoTopicTopic);
        case ComposeTopicInteractionStatus.hasChosen:
          // The topic has likely been chosen.  Since topics are not mandatory,
          // show the default topic display name as if the user has entered that
          // when they left the input empty.
          if (emptyTopicsSupported) {
            hintText = store.realmEmptyTopicDisplayName;
            hintStyle = topicTextStyle.copyWith(fontStyle: FontStyle.italic);
          } else {
            hintText = kNoTopicTopic;
            hintStyle = topicTextStyle;
          }
      }
    }

    final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! This looks good to me (removed late from late final String hintText).

Comment on lines 1031 to 1032
test('TopicAutocompleteView allow empty topic name on modern servers', () async {
final account = eg.account(user: eg.selfUser, zulipFeatureLevel: 334);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the test is about "modern servers", let's use eg.recentZulipFeatureLevel instead of hard-coded 334, bumping (in a prep commit) eg.recentZulipFeatureLevel so that it is actually recent and >=334.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly, when I write tests, I think I generally omit "on modern servers" in a test description, leaving that as implied, and I write the description (and the test) so that it won't need changes when we eventually clear out the legacy code. Then the legacy-servers test is the thing that gets a marker in its description, like "on legacy servers", and a TODO(server comment to remove the test.

So here, maybe "TopicAutocompleteView getStreamTopics request"? (And add ..method.equals('GET'), for completeness in matching that description.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. We should be able to skip a lot of changes in the first commit if we bump eg.recentZulipFeatureLevel.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description changes make sense to be, but I thought that we usually don't have TODO(server-x) comments on tests (since they ought to fail when we drop legacy server support)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm, yeah, reasonable to leave them out on the tests I think.

Comment on lines 1165 to 1166
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message),
zulipFeatureLevel: 334);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(same comment about using eg.recentZulipFeatureLevel)

PIG208 added 7 commits April 25, 2025 19:31
In particular, this assumes support for empty topics in our app code.

To make sure we are aware of possible behavior changes in the app
code we test against.  Here's a helpful `git grep` command and its result
(thanks to "zulipFeatureLevel" being quite a greppable name):

```
$ git grep 'zulipFeatureLevel [<|>]' lib
lib/api/model/narrow.dart:  final supportsOperatorDm = zulipFeatureLevel >= 177; // TODO(server-7)
lib/api/model/narrow.dart:  final supportsOperatorWith = zulipFeatureLevel >= 271; // TODO(server-9)
lib/model/autocomplete.dart:        final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9)
lib/model/autocomplete.dart:    final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8)
lib/model/compose.dart:  final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9)
lib/model/compose.dart:  final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8)
lib/model/store.dart:    assert(zulipFeatureLevel >= 334);
lib/model/store.dart:    if (zulipFeatureLevel >= 163) { // TODO(server-7)
lib/model/store.dart:  bool get isUnsupported => zulipFeatureLevel < kMinSupportedZulipFeatureLevel;
lib/widgets/action_sheet.dart:  final supportsUnmutingTopics = store.zulipFeatureLevel >= 170;
lib/widgets/action_sheet.dart:  final supportsFollowingTopics = store.zulipFeatureLevel >= 219;
lib/widgets/action_sheet.dart:  final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6)
lib/widgets/actions.dart:    final useLegacy = store.zulipFeatureLevel < 155; // TODO(server-6)
lib/widgets/actions.dart:    assert(PerAccountStoreWidget.of(context).zulipFeatureLevel >= 155); // TODO(server-6)
lib/widgets/autocomplete.dart:    final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9)
lib/widgets/compose_box.dart:    if (store.zulipFeatureLevel < 334) {
lib/widgets/compose_box.dart:    if (store.zulipFeatureLevel >= 334) {
```

We can tell that this bump only affects 3 entries from above:

```
lib/model/store.dart:    assert(zulipFeatureLevel >= 334);
lib/widgets/compose_box.dart:    if (store.zulipFeatureLevel < 334) {
lib/widgets/compose_box.dart:    if (store.zulipFeatureLevel >= 334) {
```

All are related to the FL 334 (general chat) changes.

We could have bumped it further to FL 334+, but that's beyond the
needs of changes that follow.
This logic was introduced in 769cc7d, which assumed that
TopicName.displayName is `null` when the topic is empty.
TopicName that came from the server are guaranteed to be non-empty,
but here our code can construct an empty TopicName, breaking this
assumption.

Switch to using plain strings, and go back to constructing TopicName
with empty topics once TopicName.displayName becomes nullable.
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
Before, the content input shows the "#stream > topic" hint text
as long as it has focus, and set the hint text to "#stream" when it
loses focus.

Now, the content input still shows "#stream > topic" when it gains
focus, except that it will keep showing it even after losing focus,
until the user moves focus to the topic input.
Signed-off-by: Zixuan James Li <[email protected]>
Look for `allow_empty_topic_name` and `empty_topic_name` under "Feature
level 334" in the API Changelog to verify the affected routes:
  https://zulip.com/api/changelog

To keep the API bindings thin, instead of setting
`allow_empty_topic_name` for the callers, we require the callers to pass
the appropriate values instead.

Instead of making this parameter a `bool` that defaults to `false`
(and have the bindings remove the field when it's `false`), we type it
as `bool?` and only drop it when it is `null`.  This is also for making
the API binding thin.

Fixes: zulip#1250

Signed-off-by: Zixuan James Li <[email protected]>
@PIG208 PIG208 force-pushed the pr-general-chat-3-compose-blur branch from 987e199 to f9a4f56 Compare April 25, 2025 23:33
@PIG208
Copy link
Member Author

PIG208 commented Apr 25, 2025

Thanks for the review! Pushed a new update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
maintainer review PR ready for review by Zulip maintainers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Handle no-topic topic ("general chat")
3 participants