frosty/lib/models/irc.dart
Tommy Chow 932412b75f Optimize chat (#154)
Optimized the chat so that resuming PiP does not cause stutters.

This new implementation makes it so that only the latest (100) messages are
rendered at any given time, rather than every single one.

Once autoscroll is disabled and we scroll up in chat, the most recent
messages will be added to a buffer to prevent shifting.

Essentially, I've made it so that the default chat shows only the (100)
most recent messages. When you want to scroll back, the chat will
silently switch to the version with the last (10000) messages, giving
the best of both worlds in performance and scrollback history.
2022-07-26 23:23:53 -04:00

852 lines
29 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
/// The object representation of a Twitch IRC message.
class IRCMessage {
final String raw;
Command command;
final Map<String, String> tags;
final String? user;
final Map<String, Emote>? localEmotes;
String? message;
List<String>? split;
bool? action;
bool? mention;
IRCMessage({
required this.raw,
required this.command,
required this.tags,
this.user,
this.localEmotes,
this.message,
this.split,
this.action,
this.mention,
});
/// Returns a list of messages where the given CLEARCHAT message is applied.
static List<IRCMessage> clearChat({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
// If there is no message, it means that entire chat was cleared.
if (ircMessage.message == null) {
messages.clear();
messages.add(IRCMessage.createNotice(message: 'Chat was cleared by a moderator'));
return messages;
}
final bannedUser = ircMessage.message;
final banDuration = ircMessage.tags['ban-duration'];
// Search the messages for the banned/timed-out user.
messages.asMap().forEach((i, message) {
if (message.user == bannedUser) {
// Mark the message for removal.
messages[i].command = Command.clearChat;
// If timed-out, indicate the duration.
if (banDuration != null) {
messages[i].tags['ban-duration'] = banDuration;
}
}
});
return messages;
}
/// Returns a list of messages where the gievn CLEARMSG message is applied.
static List<IRCMessage> clearMessage({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
final targetId = ircMessage.tags['target-msg-id'];
// Search for the message associated with the ID and mark the message for deletion.
for (var i = 0; i < messages.length; i++) {
if (messages[i].tags['id'] == targetId) {
messages[i].command = Command.clearMessage;
break;
}
}
return messages;
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan({
required TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool isLightTheme,
required bool launchExternal,
bool showMessage = true,
bool useZeroWidth = false,
bool useReadableColors = false,
TimestampType timestamp = TimestampType.disabled,
}) {
final emoteToObject = assetsStore.emoteToObject;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
if (timestamp != TimestampType.disabled) {
final time = tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
if (timestamp == TimestampType.twentyFour) {
span.add(
TextSpan(
text: '${DateFormat.Hm().format(parsedTime)} ',
style: style?.copyWith(color: style.color?.withOpacity(0.5)),
),
);
}
if (timestamp == TimestampType.twelve) {
span.add(
TextSpan(
text: '${DateFormat('h:mm').format(parsedTime)} ',
style: style?.copyWith(color: style.color?.withOpacity(0.5)),
),
);
}
}
// Indicator to skip adding the bot badges later when adding the rest of FFZ badges.
var skipBot = false;
final ffzUserBadges = ffzUserToBadges[tags['user-id']];
final twitchBadges = tags['badges']?.split(',');
// Pasrse and add the Twitch badges to the span if they exist.
if (twitchBadges != null) {
for (final badge in twitchBadges) {
final badgeInfo = twitchBadgeToObject[badge];
if (badgeInfo != null) {
var badgeUrl = badgeInfo.url;
// Add custom FFZ mod badge if it exists.
if (badgeInfo.name == 'Moderator' && (ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
// Check if mod is bot.
final botBadge = ffzUserBadges?.firstWhereOrNull((element) => element.name == 'Bot');
// Check if user has bot badge or room has custom FFZ mod badges
if (botBadge != null) {
badgeUrl = botBadge.url;
skipBot = true;
} else if (ffzRoomInfo?.modUrls != null) {
badgeUrl = 'https:${ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x}';
}
final newBadge = Badge(
name: skipBot ? 'Moderator (Bot)' : 'Moderator',
url: badgeUrl,
type: BadgeType.twitch,
);
span.add(
_createBadgeSpan(
badge: newBadge,
size: badgeSize,
backgroundColor: const Color(0xFF00AD03),
),
);
span.add(const TextSpan(text: ' '));
continue;
}
// Add custom FFZ vip badge if it exists
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
badgeUrl = 'https:${ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x}';
}
final newBadge = Badge(
name: badgeInfo.name,
url: badgeUrl,
type: BadgeType.twitch,
);
span.add(
_createBadgeSpan(
badge: newBadge,
size: badgeSize,
),
);
span.add(const TextSpan(text: ' '));
}
}
}
// Add FFZ badges to span
if (ffzUserBadges != null) {
for (final badge in ffzUserBadges) {
final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF')));
if (badge.name == 'Bot') {
if (!skipBot) {
span.insert(
0,
_createBadgeSpan(
badge: badge,
size: badgeSize,
backgroundColor: color,
),
);
span.add(const TextSpan(text: ' '));
}
} else {
span.add(
_createBadgeSpan(
badge: badge,
size: badgeSize,
backgroundColor: color,
),
);
span.add(const TextSpan(text: ' '));
}
}
}
// Add BTTV badges to span
final userBTTVBadge = bttvUserToBadge[tags['user-id']];
if (userBTTVBadge != null) {
span.add(
_createBadgeSpan(
badge: userBTTVBadge,
size: badgeSize,
isSvg: true,
),
);
span.add(const TextSpan(text: ' '));
}
// Add 7TV badges to end of badges span
final user7TVBadges = sevenTVUserToBadges[tags['user-id']];
if (user7TVBadges != null) {
for (final badge in user7TVBadges) {
span.add(
_createBadgeSpan(
badge: badge,
size: badgeSize,
),
);
span.add(const TextSpan(text: ' '));
}
}
var color = Color(int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')));
if (useReadableColors) {
final hsl = HSLColor.fromColor(color);
if (isLightTheme == true) {
if (hsl.lightness >= 0.5) color = hsl.withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5)).toColor();
} else {
if (hsl.lightness <= 0.5) color = hsl.withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5)).toColor();
}
}
// Printing template for debugging purposes.
// debugPrint('OLD - NAME: ${tags['display-name']!}, HUE: ${hsl.hue}, SATURATION: ${hsl.saturation}, LIGHNTESS: ${hsl.lightness}');
// debugPrint('NEW - NAME: ${tags['display-name']!}, HUE: ${hsl.hue}, SATURATION: ${hsl.saturation}, LIGHNTESS: ${hsl.lightness}');
span.add(
TextSpan(
text: tags['display-name']!,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
),
);
// Add the colon separator between the username and their message to the span.
if (action == false) span.add(const TextSpan(text: ':'));
// Italicize the text it was called with an IRC Action i.e., "/me".
final textStyle = action == true ? const TextStyle(fontStyle: FontStyle.italic) : style;
if (!showMessage) {
span.add(const TextSpan(text: ' <message deleted>'));
} else {
// Add the message and any emotes to the span.
final words = split;
if (words != null) {
if (useZeroWidth) {
// Keep a local span which will be reversed and added to the final span.
final localSpan = <InlineSpan>[];
// Iterate through the words in reverse-order.
// Index starts at the last word in the message.
var index = words.length - 1;
// Terminate after the first word in the message reached.
while (index != -1) {
// Check if current word is a valid emote.
final word = words[index];
final emote = emoteToObject[word] ?? localEmotes?[word];
if (emote != null) {
// If the emote is zero-width and not the first word, process the stack.
if (emote.zeroWidth && index != 0) {
final emoteStack = <Emote>[];
// Handle stacking consecutive zero-width emotes.
Emote? nextEmote = emote;
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
emoteStack.add(nextEmote);
index--;
nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]];
}
// If there's one more emote thats NOT zero-width, add it to the base of the stack.
if (nextEmote != null) emoteStack.add(nextEmote);
// Check if the next word is an emoji to be added to the stack.
final nextWordIsEmoji = regexEmoji.hasMatch(words[index]);
// Create the stack of emotes with the base emoji if there is one.
// Will be used as the children of the Stack widget.
final children = [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
...emoteStack.reversed.map(
(emote) => CachedNetworkImage(
imageUrl: emote.url,
fadeInDuration: const Duration(),
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
),
)
];
// Create the message for the tooltip
final message = emoteStack.reversed.map((emote) => '${emote.name} - ${emoteType[emote.type.index]}');
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
richMessage: WidgetSpan(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
Stack(
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: 75),
),
...emoteStack.reversed.map(
(emote) => CachedNetworkImage(
imageUrl: emote.url,
fadeInDuration: const Duration(),
height: 80,
),
)
],
),
const SizedBox(height: 5.0),
Column(
children: [
Text(
nextWordIsEmoji ? '${words[index]} - Emoji' : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.black),
),
Text(
nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '),
style: const TextStyle(color: Colors.black),
),
],
),
],
),
),
),
preferBelow: false,
child: Stack(
alignment: AlignmentDirectional.center,
children: children,
),
),
),
);
// If the next word is neither an emote or emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' '));
localSpan.add(_createTextSpan(text: words[index], style: textStyle, launchExternal: launchExternal));
}
} else {
localSpan.add(
_createEmoteSpan(
emote: emote,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
),
);
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(_createEmojiSpan(emoji: word, style: textStyle?.copyWith(fontSize: emoteSize - 5)));
} else {
localSpan.add(_createTextSpan(text: word, style: textStyle, launchExternal: launchExternal));
}
}
localSpan.add(const TextSpan(text: ' '));
index--;
}
// Add the the local span reversed to the final span.
span.addAll(localSpan.reversed);
} else {
// Iterate through the words and add to the span based on word, emote, and emoji.
for (final word in words) {
span.add(const TextSpan(text: ' '));
final emote = emoteToObject[word] ?? localEmotes?[word];
if (emote != null) {
span.add(
_createEmoteSpan(
emote: emote,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
),
);
} else {
if (regexEmoji.hasMatch(word)) {
span.add(_createEmojiSpan(emoji: word, style: textStyle?.copyWith(fontSize: emoteSize - 5)));
} else {
span.add(_createTextSpan(text: word, style: textStyle, launchExternal: launchExternal));
}
}
}
}
}
}
return span;
}
static WidgetSpan _createEmojiSpan({
required String emoji,
TextStyle? style,
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(
emoji,
style: style,
),
);
}
static Widget _createBadgeWidget({
required Badge badge,
required double size,
Color? backgroundColor,
bool? isSvg,
}) {
if (backgroundColor != null) {
return ColoredBox(
color: backgroundColor,
child: CachedNetworkImage(
imageUrl: badge.url,
fadeInDuration: const Duration(),
height: size,
width: size,
),
);
} else if (isSvg == true) {
return SvgPicture.network(
badge.url,
height: size,
width: size,
);
} else {
return CachedNetworkImage(
imageUrl: badge.url,
fadeInDuration: const Duration(),
height: size,
width: size,
);
}
}
static WidgetSpan _createBadgeSpan({
required Badge badge,
required double size,
Color? backgroundColor,
bool? isSvg,
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
richMessage: WidgetSpan(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
_createBadgeWidget(
badge: badge,
size: 80,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
const SizedBox(height: 5.0),
Text(
badge.name,
style: const TextStyle(color: Colors.black),
),
],
),
),
),
preferBelow: false,
child: _createBadgeWidget(
badge: badge,
size: size,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
),
);
}
static WidgetSpan _createEmoteSpan({
required Emote emote,
required double height,
required double? width,
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
richMessage: WidgetSpan(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
CachedNetworkImage(
imageUrl: emote.url,
fadeInDuration: const Duration(),
height: 80,
),
const SizedBox(height: 5.0),
Text(
emote.name,
style: const TextStyle(color: Colors.black),
),
Text(
emoteType[emote.type.index],
style: const TextStyle(color: Colors.black),
),
],
),
),
),
preferBelow: false,
child: CachedNetworkImage(
imageUrl: emote.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(),
height: height,
width: width,
),
),
);
}
static TextSpan _createTextSpan({required String text, required bool launchExternal, TextStyle? style}) {
if (text.startsWith('@')) {
return TextSpan(text: text, style: style?.copyWith(fontWeight: FontWeight.bold));
} else if (RegExp(r'https?:\/\/').hasMatch(text)) {
return TextSpan(
text: text,
style: style?.copyWith(color: Colors.blue),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(text), mode: launchExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
);
} else {
return TextSpan(text: text, style: style);
}
}
/// Parses an IRC string and returns its corresponding [IRCMessage] object.
factory IRCMessage.fromString(String whole, {String? userLogin}) {
// We have three parts:
// 1. The tags of the IRC message.
// 2. The metadata (user, command, and channel).
// 3. The message itself.
// First, slice the message tags (1) and set aside the rest for later.
// Each part is separated by a space, so we'll start off by breaking off the tags and parsing them.
// Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' ');
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags = whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
// Next, parse and map the tags.
final mappedTags = <String, String>{};
// Loop through each tag and store their key value pairs into the map.
for (final tag in tags.split(';')) {
// Skip if the tag has no value.
if (tag.endsWith('=')) continue;
final tagSplit = tag.split('=');
mappedTags[tagSplit[0]] = tagSplit[1];
}
// Now we'll parse the message itself.
// Obtain the entire substring after the tags and excluding the first : (colon)
final ircMessage = whole.substring(tagAndIrcMessageDivider + 2);
// Split each section of the message (part 2 and its subparts and part 3).
// Index 0 contains the username of sender (user) or tmi.twitch.tv depending on the command
// Index 1 is command type
// Index 2 is #channel name that we are currently connected to
// Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command
final splitMessage = ircMessage.split(' ');
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
//
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
var message = splitMessage.length > 3 ? splitMessage.sublist(3).join(' ').substring(1) : null;
// Now process any Twitch emotes contained in the message tags.
// The map containing emotes from the user's tags to their URL.
// This may include sub emotes that they can access but other users cannot.
final localEmotes = <String, Emote>{};
final emoteTags = mappedTags['emotes'];
if (emoteTags != null) {
// Emotes and their indices are separated by '/' so split them there.
final emotes = emoteTags.split('/');
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId = emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
// Else, use the one provided indices.
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
} else {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
}
// Extract the word associated with this emoteId by using the provided indices.
final indexSplit = range.split('-');
final startIndex = int.parse(indexSplit[0]);
final endIndex = int.parse(indexSplit[1]);
final emoteWord = message!.substring(startIndex, endIndex + 1);
// Store the emote word and its associated URL for later use.
localEmotes[emoteWord] = Emote(
name: emoteWord,
zeroWidth: false,
url: 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0',
type: EmoteType.twitchSub,
);
}
}
var action = false;
var mention = false;
List<String>? split;
if (message != null) {
// Check if IRC actions like "/me" were called.
if (message.startsWith('\x01') && message.endsWith('\x01')) {
action = true;
message = message.substring(8, message.length - 1);
}
// Check if the message mentions the logged-in user
if (userLogin != null) mention = message.toLowerCase().contains(userLogin);
// Escape the message
message = message.split(' ').map((word) => word.replaceAll('\u{E0000}', '').trim()).where((element) => element != '').join(' ');
final emojiBuffer = StringBuffer();
final wordBuffer = StringBuffer();
split = <String>[];
for (final character in message.characters) {
if (regexEmoji.hasMatch(character)) {
if (wordBuffer.isNotEmpty) {
split.addAll(wordBuffer.toString().split(' '));
wordBuffer.clear();
}
emojiBuffer.write(character);
} else {
if (emojiBuffer.isNotEmpty) {
split.add(emojiBuffer.toString());
emojiBuffer.clear();
}
wordBuffer.write(character);
}
}
if (wordBuffer.isNotEmpty) split.addAll(wordBuffer.toString().split(' ').where((element) => element != ''));
if (emojiBuffer.isNotEmpty) split.add(emojiBuffer.toString());
}
// Check and parse the command.
// The majority of messages will be PRIVMSG, so check that first.
final Command messageCommand;
switch (splitMessage[1]) {
case 'PRIVMSG':
messageCommand = Command.privateMessage;
break;
case 'CLEARCHAT':
messageCommand = Command.clearChat;
break;
case 'CLEARMSG':
messageCommand = Command.clearMessage;
break;
case 'NOTICE':
messageCommand = Command.notice;
break;
case 'USERNOTICE':
messageCommand = Command.userNotice;
break;
case 'ROOMSTATE':
messageCommand = Command.roomState;
break;
case 'USERSTATE':
messageCommand = Command.userState;
break;
case 'GLOBALUSERSTATE':
messageCommand = Command.globalUserState;
break;
default:
debugPrint('Unknown command: $splitMessage[1]');
messageCommand = Command.none;
}
return IRCMessage(
raw: whole,
command: messageCommand,
tags: mappedTags,
user: user,
localEmotes: localEmotes,
message: message,
split: split,
action: action,
mention: mention,
);
}
factory IRCMessage.createNotice({required String message}) => IRCMessage(
raw: '',
tags: {},
command: Command.notice,
message: message,
);
}
/// The object representation of the IRC ROOMSTATE message.
class ROOMSTATE {
final String emoteOnly;
final String followersOnly;
final String r9k;
final String slowMode;
final String subMode;
const ROOMSTATE({
this.emoteOnly = '0',
this.followersOnly = '-1',
this.r9k = '0',
this.slowMode = '0',
this.subMode = '0',
});
/// Create a new copy with the parameters from the provided [IRCMessage]
ROOMSTATE fromIRCMessage(IRCMessage ircMessage) => ROOMSTATE(
emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly,
followersOnly: ircMessage.tags['followers-only'] ?? followersOnly,
r9k: ircMessage.tags['r9k'] ?? r9k,
slowMode: ircMessage.tags['slow'] ?? slowMode,
subMode: ircMessage.tags['subs-only'] ?? subMode,
);
}
class USERSTATE {
final String? raw;
final String color;
final String displayName;
final bool mod;
final bool subscriber;
const USERSTATE({
this.raw,
this.color = '',
this.displayName = '',
this.mod = false,
this.subscriber = false,
});
USERSTATE fromIRCMessage(IRCMessage ircMessage) => USERSTATE(
raw: ircMessage.raw,
color: ircMessage.tags['color'] ?? color,
displayName: ircMessage.tags['display-name'] ?? displayName,
mod: ircMessage.tags['mod'] == '0' ? false : true,
subscriber: ircMessage.tags['subscriber'] == '0' ? false : true,
);
}
/// The possible types of Twitch IRC commands.
enum Command {
privateMessage,
clearChat,
clearMessage,
notice,
userNotice,
roomState,
userState,
globalUserState,
none,
}
// Regex from dart_emoji package; used for emoji compatibility with zero-width
final regexEmoji = RegExp(
r'[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}'
r'\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}'
r'-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}'
r'\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}'
r'-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}'
r'\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}'
r'-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}'
r'\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}'
r'-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}\u{200d}]+',
unicode: true,
);