Skip to content

Commit bf3fe42

Browse files
committed
action_sheet: Add header param; use it to show message content
See Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-29966&m=dev See also a different header variant in Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev At first I'd thought this wouldn't be possible without more work toward issue #488: - #488 "content: Support Zulip content outside messages (even outside per-account contexts)". But it turns out that our content widgets don't assume a MessageList ancestor; it's sufficient that we provide a per-account context, via PerAccountStoreWidget, and a per-message context, via InheritedMessage (created by MessageContent). Fixes: #217
1 parent 2e0cceb commit bf3fe42

File tree

3 files changed

+150
-4
lines changed

3 files changed

+150
-4
lines changed

lib/widgets/action_sheet.dart

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import '../api/route/channels.dart';
1313
import '../api/route/messages.dart';
1414
import '../generated/l10n/zulip_localizations.dart';
1515
import '../model/binding.dart';
16+
import '../model/content.dart';
1617
import '../model/emoji.dart';
1718
import '../model/internal_link.dart';
1819
import '../model/narrow.dart';
1920
import 'actions.dart';
2021
import 'color.dart';
2122
import 'compose_box.dart';
23+
import 'content.dart';
2224
import 'dialog.dart';
2325
import 'emoji.dart';
2426
import 'emoji_reaction.dart';
@@ -33,6 +35,7 @@ import 'topic_list.dart';
3335

