Implements VOD (past broadcast) browsing and chat replay that reuses the existing live chat rendering pipeline in read-only mode. Models & API: - video.dart: Helix video model with duration parsing (3h26m15s → Duration) - gql_chat_comment.dart: GQL VOD chat comment models (manual parsing) - IRCMessage.fromGqlComment(): adapter mapping GQL comments → IRC format so the existing chat rendering pipeline (generateSpan, badges, emotes) works unchanged for VOD chat - TwitchApi.getVideos(): Helix endpoint for user VOD list with pagination - TwitchGqlApi.getVideoComments(): GQL persisted query using mobile client ID (bypasses Client-Integrity) for VOD chat comments Architecture: - ChatRenderContext interface: minimal abstraction over ChatStore so ChatMessage widget works with both live ChatStore and VodChatStore - ChatStore now implements ChatRenderContext (one-line change) - ChatMessage accepts ChatRenderContext instead of ChatStore, with type guards for live-only actions (reply, paste, name tap navigation) VOD stores: - VodChatStore: MobX store implementing ChatRenderContext with identical message batching (200ms timer, 5000 limit, 100 render limit, 20% trim), scroll/autoscroll, playback position tracking, seek detection (delta < -5 or > 10 triggers clear + re-fetch), comment buffer with pre-fetching when < 50 comments remain - VodListStore: standard paginated list store for VOD browsing VOD UI: - VodPlayer: matches live channel layout exactly (no AppBar, SafeArea, 16:9 WebView + expanded chat) with JS position polling every 1s + seeked event listener via VodPosition JS channel - VodList: paginated VOD list with scroll-based loading - VodCard: thumbnail card with duration overlay and relative dates - Entry point: "Past broadcasts" in chat details drawer Tests (21 new): - video_test.dart: VideoTwitch/VideosTwitch parsing, duration parsing - gql_chat_comment_test.dart: comment parsing, response error handling, IRCMessage.fromGqlComment adapter (tags, localEmotes, deleted user) - twitch_gql_api_test.dart: getVideoComments mock tests - twitch_api_test.dart: getVideos mock tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1300 lines
40 KiB
Dart
1300 lines
40 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/services.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:frosty/cache_manager.dart';
|
|
import 'package:frosty/constants.dart';
|
|
import 'package:frosty/models/badges.dart';
|
|
import 'package:frosty/models/emotes.dart';
|
|
import 'package:frosty/models/gql_chat_comment.dart';
|
|
import 'package:frosty/models/user.dart';
|
|
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|
import 'package:frosty/utils.dart' as utils;
|
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
|
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
// Constants for IRC message rendering
|
|
const String _ircActionPrefix = '\x01';
|
|
const String _ircActionSuffix = '\x01';
|
|
const int _ircActionPrefixLength = 8;
|
|
const String _invalidUnicodeChar = '\u{E0000}';
|
|
const String _twitchEmoteBaseUrl = 'https://static-cdn.jtvnw.net/emoticons/v2';
|
|
const String _twitchIrcServer = 'tmi.twitch.tv';
|
|
const String _emoteModeSuffix = '/default/dark/3.0';
|
|
const double _emojiSizeOffset = 5.0;
|
|
|
|
/// 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;
|
|
final VoidCallback? actionCallback;
|
|
final String? actionLabel;
|
|
|
|
IRCMessage({
|
|
required this.raw,
|
|
required this.command,
|
|
required this.tags,
|
|
this.user,
|
|
this.localEmotes,
|
|
this.message,
|
|
this.split,
|
|
this.action,
|
|
this.mention,
|
|
this.actionCallback,
|
|
this.actionLabel,
|
|
});
|
|
|
|
/// Applies the given CLEARCHAT message to the provided messages and buffer.
|
|
static void clearChat({
|
|
required List<IRCMessage> messages,
|
|
required List<IRCMessage> bufferedMessages,
|
|
required IRCMessage ircMessage,
|
|
}) {
|
|
// If there is no message, it means that entire chat was cleared.
|
|
if (ircMessage.message == null) {
|
|
messages.clear();
|
|
bufferedMessages.clear();
|
|
messages.add(
|
|
IRCMessage.createNotice(message: 'Chat was cleared by a moderator'),
|
|
);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Search the buffered messages for the banned/timed-out user.
|
|
bufferedMessages.asMap().forEach((i, bufferedMessage) {
|
|
if (bufferedMessage.user == bannedUser) {
|
|
// Mark the message for removal.
|
|
bufferedMessages[i].command = Command.clearChat;
|
|
|
|
// If timed-out, indicate the duration.
|
|
if (banDuration != null) {
|
|
bufferedMessages[i].tags['ban-duration'] = banDuration;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Applies the given CLEARMSG message to the provided messages and buffer.
|
|
static void clearMessage({
|
|
required List<IRCMessage> messages,
|
|
required List<IRCMessage> bufferedMessages,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Search for the buffered message associated with the ID and mark the message for deletion.
|
|
for (var i = 0; i < bufferedMessages.length; i++) {
|
|
if (bufferedMessages[i].tags['id'] == targetId) {
|
|
bufferedMessages[i].command = Command.clearMessage;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adds timestamp to the span if enabled
|
|
void _addTimestamp(
|
|
List<InlineSpan> span,
|
|
TextStyle? style,
|
|
TimestampType timestamp,
|
|
) {
|
|
if (timestamp == TimestampType.disabled) return;
|
|
|
|
final time =
|
|
tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
|
|
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
|
|
|
|
final timeText = timestamp == TimestampType.twentyFour
|
|
? '${DateFormat.Hm().format(parsedTime)} '
|
|
: '${DateFormat('h:mm').format(parsedTime)} ';
|
|
|
|
span.add(
|
|
TextSpan(
|
|
text: timeText,
|
|
style: style?.copyWith(
|
|
color: style.color?.withValues(alpha: 0.5),
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Adds historical and channel source badges to the span
|
|
void _addHistoricalAndChannelBadges(
|
|
BuildContext context,
|
|
List<InlineSpan> span,
|
|
double badgeSize,
|
|
Map<String, UserTwitch>? channelIdToUserTwitch,
|
|
String? currentChannelId,
|
|
) {
|
|
final isHistorical = tags['historical'] == '1';
|
|
if (isHistorical) {
|
|
span.add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: Tooltip(
|
|
message: 'Historical message',
|
|
preferBelow: false,
|
|
triggerMode: TooltipTriggerMode.tap,
|
|
child: Icon(
|
|
Icons.history_rounded,
|
|
size: badgeSize,
|
|
color:
|
|
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
|
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
}
|
|
|
|
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
|
|
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
|
|
if (sourceChannelUser != null) {
|
|
final isCurrentChannel = sourceChannelId == currentChannelId;
|
|
|
|
span.add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: Tooltip(
|
|
triggerMode: TooltipTriggerMode.tap,
|
|
preferBelow: false,
|
|
message: isCurrentChannel
|
|
? 'Sent from ${sourceChannelUser.displayName} (current channel)'
|
|
: 'Sent from ${sourceChannelUser.displayName}',
|
|
child: CachedNetworkImage(
|
|
cacheManager: CustomCacheManager.instance,
|
|
imageUrl: sourceChannelUser.profileImageUrl,
|
|
imageBuilder: (context, imageProvider) => Container(
|
|
width: badgeSize,
|
|
height: badgeSize,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: isCurrentChannel
|
|
? Border.all(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
width: 1.5,
|
|
)
|
|
: null,
|
|
),
|
|
child: Container(
|
|
margin: isCurrentChannel ? const EdgeInsets.all(1.5) : null,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
image: DecorationImage(
|
|
image: imageProvider,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
placeholder: (context, url) => Container(
|
|
width: badgeSize,
|
|
height: badgeSize,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: isCurrentChannel
|
|
? Border.all(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
width: 1.5,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
}
|
|
}
|
|
|
|
/// Adds all user badges (Twitch, FFZ, BTTV, 7TV) to the span
|
|
bool _addUserBadges(
|
|
BuildContext context,
|
|
List<InlineSpan> span,
|
|
ChatAssetsStore assetsStore,
|
|
double badgeSize,
|
|
bool launchExternal,
|
|
bool isHistorical,
|
|
) {
|
|
var skipBot = false;
|
|
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
|
|
final ffzUserToBadges = assetsStore.userToFFZBadges;
|
|
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
|
|
final bttvUserToBadge = assetsStore.userToBTTVBadges;
|
|
final ffzRoomInfo = assetsStore.ffzRoomInfo;
|
|
|
|
final ffzUserBadges = ffzUserToBadges[tags['user-id']];
|
|
final twitchBadges = tags['badges']?.split(',');
|
|
|
|
// Add Twitch badges
|
|
if (twitchBadges != null) {
|
|
for (final badge in twitchBadges) {
|
|
final badgeInfo = twitchBadgeToObject[badge];
|
|
if (badgeInfo != null) {
|
|
var badgeUrl = badgeInfo.url;
|
|
|
|
// Handle custom FFZ mod badge
|
|
if (badgeInfo.name == 'Moderator' &&
|
|
(ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
|
|
final botBadge = ffzUserBadges?.firstWhereOrNull(
|
|
(element) => element.name == 'Bot',
|
|
);
|
|
|
|
if (botBadge != null) {
|
|
badgeUrl = botBadge.url;
|
|
skipBot = true;
|
|
} else if (ffzRoomInfo?.modUrls != null) {
|
|
badgeUrl =
|
|
ffzRoomInfo!.modUrls?.url4x ??
|
|
ffzRoomInfo.modUrls?.url2x ??
|
|
ffzRoomInfo.modUrls!.url1x;
|
|
}
|
|
|
|
final newBadge = ChatBadge(
|
|
name: skipBot ? 'Moderator (Bot)' : 'Moderator',
|
|
url: badgeUrl,
|
|
type: BadgeType.twitch,
|
|
);
|
|
|
|
span.add(
|
|
_createBadgeSpan(
|
|
context,
|
|
badge: newBadge,
|
|
size: badgeSize,
|
|
backgroundColor: const Color(0xFF00AD03),
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
continue;
|
|
}
|
|
|
|
// Handle custom FFZ VIP badge
|
|
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
|
|
badgeUrl =
|
|
ffzRoomInfo!.vipBadge?.url4x ??
|
|
ffzRoomInfo.vipBadge?.url2x ??
|
|
ffzRoomInfo.vipBadge!.url1x;
|
|
}
|
|
|
|
final newBadge = ChatBadge(
|
|
name: badgeInfo.name,
|
|
url: badgeUrl,
|
|
type: BadgeType.twitch,
|
|
);
|
|
|
|
span.add(
|
|
_createBadgeSpan(
|
|
context,
|
|
badge: newBadge,
|
|
size: badgeSize,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add FFZ badges
|
|
if (ffzUserBadges != null) {
|
|
for (final badge in ffzUserBadges) {
|
|
final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF')));
|
|
|
|
if (badge.name == 'Bot') {
|
|
if (!skipBot) {
|
|
final indexToInsert = isHistorical ? 2 : 0;
|
|
span.insert(
|
|
indexToInsert,
|
|
_createBadgeSpan(
|
|
context,
|
|
badge: badge,
|
|
size: badgeSize,
|
|
backgroundColor: color,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
span.insert(indexToInsert + 1, const TextSpan(text: ' '));
|
|
}
|
|
} else {
|
|
span.add(
|
|
_createBadgeSpan(
|
|
context,
|
|
badge: badge,
|
|
size: badgeSize,
|
|
backgroundColor: color,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add BTTV badges
|
|
final userBTTVBadge = bttvUserToBadge[tags['user-id']];
|
|
if (userBTTVBadge != null) {
|
|
span.add(
|
|
_createBadgeSpan(
|
|
context,
|
|
badge: userBTTVBadge,
|
|
size: badgeSize,
|
|
isSvg: true,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
}
|
|
|
|
// Add 7TV badges
|
|
final user7TVBadges = sevenTVUserToBadges[tags['user-id']];
|
|
if (user7TVBadges != null) {
|
|
for (final badge in user7TVBadges) {
|
|
span.add(
|
|
_createBadgeSpan(
|
|
context,
|
|
badge: badge,
|
|
size: badgeSize,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
span.add(const TextSpan(text: ' '));
|
|
}
|
|
}
|
|
|
|
return skipBot;
|
|
}
|
|
|
|
/// Adds the username to the span with proper color and styling
|
|
void _addUsername(
|
|
List<InlineSpan> span,
|
|
BuildContext context,
|
|
void Function()? onTapName,
|
|
) {
|
|
var color = Color(
|
|
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
|
|
);
|
|
|
|
// Adjust color for theme contrast using the common utility
|
|
color = utils.adjustChatNameColor(context, color);
|
|
|
|
final displayName = tags['display-name']!;
|
|
span.add(
|
|
TextSpan(
|
|
text: user != null
|
|
? utils.getReadableName(displayName, user!)
|
|
: displayName,
|
|
style: TextStyle(color: color, fontWeight: FontWeight.bold),
|
|
recognizer: TapGestureRecognizer()..onTap = onTapName,
|
|
),
|
|
);
|
|
|
|
// Add the colon separator between the username and their message
|
|
if (action == false) span.add(const TextSpan(text: ':'));
|
|
}
|
|
|
|
/// Adds message content with emotes, emojis and text processing
|
|
void _addMessageContent(
|
|
BuildContext context,
|
|
List<InlineSpan> span,
|
|
Map<String, Emote> emoteToObject,
|
|
double emoteSize,
|
|
double emoteScale,
|
|
bool showMessage,
|
|
bool launchExternal,
|
|
TextStyle? textStyle,
|
|
void Function(String)? onTapPingedUser,
|
|
void Function()? onTapDeletedMessage, {
|
|
Color? linkColor,
|
|
}) {
|
|
if (!showMessage) {
|
|
span.add(
|
|
TextSpan(
|
|
text: ' <message deleted>',
|
|
style: onTapDeletedMessage != null
|
|
? textStyle?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.w500,
|
|
)
|
|
: null,
|
|
recognizer: onTapDeletedMessage != null
|
|
? (TapGestureRecognizer()..onTap = onTapDeletedMessage)
|
|
: null,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Check if the message is a reply. If it is, remove the reply username from the message.
|
|
final words = tags.containsKey('reply-parent-display-name')
|
|
? split?.sublist(1)
|
|
: split;
|
|
|
|
if (words != null) {
|
|
// Keep a local span which will be reversed and added to the final span.
|
|
final localSpan = <InlineSpan>[];
|
|
|
|
// Iterate through the words in reverse-order.
|
|
var index = words.length - 1;
|
|
|
|
// Terminate after the first word in the message reached.
|
|
while (index != -1) {
|
|
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) {
|
|
index = _processZeroWidthEmoteStack(
|
|
context,
|
|
localSpan,
|
|
words,
|
|
index,
|
|
emote,
|
|
emoteToObject,
|
|
emoteSize,
|
|
emoteScale,
|
|
textStyle,
|
|
launchExternal,
|
|
linkColor: linkColor,
|
|
);
|
|
} else {
|
|
localSpan.add(
|
|
_createEmoteSpan(
|
|
context,
|
|
emote: emote,
|
|
height: emote.height != null
|
|
? emote.height! * emoteScale
|
|
: emoteSize,
|
|
width: emote.width != null ? emote.width! * emoteScale : null,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
localSpan.add(const TextSpan(text: ' '));
|
|
index--;
|
|
}
|
|
} else {
|
|
if (regexEmoji.hasMatch(word)) {
|
|
localSpan.add(
|
|
_createEmojiSpan(
|
|
emoji: word,
|
|
style: textStyle?.copyWith(
|
|
fontSize: emoteSize - _emojiSizeOffset,
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
localSpan.add(
|
|
_createTextSpan(
|
|
text: word,
|
|
style: textStyle,
|
|
linkColor: linkColor,
|
|
launchExternal: launchExternal,
|
|
onTapPingedUser: onTapPingedUser,
|
|
),
|
|
);
|
|
}
|
|
localSpan.add(const TextSpan(text: ' '));
|
|
index--;
|
|
}
|
|
}
|
|
|
|
// Add the local span reversed to the final span.
|
|
span.addAll(localSpan.reversed);
|
|
}
|
|
}
|
|
|
|
/// Handles zero-width emote stacking logic and returns updated index
|
|
int _processZeroWidthEmoteStack(
|
|
BuildContext context,
|
|
List<InlineSpan> localSpan,
|
|
List<String> words,
|
|
int startIndex,
|
|
Emote startEmote,
|
|
Map<String, Emote> emoteToObject,
|
|
double emoteSize,
|
|
double emoteScale,
|
|
TextStyle? textStyle,
|
|
bool launchExternal, {
|
|
Color? linkColor,
|
|
}) {
|
|
final emoteStack = <Emote>[];
|
|
var index = startIndex;
|
|
|
|
// Handle stacking consecutive zero-width emotes.
|
|
Emote? nextEmote = startEmote;
|
|
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
|
|
emoteStack.add(nextEmote);
|
|
index--;
|
|
nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]];
|
|
}
|
|
|
|
// If there's one more emote that's 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.
|
|
final children = [
|
|
if (nextWordIsEmoji)
|
|
Text(
|
|
words[index],
|
|
style: textStyle?.copyWith(fontSize: emoteSize - _emojiSizeOffset),
|
|
),
|
|
...emoteStack.reversed.map(
|
|
(emote) => FrostyCachedNetworkImage(
|
|
imageUrl: emote.url,
|
|
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
|
|
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
|
|
useFade: false,
|
|
),
|
|
),
|
|
];
|
|
|
|
// Create the message for the tooltip
|
|
final message = emoteStack.reversed.map((emote) => emote.name);
|
|
final emoji = words[index];
|
|
|
|
localSpan.add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: InkWell(
|
|
onTap: () => _showAssetDetailsBottomSheet(
|
|
context,
|
|
leading: Stack(
|
|
alignment: AlignmentDirectional.center,
|
|
children: [
|
|
if (nextWordIsEmoji)
|
|
Text(emoji, style: textStyle?.copyWith(fontSize: 40)),
|
|
...emoteStack.reversed.map(
|
|
(emote) =>
|
|
FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
|
|
),
|
|
],
|
|
),
|
|
url: emoteStack.last.url,
|
|
title: nextWordIsEmoji
|
|
? emoji
|
|
: '${emoteStack.last.name} (${emoteStack.last.type})',
|
|
subtitle: Text(
|
|
'with ${nextWordIsEmoji ? message.join(' + ') : message.skip(1).join(' + ')}',
|
|
),
|
|
launchExternal: launchExternal,
|
|
),
|
|
child: Stack(
|
|
alignment: AlignmentDirectional.center,
|
|
children: children,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// If the next word is neither an emote nor emoji, add it as a text span.
|
|
if (nextEmote == null && !nextWordIsEmoji) {
|
|
localSpan.add(const TextSpan(text: ' '));
|
|
localSpan.add(
|
|
_createTextSpan(
|
|
text: words[index],
|
|
style: textStyle,
|
|
linkColor: linkColor,
|
|
launchExternal: launchExternal,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Add space after the zero-width emote stack (like all other elements)
|
|
localSpan.add(const TextSpan(text: ' '));
|
|
|
|
return index - 1;
|
|
}
|
|
|
|
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
|
|
List<InlineSpan> generateSpan(
|
|
BuildContext context, {
|
|
TextStyle? style,
|
|
required ChatAssetsStore assetsStore,
|
|
required double badgeScale,
|
|
required double emoteScale,
|
|
required bool launchExternal,
|
|
void Function()? onTapName,
|
|
void Function(String)? onTapPingedUser,
|
|
void Function()? onTapDeletedMessage,
|
|
bool showMessage = true,
|
|
Map<String, UserTwitch>? channelIdToUserTwitch,
|
|
TimestampType timestamp = TimestampType.disabled,
|
|
String? currentChannelId,
|
|
}) {
|
|
final emoteToObject = assetsStore.emoteToObject;
|
|
final badgeSize = defaultBadgeSize * badgeScale;
|
|
final emoteSize = defaultEmoteSize * emoteScale;
|
|
final linkColor = Theme.of(context).colorScheme.primary;
|
|
|
|
// The span list that will be used to render the chat message
|
|
final span = <InlineSpan>[];
|
|
|
|
_addTimestamp(span, style, timestamp);
|
|
_addHistoricalAndChannelBadges(
|
|
context,
|
|
span,
|
|
badgeSize,
|
|
channelIdToUserTwitch,
|
|
currentChannelId,
|
|
);
|
|
|
|
final isHistorical = tags['historical'] == '1';
|
|
_addUserBadges(
|
|
context,
|
|
span,
|
|
assetsStore,
|
|
badgeSize,
|
|
launchExternal,
|
|
isHistorical,
|
|
);
|
|
|
|
_addUsername(span, context, onTapName);
|
|
|
|
// Italicize the text if it was called with an IRC Action (e.g., "/me").
|
|
final textStyle = action == true
|
|
? const TextStyle(fontStyle: FontStyle.italic)
|
|
: style;
|
|
|
|
_addMessageContent(
|
|
context,
|
|
span,
|
|
emoteToObject,
|
|
emoteSize,
|
|
emoteScale,
|
|
showMessage,
|
|
launchExternal,
|
|
textStyle,
|
|
onTapPingedUser,
|
|
onTapDeletedMessage,
|
|
linkColor: linkColor,
|
|
);
|
|
|
|
return span;
|
|
}
|
|
|
|
static WidgetSpan _createEmojiSpan({
|
|
required String emoji,
|
|
TextStyle? style,
|
|
}) {
|
|
return WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: Text(emoji, style: style),
|
|
);
|
|
}
|
|
|
|
static Widget _createBadgeWidget({
|
|
required ChatBadge badge,
|
|
double? size,
|
|
Color? backgroundColor,
|
|
bool? isSvg,
|
|
}) {
|
|
if (backgroundColor != null) {
|
|
return ColoredBox(
|
|
color: backgroundColor,
|
|
child: FrostyCachedNetworkImage(
|
|
imageUrl: badge.url,
|
|
height: size,
|
|
width: size,
|
|
useFade: false,
|
|
),
|
|
);
|
|
} else if (isSvg == true) {
|
|
return SvgPicture.network(badge.url, height: size, width: size);
|
|
} else {
|
|
return FrostyCachedNetworkImage(
|
|
imageUrl: badge.url,
|
|
height: size,
|
|
width: size,
|
|
useFade: false,
|
|
);
|
|
}
|
|
}
|
|
|
|
static WidgetSpan _createBadgeSpan(
|
|
BuildContext context, {
|
|
required ChatBadge badge,
|
|
required double size,
|
|
required bool launchExternal,
|
|
Color? backgroundColor,
|
|
bool? isSvg,
|
|
}) {
|
|
return WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: InkWell(
|
|
onTap: () => _showAssetDetailsBottomSheet(
|
|
context,
|
|
leading: _createBadgeWidget(
|
|
badge: badge,
|
|
backgroundColor: backgroundColor,
|
|
isSvg: isSvg,
|
|
size: 56,
|
|
),
|
|
url: badge.url,
|
|
title: badge.name,
|
|
subtitle: Text(badge.type.toString()),
|
|
launchExternal: launchExternal,
|
|
showCopyName: false,
|
|
),
|
|
child: _createBadgeWidget(
|
|
badge: badge,
|
|
size: size,
|
|
backgroundColor: backgroundColor,
|
|
isSvg: isSvg,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static WidgetSpan _createEmoteSpan(
|
|
BuildContext context, {
|
|
required Emote emote,
|
|
required double height,
|
|
required double? width,
|
|
required bool launchExternal,
|
|
}) {
|
|
return WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: InkWell(
|
|
onTap: () => showEmoteDetailsBottomSheet(
|
|
context,
|
|
emote: emote,
|
|
launchExternal: launchExternal,
|
|
),
|
|
child: FrostyCachedNetworkImage(
|
|
imageUrl: emote.url,
|
|
height: height,
|
|
width: width,
|
|
useFade: false,
|
|
placeholder: (context, url) => const SizedBox(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static TextSpan _createTextSpan({
|
|
required String text,
|
|
required bool launchExternal,
|
|
TextStyle? style,
|
|
Color? linkColor,
|
|
Function(String)? onTapPingedUser,
|
|
}) {
|
|
if (text.startsWith('@')) {
|
|
return TextSpan(
|
|
text: text,
|
|
style: style?.copyWith(fontWeight: FontWeight.bold),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () {
|
|
if (onTapPingedUser != null) {
|
|
onTapPingedUser(text.substring(1).split(',')[0]);
|
|
}
|
|
},
|
|
);
|
|
} else if (regexLink.hasMatch(text)) {
|
|
return TextSpan(
|
|
text: text,
|
|
style: style?.copyWith(
|
|
color: linkColor,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: linkColor,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => launchUrl(
|
|
Uri.parse(text),
|
|
mode: launchExternal
|
|
? LaunchMode.externalApplication
|
|
: LaunchMode.inAppBrowserView,
|
|
),
|
|
);
|
|
} else {
|
|
return TextSpan(text: text, style: style);
|
|
}
|
|
}
|
|
|
|
static void showEmoteDetailsBottomSheet(
|
|
BuildContext context, {
|
|
required Emote emote,
|
|
required bool launchExternal,
|
|
}) {
|
|
_showAssetDetailsBottomSheet(
|
|
context,
|
|
leading: FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
|
|
url: emote.url,
|
|
title: emote.realName != null
|
|
? '${emote.name} (${emote.realName})'
|
|
: emote.name,
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(emote.type.toString()),
|
|
if (emote.ownerDisplayName != null && emote.ownerUsername != null)
|
|
Text(
|
|
'by ${utils.getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
|
|
),
|
|
],
|
|
),
|
|
launchExternal: launchExternal,
|
|
);
|
|
}
|
|
|
|
static void _showAssetDetailsBottomSheet(
|
|
BuildContext context, {
|
|
required Widget leading,
|
|
required String url,
|
|
required String title,
|
|
required Widget subtitle,
|
|
required bool launchExternal,
|
|
bool showCopyName = true,
|
|
}) {
|
|
showModalBottomSheetWithProperFocus(
|
|
context: context,
|
|
builder: (context) => ListView(
|
|
shrinkWrap: true,
|
|
primary: false,
|
|
children: [
|
|
ListTile(
|
|
leading: InkWell(
|
|
onTap: () => showDialog(
|
|
context: context,
|
|
builder: (context) => FrostyPhotoViewDialog(imageUrl: url),
|
|
),
|
|
child: leading,
|
|
),
|
|
title: Text(
|
|
title,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
subtitle: subtitle,
|
|
),
|
|
const Divider(indent: 16, endIndent: 16),
|
|
if (showCopyName)
|
|
ListTile(
|
|
leading: const Icon(Icons.copy_rounded),
|
|
title: const Text('Copy name'),
|
|
onTap: () {
|
|
Clipboard.setData(ClipboardData(text: title.split(' (')[0]));
|
|
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.copy_rounded),
|
|
title: const Text('Copy image URL'),
|
|
onTap: () {
|
|
Clipboard.setData(ClipboardData(text: url));
|
|
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.launch_rounded),
|
|
title: const Text('Open in browser'),
|
|
onTap: () {
|
|
launchUrl(
|
|
Uri.parse(url),
|
|
mode: launchExternal
|
|
? LaunchMode.externalApplication
|
|
: LaunchMode.inAppBrowserView,
|
|
);
|
|
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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] == _twitchIrcServer
|
|
? mappedTags['login']
|
|
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
|
|
|
|
// If there is an associated message, set it.
|
|
String? message;
|
|
if (splitMessage.length > 3) {
|
|
final combinedMessage = splitMessage.sublist(3).join(' ');
|
|
|
|
if (combinedMessage.startsWith(':')) {
|
|
message = combinedMessage.substring(1);
|
|
} else {
|
|
message = combinedMessage;
|
|
}
|
|
}
|
|
|
|
// 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 && message != 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: '$_twitchEmoteBaseUrl/$emoteId$_emoteModeSuffix',
|
|
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(_ircActionPrefix) &&
|
|
message.endsWith(_ircActionSuffix)) {
|
|
action = true;
|
|
message = message.substring(_ircActionPrefixLength, 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(' ')
|
|
// 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.
|
|
.map((word) => word.replaceAll(_invalidUnicodeChar, '').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,
|
|
);
|
|
}
|
|
|
|
/// Creates an [IRCMessage] from a GQL VOD chat comment.
|
|
///
|
|
/// Maps GQL fields to IRC tags so [generateSpan] works identically
|
|
/// for both live and VOD chat messages.
|
|
factory IRCMessage.fromGqlComment(GqlChatComment comment) {
|
|
final tags = <String, String>{
|
|
'display-name': comment.commenterDisplayName ?? 'Deleted User',
|
|
'user-id': comment.commenterId ?? '',
|
|
'id': comment.id,
|
|
'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}',
|
|
if (comment.userColor != null) 'color': comment.userColor!,
|
|
if (comment.userBadges.isNotEmpty)
|
|
'badges': comment.userBadges
|
|
.map((b) => '${b.setId}/${b.version}')
|
|
.join(','),
|
|
};
|
|
|
|
// Reconstruct message text from fragments.
|
|
final messageText = comment.messageFragments
|
|
.map((f) => f.text ?? '')
|
|
.join();
|
|
|
|
// Build local emotes map from fragments that have Twitch emotes.
|
|
// generateSpan() will match these by name in the split words.
|
|
final localEmotes = <String, Emote>{};
|
|
for (final fragment in comment.messageFragments) {
|
|
if (fragment.emote != null && fragment.text != null) {
|
|
localEmotes[fragment.text!] = Emote(
|
|
name: fragment.text!,
|
|
zeroWidth: false,
|
|
url:
|
|
'$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix',
|
|
type: EmoteType.twitchSub,
|
|
);
|
|
}
|
|
}
|
|
|
|
return IRCMessage(
|
|
raw: '',
|
|
command: Command.privateMessage,
|
|
tags: tags,
|
|
user: comment.commenterLogin,
|
|
localEmotes: localEmotes.isNotEmpty ? localEmotes : null,
|
|
message: messageText,
|
|
split: messageText.split(' ').where((w) => w.isNotEmpty).toList(),
|
|
action: false,
|
|
mention: false,
|
|
);
|
|
}
|
|
|
|
factory IRCMessage.createNotice({
|
|
required String message,
|
|
VoidCallback? actionCallback,
|
|
String? actionLabel,
|
|
}) => IRCMessage(
|
|
raw: '',
|
|
tags: {
|
|
'tmi-sent-ts': '${DateTime.now().millisecondsSinceEpoch}',
|
|
},
|
|
command: Command.notice,
|
|
message: message,
|
|
actionCallback: actionCallback,
|
|
actionLabel: actionLabel,
|
|
);
|
|
}
|
|
|
|
/// 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,
|
|
);
|