mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
Scope heart rendering fix to standalone reactions and validate on iOS
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -63,7 +63,6 @@ List<MarkdownComponent> _useMessageInlineComponents({
|
||||
channelNames: inputs.channelNames,
|
||||
onChannelTap: channelTap,
|
||||
),
|
||||
NativeEmojiMd(finalContent),
|
||||
...MarkdownComponent.inlineComponents,
|
||||
],
|
||||
[inputs],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
51
mobile/test/visual/heart_sim_app.dart
Normal file
51
mobile/test/visual/heart_sim_app.dart
Normal 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: (_) {},
|
||||
);
|
||||
Reference in New Issue
Block a user