Skip to content

Commit a67621b

Browse files
content: Add support for displaying unicode emojis
- Parse the zulip emoji code class names to unicode codepoints - Generate a tappable TextSpan for each unicode emoji - Remove fallback emoji name container
1 parent f22dafb commit a67621b

File tree

4 files changed

+72
-29
lines changed

4 files changed

+72
-29
lines changed

lib/model/content.dart

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:collection/collection.dart';
12
import 'package:flutter/foundation.dart';
23
import 'package:html/dom.dart' as dom;
34
import 'package:html/parser.dart';
@@ -452,22 +453,22 @@ abstract class EmojiNode extends InlineContentNode {
452453
}
453454

454455
class UnicodeEmojiNode extends EmojiNode {
455-
const UnicodeEmojiNode({super.debugHtmlNode, required this.text});
456+
const UnicodeEmojiNode({super.debugHtmlNode, required this.emojiUnicode});
456457

457-
final String text;
458+
final String emojiUnicode;
458459

459460
@override
460461
bool operator ==(Object other) {
461-
return other is UnicodeEmojiNode && other.text == text;
462+
return other is UnicodeEmojiNode && other.emojiUnicode == emojiUnicode;
462463
}
463464

464465
@override
465-
int get hashCode => Object.hash('UnicodeEmojiNode', text);
466+
int get hashCode => Object.hash('UnicodeEmojiNode', emojiUnicode);
466467

467468
@override
468469
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
469470
super.debugFillProperties(properties);
470-
properties.add(StringProperty('text', text));
471+
properties.add(StringProperty('emojiUnicode', emojiUnicode));
471472
}
472473
}
473474

