Repository: zulip/zulip-flutter (language: dart)

Issue:
Show message text in bottom sheet
In the redesigned message bottom sheet #90 from @terpimost, the top of the message sheet shows the message you're acting on.

We've left that part out of the scope of #90; this is a follow-up issue for adding it. See Figma design:
https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-29796&m=dev
![image](https://github.com/user-attachments/assets/1909713a-337f-4be5-8fa4-6bb04c3f52e2)

and quoting a note from there:
> The message is exactly the same as in the feed. It shouldn’t be just a picture, because we want to allow TEXT SELECTION HERE.

See also the notes to the right of there in the Figma design, about scrolling behavior.


#### Implementation

I expect this will involve reusing most of the implementation of MessageWithPossibleSender. That will probably require some refactoring, in order to make the relevant parts reusable without other aspects that aren't desired here.

Also if the message in its original context in the message list didn't show the sender, we'll want to still show the sender here.

<details><summary>Old ideas</summary>

In the redesigned message bottom sheet #90 from @terpimost, the top of the message sheet shows the text of the message you're acting on (cutting it off at two lines). This is helpful when the message itself isn't visible, which becomes more and more likely as we add more actions to the bottom sheet.

I'm splitting this out from the rest of #90, because the concept of "the text of the message" is actually a bit slippery and raises some product/design questions.

* We could use the message's Markdown source. But that isn't great for this UI context: it may have a bunch of markup that obscures what the message actually says.
  * Consider in particular the case where the message starts with a quote-reply.
  * Consider also a message that starts with one short paragraph like "Yes.", and then has more details in a second paragraph. The first two lines of the Markdown source will be just "Yes." followed by a blank line. That's not very informative, and we might want to show more text than that.

  If we decide we're OK with that, there's a second pitfall which we'll need to pick a response to: we don't normally keep around the Markdown source for all the messages we have, only the rendered HTML. On the occasions where we need Markdown source (like for generating a quote-and-reply), we make a separate request to the server to get it. We can do that here… but then we need to decide what to show when that request hasn't completed yet, which will be at least a frame or two and often more.
  * We could delay the whole bottom sheet. That seems bad.
  * We could show nothing; when the text arrives, the top edge of the bottom sheet jumps up to make room.
  * We could show a blank space; when the text arrives, it pops into existence in the top area of the bottom sheet. Until it arrives, that area looks oddly blank.
  * We could show a [CircularProgressIndicator](https://api.flutter.dev/flutter/material/CircularProgressIndicator-class.html), or another loading indicator. Might feel noisy; this message text isn't meant to be a point of focus, just a helpful bit of context in the nature of a breadcrumb.

* We could use the message's HTML. That will definitely have a bunch of cluttery-feeling markup, more so than the Markdown. On the other hand we have it immediately and don't have to wait.

* We could introduce some new notion of a "text summary" of a message's content: we'd walk the content parse tree, and try to generate something like its plain text without markup. This will require some work to define just what we want and to implement it.

</details>



Discussion hints:
Another option is to take a screenshot of the rendered message on which user has clicked and show top part of that, cutting out the rest:
<img width="764" alt="image" src="https://github.com/zulip/zulip-flutter/assets/1903309/a7874ea3-35f6-4463-8e05-7ad65bbb79b2">
It might be ok to copy that with a background:
<img width="726" alt="image" src="https://github.com/zulip/zulip-flutter/assets/1903309/8ea0af01-9f31-45d9-8d5e-48430a8986d0">

Hmm yeah, that's an interesting idea. That'd be a good experiment to try.


Pull request: Show message content, sender, and timestamp in message action sheet
Fixes #217.

We've had some complaints about not being able to see the timestamps of messages in a run of messages from the same sender, e.g.:

[#mobile > Sent time not visible @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Sent.20time.20not.20visible/near/2201862)
> There is a "message sent" time shown in an upper right hand corner of the message. But when the messages are sent by bot, the time is only shown for the first message of the day. The old android app at least shown the time when tapped on the message, but I haven't found a way how to display the time here.

We have #457 for how to offer that in a really quick interaction. But this PR lets you get that information, and it aligns with a different part of the Figma, namely #217. As discussed: [#mobile > Sent time not visible @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Sent.20time.20not.20visible/near/2202542)

(edit: all but one of these screenshots are outdated in the timestamp format; the one with the full date and time is current)

| Light | Dark |
| --- | --- |
| <img width="559" alt="image" src="https://github.com/user-attachments/assets/a04009eb-c83b-4896-95bf-3f421a404ffc" /> | <img width="559" alt="image" src="https://github.com/user-attachments/assets/9e1f5d37-3594-47fd-8a9c-8358cc904ab8" /> |
| <img width="559" alt="image" src="https://github.com/user-attachments/assets/f69bf3f2-d8cd-4e26-9c0e-a4c512bad400" /> | <img width="559" alt="image" src="https://github.com/user-attachments/assets/f1bbdbb6-3e36-4afa-8c75-e035cb920b40" /> |
| <img width="559" alt="image" src="https://github.com/user-attachments/assets/087b788f-da67-4998-b878-384663b09113" /> | <img width="559" alt="image" src="https://github.com/user-attachments/assets/a1655de4-1693-4e7f-860a-ff67401f96d5" /> |

This revision leaves the message completely interactable; you can open images in the lightbox, open and close spoilers, etc.; that seemed most straightforward to implement. It does not implement text selection of the content; that's requested in the Figma, and #10 is for that, and I've left a TODO for it.

Test command: flutter test test/widgets/action_sheet_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/model/content.dart b/lib/model/content.dart
index 79f3181963..e80247325f 100644
--- a/lib/model/content.dart
+++ b/lib/model/content.dart
@@ -1927,3 +1927,9 @@ class _ZulipContentParser {
 ZulipContent parseContent(String html) {
   return _ZulipContentParser().parse(html);
 }
+
+ZulipMessageContent parseMessageContent(Message message) {
+  final poll = message.poll;
+  if (poll != null) return PollContent(poll);
+  return parseContent(message.content);
+}
diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index 9128617b13..8e2a183fa7 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -257,16 +257,10 @@ mixin _MessageSequence {
     }
   }
 
-  ZulipMessageContent _parseMessageContent(Message message) {
-    final poll = message.poll;
-    if (poll != null) return PollContent(poll);
-    return parseContent(message.content);
-  }
-
   /// Update data derived from the content of the index-th message.
   void _reparseContent(int index) {
     final message = messages[index];
-    final content = _parseMessageContent(message);
+    final content = parseMessageContent(message);
     contents[index] = content;
 
     final itemIndex = findItemWithMessageId(message.id);
@@ -283,7 +277,7 @@ mixin _MessageSequence {
   void _addMessage(Message message) {
     assert(contents.length == messages.length);
     messages.add(message);
-    contents.add(_parseMessageContent(message));
+    contents.add(parseMessageContent(message));
     assert(contents.length == messages.length);
     _processMessage(messages.length - 1);
   }
@@ -362,7 +356,7 @@ mixin _MessageSequence {
     assert(contents.length == messages.length);
     messages.insertAll(index, toInsert);
     contents.insertAll(index, toInsert.map(
-      (message) => _parseMessageContent(message)));
+      (message) => parseMessageContent(message)));
     assert(contents.length == messages.length);
     if (index <= middleMessage) {
       middleMessage += messages.length - oldLength;
@@ -425,7 +419,7 @@ mixin _MessageSequence {
   void _recompute() {
     assert(contents.length == messages.length);
     contents.clear();
-    contents.addAll(messages.map((message) => _parseMessageContent(message)));
+    contents.addAll(messages.map((message) => parseMessageContent(message)));
     assert(contents.length == messages.length);
     _reprocessAll();
   }
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index d040ea8bcf..abc3522b5e 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -13,12 +13,14 @@ import '../api/route/channels.dart';
 import '../api/route/messages.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import '../model/binding.dart';
+import '../model/content.dart';
 import '../model/emoji.dart';
 import '../model/internal_link.dart';
 import '../model/narrow.dart';
 import 'actions.dart';
 import 'color.dart';
 import 'compose_box.dart';
+import 'content.dart';
 import 'dialog.dart';
 import 'emoji.dart';
 import 'emoji_reaction.dart';
@@ -32,11 +34,15 @@ import 'theme.dart';
 import 'topic_list.dart';
 
 void _showActionSheet(
-  BuildContext context, {
+  BuildContext pageContext, {
+  Widget? header,
   required List<Widget> optionButtons,
 }) {
+  // Could omit this if we need _showActionSheet outside a per-account context.
+  final accountId = PerAccountStoreWidget.accountIdOf(pageContext);
+
   showModalBottomSheet<void>(
-    context: context,
+    context: pageContext,
     // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
     // on my iPhone 13 Pro but is marked as "much slower":
     //   https://api.flutter.dev/flutter/dart-ui/Clip.html
@@ -44,27 +50,52 @@ void _showActionSheet(
     useSafeArea: true,
     isScrollControlled: true,
     builder: (BuildContext _) {
-      return Semantics(
-        role: SemanticsRole.menu,
-        child: SafeArea(
-          minimum: const EdgeInsets.only(bottom: 16),
-          child: Padding(
-            padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
+      final designVariables = DesignVariables.of(pageContext);
+      return PerAccountStoreWidget(
+        accountId: accountId,
+        child: Semantics(
+          role: SemanticsRole.menu,
+          child: SafeArea(
+            minimum: const EdgeInsets.only(bottom: 16),
             child: Column(
-              crossAxisAlignment: CrossAxisAlignment.stretch,
               mainAxisSize: MainAxisSize.min,
               children: [
-                // TODO(#217): show message text
-                Flexible(child: InsetShadowBox(
-                  top: 8, bottom: 8,
-                  color: DesignVariables.of(context).bgContextMenu,
-                  child: SingleChildScrollView(
-                    padding: const EdgeInsets.only(top: 16, bottom: 8),
-                    child: ClipRRect(
-                      borderRadius: BorderRadius.circular(7),
-                      child: Column(spacing: 1,
-                        children: optionButtons))))),
-                const ActionSheetCancelButton(),
+                if (header != null)
+                  Flexible(
+                    // TODO(upstream) Enforce a flex ratio (e.g. 1:3)
+                    //   only when the header height plus the buttons' height
+                    //   exceeds available space. Otherwise let one or the other
+                    //   grow to fill available space even if it breaks the ratio.
+                    //   Needs support for separate properties like `flex-grow`
+                    //   and `flex-shrink`.
+                    flex: 1,
+                    child: InsetShadowBox(
+                      top: 8, bottom: 8,
+                      color: designVariables.bgContextMenu,
+                      child: SingleChildScrollView(
+                        padding: EdgeInsets.symmetric(vertical: 8),
+                        child: header)))
+                else
+                  SizedBox(height: 8),
+                Flexible(
+                  flex: 3,
+                  child: Padding(
+                    padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.stretch,
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
+                        Flexible(child: InsetShadowBox(
+                          top: 8, bottom: 8,
+                          color: designVariables.bgContextMenu,
+                          child: SingleChildScrollView(
+                            padding: const EdgeInsets.symmetric(vertical: 8),
+                            child: ClipRRect(
+                              borderRadius: BorderRadius.circular(7),
+                              child: Column(spacing: 1,
+                                children: optionButtons))))),
+                        const ActionSheetCancelButton(),
+                      ]))),
               ]))));
     });
 }
@@ -609,7 +640,42 @@ void showMessageActionSheet({required BuildContext context, required Message mes
       EditButton(message: message, pageContext: pageContext),
   ];
 
-  _showActionSheet(pageContext, optionButtons: optionButtons);
+  _showActionSheet(pageContext,
+    optionButtons: optionButtons,
+    header: _MessageActionSheetHeader(message: message));
+}
+
+class _MessageActionSheetHeader extends StatelessWidget {
+  const _MessageActionSheetHeader({required this.message});
+
+  final Message message;
+
+  @override
+  Widget build(BuildContext context) {
+    final designVariables = DesignVariables.of(context);
+
+    // TODO this seems to lose the hero animation when opening an image;
+    //   investigate.
+    // TODO should we close the sheet before opening a narrow link?
+    //   On popping the pushed narrow route, the sheet is still open.
+
+    return Container(
+      // TODO(#647) use different color for highlighted messages
+      // TODO(#681) use different color for DM messages
+      color: designVariables.bgMessageRegular,
+      padding: EdgeInsets.symmetric(vertical: 4),
+      child: Column(
+        spacing: 4,
+        children: [
+          SenderRow(message: message,
+            timestampStyle: MessageTimestampStyle.full),
+          Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 16),
+            // TODO(#10) offer text selection; the Figma asks for it here:
+            //   https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-30210&m=dev
+            child: MessageContent(message: message, content: parseMessageContent(message))),
+        ]));
+  }
 }
 
 abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton {
@@ -927,7 +993,7 @@ class UnrevealMutedMessageButton extends MessageActionSheetMenuItemButton {
   @override
   void onPressed() {
     // The message should have been revealed in order to reach this action sheet.
-    assert(MessageListPage.revealedMutedMessagesOf(pageContext)
+    assert(MessageListPage.maybeRevealedMutedMessagesOf(pageContext)!
       .isMutedMessageRevealed(message.id));
     findMessageListPage().unrevealMutedMessage(message.id);
   }
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 00fca21b16..eb0b972337 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -175,19 +175,19 @@ class MessageListPage extends StatefulWidget {
         initNarrow: narrow, initAnchorMessageId: initAnchorMessageId));
   }
 
-  /// The "revealed" state of a message from a muted sender.
+  /// The "revealed" state of a message from a muted sender,
+  /// if there is a [MessageListPage] ancestor, else null.
   ///
   /// This is updated via [MessageListPageState.revealMutedMessage]
   /// and [MessageListPageState.unrevealMutedMessage].
   ///
   /// Uses the efficient [BuildContext.dependOnInheritedWidgetOfExactType],
   /// so this is safe to call in a build method.
-  static RevealedMutedMessagesState revealedMutedMessagesOf(BuildContext context) {
+  static RevealedMutedMessagesState? maybeRevealedMutedMessagesOf(BuildContext context) {
     final state =
       context.dependOnInheritedWidgetOfExactType<_RevealedMutedMessagesProvider>()
       ?.state;
-    assert(state != null, 'No MessageListPage ancestor');
-    return state!;
+    return state;
   }
 
   /// The [MessageListPageState] above this context in the tree.
@@ -1880,21 +1880,23 @@ String formatHeaderDate(
   }
 }
 
-// TODO(i18n): web seems to ignore locale in formatting time, but we could do better
-final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US');
-
-class _SenderRow extends StatelessWidget {
-  const _SenderRow({required this.message, required this.showTimestamp});
+class SenderRow extends StatelessWidget {
+  const SenderRow({super.key, required this.message, required this.timestampStyle});
 
   final MessageBase message;
-  final bool showTimestamp;
+  final MessageTimestampStyle timestampStyle;
 
   bool _showAsMuted(BuildContext context, PerAccountStore store) {
     final message = this.message;
     if (!store.isUserMuted(message.senderId)) return false;
     if (message is! Message) return false; // i.e., if an outbox message
-    return !MessageListPage.revealedMutedMessagesOf(context)
-      .isMutedMessageRevealed(message.id);
+    final revealedMutedMessagesState =
+      MessageListPage.maybeRevealedMutedMessagesOf(context);
+    // The "unrevealed" state only exists in the message list,
+    // and we show a sender row in at least one place outside the message list
+    // (the message action sheet).
+    if (revealedMutedMessagesState == null) return false;
+    return !revealedMutedMessagesState.isMutedMessageRevealed(message.id);
   }
 
   @override
@@ -1904,8 +1906,7 @@ class _SenderRow extends StatelessWidget {
     final designVariables = DesignVariables.of(context);
 
     final sender = store.getUser(message.senderId);
-    final time = _kMessageTimestampFormat
-      .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp));
+    final timestamp = timestampStyle.format(message.timestamp);
 
     final showAsMuted = _showAsMuted(context, store);
 
@@ -1951,9 +1952,9 @@ class _SenderRow extends StatelessWidget {
                     ),
                   ],
                 ]))),
-          if (showTimestamp) ...[
+          if (timestamp != null) ...[
             const SizedBox(width: 4),
-            Text(time,
+            Text(timestamp,
               style: TextStyle(
                 color: messageListTheme.labelTime,
                 fontSize: 16,
@@ -1965,6 +1966,42 @@ class _SenderRow extends StatelessWidget {
   }
 }
 
+// TODO centralize on this for wherever we show message timestamps
+enum MessageTimestampStyle {
+  none,
+  timeOnly,
+
+  /// The longest format, with full date and time as numbers, not "Today"/etc.
+  ///
+  /// For UI contexts focused just on the one message,
+  /// or as a tooltip on a shorter-formatted timestamp.
+  ///
+  /// The detail won't always be needed, but this format makes mental timezone
+  /// conversions easier, which is helpful when the user is thinking about
+  /// business hours on a different continent,
+  /// or traveling and they know their device timezone setting is wrong, etc.
+  // TODO(design) show "Today"/etc. after all? Discussion:
+  //   https://github.com/zulip/zulip-flutter/pull/1624#issuecomment-3050296488
+  full,
+  ;
+
+  static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US');
+  static final _fullFormat = DateFormat.yMMMd().add_jm();
+
+  /// Format a [Message.timestamp] for this mode.
+  // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi)
+  String? format(int messageTimestamp) {
+    final asDateTime =
+      DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp);
+
+    switch (this) {
+      case none:     return null;
+      case timeOnly: return _timeOnlyFormat.format(asDateTime);
+      case full: return _fullFormat.format(asDateTime);
+    }
+  }
+}
+
 /// A Zulip message, showing the sender's name and avatar if specified.
 // Design referenced from:
 //   - https://github.com/zulip/zulip-mobile/issues/5511
@@ -2036,7 +2073,7 @@ class MessageWithPossibleSender extends StatelessWidget {
     };
 
     final showAsMuted = store.isUserMuted(message.senderId)
-      && !MessageListPage.revealedMutedMessagesOf(context)
+      && !MessageListPage.maybeRevealedMutedMessagesOf(context)!
                          .isMutedMessageRevealed(message.id);
 
     return GestureDetector(
@@ -2055,7 +2092,8 @@ class MessageWithPossibleSender extends StatelessWidget {
         padding: const EdgeInsets.only(top: 4),
         child: Column(children: [
           if (item.showSender)
-            _SenderRow(message: message, showTimestamp: true),
+            SenderRow(message: message,
+              timestampStyle: MessageTimestampStyle.timeOnly),
           Row(
             crossAxisAlignment: CrossAxisAlignment.baseline,
             textBaseline: localizedTextBaseline(context),
@@ -2218,7 +2256,7 @@ class OutboxMessageWithPossibleSender extends StatelessWidget {
       padding: const EdgeInsets.only(top: 4),
       child: Column(children: [
         if (item.showSender)
-          _SenderRow(message: message, showTimestamp: false),
+          SenderRow(message: message, timestampStyle: MessageTimestampStyle.none),
         Padding(
           padding: const EdgeInsets.symmetric(horizontal: 16),
           child: Column(crossAxisAlignment: CrossAxisAlignment.stretch,


Test patch:
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index b2a3ed1de0..177aa43503 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -38,6 +38,7 @@ import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
 import '../flutter_checks.dart';
 import '../model/binding.dart';
+import '../model/content_test.dart';
 import '../model/test_store.dart';
 import '../stdlib_checks.dart';
 import '../test_clipboard.dart';
@@ -860,6 +861,81 @@ void main() {
   });
 
   group('message action sheet', () {
+    group('header', () {
+      void checkSenderAndTimestampShown(WidgetTester tester, {required int senderId}) {
+        check(find.descendant(
+          of: find.byType(BottomSheet),
+          matching: find.byWidgetPredicate(
+            (widget) => widget is Avatar && widget.userId == senderId))
+        ).findsOne();
+        final expectedTimestampColor = MessageListTheme.of(
+          tester.element(find.byType(BottomSheet))).labelTime;
+        // TODO check the timestamp text itself, when it's convenient to do so:
+        //   https://github.com/zulip/zulip-flutter/pull/1624#discussion_r2181383754
+        check(find.descendant(
+          of: find.byType(BottomSheet),
+          matching: find.byWidgetPredicate((widget) =>
+            widget is Text
+            && widget.style?.color == expectedTimestampColor
+            && (widget.style?.fontFeatures?.contains(FontFeature.enable('c2sc')) ?? false)))
+        ).findsOne();
+      }
+
+      testWidgets('message sender and content shown', (tester) async {
+        final message = eg.streamMessage(
+          timestamp: 1671409088,
+          content: ContentExample.userMentionPlain.html);
+        await setupToMessageActionSheet(tester,
+          message: message,
+          narrow: TopicNarrow.ofMessage(message));
+        checkSenderAndTimestampShown(tester, senderId: message.senderId);
+        check(find.descendant(
+          of: find.byType(BottomSheet),
+          matching: find.byType(UserMention))
+        ).findsOne();
+      });
+
+      testWidgets('muted sender also shown', (tester) async {
+        final message = eg.streamMessage(
+          timestamp: 1671409088,
+          content: ContentExample.userMentionPlain.html);
+        await setupToMessageActionSheet(tester,
+          message: message,
+          narrow: TopicNarrow.ofMessage(message),
+          mutedUserIds: [message.senderId],
+          beforeLongPress: () async {
+            check(find.byType(MessageContent)).findsNothing();
+            await tester.tap(
+              find.widgetWithText(ZulipWebUiKitButton, 'Reveal message'));
+            await tester.pump();
+            check(find.byType(MessageContent)).findsOne();
+          },
+        );
+        checkSenderAndTimestampShown(tester, senderId: message.senderId);
+        check(find.descendant(
+          of: find.byType(BottomSheet),
+          matching: find.byType(UserMention))
+        ).findsOne();
+      });
+
+      testWidgets('poll is rendered', (tester) async {
+        final submessageContent = eg.pollWidgetData(
+          question: 'poll', options: ['First option', 'Second option']);
+        final message = eg.streamMessage(
+          timestamp: 1671409088,
+          sender: eg.selfUser,
+          submessages: [eg.submessage(content: submessageContent)]);
+        await setupToMessageActionSheet(tester,
+          message: message,
+          narrow: TopicNarrow.ofMessage(message));
+        checkSenderAndTimestampShown(tester, senderId: message.senderId);
+        check(find.descendant(
+          of: find.byType(BottomSheet),
+          matching: find.text('First option'))
+        ).findsOne();
+      });
+    });
+
     group('ReactionButtons', () {
       testWidgets('absent if ServerEmojiData not loaded', (tester) async {
         final message = eg.streamMessage();
@@ -1651,7 +1727,11 @@ void main() {
             connection.prepare(json: GetMessageResult(
               message: eg.streamMessage(content: 'foo')).toJson());
             await tapEdit(tester);
-            await tester.pump(Duration.zero);
+            await tester.pump();
+            // Default duration of bottom-sheet exit animation,
+            // plus 1ms fudge factor (why needed?)
+            // TODO(#1668) get this dynamically instead of hard-coding
+            await tester.pump(Duration(milliseconds: 200 + 1));
             await tester.enterText(find.byWidgetPredicate(
                 (widget) => widget is TextField && widget.controller?.text == 'foo'),
               'bar');