fix(mobile): render hearts with native color emoji fonts

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-09-23 08:13:29 -07:00
parent ec7ea38f62
commit 57d769291e
7 changed files with 306 additions and 1 deletions

View File

@@ -27,6 +27,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_only.dart';
import '../../shared/emoji/native_emoji_md.dart';
import 'channels_provider.dart';
import 'media_viewer_page.dart';
import 'message_content/link_normalizer.dart';

View File

@@ -63,6 +63,7 @@ List<MarkdownComponent> _useMessageInlineComponents({
channelNames: inputs.channelNames,
onChannelTap: channelTap,
),
NativeEmojiMd(finalContent),
...MarkdownComponent.inlineComponents,
],
[inputs],

View File

@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'native_emoji_style.dart';
/// A standalone system emoji whose visual centre matches its surrounding UI.
///
/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the
@@ -31,7 +33,7 @@ class NativeEmojiGlyph extends StatelessWidget {
maxLines: 1,
softWrap: false,
textScaler: TextScaler.noScaling,
style: TextStyle(fontSize: size, height: 1),
style: nativeEmojiTextStyle.copyWith(fontSize: size, height: 1),
);
if (defaultTargetPlatform == TargetPlatform.iOS) {
glyph = Transform.translate(offset: const Offset(0, -1), child: glyph);

View File

@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'native_emoji_style.dart';
/// Paints native emoji with color fonts while preserving surrounding typography.
///
/// Match complete grapheme clusters from this message so selectors, skin tones,
/// flags, keycaps and ZWJ sequences stay together. Text-presentation sequences
/// using VS15 and default-text symbols without VS16 keep their text appearance.
class NativeEmojiMd extends InlineMd {
/// Creates a component matching only the emoji present in [content].
NativeEmojiMd(String content) : _exp = _pattern(content);
final RegExp _exp;
static final _emoji = RegExp(
r'\p{Emoji_Presentation}|\uFE0F|\u20E3',
unicode: true,
);
static RegExp _pattern(String content) {
final clusters =
content.characters
.where(
(cluster) =>
!cluster.contains('\uFE0E') && _emoji.hasMatch(cluster),
)
.toSet()
.toList()
..sort((a, b) => b.length.compareTo(a.length));
if (clusters.isEmpty) return RegExp(r'(?!x)x');
// A shorter emoji elsewhere in the message must not consume the start of
// an explicit text-presentation or a longer emoji sequence.
return RegExp(
'(?:${clusters.map(RegExp.escape).join('|')})'
r'(?![\uFE0E\uFE0F\u200D\u20E3]|\uD83C[\uDFFB-\uDFFF])',
);
}
@override
RegExp get exp => _exp;
@override
InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
return TextSpan(
text: text,
style: (config.style ?? const TextStyle()).merge(nativeEmojiTextStyle),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/painting.dart';
/// Prefer color emoji fonts over monochrome symbols in the surrounding font.
///
/// Inter contains a heart glyph, so a fallback after Inter never gets selected
/// for it, even when the text includes the emoji presentation selector.
const nativeEmojiTextStyle = TextStyle(
fontFamily: 'Apple Color Emoji',
fontFamilyFallback: ['Noto Color Emoji', 'Segoe UI Emoji'],
);

View File

@@ -0,0 +1,91 @@
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/shared/emoji/native_emoji_md.dart';
import 'package:buzz/shared/emoji/native_emoji_style.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
Iterable<TextSpan> _spans(InlineSpan span) sync* {
if (span is TextSpan) {
yield span;
for (final child in span.children ?? <InlineSpan>[]) {
yield* _spans(child);
}
}
}
void main() {
test('keeps emoji graphemes intact and respects text presentation', () {
const content =
'❤ ❤️ ❤️‍🔥 ❤️‍🩹 ❤︎ 👍🏽 👩‍👩‍👧‍👦 🇺🇸 1️⃣ #️⃣ © ™ ® text';
final component = NativeEmojiMd(content);
expect(component.exp.allMatches(content).map((match) => match.group(0)), [
'❤️',
'❤️‍🔥',
'❤️‍🩹',
'👍🏽',
'👩‍👩‍👧‍👦',
'🇺🇸',
'1️⃣',
'#️⃣',
]);
expect(
NativeEmojiMd('plain text 123').exp.hasMatch('plain text 123'),
isFalse,
);
});
testWidgets('message emoji use color fonts without changing prose or code', (
tester,
) async {
const content =
'Thanks ❤️ **great ❤️** ❤︎ `❤️` [love ❤️](https://example.com)';
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [customEmojiListProvider.overrideWithValue(const [])],
child: const AppMarkdownTheme(
child: MessageContent(
content: content,
channelNames: {'general': 'general'},
),
),
),
);
await tester.pumpAndSettle();
final spans = tester
.widgetList<RichText>(
find.byWidgetPredicate((widget) => widget is RichText),
)
.expand((richText) => _spans(richText.text))
.toList();
final emojiSpans = spans.where(
(span) => span.style?.fontFamily == nativeEmojiTextStyle.fontFamily,
);
expect(emojiSpans.map((span) => span.text), ['❤️', '❤️', '❤️']);
expect(
emojiSpans.any((span) => span.style?.fontWeight == FontWeight.bold),
isTrue,
);
expect(
spans.any(
(span) =>
(span.text?.contains('Thanks') ?? false) &&
span.style?.fontFamily == 'Inter',
),
isTrue,
);
expect(
spans.any(
(span) => span.text == '❤️' && span.style?.fontFamily == 'GeistMono',
),
isTrue,
);
expect(
emojiSpans.any((span) => span.text?.contains('\uFE0E') ?? false),
isFalse,
);
});
}

View File

@@ -0,0 +1,150 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/reaction_row.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
// Run on macOS to exercise real Inter and Apple color-emoji glyphs. The normal
// widget-test font (Ahem) cannot distinguish a red emoji from a black symbol.
// Optionally set HEART_SCREENSHOT to a PNG path to capture the real widgets.
void main() {
testWidgets(
'hearts render in color in reactions and message bodies',
(tester) async {
await tester.runAsync(() async {
final inter = FontLoader('Inter')
..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'));
await inter.load();
final emoji = FontLoader('Apple Color Emoji')
..addFont(
File(
'/System/Library/Fonts/Apple Color Emoji.ttc',
).readAsBytes().then((bytes) => bytes.buffer.asByteData()),
);
await emoji.load();
});
final scene = GlobalKey();
final reaction = GlobalKey();
final message = GlobalKey();
final emojiOnly = GlobalKey();
await tester.pumpWidget(
ProviderScope(
overrides: [customEmojiListProvider.overrideWithValue(const [])],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Center(
child: RepaintBoundary(
key: scene,
child: Container(
width: 360,
color: Colors.white,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Reactions'),
const SizedBox(height: 8),
RepaintBoundary(
key: reaction,
child: ReactionRow(
messageId: 'heart',
reactions: const [
TimelineReaction(
emoji: '❤️',
count: 5,
reactedByCurrentUser: true,
userPubkeys: [],
),
],
onToggle: (_) {},
),
),
const SizedBox(height: 24),
const Text('Message text'),
const SizedBox(height: 8),
RepaintBoundary(
key: message,
child: const MessageContent(
content: 'Thanks for the release ❤️ **Love it ❤️**',
channelNames: {'general': 'general'},
),
),
const SizedBox(height: 24),
const Text('Emoji-only message'),
const SizedBox(height: 8),
RepaintBoundary(
key: emojiOnly,
child: const MessageContent(
content: '❤️',
channelNames: {'general': 'general'},
scaleEmojiOnly: true,
),
),
],
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.runAsync(() async {
final screenshot = Platform.environment['HEART_SCREENSHOT'];
if (screenshot != null) {
final image = await _capture(scene);
final png = (await image.toByteData(format: ui.ImageByteFormat.png))!;
await File(screenshot).parent.create(recursive: true);
await File(screenshot).writeAsBytes(png.buffer.asUint8List());
image.dispose();
}
for (final entry in {
'reaction': reaction,
'message': message,
'emoji-only message': emojiOnly,
}.entries) {
final image = await _capture(entry.value);
final rgba = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
var redPixels = 0;
for (var i = 0; i < rgba.lengthInBytes; i += 4) {
final red = rgba.getUint8(i);
if (red > 140 &&
red > rgba.getUint8(i + 1) * 1.5 &&
red > rgba.getUint8(i + 2) * 1.5 &&
rgba.getUint8(i + 3) > 200) {
redPixels++;
}
}
image.dispose();
expect(
redPixels,
greaterThan(20),
reason:
'${entry.key} must contain red emoji, not monochrome hearts',
);
}
});
},
skip: !Platform.isMacOS,
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
Future<ui.Image> _capture(GlobalKey key) {
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
return boundary.toImage(pixelRatio: 3);
}