Skip to content

compose: Refactor some logic; implement redesigned error banner #1090

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

Merged
merged 24 commits into from
Dec 3, 2024

Conversation

chrisbobbe
Copy link
Collaborator

Prompted by reviewing Zixuan's #924, this is meant to make the compose-box code better prepared for that work than it is in main; details at #924 (review) and in the commits.

Please let me know as soon as anything doesn't look clear. I'm tired (it's almost bedtime 🙂) but I've spent a while on this and I wanted to get it in today if possible. Since this makes a UI change, screenshots coming soon.

Fixes: #1089

@chrisbobbe chrisbobbe requested review from gnprice and PIG208 November 27, 2024 07:44
@chrisbobbe
Copy link
Collaborator Author

main #924 (revision at 01c91ce) This PR
image image image
image image image
image image image

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Neat, thanks! I agree this direction of abstracting and deduplicating this logic looks useful.

Quick comments below since I'll be out for the next couple of workdays (plus the holiday).

Comment on lines 1159 to 1161
topicController: controller.topic,
controller: controller.content,
focusNode: controller.contentFocusNode,
Copy link
Member

Choose a reason for hiding this comment

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

A further step that I think could be helpful is if ComposeBox grew an InheritedWidget and a static of method. Then instead of passing these controller pieces down through each layer, the descendant widgets that need them could say ComposeBox.of(context).controller and get what they need.

Or maybe the of method would even just return the controller ­— that's the meaningful part of the state anyway.

Copy link
Member

Choose a reason for hiding this comment

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

Alternatively, the widgets could continue taking this information as parameters — but as a single parameter that is the whole controller. That seems like it'd also clean this up a lot.

Either way, one thing I'm thinking of in particular is how #924 adds enabled and sendMessageError, and has to pass them down through layers in a bunch of places (especially enabled). So ideally I'd like to see a refactor that makes it so a change like #924 can add a field to the controller and then not have to add references to it at a bunch of widget constructors but still be able to use it at various widgets deeper inside the compose box, like the various buttons.

Copy link
Member

Choose a reason for hiding this comment

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

With any of these approaches, we still need to listen to the possible updates. Our current setup for listener has a fine granularity — _ContentInputState listens to ComposeContentController and a FocusNode, _StreamContentInputState just listens to ComposeTopicController

Continuing this trend, we may have individual listeners for enabled and sendMessageError as well, while turning _TopicInput and probably some others into StatefulWidgets.

With InheritedWidget, maybe we can use BuildContext.dependOnInheritedWidgetOfExactType to simplify that part.

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 the existing ways we have things listen don't need to change.

The one thing worth avoiding with fine vs. coarse granularity here is that it'd be good to minimize how much needs to rebuild every time the user types another character. So things that don't need to depend on the text controllers should avoid doing so. But if things depend on enabled or sendMessageError when they don't strictly need to, that isn't a problem - those change much less frequently.

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Dec 2, 2024

Choose a reason for hiding this comment

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

InheritedWidget

I almost made a commit like this, but then noticed the branch was getting pretty long. :) I also noticed the InheritedWidget API prompting me to think about correctly handling changes to the value (the controller), such as by not calling dependOnInheritedWidgetOfExactType in an initState method. It seemed like something to consider carefully, but I didn't have a clear idea of when the controller would change (or if/when we'd want it to), so I didn't go ahead with it.

bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!,
bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!,
bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!,
borderBar: Color.lerp(borderBar, other.borderBar, t)!,
btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!,
btnLabelAttMediumDanger: Color.lerp(btnLabelAttMediumDanger, other.btnLabelAttMediumDanger, t)!,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

note to self: same comment as #924 (comment) ; this should be btnLabelAttMediumIntDanger; it's btn-label-att_medium-int_danger in the Figma

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for working on this. Looks good overall! Left some comments.

