Skip to content

Commit 010212f

Browse files
committed
compose: Disable compose box widgets when sending message
Also clear the content input only after the message is successfully sent. For the send button, unlike when there are validation errors, having an error dialog when its disabled for sending a message feels a bit redundant and doesn't help much. This can be annoying when the user misclicks and hit the send button multiple times. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 12d9849 commit 010212f

File tree

2 files changed

+150
-67
lines changed

2 files changed

+150
-67
lines changed

lib/widgets/compose_box.dart

Lines changed: 112 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
295295
super.initState();
296296
widget.controller.content.addListener(_contentChanged);
297297
widget.controller.contentFocusNode.addListener(_focusChanged);
298+
widget.controller._enabled.addListener(_enabledChanged);
298299
WidgetsBinding.instance.addObserver(this);
299300
}
300301

@@ -306,13 +307,16 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
306307
widget.controller.content.addListener(_contentChanged);
307308
oldWidget.controller.contentFocusNode.removeListener(_focusChanged);
308309
widget.controller.contentFocusNode.addListener(_focusChanged);
310+
oldWidget.controller._enabled.removeListener(_enabledChanged);
311+
widget.controller._enabled.addListener(_enabledChanged);
309312
}
310313
}
311314

312315
@override
313316
void dispose() {
314317
widget.controller.content.removeListener(_contentChanged);
315318
widget.controller.contentFocusNode.removeListener(_focusChanged);
319+
widget.controller._enabled.removeListener(_enabledChanged);
316320
WidgetsBinding.instance.removeObserver(this);
317321
super.dispose();
318322
}
@@ -334,6 +338,12 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
334338
store.typingNotifier.stoppedComposing();
335339
}
336340

341+
void _enabledChanged() {
342+
setState(() {
343+
// The actual state lives in `widget.controller._enabled`.
344+
});
345+
}
346+
337347
@override
338348
void didChangeAppLifecycleState(AppLifecycleState state) {
339349
switch (state) {
@@ -395,44 +405,47 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
395405
narrow: widget.narrow,
396406
controller: widget.controller.content,
397407
focusNode: widget.controller.contentFocusNode,
398-
fieldViewBuilder: (context) => ConstrainedBox(
399-
constraints: BoxConstraints(maxHeight: maxHeight(context)),
400-
// This [ClipRect] replaces the [TextField] clipping we disable below.
401-
child: ClipRect(
402-
child: InsetShadowBox(
403-
top: _verticalPadding, bottom: _verticalPadding,
404-
color: designVariables.composeBoxBg,
405-
child: TextField(
406-
controller: widget.controller.content,
407-
focusNode: widget.controller.contentFocusNode,
408-
// Let the content show through the `contentPadding` so that
409-
// our [InsetShadowBox] can fade it smoothly there.
410-
clipBehavior: Clip.none,
411-
style: TextStyle(
412-
fontSize: _fontSize,
413-
height: _lineHeightRatio,
414-
color: designVariables.textInput),
415-
// From the spec at
416-
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
417-
// > Compose box has the height to fit 2 lines. This is [done] to
418-
// > have a bigger hit area for the user to start the input. […]
419-
minLines: 2,
420-
maxLines: null,
421-
textCapitalization: TextCapitalization.sentences,
422-
decoration: InputDecoration(
423-
// This padding ensures that the user can always scroll long
424-
// content entirely out of the top or bottom shadow if desired.
425-
// With this and the `minLines: 2` above, an empty content input
426-
// gets 60px vertical distance (with no text-size scaling)
427-
// between the top of the top shadow and the bottom of the
428-
// bottom shadow. That's a bit more than the 54px given in the
429-
// Figma, and we can revisit if needed, but it's tricky to get
430-
// that 54px distance while also making the scrolling work like
431-
// this and offering two lines of touchable area.
432-
contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding),
433-
hintText: widget.hintText,
434-
hintStyle: TextStyle(
435-
color: designVariables.textInput.withFadedAlpha(0.5))))))));
408+
fieldViewBuilder: (context) => Opacity(
409+
opacity: widget.controller.enabled ? 1 : 0.5,
410+
child: ConstrainedBox(
411+
constraints: BoxConstraints(maxHeight: maxHeight(context)),
412+
// This [ClipRect] replaces the [TextField] clipping we disable below.
413+
child: ClipRect(
414+
child: InsetShadowBox(
415+
top: _verticalPadding, bottom: _verticalPadding,
416+
color: designVariables.composeBoxBg,
417+
child: TextField(
418+
enabled: widget.controller.enabled,
419+
controller: widget.controller.content,
420+
focusNode: widget.controller.contentFocusNode,
421+
// Let the content show through the `contentPadding` so that
422+
// our [InsetShadowBox] can fade it smoothly there.
423+
clipBehavior: Clip.none,
424+
style: TextStyle(
425+
fontSize: _fontSize,
426+
height: _lineHeightRatio,
427+
color: designVariables.textInput),
428+
// From the spec at
429+
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
430+
// > Compose box has the height to fit 2 lines. This is [done] to
431+
// > have a bigger hit area for the user to start the input. […]
432+
minLines: 2,
433+
maxLines: null,
434+
textCapitalization: TextCapitalization.sentences,
435+
decoration: InputDecoration(
436+
// This padding ensures that the user can always scroll long
437+
// content entirely out of the top or bottom shadow if desired.
438+
// With this and the `minLines: 2` above, an empty content input
439+
// gets 60px vertical distance (with no text-size scaling)
440+
// between the top of the top shadow and the bottom of the
441+
// bottom shadow. That's a bit more than the 54px given in the
442+
// Figma, and we can revisit if needed, but it's tricky to get
443+
// that 54px distance while also making the scrolling work like
444+
// this and offering two lines of touchable area.
445+
contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding),
446+
hintText: widget.hintText,
447+
hintStyle: TextStyle(
448+
color: designVariables.textInput.withFadedAlpha(0.5)))))))));
436449
}
437450
}
438451

