diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index bf60ab6adb..d732b18b5f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -507,6 +507,35 @@ enum UserTopicVisibilityPolicy { int? toJson() => apiValue; } +/// Convert a Unicode emoji's Zulip "emoji code" into the +/// actual Unicode code points. +/// +/// The argument corresponds to [Reaction.emojiCode] when [Reaction.emojiType] +/// is [ReactionType.unicodeEmoji]. For docs, see: +/// https://zulip.com/api/add-reaction#parameter-reaction_type +/// +/// In addition to reactions, these appear in Zulip content HTML; +/// see [UnicodeEmojiNode.emojiUnicode]. +String? tryParseEmojiCodeToUnicode(String emojiCode) { + // Ported from: https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112 + // which refers to a comment in the server implementation: + // https://github.com/zulip/zulip/blob/63c9296d5339517450f79f176dc02d77b08020c8/zerver/models.py#L3235-L3242 + // In addition to what's in the doc linked above, that comment adds: + // + // > For examples, see "non_qualified" or "unified" in the following data, + // > with "non_qualified" taking precedence when both present: + // > https://raw.githubusercontent.com/iamcal/emoji-data/a8174c74675355c8c6a9564516b2e961fe7257ef/emoji_pretty.json + // > [link fixed to permalink; original comment says "master" for the commit] + try { + return String.fromCharCodes(emojiCode.split('-') + .map((hex) => int.parse(hex, radix: 16))); + } on FormatException { // thrown by `int.parse` + return null; + } on ArgumentError { // thrown by `String.fromCharCodes` + return null; + } +} + /// As in the get-messages response. /// /// https://zulip.com/api/get-messages#response diff --git a/lib/model/content.dart b/lib/model/content.dart index 64aa5a4bd9..f717de7434 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart'; +import '../api/model/model.dart'; import 'code_block.dart'; /// A node in a parse tree for Zulip message-style content. @@ -716,27 +717,6 @@ class GlobalTimeNode extends InlineContentNode { //////////////////////////////////////////////////////////////// -// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112 -// -// Which was in turn ported from https://github.com/zulip/zulip/blob/63c9296d5339517450f79f176dc02d77b08020c8/zerver/models.py#L3235-L3242 -// and that describes the encoding as follows: -// -// > * For Unicode emoji, [emoji_code is] a dash-separated hex encoding of -// > the sequence of Unicode codepoints that define this emoji in the -// > Unicode specification. For examples, see "non_qualified" or -// > "unified" in the following data, with "non_qualified" taking -// > precedence when both present: -// > https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji_pretty.json -String? tryParseEmojiCodeToUnicode(String code) { - try { - return String.fromCharCodes(code.split('-').map((hex) => int.parse(hex, radix: 16))); - } on FormatException { // thrown by `int.parse` - return null; - } on ArgumentError { // thrown by `String.fromCharCodes` - return null; - } -} - /// What sort of nodes a [_ZulipContentParser] is currently expecting to find. enum _ParserContext { /// The parser is currently looking for block nodes. diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart new file mode 100644 index 0000000000..688cec6549 --- /dev/null +++ b/lib/model/emoji.dart @@ -0,0 +1,137 @@ +import '../api/model/events.dart'; +import '../api/model/initial_snapshot.dart'; +import '../api/model/model.dart'; + +/// An emoji, described by how to display it in the UI. +sealed class EmojiDisplay { + /// The emoji's name, as in [Reaction.emojiName]. + final String emojiName; + + EmojiDisplay({required this.emojiName}); + + EmojiDisplay resolve(UserSettings? userSettings) { // TODO(server-5) + if (this is TextEmojiDisplay) return this; + if (userSettings?.emojiset == Emojiset.text) { + return TextEmojiDisplay(emojiName: emojiName); + } + return this; + } +} + +/// An emoji to display as Unicode text, relying on an emoji font. +class UnicodeEmojiDisplay extends EmojiDisplay { + /// The actual Unicode text representing this emoji; for example, "🙂". + final String emojiUnicode; + + UnicodeEmojiDisplay({required super.emojiName, required this.emojiUnicode}); +} + +/// An emoji to display as an image. +class ImageEmojiDisplay extends EmojiDisplay { + /// An absolute URL for the emoji's image file. + final Uri resolvedUrl; + + /// An absolute URL for a still version of the emoji's image file; + /// compare [RealmEmojiItem.stillUrl]. + final Uri? resolvedStillUrl; + + ImageEmojiDisplay({ + required super.emojiName, + required this.resolvedUrl, + required this.resolvedStillUrl, + }); +} + +/// An emoji to display as its name, in plain text. +/// +/// We do this based on a user preference, +/// and as a fallback when the Unicode or image approaches fail. +class TextEmojiDisplay extends EmojiDisplay { + TextEmojiDisplay({required super.emojiName}); +} + +/// The portion of [PerAccountStore] describing what emoji exist. +mixin EmojiStore { + /// The realm's custom emoji (for [ReactionType.realmEmoji], + /// indexed by [Reaction.emojiCode]. + Map get realmEmoji; + + EmojiDisplay emojiDisplayFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName, + }); +} + +/// The implementation of [EmojiStore] that does the work. +/// +/// Generally the only code that should need this class is [PerAccountStore] +/// itself. Other code accesses this functionality through [PerAccountStore], +/// or through the mixin [EmojiStore] which describes its interface. +class EmojiStoreImpl with EmojiStore { + EmojiStoreImpl({ + required this.realmUrl, + required this.realmEmoji, + }); + + /// The same as [PerAccountStore.realmUrl]. + final Uri realmUrl; + + @override + Map realmEmoji; + + /// The realm-relative URL of the unique "Zulip extra emoji", :zulip:. + static const kZulipEmojiUrl = '/static/generated/emoji/images/emoji/unicode/zulip.png'; + + @override + EmojiDisplay emojiDisplayFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName, + }) { + switch (emojiType) { + case ReactionType.unicodeEmoji: + final parsed = tryParseEmojiCodeToUnicode(emojiCode); + if (parsed == null) break; + return UnicodeEmojiDisplay(emojiName: emojiName, emojiUnicode: parsed); + + case ReactionType.realmEmoji: + final item = realmEmoji[emojiCode]; + if (item == null) break; + // TODO we don't check emojiName matches the known realm emoji; is that right? + return _tryImageEmojiDisplay( + sourceUrl: item.sourceUrl, stillUrl: item.stillUrl, + emojiName: emojiName); + + case ReactionType.zulipExtraEmoji: + return _tryImageEmojiDisplay( + sourceUrl: kZulipEmojiUrl, stillUrl: null, emojiName: emojiName); + } + return TextEmojiDisplay(emojiName: emojiName); + } + + EmojiDisplay _tryImageEmojiDisplay({ + required String sourceUrl, + required String? stillUrl, + required String emojiName, + }) { + final source = Uri.tryParse(sourceUrl); + if (source == null) return TextEmojiDisplay(emojiName: emojiName); + + Uri? still; + if (stillUrl != null) { + still = Uri.tryParse(stillUrl); + if (still == null) return TextEmojiDisplay(emojiName: emojiName); + } + + return ImageEmojiDisplay( + emojiName: emojiName, + resolvedUrl: realmUrl.resolveUri(source), + resolvedStillUrl: still == null ? null : realmUrl.resolveUri(still), + ); + } + + void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) { + realmEmoji = event.realmEmoji; + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index 9e7c661f6e..309f12f287 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -20,6 +20,7 @@ import '../log.dart'; import '../notifications/receive.dart'; import 'autocomplete.dart'; import 'database.dart'; +import 'emoji.dart'; import 'message.dart'; import 'message_list.dart'; import 'recent_dm_conversations.dart'; @@ -202,7 +203,7 @@ abstract class GlobalStore extends ChangeNotifier { /// This class does not attempt to poll an event queue /// to keep the data up to date. For that behavior, see /// [UpdateMachine]. -class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { +class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -227,16 +228,18 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { connection ??= globalStore.apiConnectionFromAccount(account); assert(connection.zulipFeatureLevel == account.zulipFeatureLevel); + final realmUrl = account.realmUrl; final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); return PerAccountStore._( globalStore: globalStore, connection: connection, - realmUrl: account.realmUrl, + realmUrl: realmUrl, maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib, realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, - realmEmoji: initialSnapshot.realmEmoji, customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), emailAddressVisibility: initialSnapshot.emailAddressVisibility, + emoji: EmojiStoreImpl( + realmUrl: realmUrl, realmEmoji: initialSnapshot.realmEmoji), accountId: accountId, selfUserId: account.userId, userSettings: initialSnapshot.userSettings, @@ -268,9 +271,9 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { required this.realmUrl, required this.maxFileUploadSizeMib, required this.realmDefaultExternalAccounts, - required this.realmEmoji, required this.customProfileFields, required this.emailAddressVisibility, + required EmojiStoreImpl emoji, required this.accountId, required this.selfUserId, required this.userSettings, @@ -284,7 +287,9 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { }) : assert(selfUserId == globalStore.getAccount(accountId)!.userId), assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl), assert(realmUrl == connection.realmUrl), + assert(emoji.realmUrl == realmUrl), _globalStore = globalStore, + _emoji = emoji, _channels = channels, _messages = messages; @@ -320,11 +325,28 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { String get zulipVersion => account.zulipVersion; final int maxFileUploadSizeMib; // No event for this. final Map realmDefaultExternalAccounts; - Map realmEmoji; List customProfileFields; /// For docs, please see [InitialSnapshot.emailAddressVisibility]. final EmailAddressVisibility? emailAddressVisibility; // TODO(#668): update this realm setting + //////////////////////////////// + // The realm's repertoire of available emoji. + + @override + Map get realmEmoji => _emoji.realmEmoji; + + @override + EmojiDisplay emojiDisplayFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName + }) { + return _emoji.emojiDisplayFor( + emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName); + } + + EmojiStoreImpl _emoji; + //////////////////////////////// // Data attached to the self-account on the realm. @@ -423,7 +445,7 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore { case RealmEmojiUpdateEvent(): assert(debugLog("server event: realm_emoji/update")); - realmEmoji = event.realmEmoji; + _emoji.handleRealmEmojiUpdateEvent(event); notifyListeners(); case AlertWordsEvent(): diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 4468cbb857..3d87b7a8a0 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; -import '../model/content.dart'; +import '../model/emoji.dart'; import 'content.dart'; import 'store.dart'; import 'text.dart'; @@ -149,8 +149,6 @@ class ReactionChip extends StatelessWidget { final emojiName = reactionWithVotes.emojiName; final userIds = reactionWithVotes.userIds; - final emojiset = store.userSettings?.emojiset ?? Emojiset.google; - final selfVoted = userIds.contains(store.selfUserId); final label = showName // TODO(i18n): List formatting, like you can do in JavaScript: @@ -176,26 +174,20 @@ class ReactionChip extends StatelessWidget { ); final shape = StadiumBorder(side: borderSide); - final Widget emoji; - if (emojiset == Emojiset.text) { - emoji = _TextEmoji(emojiName: emojiName, selected: selfVoted); - } else { - switch (reactionType) { - case ReactionType.unicodeEmoji: - emoji = _UnicodeEmoji( - emojiCode: emojiCode, - emojiName: emojiName, - selected: selfVoted, - ); - case ReactionType.realmEmoji: - case ReactionType.zulipExtraEmoji: - emoji = _ImageEmoji( - emojiCode: emojiCode, - emojiName: emojiName, - selected: selfVoted, - ); - } - } + final emojiDisplay = store.emojiDisplayFor( + emojiType: reactionType, + emojiCode: emojiCode, + emojiName: emojiName, + ).resolve(store.userSettings); + + final emoji = switch (emojiDisplay) { + UnicodeEmojiDisplay() => _UnicodeEmoji( + emojiDisplay: emojiDisplay, selected: selfVoted), + ImageEmojiDisplay() => _ImageEmoji( + emojiDisplay: emojiDisplay, emojiName: emojiName, selected: selfVoted), + TextEmojiDisplay() => _TextEmoji( + emojiDisplay: emojiDisplay, selected: selfVoted), + }; return Tooltip( // TODO(#434): Semantics with eg "Reaction: ; you and N others: " @@ -302,22 +294,15 @@ TextScaler _labelTextScalerClamped(BuildContext context) => class _UnicodeEmoji extends StatelessWidget { const _UnicodeEmoji({ - required this.emojiCode, - required this.emojiName, + required this.emojiDisplay, required this.selected, }); - final String emojiCode; - final String emojiName; + final UnicodeEmojiDisplay emojiDisplay; final bool selected; @override Widget build(BuildContext context) { - final parsed = tryParseEmojiCodeToUnicode(emojiCode); - if (parsed == null) { // TODO(log) - return _TextEmoji(emojiName: emojiName, selected: selected); - } - switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -330,7 +315,7 @@ class _UnicodeEmoji extends StatelessWidget { fontSize: _notoColorEmojiTextSize, ), strutStyle: const StrutStyle(fontSize: _notoColorEmojiTextSize, forceStrutHeight: true), - parsed); + emojiDisplay.emojiUnicode); case TargetPlatform.iOS: case TargetPlatform.macOS: // We expect the font "Apple Color Emoji" to be used. There are some @@ -356,7 +341,7 @@ class _UnicodeEmoji extends StatelessWidget { textScaler: _squareEmojiScalerClamped(context), style: const TextStyle(fontSize: _squareEmojiSize), strutStyle: const StrutStyle(fontSize: _squareEmojiSize, forceStrutHeight: true), - parsed)), + emojiDisplay.emojiUnicode)), ]); } } @@ -364,21 +349,17 @@ class _UnicodeEmoji extends StatelessWidget { class _ImageEmoji extends StatelessWidget { const _ImageEmoji({ - required this.emojiCode, + required this.emojiDisplay, required this.emojiName, required this.selected, }); - final String emojiCode; + final ImageEmojiDisplay emojiDisplay; final String emojiName; final bool selected; - Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected); - @override Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - // Some people really dislike animated emoji. final doNotAnimate = // From reading code, this doesn't actually get set on iOS: @@ -391,43 +372,38 @@ class _ImageEmoji extends StatelessWidget { // See GitHub comment linked above. && WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion); - final String src; - switch (emojiCode) { - case 'zulip': // the single "zulip extra emoji" - src = '/static/generated/emoji/images/emoji/unicode/zulip.png'; - default: - final item = store.realmEmoji[emojiCode]; - if (item == null) { - return _textFallback; - } - src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl; - } - final parsedSrc = Uri.tryParse(src); - if (parsedSrc == null) { // TODO(log) - return _textFallback; - } - final resolved = store.realmUrl.resolveUri(parsedSrc); + final resolvedUrl = doNotAnimate + ? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl) + : emojiDisplay.resolvedUrl; // Unicode and text emoji get scaled; it would look weird if image emoji didn't. final size = _squareEmojiScalerClamped(context).scale(_squareEmojiSize); return RealmContentNetworkImage( - resolved, + resolvedUrl, width: size, height: size, - errorBuilder: (context, _, __) => _textFallback, + errorBuilder: (context, _, __) => _TextEmoji( + emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected), ); } } class _TextEmoji extends StatelessWidget { - const _TextEmoji({required this.emojiName, required this.selected}); + const _TextEmoji({required this.emojiDisplay, required this.selected}); - final String emojiName; + final TextEmojiDisplay emojiDisplay; final bool selected; @override Widget build(BuildContext context) { + final emojiName = emojiDisplay.emojiName; + + // Encourage line breaks before "_" (common in these), but try not + // to leave a colon alone on a line. See: + // + final text = ':\ufeff${emojiName.replaceAll('_', '\u200b_')}\ufeff:'; + final reactionTheme = EmojiReactionTheme.of(context); return Text( textAlign: TextAlign.end, @@ -439,9 +415,6 @@ class _TextEmoji extends StatelessWidget { color: selected ? reactionTheme.textSelected : reactionTheme.textUnselected, ).merge(weightVariableTextStyle(context, wght: selected ? 600 : null)), - // Encourage line breaks before "_" (common in these), but try not - // to leave a colon alone on a line. See: - // - ':\ufeff${emojiName.replaceAll('_', '\u200b_')}\ufeff:'); + text); } } diff --git a/test/example_data.dart b/test/example_data.dart index ca9e20c86d..b4bd5159f0 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -62,6 +62,25 @@ GetServerSettingsResult serverSettings({ ); } +RealmEmojiItem realmEmojiItem({ + required String emojiCode, + required String emojiName, + String? sourceUrl, + String? stillUrl, + bool deactivated = false, + int? authorId, +}) { + assert(RegExp(r'^[1-9][0-9]*$').hasMatch(emojiCode)); + return RealmEmojiItem( + id: emojiCode, + name: emojiName, + sourceUrl: sourceUrl ?? '/emoji/$emojiCode.png', + stillUrl: stillUrl, + deactivated: deactivated, + authorId: authorId ?? user().userId, + ); +} + //////////////////////////////////////////////////////////////// // Users and accounts. // diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart new file mode 100644 index 0000000000..f3fdbf3b0a --- /dev/null +++ b/test/model/emoji_test.dart @@ -0,0 +1,89 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/emoji.dart'; + +import '../example_data.dart' as eg; + +void main() { + group('emojiDisplayFor', () { + test('Unicode emoji', () { + check(eg.store().emojiDisplayFor(emojiType: ReactionType.unicodeEmoji, + emojiCode: '1f642', emojiName: 'smile') + ).isA() + ..emojiName.equals('smile') + ..emojiUnicode.equals('🙂'); + }); + + test('invalid Unicode emoji -> no crash', () { + check(eg.store().emojiDisplayFor(emojiType: ReactionType.unicodeEmoji, + emojiCode: 'asdf', emojiName: 'invalid') + ).isA() + .emojiName.equals('invalid'); + }); + + test('realm emoji', () { + final store = eg.store(initialSnapshot: eg.initialSnapshot(realmEmoji: { + '100': eg.realmEmojiItem(emojiCode: '100', emojiName: 'logo', + sourceUrl: '/emoji/100.png'), + '123': eg.realmEmojiItem(emojiCode: '123', emojiName: '100', + sourceUrl: '/emoji/123.png'), + '200': eg.realmEmojiItem(emojiCode: '200', emojiName: 'dancing', + sourceUrl: '/emoji/200.png', stillUrl: '/emoji/200-still.png'), + })); + + Subject checkDisplay({ + required String emojiCode, required String emojiName}) { + return check(store.emojiDisplayFor(emojiType: ReactionType.realmEmoji, + emojiCode: emojiCode, emojiName: emojiName) + )..emojiName.equals(emojiName); + } + + checkDisplay(emojiCode: '100', emojiName: 'logo').isA() + ..resolvedUrl.equals(eg.realmUrl.resolve('/emoji/100.png')) + ..resolvedStillUrl.isNull(); + + // Emoji code matches against emoji code, not against emoji name. + checkDisplay(emojiCode: '123', emojiName: '100').isA() + ..resolvedUrl.equals(eg.realmUrl.resolve('/emoji/123.png')) + ..resolvedStillUrl.isNull(); + + // Unexpected name is accepted. + checkDisplay(emojiCode: '100', emojiName: 'other').isA() + ..resolvedUrl.equals(eg.realmUrl.resolve('/emoji/100.png')) + ..resolvedStillUrl.isNull(); + + // Unexpected code falls back to text. + checkDisplay(emojiCode: '99', emojiName: 'another') + .isA(); + + checkDisplay(emojiCode: '200', emojiName: 'dancing').isA() + ..resolvedUrl.equals(eg.realmUrl.resolve('/emoji/200.png')) + ..resolvedStillUrl.equals(eg.realmUrl.resolve('/emoji/200-still.png')); + + // TODO test URLs not parsing + }); + + test(':zulip:', () { + check(eg.store().emojiDisplayFor(emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', emojiName: 'zulip') + ).isA() + ..emojiName.equals('zulip') + ..resolvedUrl.equals(eg.realmUrl.resolve(EmojiStoreImpl.kZulipEmojiUrl)) + ..resolvedStillUrl.isNull(); + }); + }); +} + +extension EmojiDisplayChecks on Subject { + Subject get emojiName => has((x) => x.emojiName, 'emojiName'); +} + +extension UnicodeEmojiDisplayChecks on Subject { + Subject get emojiUnicode => has((x) => x.emojiUnicode, 'emojiUnicode'); +} + +extension ImageEmojiDisplayChecks on Subject { + Subject get resolvedUrl => has((x) => x.resolvedUrl, 'resolvedUrl'); + Subject get resolvedStillUrl => has((x) => x.resolvedStillUrl, 'resolvedStillUrl'); +} diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 36fc0ea28d..eafe7a6795 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -167,10 +167,8 @@ void main() { final users = [user1, user2, user3, user4, user5]; final realmEmoji = { - '181': RealmEmojiItem(id: '181', name: 'twocents', authorId: 7, - deactivated: false, sourceUrl: '/foo/2', stillUrl: null), - '182': RealmEmojiItem(id: '182', name: 'threecents', authorId: 7, - deactivated: false, sourceUrl: '/foo/3', stillUrl: null), + '181': eg.realmEmojiItem(emojiCode: '181', emojiName: 'twocents'), + '182': eg.realmEmojiItem(emojiCode: '182', emojiName: 'threecents'), }; runSmokeTest('same reaction, different users, with one unknown user', [