From d9a5bdd6e971bfd8f436fae6e6bcad80acc9ceb2 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 23 Sep 2026 17:21:37 -0700 Subject: [PATCH] Scope heart rendering fix to standalone reactions and validate on iOS Signed-off-by: Tom Brow --- .../features/channels/message_content.dart | 1 - .../message_content/inline_components.dart | 1 - .../lib/shared/emoji/native_emoji_glyph.dart | 10 +- mobile/lib/shared/emoji/native_emoji_md.dart | 50 ------ .../lib/shared/emoji/native_emoji_style.dart | 10 -- .../features/channels/reaction_row_test.dart | 19 +++ .../shared/emoji/native_emoji_md_test.dart | 91 ----------- mobile/test/visual/heart_rendering_test.dart | 150 ------------------ mobile/test/visual/heart_sim_app.dart | 51 ++++++ 9 files changed, 77 insertions(+), 306 deletions(-) delete mode 100644 mobile/lib/shared/emoji/native_emoji_md.dart delete mode 100644 mobile/lib/shared/emoji/native_emoji_style.dart delete mode 100644 mobile/test/shared/emoji/native_emoji_md_test.dart delete mode 100644 mobile/test/visual/heart_rendering_test.dart create mode 100644 mobile/test/visual/heart_sim_app.dart diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 5495c639d0..07d4f86a14 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -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'; diff --git a/mobile/lib/features/channels/message_content/inline_components.dart b/mobile/lib/features/channels/message_content/inline_components.dart index a22d1045a1..6e17db769e 100644 --- a/mobile/lib/features/channels/message_content/inline_components.dart +++ b/mobile/lib/features/channels/message_content/inline_components.dart @@ -63,7 +63,6 @@ List _useMessageInlineComponents({ channelNames: inputs.channelNames, onChannelTap: channelTap, ), - NativeEmojiMd(finalContent), ...MarkdownComponent.inlineComponents, ], [inputs], diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 187be97e77..786d2e7dbf 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -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); diff --git a/mobile/lib/shared/emoji/native_emoji_md.dart b/mobile/lib/shared/emoji/native_emoji_md.dart deleted file mode 100644 index f5b6dfdf2f..0000000000 --- a/mobile/lib/shared/emoji/native_emoji_md.dart +++ /dev/null @@ -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), - ); - } -} diff --git a/mobile/lib/shared/emoji/native_emoji_style.dart b/mobile/lib/shared/emoji/native_emoji_style.dart deleted file mode 100644 index 03db07f6dc..0000000000 --- a/mobile/lib/shared/emoji/native_emoji_style.dart +++ /dev/null @@ -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'], -); diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 1f3ef745ef..82edd68057 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -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(find.text('❤️')); + final count = tester.widget(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 { diff --git a/mobile/test/shared/emoji/native_emoji_md_test.dart b/mobile/test/shared/emoji/native_emoji_md_test.dart deleted file mode 100644 index 129293770d..0000000000 --- a/mobile/test/shared/emoji/native_emoji_md_test.dart +++ /dev/null @@ -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 _spans(InlineSpan span) sync* { - if (span is TextSpan) { - yield span; - for (final child in span.children ?? []) { - 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( - 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, - ); - }); -} diff --git a/mobile/test/visual/heart_rendering_test.dart b/mobile/test/visual/heart_rendering_test.dart deleted file mode 100644 index ebd09ef133..0000000000 --- a/mobile/test/visual/heart_rendering_test.dart +++ /dev/null @@ -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 _capture(GlobalKey key) { - final boundary = - key.currentContext!.findRenderObject()! as RenderRepaintBoundary; - return boundary.toImage(pixelRatio: 3); -} diff --git a/mobile/test/visual/heart_sim_app.dart b/mobile/test/visual/heart_sim_app.dart new file mode 100644 index 0000000000..f859cc7081 --- /dev/null +++ b/mobile/test/visual/heart_sim_app.dart @@ -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 -t test/visual/heart_sim_app.dart +// xcrun simctl io screenshot +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: (_) {}, +);