diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 07d4f86a14..5495c639d0 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -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'; diff --git a/mobile/lib/features/channels/message_content/inline_components.dart b/mobile/lib/features/channels/message_content/inline_components.dart index 6e17db769e..a22d1045a1 100644 --- a/mobile/lib/features/channels/message_content/inline_components.dart +++ b/mobile/lib/features/channels/message_content/inline_components.dart @@ -63,6 +63,7 @@ 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 40b6f81b79..187be97e77 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -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); diff --git a/mobile/lib/shared/emoji/native_emoji_md.dart b/mobile/lib/shared/emoji/native_emoji_md.dart new file mode 100644 index 0000000000..f5b6dfdf2f --- /dev/null +++ b/mobile/lib/shared/emoji/native_emoji_md.dart @@ -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), + ); + } +} diff --git a/mobile/lib/shared/emoji/native_emoji_style.dart b/mobile/lib/shared/emoji/native_emoji_style.dart new file mode 100644 index 0000000000..03db07f6dc --- /dev/null +++ b/mobile/lib/shared/emoji/native_emoji_style.dart @@ -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'], +); diff --git a/mobile/test/shared/emoji/native_emoji_md_test.dart b/mobile/test/shared/emoji/native_emoji_md_test.dart new file mode 100644 index 0000000000..129293770d --- /dev/null +++ b/mobile/test/shared/emoji/native_emoji_md_test.dart @@ -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 _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 new file mode 100644 index 0000000000..ebd09ef133 --- /dev/null +++ b/mobile/test/visual/heart_rendering_test.dart @@ -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 _capture(GlobalKey key) { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + return boundary.toImage(pixelRatio: 3); +}