@@ -513,20 +526,28 @@ class _TopicInput extends StatelessWidget {
513526
controller: controller.topic,
514527
focusNode: controller.topicFocusNode,
515528
contentFocusNode: controller.contentFocusNode,
516-
fieldViewBuilder: (context) => Container(
517-
padding: const EdgeInsets.only(top: 10, bottom: 9),
518-
decoration: BoxDecoration(border: Border(bottom: BorderSide(
519-
width: 1,
520-
color: designVariables.foreground.withFadedAlpha(0.2)))),
521-
child: TextField(
522-
controller: controller.topic,
523-
focusNode: controller.topicFocusNode,
524-
textInputAction: TextInputAction.next,
525-
style: topicTextStyle,
526-
decoration: InputDecoration(
527-
hintText: zulipLocalizations.composeBoxTopicHintText,
528-
hintStyle: topicTextStyle.copyWith(
529-
color: designVariables.textInput.withFadedAlpha(0.5))))));
529+
fieldViewBuilder: (context) => ValueListenableBuilder(
530+
valueListenable: controller._enabled,
531+
builder: (context, enabled, child) {
532+
return Opacity(
533+
opacity: enabled ? 1 : 0.5,
534+
child: Container(
535+
padding: const EdgeInsets.only(top: 10, bottom: 9),
536+
decoration: BoxDecoration(border: Border(bottom: BorderSide(
537+
width: 1,
538+
color: designVariables.foreground.withFadedAlpha(0.2)))),
539+
child: TextField(
540+
enabled: enabled,
541+
controller: controller.topic,
542+
focusNode: controller.topicFocusNode,
543+
textInputAction: TextInputAction.next,
544+
style: topicTextStyle,
545+
decoration: InputDecoration(
546+
hintText: zulipLocalizations.composeBoxTopicHintText,
547+
hintStyle: topicTextStyle.copyWith(
548+
color: designVariables.textInput.withFadedAlpha(0.5))))));
549+
}
550+
));
530551
}
531552
}
532553