3436
void _showActionSheet(
3537
BuildContext pageContext, {
38+
Widget? header,
3639
required List<Widget> optionButtons,
3740
}) {
3841
// Could omit this if we need _showActionSheet outside a per-account context.
@@ -57,15 +60,31 @@ void _showActionSheet(
5760
child: Column(
5861
mainAxisSize: MainAxisSize.min,
5962
children: [
60-
SizedBox(height: 8),
63+
if (header != null)
64+
Flexible(
65+
// TODO(upstream) Enforce a flex ratio (e.g. 1:3)
66+
// only when the header height plus the buttons' height
67+
// exceeds available space. Otherwise let one or the other
68+
// grow to fill available space even if it breaks the ratio.
69+
// Needs support for separate properties like `flex-grow`
70+
// and `flex-shrink`.
71+
flex: 1,
72+
child: InsetShadowBox(
73+
top: 8, bottom: 8,
74+
color: designVariables.bgContextMenu,
75+
child: SingleChildScrollView(
76+
padding: EdgeInsets.symmetric(vertical: 8),
77+
child: header)))
78+
else
79+
SizedBox(height: 8),
6180
Flexible(
81+
flex: 3,
6282
child: Padding(
6383
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
6484
child: Column(
6585
crossAxisAlignment: CrossAxisAlignment.stretch,
6686
mainAxisSize: MainAxisSize.min,
6787
children: [
68-
// TODO(#217): show message text
6988
Flexible(child: InsetShadowBox(
7089
top: 8, bottom: 8,
7190
color: designVariables.bgContextMenu,
@@ -621,7 +640,42 @@ void showMessageActionSheet({required BuildContext context, required Message mes
621640
EditButton(message: message, pageContext: pageContext),
622641
];
623642

624-
_showActionSheet(pageContext, optionButtons: optionButtons);
643+
_showActionSheet(pageContext,
644+
optionButtons: optionButtons,
645+
header: _MessageActionSheetHeader(message: message));
646+
}
647+
648+
class _MessageActionSheetHeader extends StatelessWidget {
649+
const _MessageActionSheetHeader({required this.message});
650+
651+
final Message message;
652+
653+
@override
654+
Widget build(BuildContext context) {
655+
final designVariables = DesignVariables.of(context);
656+
657+
// TODO this seems to lose the hero animation when opening an image;
658+
// investigate.
659+
// TODO should we close the sheet before opening a narrow link?
660+
// On popping the pushed narrow route, the sheet is still open.
661+
662+
return Container(
663+
// TODO(#647) use different color for highlighted messages
664+
// TODO(#681) use different color for DM messages
665+
color: designVariables.bgMessageRegular,
666+
padding: EdgeInsets.symmetric(vertical: 4),
667+
child: Column(
668+
spacing: 4,
669+
children: [
670+
SenderRow(message: message,
671+
timestampStyle: MessageTimestampStyle.full),
672+
Padding(
673+
padding: const EdgeInsets.symmetric(horizontal: 16),
674+
// TODO(#10) offer text selection; the Figma asks for it here:
675+
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3483-30210&m=dev
676+
child: MessageContent(message: message, content: parseMessageContent(message))),
677+
]));
678+
}
625679
}
626680

627681
abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton {

lib/widgets/message_list.dart

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1893,7 +1893,8 @@ class SenderRow extends StatelessWidget {
18931893
final revealedMutedMessagesState =
18941894
MessageListPage.maybeRevealedMutedMessagesOf(context);
18951895
// The "unrevealed" state only exists in the message list,
1896-
// and we're about to start showing a sender row outside the message list.
1896+
// and we show a sender row in at least one place outside the message list
1897+
// (the message action sheet).
18971898
if (revealedMutedMessagesState == null) return false;
18981899
return !revealedMutedMessagesState.isMutedMessageRevealed(message.id);
18991900
}
@@ -1969,9 +1970,23 @@ class SenderRow extends StatelessWidget {
19691970
enum MessageTimestampStyle {
19701971
none,
19711972
timeOnly,
1973+
1974+
/// The longest format, with full date and time as numbers, not "Today"/etc.
1975+
///
1976+
/// For UI contexts focused just on the one message,
1977+
/// or as a tooltip on a shorter-formatted timestamp.
1978+
///
1979+
/// The detail won't always be needed, but this format makes mental timezone
1980+
/// conversions easier, which is helpful when the user is thinking about
1981+
/// business hours on a different continent,
1982+
/// or traveling and they know their device timezone setting is wrong, etc.
1983+
// TODO(design) show "Today"/etc. after all? Discussion:
1984+
// https://github.com/zulip/zulip-flutter/pull/1624#issuecomment-3050296488
1985+
full,
19721986
;
19731987

19741988
static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US');
1989+
static final _fullFormat = DateFormat.yMMMd().add_jm();
19751990

19761991
/// Format a [Message.timestamp] for this mode.
19771992
// TODO(i18n): locale-specific formatting (see #45 for a plan with ffi)
@@ -1982,6 +1997,7 @@ enum MessageTimestampStyle {
19821997
switch (this) {
19831998
case none: return null;
19841999
case timeOnly: return _timeOnlyFormat.format(asDateTime);
2000+
case full: return _fullFormat.format(asDateTime);
19852001
}
19862002
}
19872003
}

test/widgets/action_sheet_test.dart

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import '../api/fake_api.dart';
3838
import '../example_data.dart' as eg;
3939
import '../flutter_checks.dart';
4040
import '../model/binding.dart';
41+
import '../model/content_test.dart';
4142
import '../model/test_store.dart';
4243
import '../stdlib_checks.dart';
4344
import '../test_clipboard.dart';
@@ -860,6 +861,81 @@ void main() {
860861
});
861862

862863
group('message action sheet', () {
864+
group('header', () {
865+
void checkSenderAndTimestampShown(WidgetTester tester, {required int senderId}) {
866+
check(find.descendant(
867+
of: find.byType(BottomSheet),
868+
matching: find.byWidgetPredicate(
869+
(widget) => widget is Avatar && widget.userId == senderId))
870+
).findsOne();
871+
final expectedTimestampColor = MessageListTheme.of(
872+
tester.element(find.byType(BottomSheet))).labelTime;
873+
// TODO check the timestamp text itself, when it's convenient to do so:
874+
// https://github.com/zulip/zulip-flutter/pull/1624#discussion_r2181383754
875+
check(find.descendant(
876+
of: find.byType(BottomSheet),
877+
matching: find.byWidgetPredicate((widget) =>
878+
widget is Text
879+
&& widget.style?.color == expectedTimestampColor
880+
&& (widget.style?.fontFeatures?.contains(FontFeature.enable('c2sc')) ?? false)))
881+
).findsOne();
882+
}
883+
884+
testWidgets('message sender and content shown', (tester) async {
885+
final message = eg.streamMessage(
886+
timestamp: 1671409088,
887+
content: ContentExample.userMentionPlain.html);
888+
await setupToMessageActionSheet(tester,
889+
message: message,
890+
narrow: TopicNarrow.ofMessage(message));
891+
checkSenderAndTimestampShown(tester, senderId: message.senderId);
892+
check(find.descendant(
893+
of: find.byType(BottomSheet),
894+
matching: find.byType(UserMention))
895+
).findsOne();
896+
});
897+
898+
testWidgets('muted sender also shown', (tester) async {
899+
final message = eg.streamMessage(
900+
timestamp: 1671409088,
901+
content: ContentExample.userMentionPlain.html);
902+
await setupToMessageActionSheet(tester,
903+
message: message,
904+
narrow: TopicNarrow.ofMessage(message),
905+
mutedUserIds: [message.senderId],
906+
beforeLongPress: () async {
907+
check(find.byType(MessageContent)).findsNothing();
908+
await tester.tap(
909+
find.widgetWithText(ZulipWebUiKitButton, 'Reveal message'));
910+
await tester.pump();
911+
check(find.byType(MessageContent)).findsOne();
912+
},
913+
);
914+
checkSenderAndTimestampShown(tester, senderId: message.senderId);
915+
check(find.descendant(
916+
of: find.byType(BottomSheet),
917+
matching: find.byType(UserMention))
918+
).findsOne();
919+
});
920+
921+
testWidgets('poll is rendered', (tester) async {
922+
final submessageContent = eg.pollWidgetData(
923+
question: 'poll', options: ['First option', 'Second option']);
924+
final message = eg.streamMessage(
925+
timestamp: 1671409088,
926+
sender: eg.selfUser,
927+
submessages: [eg.submessage(content: submessageContent)]);
928+
await setupToMessageActionSheet(tester,
929+
message: message,
930+
narrow: TopicNarrow.ofMessage(message));
931+
checkSenderAndTimestampShown(tester, senderId: message.senderId);
932+
check(find.descendant(
933+
of: find.byType(BottomSheet),
934+
matching: find.text('First option'))
935+
).findsOne();
936+
});
937+
});
938+
863939
group('ReactionButtons', () {
864940
testWidgets('absent if ServerEmojiData not loaded', (tester) async {
865941
final message = eg.streamMessage();

0 commit comments

Comments
 (0)