final _contentFocusNode = FocusNode();
class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox> {
FixedDestinationComposeBoxController get controller => _controller;
final FixedDestinationComposeBoxController _controller = FixedDestinationComposeBoxController();
Copy link
Member

Choose a reason for hiding this comment

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

nit: it should be fine to just use final without specifying the type here

final _streamComposeBoxControllerKey = GlobalKey<_StreamComposeBoxState>();
final _fixedDestinationComposeBoxControllerKey = GlobalKey<_FixedDestinationComposeBoxState>();


Copy link
Member

Choose a reason for hiding this comment

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

nit: extra newline

// The compose box doesn't null out its controller; it's either always null
// (e.g. in Combined Feed) or always non-null; it can't have been nulled out
// after the action sheet opened.
assert (composeBoxController != null);
Copy link
Member

Choose a reason for hiding this comment

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

Do we still need this assertion given the crunchy-shell check that follows this?

Comment on lines 1322 to 1323
assert(_controller is FixedDestinationComposeBoxController);
return _FixedDestinationComposeBox(controller: (_controller as FixedDestinationComposeBoxController),
Copy link
Member

@PIG208 PIG208 Nov 27, 2024

Choose a reason for hiding this comment

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

nit: how about

Suggested change
assert(_controller is FixedDestinationComposeBoxController);
return _FixedDestinationComposeBox(controller: (_controller as FixedDestinationComposeBoxController),
case TopicNarrow():
_controller as FixedDestinationComposeBoxController;
return _FixedDestinationComposeBox(controller: _controller, narrow: narrow);

for here and similarly for case DmNarrow(), so that the return's are just a bit over 70 characters. I'm not sure if the assertions are necessary.

narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]);
checkComposeBoxTextFields(tester, controllerKey: key,
final controller = (await prepareComposeBox(tester,
narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]))!;
Copy link
Member

Choose a reason for hiding this comment

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

It might be cleaner to make controller a shared late variable so that we don't have to wrap the await prepareComposeBox() call.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ooh nice idea, thanks.

if (errorBanner != null) {
body = errorBanner;
body = null;
Copy link
Member

Choose a reason for hiding this comment

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

I feel that having to do this makes only calling _ComposeBoxContainer once less appealing.

Using an early return here when the errorBanner is present can make it easier to reason about the value of body and errorBanner. And we can comfortably pass errorBanner: null at the end, or body: null at the beginning, for clarity.

How about:

diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 1ca2e4ae..6514a331 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -1343,12 +1343,12 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
   @override
   Widget build(BuildContext context) {
     final Widget? body;
-    Widget? errorBanner;
 
-    errorBanner = _errorBanner(context);
+    final errorBanner = _errorBanner(context);
     if (errorBanner != null) {
-      body = null;
-    } else {
+      return _ComposeBoxContainer(body: null, errorBanner: errorBanner);
+    }
+
     // TODO(#720) dismissable message-send error, maybe something like:
     //     if (controller.sendMessageError.value != null) {
     //       errorBanner = _ErrorBanner(label:
@@ -1374,8 +1374,7 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
         assert(false);
         body = null;
     }
-    }
 
-    return _ComposeBoxContainer(body: body, errorBanner: errorBanner);
+    return _ComposeBoxContainer(body: body, errorBanner: null);
   }
 }

chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Dec 3, 2024
For zulip#720, we'd like to add fields to ComposeBoxController and use
them in a bunch of widgets deeper in the compose box, like the
various buttons, without having to add references to them in those
widgets' constructors. With that in mind, pass the whole controller
down, and the widgets can access the fields directly from it. As
suggested by Greg:
  zulip#1090 (comment)
@chrisbobbe chrisbobbe force-pushed the pr-compose-box-refactors branch from 84c812e to 6b36b35 Compare December 3, 2024 00:02
@chrisbobbe
Copy link
Collaborator Author

Thanks for the reviews! Revision pushed.

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

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

Thanks for the revision! This looks good to me. The final commit sequence seems to imply that there is the 6/6 commit passing controller down. I went through the file and found no other places left to refactor, so we should be all good on this, except for updating the commit summaries.

result = controller.topic.hasValidationErrors.value;
}
result |= controller.content.hasValidationErrors.value;
return result;
Copy link
Member

Choose a reason for hiding this comment

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

I feel that this is an indicator to further abstract away logic around validation errors, probably as a future follow-up.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not yet picturing what that might look like, so I won't add a TODO in this revision, but I'm happy to if you help me understand better (or you can push that TODO yourself, wiki-style, after this is merged).