@@ -525,6 +526,27 @@ class _ZulipContentParser {
525526

526527
static final _emojiClassRegexp = RegExp(r"^emoji(-[0-9a-f]+)*$");
527528

529+
// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
530+
//
531+
// Which was in turn ported from https://github.com/zulip/zulip/blob/63c9296d5339517450f79f176dc02d77b08020c8/zerver/models.py#L3235-L3242
532+
// and that describes the encoding as follows:
533+
//
534+
// > * For Unicode emoji, [emoji_code is] a dash-separated hex encoding of
535+
// > the sequence of Unicode codepoints that define this emoji in the
536+
// > Unicode specification. For examples, see "non_qualified" or
537+
// > "unified" in the following data, with "non_qualified" taking
538+
// > precedence when both present:
539+
// > https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji_pretty.json
540+
String? tryParseEmojiCodeToUnicode(String code) {
541+
try {
542+
return String.fromCharCodes(code.split('-').map((hex) => int.parse(hex, radix: 16)));
543+
} on FormatException { // thrown by `int.parse`
544+
return null;
545+
} on ArgumentError { // thrown by `String.fromCharCodes`
546+
return null;
547+
}
548+
}
549+
528550
InlineContentNode parseInlineContent(dom.Node node) {
529551
assert(_debugParserContext == _ParserContext.inline);
530552
final debugHtmlNode = kDebugMode ? node : null;
@@ -582,7 +604,14 @@ class _ZulipContentParser {
582604
&& classes.length == 2
583605
&& classes.contains('emoji')
584606
&& classes.every(_emojiClassRegexp.hasMatch)) {
585-
return UnicodeEmojiNode(text: element.text, debugHtmlNode: debugHtmlNode);
607+
final emojiCode = classes
608+
.firstWhereOrNull((className) => className.startsWith('emoji-'))!
609+
.replaceFirst('emoji-', '');
610+
assert(emojiCode.isNotEmpty);
611+
612+
final unicode = tryParseEmojiCodeToUnicode(emojiCode);
613+
if (unicode == null) return unimplemented();
614+
return UnicodeEmojiNode(emojiUnicode: unicode, debugHtmlNode: debugHtmlNode);
586615
}
587616

588617
if (localName == 'img'

lib/widgets/content.dart

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,7 @@ class _InlineContentBuilder {
460460
return WidgetSpan(alignment: PlaceholderAlignment.middle,
461461
child: UserMention(node: node));
462462
} else if (node is UnicodeEmojiNode) {
463-
return WidgetSpan(alignment: PlaceholderAlignment.middle,
464-
child: MessageUnicodeEmoji(node: node));
463+
return TextSpan(text: node.emojiUnicode, recognizer: _recognizer);
465464
} else if (node is ImageEmojiNode) {
466465
return WidgetSpan(alignment: PlaceholderAlignment.middle,
467466
child: MessageImageEmoji(node: node));
@@ -620,23 +619,6 @@ class UserMention extends StatelessWidget {
620619
// borderRadius: BorderRadius.all(Radius.circular(3))));
621620
}
622621

623-
class MessageUnicodeEmoji extends StatelessWidget {
624-
const MessageUnicodeEmoji({super.key, required this.node});
625-
626-
final UnicodeEmojiNode node;
627-
628-
@override
629-
Widget build(BuildContext context) {
630-
// TODO(#58) get spritesheet and show actual emoji glyph
631-
final text = node.text;
632-
return Container(
633-
padding: const EdgeInsets.all(2),
634-
decoration: BoxDecoration(
635-
color: Colors.white, border: Border.all(color: Colors.purple)),
636-
child: Text(text));
637-
}
638-
}
639-
640622
class MessageImageEmoji extends StatelessWidget {
641623
const MessageImageEmoji({super.key, required this.node});
642624

test/model/content_test.dart

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,20 @@ void main() {
141141
// TODO test group mentions and wildcard mentions
142142
});
143143

144-
testParseInline('parse Unicode emoji',
144+
testParseInline('parse Unicode emoji, encoded in span element',
145145
// ":thumbs_up:"
146146
'<p><span aria-label="thumbs up" class="emoji emoji-1f44d" role="img" title="thumbs up">:thumbs_up:</span></p>',
147-
const UnicodeEmojiNode(text: ':thumbs_up:'));
147+
const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}')); // "👍"
148148

149-
testParseInline('parse Unicode emoji, multiple codepoints',
149+
testParseInline('parse Unicode emoji, encoded in span element, multiple codepoints',
150150
// ":transgender_flag:"
151151
'<p><span aria-label="transgender flag" class="emoji emoji-1f3f3-fe0f-200d-26a7-fe0f" role="img" title="transgender flag">:transgender_flag:</span></p>',
152-
const UnicodeEmojiNode(text: ':transgender_flag:'));
152+
const UnicodeEmojiNode(emojiUnicode: '\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}')); // "🏳️‍⚧️"
153+
154+
testParseInline('parse Unicode emoji, not encoded in span element',
155+
// "\u{1fabf}"
156+
'<p>\u{1fabf}</p>',
157+
const TextNode('\u{1fabf}')); // "🪿"
153158

154159
testParseInline('parse custom emoji',
155160
// ":flutter:"

test/widgets/content_test.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,33 @@ void main() {
155155
});
156156
});
157157

158+
group('UnicodeEmoji', () {
159+
Future<void> prepareContent(WidgetTester tester, String html) async {
160+
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes)));
161+
}
162+
163+
testWidgets('encoded emoji span', (tester) async {
164+
await prepareContent(tester,
165+
// ":thumbs_up:"
166+
'<p><span aria-label="thumbs up" class="emoji emoji-1f44d" role="img" title="thumbs up">:thumbs_up:</span></p>');
167+
tester.widget(find.text('\u{1f44d}')); // "👍"
168+
});
169+
170+
testWidgets('encoded emoji span, with multiple codepoints', (tester) async {
171+
await prepareContent(tester,
172+
// ":transgender_flag:"
173+
'<p><span aria-label="transgender flag" class="emoji emoji-1f3f3-fe0f-200d-26a7-fe0f" role="img" title="transgender flag">:transgender_flag:</span></p>');
174+
tester.widget(find.text('\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}')); // "🏳️‍⚧️"
175+
});
176+
177+
testWidgets('non encoded emoji', (tester) async {
178+
await prepareContent(tester,
179+
// "\u{1fabf}"
180+
'<p>\u{1fabf}</p>');
181+
tester.widget(find.text('\u{1fabf}')); // "🪿"
182+
});
183+
});
184+
158185
group('RealmContentNetworkImage', () {
159186
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
160187

0 commit comments

Comments
 (0)