@@ -699,10 +720,14 @@ abstract class _AttachUploadsButton extends StatelessWidget {
699720
final zulipLocalizations = ZulipLocalizations.of(context);
700721
return SizedBox(
701722
width: _composeButtonSize,
702-
child: IconButton(
703-
icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)),
704-
tooltip: tooltip(zulipLocalizations),
705-
onPressed: () => _handlePress(context)));
723+
child: ValueListenableBuilder(
724+
valueListenable: controller._enabled,
725+
builder: (context, enabled, child) {
726+
return IconButton(
727+
icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)),
728+
tooltip: tooltip(zulipLocalizations),
729+
onPressed: enabled ? () => _handlePress(context) : null);
730+
}));
706731
}
707732
}
708733

@@ -882,6 +907,12 @@ class _SendButtonState extends State<_SendButton> {
882907
});
883908
}
884909

910+
void _hasEnabledChanged() {
911+
setState(() {
912+
// The actual state lives in `widget.controller._enabled`.
913+
});
914+
}
915+
885916
@override
886917
void initState() {
887918
super.initState();
@@ -890,6 +921,7 @@ class _SendButtonState extends State<_SendButton> {
890921
controller.topic.hasValidationErrors.addListener(_hasErrorsChanged);
891922
}
892923
controller.content.hasValidationErrors.addListener(_hasErrorsChanged);
924+
controller._enabled.addListener(_hasEnabledChanged);
893925
}
894926

895927
@override
@@ -908,6 +940,8 @@ class _SendButtonState extends State<_SendButton> {
908940
}
909941
oldController.content.hasValidationErrors.removeListener(_hasErrorsChanged);
910942
controller.content.hasValidationErrors.addListener(_hasErrorsChanged);
943+
oldController._enabled.removeListener(_hasEnabledChanged);
944+
controller._enabled.addListener(_hasEnabledChanged);
911945
}
912946

913947
@override
@@ -917,6 +951,7 @@ class _SendButtonState extends State<_SendButton> {
917951
controller.topic.hasValidationErrors.removeListener(_hasErrorsChanged);
918952
}
919953
controller.content.hasValidationErrors.removeListener(_hasErrorsChanged);
954+
controller._enabled.removeListener(_hasEnabledChanged);
920955
super.dispose();
921956
}
922957

@@ -950,20 +985,20 @@ class _SendButtonState extends State<_SendButton> {
950985
return;
951986
}
952987

988+
if (!widget.controller.enabled) {
989+
// TODO update comment
990+
return;
991+
}
992+
953993
final store = PerAccountStoreWidget.of(context);
954994
final content = controller.content.textNormalized;
955995

956-
controller.content.clear();
957-
// The following `stoppedComposing` call is currently redundant,
958-
// because clearing input sends a "typing stopped" notice.
959-
// It will be necessary once we resolve #720.
960996
store.typingNotifier.stoppedComposing();
997+
widget.controller._enabled.value = false;
961998

962999
try {
963-
// TODO(#720) clear content input only on success response;
964-
// while waiting, put input(s) and send button into a disabled
965-
// "working on it" state (letting input text be selected for copying).
9661000
await store.sendMessage(destination: widget.getDestination(), content: content);
1001+
widget.controller.content.clear();
9671002
} on ApiRequestException catch (e) {
9681003
if (!mounted) return;
9691004
final zulipLocalizations = ZulipLocalizations.of(context);
@@ -975,6 +1010,8 @@ class _SendButtonState extends State<_SendButton> {
9751010
title: zulipLocalizations.errorMessageNotSent,
9761011
message: message);
9771012
return;
1013+
} finally {
1014+
widget.controller._enabled.value = true;
9781015
}
9791016
}
9801017