@PIG208 PIG208 added the integration review Added by maintainers when PR may be ready for integration label Dec 3, 2024
@PIG208 PIG208 requested a review from gnprice December 3, 2024 17:57
@chrisbobbe
Copy link
Collaborator Author

Oh, oops! Looks like I did 1, 2, 3, 3, 4, 5 😅—will fix.

@chrisbobbe chrisbobbe force-pushed the pr-compose-box-refactors branch from 6b36b35 to 695624b Compare December 3, 2024 19:56
@chrisbobbe
Copy link
Collaborator Author

(Done.)

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks! This all looks good — just nits below. Also pushing a small added commit for one other nit:
ff85cef compose [nfc]: Simplify controller getters to final fields

Comment on lines -328 to -331
// This will be null only if the compose box disappeared after the
// message action sheet opened, and before "Quote and reply" was pressed.
// Currently a compose box can't ever disappear, so this is impossible.
var composeBoxController = findMessageListPage().composeBoxController!;
Copy link
Member

Choose a reason for hiding this comment

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

Convenient that we had these comments explaining why the ! had been appropriate in the first place. That particularly helps with the commit
cfe6b3a action_sheet: Fix rare null-check error on quote-and-reply

that points out it's no longer appropriate.

_contentController.dispose();
_contentFocusNode.dispose();
_topic.dispose();
_topicFocusNode.dispose();
Copy link
Member

Choose a reason for hiding this comment

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

nit:

compose [nfc]: Make state "has-a" instead of "is-a" ComposeBoxController

It looks like there's a small bug this commit fixes, in addition to its NFC changes: previously we weren't disposing the topic FocusNode.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ooh thanks for the catch; I'll put the bugfix in a separate commit just before this one.

