every 200ms flush shifted all sliver indices (reverse list), and the per-row ObjectKeys without findChildIndexCallback made the framework deactivate and re-inflate every built row instead of moving it. add the callback so elements move, and cache ChatMessage widget instances per message (Expando) so unchanged rows skip build() entirely via the identical-widget short-circuit. IRCMessage.renderRevision invalidates rows the CLEARCHAT/CLEARMSG handlers mutate in place. also decode emotes/badges at display size (memCacheHeight = logical size x devicePixelRatio) instead of the CDN's 3x-4x intrinsic size: several-fold less image memory and raster cost, animation preserved (ResizeImage applies the target size to the whole codec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1363 lines
42 KiB
Dart
1363 lines
42 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/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;
|
|
|
|
/// Bumped whenever the message is mutated in place after creation (the
|
|
/// moderation marking in [clearChat]/[clearMessage]), so render output
|
|
/// cached against this message's identity knows to invalidate. Purely a
|
|
/// render-cache signal — not part of the IRC model.
|
|
int renderRevision = 0;
|
|
|
|
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,
|
|
});
|
|
|
|
/// The channel this message actually originated from. In Twitch shared
|
|
/// chat, `source-room-id` names the participant channel a message was
|
|
/// sent in; otherwise it's just this connection's own room.
|
|
String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id'];
|
|
|
|
/// 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;
|
|
messages[i].renderRevision++;
|
|
|
|
// 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;
|
|
bufferedMessages[i].renderRevision++;
|
|
|
|
// 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;
|
|
messages[i].renderRevision++;
|
|
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;
|
|
bufferedMessages[i].renderRevision++;
|
|
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 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,
|
|
memCacheHeight: _memCacheDimension(
|
|
context,
|
|
emote.height != null ? emote.height! * 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,
|
|
bool forceTimestamp = false,
|
|
bool showHistoricalTimestamps = false,
|
|
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>[];
|
|
|
|
// When the user has timestamps disabled globally, fall back to the device's
|
|
// locale preference for historical messages (opt-in via setting) and for
|
|
// forced contexts like previews.
|
|
final isHistorical = tags['historical'] == '1';
|
|
final effectiveTimestamp =
|
|
(timestamp == TimestampType.disabled &&
|
|
((isHistorical && showHistoricalTimestamps) || forceTimestamp))
|
|
? (MediaQuery.alwaysUse24HourFormatOf(context)
|
|
? TimestampType.twentyFour
|
|
: TimestampType.twelve)
|
|
: timestamp;
|
|
|
|
_addTimestamp(span, style, effectiveTimestamp);
|
|
_addHistoricalAndChannelBadges(
|
|
context,
|
|
span,
|
|
badgeSize,
|
|
channelIdToUserTwitch,
|
|
currentChannelId,
|
|
);
|
|
|
|
_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,
|
|
int? memCacheHeight,
|
|
}) {
|
|
if (backgroundColor != null) {
|
|
return ColoredBox(
|
|
color: backgroundColor,
|
|
child: FrostyCachedNetworkImage(
|
|
imageUrl: badge.url,
|
|
height: size,
|
|
width: size,
|
|
memCacheHeight: memCacheHeight,
|
|
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,
|
|
memCacheHeight: memCacheHeight,
|
|
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,
|
|
memCacheHeight: _memCacheDimension(context, size),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
// Decode at the rendered size instead of the CDN's 3x-4x intrinsic
|
|
// size. Height-only preserves the aspect ratio.
|
|
memCacheHeight: _memCacheDimension(context, height),
|
|
useFade: false,
|
|
placeholder: (context, url) => const SizedBox(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Converts a layout dimension to a physical-pixel decode cap for the
|
|
/// in-memory image cache.
|
|
static int _memCacheDimension(BuildContext context, double logicalSize) =>
|
|
(logicalSize * MediaQuery.devicePixelRatioOf(context)).round();
|
|
|
|
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(' ');
|
|
|
|
// Without a separating space there are no tags to slice and no message body
|
|
// to parse — bail out rather than throw on the substring/index calls below.
|
|
if (tagAndIrcMessageDivider == -1) {
|
|
return IRCMessage(raw: whole, command: Command.none, tags: const {});
|
|
}
|
|
|
|
// Parse the IRCv3 tags into a map. The tags section is everything between
|
|
// the leading '@' and the first space. We split on the *raw* string: a
|
|
// literal ';' is always a separator because escaped semicolons inside a
|
|
// value are encoded as `\:` (unescaped per-value below).
|
|
final mappedTags = <String, String>{};
|
|
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
|
|
|
|
for (final tag in rawTags.split(';')) {
|
|
// Split on the first '=' only — IRCv3 tag values may contain '='.
|
|
final equalsIndex = tag.indexOf('=');
|
|
|
|
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
|
|
// and an empty value (`key=`) are equivalent — live Twitch sends the
|
|
// latter, the recent-messages API the former.
|
|
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
|
|
|
|
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
|
|
tag.substring(equalsIndex + 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(' ');
|
|
|
|
// A well-formed message is at least `<prefix> <COMMAND>`. A malformed or
|
|
// truncated line (e.g. from the recent-messages history API) can split into
|
|
// a single token, which would otherwise throw a RangeError on the
|
|
// splitMessage[0]/[1] accesses below.
|
|
if (splitMessage.length < 2) {
|
|
return IRCMessage(raw: whole, command: Command.none, tags: mappedTags);
|
|
}
|
|
|
|
// 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 prefix = splitMessage[0];
|
|
final String? user;
|
|
if (prefix == _twitchIrcServer) {
|
|
user = mappedTags['login'];
|
|
} else {
|
|
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
|
|
// null) if the expected `!` is absent so an unexpected prefix shape can
|
|
// never throw a RangeError and take down the whole message.
|
|
final bangIndex = prefix.indexOf('!');
|
|
user = bangIndex == -1
|
|
? mappedTags['login']
|
|
: prefix.substring(0, bangIndex);
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
|
|
/// Unescapes an IRCv3 message-tag value.
|
|
///
|
|
/// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`,
|
|
/// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing
|
|
/// backslash is dropped. Done in a single pass so sequences can't be
|
|
/// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space).
|
|
static String _unescapeTagValue(String value) {
|
|
// Fast path: the vast majority of tag values contain no escape sequences.
|
|
if (!value.contains('\\')) return value;
|
|
|
|
final buffer = StringBuffer();
|
|
for (var i = 0; i < value.length; i++) {
|
|
if (value[i] != '\\') {
|
|
buffer.write(value[i]);
|
|
continue;
|
|
}
|
|
|
|
// A trailing backslash with nothing after it is dropped.
|
|
if (i + 1 == value.length) break;
|
|
|
|
switch (value[i + 1]) {
|
|
case ':':
|
|
buffer.write(';');
|
|
break;
|
|
case 's':
|
|
buffer.write(' ');
|
|
break;
|
|
case '\\':
|
|
buffer.write('\\');
|
|
break;
|
|
case 'r':
|
|
buffer.write('\r');
|
|
break;
|
|
case 'n':
|
|
buffer.write('\n');
|
|
break;
|
|
default:
|
|
buffer.write(value[i + 1]);
|
|
}
|
|
i++; // Skip the escaped character we just consumed.
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
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,
|
|
);
|