@@ -983,7 +1020,7 @@ class _SendButtonState extends State<_SendButton> {
9831020
final designVariables = DesignVariables.of(context);
9841021
final zulipLocalizations = ZulipLocalizations.of(context);
9851022

986-
final iconColor = _hasValidationErrors
1023+
final iconColor = _hasValidationErrors || !widget.controller.enabled
9871024
? designVariables.icon.withFadedAlpha(0.5)
9881025
: designVariables.icon;
9891026

@@ -997,7 +1034,7 @@ class _SendButtonState extends State<_SendButton> {
9971034
// ambient [ButtonStyle.overlayColor], where we set the color for
9981035
// the highlight state to match the Figma design.
9991036
color: iconColor),
1000-
onPressed: _send));
1037+
onPressed: (widget.controller.enabled) ? _send : null));
10011038
}
10021039
}
10031040

@@ -1117,7 +1154,12 @@ abstract class _ComposeBoxBody extends StatelessWidget {
11171154
child: Row(
11181155
mainAxisAlignment: MainAxisAlignment.spaceBetween,
11191156
children: [
1120-
Row(children: composeButtons),
1157+
ValueListenableBuilder(
1158+
valueListenable: controller._enabled,
1159+
builder: (context, enabled, child) {
1160+
return Opacity(opacity: enabled ? 1 : 0.5, child: child);
1161+
},
1162+
child: Row(children: composeButtons)),
11211163
buildSendButton(),
11221164
])),
11231165
]);
@@ -1180,10 +1222,14 @@ sealed class ComposeBoxController {
11801222
final content = ComposeContentController();
11811223
final contentFocusNode = FocusNode();
11821224

1225+
bool get enabled => _enabled.value;
1226+
final ValueNotifier<bool> _enabled = ValueNotifier<bool>(true);
1227+
11831228
@mustCallSuper
11841229
void dispose() {
11851230
content.dispose();
11861231
contentFocusNode.dispose();
1232+
_enabled.dispose();
11871233
}
11881234
}
11891235

test/widgets/compose_box_test.dart

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ void main() {
410410
await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip));
411411
await tester.pump(Duration.zero);
412412

413-
check(connection.lastRequest).isA<http.Request>()
413+
check(connection.takeRequests()).single.isA<http.Request>()
414414
..method.equals('POST')
415415
..url.path.equals('/api/v1/messages')
416416
..bodyFields.deepEquals({
@@ -430,6 +430,43 @@ void main() {
430430
check(errorDialogs).isEmpty();
431431
});
432432

433+
testWidgets('disable compose box while pending; clear text when finished', (tester) async {
434+
await setupAndTapSend(tester, prepareResponse: (int messageId) {
435+
connection.prepare(json: SendMessageResult(
436+
id: messageId).toJson(), delay: const Duration(seconds: 2));
437+
});
438+
check(controller!.enabled).isFalse();
439+
check(controller!.content.text).isNotEmpty();
440+
441+
await tester.tap(find.byIcon(ZulipIcons.send));
442+
await tester.pump(Duration.zero);
443+
check(connection.takeRequests()).isEmpty();
444+
445+
await tester.tap(find.byIcon(ZulipIcons.attach_file));
446+
await tester.pump(Duration.zero);
447+
check(testBinding.takePickFilesCalls()).isEmpty();
448+
449+
await tester.pump(const Duration(seconds: 2));
450+
check(controller!.enabled).isTrue();
451+
check(controller!.content.text).isEmpty();
452+
});
453+
454+
testWidgets('re-enable compose box even on failure; do not clear text', (tester) async {
455+
await setupAndTapSend(tester, prepareResponse: (_) {
456+
connection.prepare(
457+
httpStatus: 400,
458+
json: {'result': 'error', 'code': 'BAD_REQUEST'},
459+
delay: const Duration(seconds: 2));
460+
});
461+
check(controller!.enabled).isFalse();
462+
final oldText = controller!.content.text;
463+
check(oldText).isNotEmpty();
464+
465+
await tester.pump(const Duration(seconds: 2));
466+
check(controller!.enabled).isTrue();
467+
check(controller!.content.text).equals(oldText);
468+
});
469+
433470
testWidgets('ZulipApiException', (tester) async {
434471
await setupAndTapSend(tester, prepareResponse: (message) {
435472
connection.prepare(

0 commit comments

Comments
 (0)