Comment on lines 204 to 207
check(controller).isA<StreamComposeBoxController>();
final topicTextField = tester.widgetList<TextField>(
find.byWidgetPredicate((widget) {
final topicController = (controller as StreamComposeBoxController).topic;
Copy link
Member

Choose a reason for hiding this comment

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

nit: can pull topicController's initializer outside the predicate:

Suggested change
check(controller).isA<StreamComposeBoxController>();
final topicTextField = tester.widgetList<TextField>(
find.byWidgetPredicate((widget) {
final topicController = (controller as StreamComposeBoxController).topic;
check(controller).isA<StreamComposeBoxController>();
final topicController = (controller as StreamComposeBoxController).topic;
final topicTextField = tester.widgetList<TextField>(
find.byWidgetPredicate((widget) {

Comment on lines 1070 to 1072
Widget? topicInput();
Widget contentInput();
Widget sendButton();
Copy link
Member

Choose a reason for hiding this comment

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

nit: as methods, these should be named buildTopicInput etc

child: Column(children: [
if (topicInput != null) topicInput!,
contentInput,
final topicInput = this.topicInput();
Copy link
Member

Choose a reason for hiding this comment

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

so e.g.

Suggested change
final topicInput = this.topicInput();
final topicInput = buildTopicInput();

Comment on lines 1022 to 1024
/// This widget should use a [SafeArea] to pad the left/right device insets.
/// If [body] is null it will also receive a bottom inset to pad;
/// it should pad that too.
Copy link
Member

Choose a reason for hiding this comment

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

How about this version?

Suggested change
/// This widget should use a [SafeArea] to pad the left/right device insets.
/// If [body] is null it will also receive a bottom inset to pad;
/// it should pad that too.
/// This widget should use a [SafeArea] to pad the left, right,
/// and bottom device insets.
/// (A bottom inset may occur if [body] is null.)

That seems like simpler advice to follow, unless there's some reason I'm missing that makes it hard in some case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep, looks good!

It's a little confusing to see a widget named ComposeBox returning
something that's neither a compose box nor a substitute/placeholder
for one, but something (SizedBox.shrink) that's meant to show
nothing at all. So, pull that logic out to the caller, which nicely
puts it near some related logic (the `hasComposeBox` condition for a
MediaQuery.removePadding).
Each of these tests will get a new related test, coming up.
In 052f203 it became possible for the compose box to disappear,
which would null out
MessageListPageState._composeBoxKey.currentState. Anyway, a small
and rare bug, but easy to fix.
We've been using ComposeBoxController as an interface, implemented
by _StreamComposeBoxState and _FixedDestinationComposeBoxState.

This commit puts the relevant implementation in ComposeBoxController
itself -- really in subclasses StreamComposeBoxController and
FixedDestinationComposeBoxController -- and makes those _FooState
classes instantiate it and store the instance in a field.

Next, we'll move the field up from those _FooState classes onto
ComposeBox's state to make the controller straightforwardly
available there (i.e. available without going through GlobalKey
logic). That will be helpful for zulip#720 when the controller grows a
send-in-progress flag; ComposeBox will be a conveniently central
place to build a progress indicator for that state.
Now there's just one place in the code where the controller is
stored, instead of in both _StreamComposeBoxState and
_FixedDestinationComposeBoxState. Also, those two widgets are
simplified to be stateless.

One small functional change: if the compose box disappears and then
reappears -- because the self-user lost and gained posting
permission, or a DM recipient was deactivated then reactivated --
the compose box returns to its state before it disappeared, instead
of being re-initialized. This should be rare, but seems fine and
helpful when it happens; it's one fewer reason your compose progress
might get lost.
This widget is already doing more than layout (sizing and
positioning its inputs on the screen): it creates the attachment
buttons and styles their touch feedback, and it defeats an unwanted
border that its `topicInput` and `contentInput` params might
otherwise create.

I chose the name "body" to distinguish this from some other elements
that will need to share the compose-box surface, for the UI part of
issue zulip#720: an error banner and a linear progress indicator.

Then also add "body" to the names _StreamComposeBox and
_FixedDestinationComposeBox. Since the recent commits that
simplified those, they're really just thin, stateless wrappers that
configure a _ComposeBoxBody.
As mentioned in a previous commit, these have become thin, stateless
wrappers that configure _ComposeBoxBody. Factor them as subclasses,
which feels neater and should help keep their implementations from
diverging unintentionally.
chrisbobbe and others added 12 commits December 3, 2024 14:34
Now, _ComposeBoxContainer is available as a nice place to put things
other than the "body" that (unlike the body) will want to consume
the left and right insets. Specifically, for zulip#720, an error banner
and an overlaid progress indicator.

Next, we'll rename `_ComposeBoxContainer.child` to `body`. We'll
also go ahead and add a param for the error banner, redesign the
error banner that we currently have and are passing as `child` /
`body`, and pass the banner as that new param instead.
This cherry-picks (with some adjustments) UI changes from Zixuan's
current revision of PR zulip#924 (for issue zulip#720):

  compose: Support error banner redesign for replacing the compose box

See the Figma:
  https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-15770&node-type=frame&t=vOSEWlXAhBa5EsAv-0

The new design is meant to put the banner at the very top of the
compose-box surface, with no margin. So zulip#1089, where the old
banner's top margin disappeared, looking buggy, is resolved.

The surface (but not content) of the redesigned banner seems like it
should extend through any left/right device insets to those edges of
the screen. By taking the banner as param separate from `body`,
_ComposeBoxContainer lets the banner do that -- without dropping its
responsibility for keeping the body (inputs, compose buttons, and
send button) out of the insets. The body doesn't need to consume the
insets itself and it's already complicated enough without needing
its own SafeAreas.

It also seems like the banner should pad the bottom inset when it's
meant to replace the body, so we do that and make the expectation
clear in the `errorBanner` dartdoc.

Co-authored-by: Zixuan James Li <[email protected]>
Fixes: zulip#1089
For zulip#720, we'd like to add fields to ComposeBoxController and use
them in a bunch of widgets deeper in the compose box, like the
various buttons, without having to add references to them in those
widgets' constructors. With that in mind, pass the whole controller
down, and the widgets can access the fields directly from it. As
suggested by Greg:
  zulip#1090 (comment)
A private final field exposed by a public getter is equivalent to
a public final field: either means that any library can get, and
no library can set.
@chrisbobbe chrisbobbe force-pushed the pr-compose-box-refactors branch from ff85cef to f46187a Compare December 3, 2024 22:40
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

@gnprice
Copy link
Member

gnprice commented Dec 3, 2024

Thanks for the revision! Looks good — merging.

@gnprice gnprice merged commit f46187a into zulip:main Dec 3, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

compose: Error banner for deactivated DM recipient has lost its top margin
3 participants