mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
## Summary Replace bubble photo carousels with swipeable, overlapping photo stacks, with distinct sent/received layouts and a stationary photo count. Opening and closing the gallery preserves the selected photo. Photo frames follow the active theme. Add an opt-in iOS 18+ native zoom gallery (`BUZZ_NATIVE_PHOTO_ZOOM=true`) with paging, continuous dot scrubbing, preview-backed loading, Reply/More actions, and Liquid Glass controls on iOS 26+. The existing gallery remains the fallback. UIKit’s late corner settling and brief return-transition input lockout remain known preview limitations. ### Related issue None found. ### Testing Validation: full mobile pre-push checks/tests passed on `2b5bac3075`; light/dark sent/received widget renders verified. Earlier native simulator checks covered paging, scrubbing, downward close, and Reply. Snapshots below are actual Flutter widget renders with sample photos (not device screenshots). | Before: carousel | After: sent | After: received | | --- | --- | --- | |  |  |  | | Dark mode: sent | Dark mode: received | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
321 lines
10 KiB
Dart
321 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
|
|
: receivedMessageSurface(brightness),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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 = ChatColors.of(
|
|
tester.element(find.byType(MessageContent)),
|
|
).foreground;
|
|
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-stack'));
|
|
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:
|
|
// The escaped destination must still match its plain imeta URL.
|
|
'Before\n\n[file.txt](https://example.com/file\\_a.txt)\n\nAfter',
|
|
tags: [
|
|
['imeta', 'url https://example.com/file_a.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(
|
|
size: Size(320, 800),
|
|
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);
|
|
});
|
|
}
|