mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Builds on the merged themes work in #8358 and inherits its appearance and chat-color settings. Polishes mobile conversations with uniform rounded bubbles, desktop-style text-only mentions and agent icon, aligned gallery counts, fading gallery avatars, and standard iOS scrolling. Huddles use rounded cards with attached reaction trays, without duplicate start/end notices; other activity rows stay centered with smaller text. Validation: mobile checks, the full mobile test suite, and the gallery regression test passed. Release build installed on iPhone. Full `just ci` could not finish: desktop compilation exhausted local disk space. ### Snapshots Flutter-rendered light and dark previews using seeded sample messages. | Light | Dark | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
319 lines
10 KiB
Dart
319 lines
10 KiB
Dart
part of 'message_content_test.dart';
|
|
|
|
void bubbleContentTests() {
|
|
final bubbles = find.byWidgetPredicate(
|
|
(widget) =>
|
|
widget.key is ValueKey<String> &&
|
|
(widget.key! as ValueKey<String>).value.startsWith(
|
|
'message-text-bubble-',
|
|
),
|
|
);
|
|
testWidgets('message shapes remain inside their bounds without tails', (
|
|
tester,
|
|
) async {
|
|
for (final tail in [false, true]) {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(content: 'Hello', bubble: true, bubbleEnd: tail),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final decoration = tester.widget<LocalSendBubbleSurface>(
|
|
find.descendant(
|
|
of: bubbles,
|
|
matching: find.byType(LocalSendBubbleSurface),
|
|
),
|
|
);
|
|
final bounds = decoration.shape
|
|
.getOuterPath(Offset.zero & tester.getSize(bubbles))
|
|
.getBounds();
|
|
expect(bounds.left, 0);
|
|
expect(bounds.bottom, tester.getSize(bubbles).height);
|
|
}
|
|
});
|
|
|
|
for (final brightness in Brightness.values) {
|
|
testWidgets('bubbles inherit every chat color in $brightness', (
|
|
tester,
|
|
) async {
|
|
for (final choice in chatColorChoices) {
|
|
final theme = mobileAppearanceTheme(brightness, choice.wireValue);
|
|
for (final own in [false, true]) {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(
|
|
content: 'Themed message',
|
|
bubble: true,
|
|
ownMessage: own,
|
|
),
|
|
theme: theme,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final surface = tester.widget<LocalSendBubbleSurface>(
|
|
find.byType(LocalSendBubbleSurface),
|
|
);
|
|
expect(
|
|
surface.color,
|
|
own
|
|
? theme.extension<ChatColors>()!.outgoing
|
|
: theme.colorScheme.secondaryContainer,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
for (final dark in [false, true]) {
|
|
testWidgets('outgoing headings, mentions and links contrast in dark=$dark', (
|
|
tester,
|
|
) async {
|
|
String? tapped;
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(
|
|
content:
|
|
'# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6\n\n@Alice @Helper [Website](https://example.com)',
|
|
bubble: true,
|
|
ownMessage: true,
|
|
mentionNames: const {'person': 'Alice', 'agent': 'Helper'},
|
|
agentMentionPubkeys: const {'agent'},
|
|
onMentionTap: (pubkey) => tapped = pubkey,
|
|
),
|
|
dark: dark,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final foreground = ChatColors.of(
|
|
tester.element(find.byType(MessageContent)),
|
|
).foreground;
|
|
for (final heading in ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']) {
|
|
final text = tester.widget<RichText>(_findRich(heading).last);
|
|
final colors = <Color?>[];
|
|
void inspect(InlineSpan span, TextStyle? inherited) {
|
|
final effective = inherited?.merge(span.style) ?? span.style;
|
|
if (span is TextSpan) {
|
|
if (span.text?.contains(heading) == true) {
|
|
colors.add(effective?.color);
|
|
}
|
|
for (final child in span.children ?? const <InlineSpan>[]) {
|
|
inspect(child, effective);
|
|
}
|
|
}
|
|
}
|
|
|
|
inspect(text.text, null);
|
|
expect(colors, [foreground]);
|
|
}
|
|
final linkColor = foreground == Colors.white
|
|
? foreground
|
|
: dark
|
|
? const Color(0xFF83C4FF)
|
|
: const Color(0xFF0B5FA8);
|
|
for (final label in ['Alice', 'Helper']) {
|
|
expect(tester.widget<Text>(find.text(label)).style?.color, linkColor);
|
|
}
|
|
expect(
|
|
tester.widget<BuzzAgentIcon>(find.byType(BuzzAgentIcon)).color,
|
|
linkColor,
|
|
);
|
|
final link = tester.widget<Text>(
|
|
find.byWidgetPredicate(
|
|
(w) => w is Text && w.textSpan?.toPlainText() == 'Website',
|
|
),
|
|
);
|
|
expect(link.textSpan?.style?.color, linkColor);
|
|
await tester.tap(find.text('Alice'));
|
|
expect(tapped, 'person');
|
|
await tester.tap(find.text('Helper'));
|
|
expect(tapped, 'agent');
|
|
});
|
|
}
|
|
|
|
for (final content in [
|
|
'- screenshots:\n \n explanation',
|
|
'1. screenshots:\n \n explanation',
|
|
'> screenshots:\n> \n> explanation',
|
|
'> screenshots:\n\nexplanation',
|
|
]) {
|
|
testWidgets('nested media retains its Markdown block: $content', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_testable(MessageContent(content: content, bubble: true)),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(bubbles, findsOneWidget);
|
|
final markdown = tester.widget<GptMarkdown>(find.byType(GptMarkdown));
|
|
expect(markdown.data, content);
|
|
final media = find.byKey(
|
|
const ValueKey(
|
|
'message-media-image-preview:https://example.com/result.png',
|
|
),
|
|
);
|
|
expect(find.descendant(of: bubbles, matching: media), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
|
|
testWidgets(
|
|
'image corners remain uniform throughout the conversation group',
|
|
(tester) async {
|
|
for (final grouped in [false, true]) {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(
|
|
content: '',
|
|
bubble: true,
|
|
bubbleStart: !grouped,
|
|
bubbleEnd: !grouped,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final frame = tester.widget<Container>(
|
|
find.byKey(
|
|
const ValueKey(
|
|
'message-media-image-preview:https://example.com/photo.png',
|
|
),
|
|
),
|
|
);
|
|
final corners =
|
|
(frame.decoration! as BoxDecoration).borderRadius! as BorderRadius;
|
|
expect(corners.topLeft.x, Radii.container);
|
|
expect(corners.bottomLeft.x, Radii.container);
|
|
expect(corners.topRight.x, Radii.container);
|
|
expect(corners.bottomRight.x, Radii.container);
|
|
}
|
|
},
|
|
);
|
|
|
|
testWidgets('photo gallery stays outside its caption bubble', (tester) async {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
const MessageContent(
|
|
content:
|
|
'Photos\n\n',
|
|
bubble: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final gallery = find.byKey(const ValueKey('message-media-carousel'));
|
|
expect(gallery, findsOneWidget);
|
|
expect(bubbles, findsOneWidget);
|
|
expect(find.descendant(of: bubbles, matching: gallery), findsNothing);
|
|
expect(
|
|
tester.getTopLeft(gallery).dy,
|
|
greaterThanOrEqualTo(tester.getBottomLeft(bubbles).dy),
|
|
);
|
|
});
|
|
|
|
testWidgets('neutral bubbles hug short text and distinguish own messages', (
|
|
tester,
|
|
) async {
|
|
for (final own in [false, true]) {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(content: 'Hello', bubble: true, ownMessage: own),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(bubbles, findsOneWidget);
|
|
expect(tester.getSize(bubbles).width, lessThan(160));
|
|
final decoration = tester.widget<LocalSendBubbleSurface>(
|
|
find.descendant(
|
|
of: bubbles,
|
|
matching: find.byType(LocalSendBubbleSurface),
|
|
),
|
|
);
|
|
expect(decoration.color, isNotNull);
|
|
expect(
|
|
find.descendant(of: bubbles, matching: _findRich('Hello')),
|
|
findsWidgets,
|
|
);
|
|
}
|
|
});
|
|
|
|
testWidgets('attachments stay outside bubbles and text order is preserved', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
const MessageContent(
|
|
content:
|
|
'Before\n\n[file.txt](https://example.com/file.txt)\n\nAfter',
|
|
tags: [
|
|
['imeta', 'url https://example.com/file.txt', 'm text/plain'],
|
|
],
|
|
bubble: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(bubbles, findsNWidgets(2));
|
|
expect(
|
|
find.descendant(of: bubbles, matching: _findRich('file.txt')),
|
|
findsNothing,
|
|
);
|
|
expect(
|
|
tester.getTopLeft(_findRich('Before')).dy,
|
|
lessThan(tester.getTopLeft(_findRich('file.txt')).dy),
|
|
);
|
|
expect(
|
|
tester.getTopLeft(_findRich('file.txt')).dy,
|
|
lessThan(tester.getTopLeft(_findRich('After')).dy),
|
|
);
|
|
});
|
|
|
|
testWidgets('fenced attachment examples remain in the text bubble', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
const MessageContent(
|
|
content: '```text\n\n```',
|
|
bubble: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(bubbles, findsOneWidget);
|
|
expect(
|
|
find.descendant(
|
|
of: bubbles,
|
|
matching: _findRich(''),
|
|
),
|
|
findsWidgets,
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('bubble text reflows at narrow width and large text', (
|
|
tester,
|
|
) async {
|
|
_setSurfaceSize(tester, const Size(320, 800));
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MediaQuery(
|
|
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
|
|
child: const SizedBox(
|
|
width: 230,
|
|
child: MessageContent(
|
|
content:
|
|
'A longer message that wraps across multiple lines without clipping.',
|
|
bubble: true,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(tester.getSize(bubbles).width, lessThanOrEqualTo(230));
|
|
expect(tester.getSize(bubbles).height, greaterThan(80));
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|