Scope heart rendering fix to standalone reactions and validate on iOS

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-09-23 17:21:37 -07:00
parent 57d769291e
commit d9a5bdd6e9
9 changed files with 77 additions and 306 deletions

View File

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

View File

@@ -1,8 +1,6 @@
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
@@ -33,7 +31,13 @@ class NativeEmojiGlyph extends StatelessWidget {
maxLines: 1,
softWrap: false,
textScaler: TextScaler.noScaling,
style: nativeEmojiTextStyle.copyWith(fontSize: size, height: 1),
// Inter contains a monochrome heart, so emoji fonts must take priority.
style: TextStyle(
fontFamily: 'Apple Color Emoji',
fontFamilyFallback: const ['Noto Color Emoji', 'Segoe UI Emoji'],
fontSize: size,
height: 1,
),
);
if (defaultTargetPlatform == TargetPlatform.iOS) {
glyph = Transform.translate(offset: const Offset(0, -1), child: glyph);

View File

@@ -1,50 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -95,6 +95,25 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
void main() {
group('ReactionRow', () {
testWidgets(
'heart reactions prefer a color font without recoloring counts',
(tester) async {
await _pumpRow(tester, reactions: [_reaction(emoji: '❤️', count: 5)]);
final heart = tester.widget<Text>(find.text('❤️'));
final count = tester.widget<Text>(find.text('5'));
expect(heart.style?.fontFamily, 'Apple Color Emoji');
expect(heart.style?.fontFamilyFallback, [
'Noto Color Emoji',
'Segoe UI Emoji',
]);
expect(count.style?.fontFamily, 'Inter');
expect(
tester.getSize(find.byKey(const ValueKey('reaction-pill-❤️'))).height,
28,
);
},
);
testWidgets('shows the count even at one, matching desktop', (
tester,
) async {

View File

@@ -1,91 +0,0 @@
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

@@ -1,150 +0,0 @@
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);
}

View File

@@ -0,0 +1,51 @@
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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
// Native iOS visual fixture using production reaction widgets.
// flutter run -d <simulator> -t test/visual/heart_sim_app.dart
// xcrun simctl io <simulator> screenshot <path>
void main() => runApp(
ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
home: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
const Text('Reaction rendering'),
const SizedBox(height: 24),
const Text('Selected'),
_reactions(true),
const SizedBox(height: 24),
const Text('Unselected'),
_reactions(false),
],
),
),
),
),
),
),
);
Widget _reactions(bool selected) => ReactionRow(
messageId: 'heart-$selected',
reactions: [
for (final emoji in ['❤️', '👍', '🎉'])
TimelineReaction(
emoji: emoji,
count: 5,
reactedByCurrentUser: selected,
userPubkeys: const [],
),
],
onToggle: (_) {},
);