mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
fix(mobile): render hearts with native color emoji fonts
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -63,6 +63,7 @@ List<MarkdownComponent> _useMessageInlineComponents({
|
||||
channelNames: inputs.channelNames,
|
||||
onChannelTap: channelTap,
|
||||
),
|
||||
NativeEmojiMd(finalContent),
|
||||
...MarkdownComponent.inlineComponents,
|
||||
],
|
||||
[inputs],
|
||||
|
||||
@@ -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);
|
||||
|
||||
50
mobile/lib/shared/emoji/native_emoji_md.dart
Normal file
50
mobile/lib/shared/emoji/native_emoji_md.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
mobile/lib/shared/emoji/native_emoji_style.dart
Normal file
10
mobile/lib/shared/emoji/native_emoji_style.dart
Normal 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'],
|
||||
);
|
||||
91
mobile/test/shared/emoji/native_emoji_md_test.dart
Normal file
91
mobile/test/shared/emoji/native_emoji_md_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
150
mobile/test/visual/heart_rendering_test.dart
Normal file
150
mobile/test/visual/heart_rendering_test.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user