Skip to content

Commit ead7cb6

Browse files
content: Render large emoji in emoji-only paragraphs
When a message consists of a single paragraph containing only emojis (Unicode or custom image emojis) and whitespace, render them at a larger size. This brings the mobile app's presentation into parity with the web app. To support this, `MessageImageEmoji` is updated to derive its size from the ambient text style rather than a fixed 20px value. This ensures custom emojis scale proportionally with the increased font size. Fixes: #1995
1 parent 9296fb3 commit ead7cb6

File tree

2 files changed

+71
-9
lines changed

2 files changed

+71
-9
lines changed

lib/widgets/content.dart

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,10 @@ class BlockContentList extends StatelessWidget {
333333

334334
@override
335335
Widget build(BuildContext context) {
336+
// Filter out LineBreakNode to determine if there's only one meaningful content block
337+
final meaningfulNodes = nodes.where((n) => n is! LineBreakNode).toList();
338+
final isSoleContent = meaningfulNodes.length == 1;
339+
336340
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
337341
...nodes.map((node) {
338342
return switch (node) {
@@ -341,7 +345,7 @@ class BlockContentList extends StatelessWidget {
341345
// just use an empty Text.
342346
const Text(''),
343347
ThematicBreakNode() => const ThematicBreak(),
344-
ParagraphNode() => Paragraph(node: node),
348+
ParagraphNode() => Paragraph(node: node, isSoleContent: isSoleContent),
345349
HeadingNode() => Heading(node: node),
346350
QuotationNode() => Quotation(node: node),
347351
ListNode() => ListNodeWidget(node: node),
@@ -383,6 +387,27 @@ class BlockContentList extends StatelessWidget {
383387
}
384388
}
385389

390+
// Return true when the paragraph contains one or more emoji nodes and
391+
// otherwise consists only of whitespace text nodes. This matches the
392+
// "emoji-only paragraph" rule used to render large emoji on web.
393+
bool _isEmojiOnlyParagraph(ParagraphNode p) {
394+
if (p.nodes.isEmpty) return false;
395+
var foundEmoji = false;
396+
for (final n in p.nodes) {
397+
if (n is TextNode) {
398+
if (n.text.trim().isEmpty) continue;
399+
return false;
400+
}
401+
if (n is UnicodeEmojiNode || n is ImageEmojiNode) {
402+
foundEmoji = true;
403+
continue;
404+
}
405+
// Any other inline node disqualifies the paragraph
406+
return false;
407+
}
408+
return foundEmoji;
409+
}
410+
386411
class ThematicBreak extends StatelessWidget {
387412
const ThematicBreak({super.key});
388413

@@ -400,19 +425,30 @@ class ThematicBreak extends StatelessWidget {
400425
}
401426

402427
class Paragraph extends StatelessWidget {
403-
const Paragraph({super.key, required this.node});
428+
const Paragraph({super.key, required this.node, this.isSoleContent = false});
404429

405430
final ParagraphNode node;
431+
final bool isSoleContent;
406432

407433
@override
408434
Widget build(BuildContext context) {
409435
// Empty paragraph winds up with zero height.
410436
// The paragraph has vertical CSS margins, but those have no effect.
411437
if (node.nodes.isEmpty) return const SizedBox();
412438

439+
// Detect emoji-only paragraphs: a paragraph that contains one or more
440+
// emoji nodes (Unicode or image emoji), and otherwise only whitespace.
441+
// When present, render emoji at a larger size to match web behavior.
442+
// Only apply this when the paragraph is the sole content block.
443+
final baseStyle = DefaultTextStyle.of(context).style;
444+
final isEmojiOnly = isSoleContent && _isEmojiOnlyParagraph(node);
445+
final effectiveStyle = isEmojiOnly
446+
? baseStyle.copyWith(fontSize: baseStyle.fontSize! * 2)
447+
: baseStyle;
448+
413449
final text = _buildBlockInlineContainer(
414450
node: node,
415-
style: DefaultTextStyle.of(context).style,
451+
style: effectiveStyle,
416452
);
417453

418454
// If the paragraph didn't actually have a `p` element in the HTML,
@@ -1267,18 +1303,21 @@ class MessageImageEmoji extends StatelessWidget {
12671303
Widget build(BuildContext context) {
12681304
final store = PerAccountStoreWidget.of(context);
12691305
final resolvedSrc = store.tryResolveUrl(node.src);
1270-
1271-
const size = 20.0;
1306+
// Make image emoji scale with the ambient font size so they match
1307+
// Unicode emoji rendered via text spans. Use the current DefaultTextStyle
1308+
// fontSize as the reference.
1309+
final ambientFontSize = DefaultTextStyle.of(context).style.fontSize ?? kBaseFontSize;
1310+
final size = ambientFontSize;
12721311

12731312
return Stack(
12741313
alignment: Alignment.center,
12751314
clipBehavior: Clip.none,
12761315
children: [
1277-
const SizedBox(width: size, height: kBaseFontSize),
1316+
SizedBox(width: size, height: ambientFontSize),
12781317
Positioned(
1279-
// Web's css makes this seem like it should be -0.5, but that looks
1280-
// too low.
1281-
top: -1.5,
1318+
// Keep a small upward offset similar to previous value, scaled
1319+
// to current font size.
1320+
top: -1.5 * ambientFontSize / kBaseFontSize,
12821321
child: resolvedSrc == null ? const SizedBox.shrink() // TODO(log)
12831322
: RealmContentNetworkImage(
12841323
resolvedSrc,

test/widgets/content_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,29 @@ void main() {
358358

359359
testContentSmoke(ContentExample.quotation);
360360

361+
group('emoji-only rendering', () {
362+
testWidgets('Unicode emoji in span are rendered at double size', (tester) async {
363+
final example = ContentExample.emojiUnicode;
364+
await prepareContent(tester, messageContent(example.html));
365+
final style = mergedStyleOf(tester, example.expectedText!);
366+
check(style?.fontSize).equals(kBaseFontSize * 2);
367+
});
368+
369+
testWidgets('plain-text emoji (text node) are not affected', (tester) async {
370+
final example = ContentExample.emojiUnicodeLiteral;
371+
await prepareContent(tester, messageContent(example.html));
372+
final style = mergedStyleOf(tester, example.expectedText!);
373+
check(style?.fontSize).equals(kBaseFontSize);
374+
});
375+
376+
testWidgets('multi-paragraph message does not show large emojis', (tester) async {
377+
const html = '<p>Text</p><p>🚀</p>';
378+
await prepareContent(tester, messageContent(html));
379+
final style = mergedStyleOf(tester, '🚀');
380+
check(style?.fontSize).equals(kBaseFontSize);
381+
});
382+
});
383+
361384
group('MessageImagePreview, MessageImagePreviewList', () {
362385
Future<void> prepare(WidgetTester tester, String html) async {
363386
await prepareContent(tester,

0 commit comments

Comments
 (0)