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 tags; final String? user; final Map? localEmotes; String? message; List? 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, }); /// 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 messages, required List 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 messages, required List 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 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 span, double badgeSize, Map? 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 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 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 span, Map 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: ' ', 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 = []; // 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 localSpan, List words, int startIndex, Emote startEmote, Map emoteToObject, double emoteSize, double emoteScale, TextStyle? textStyle, bool launchExternal, { Color? linkColor, }) { final emoteStack = []; 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 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? 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 = []; // 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, }) { 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(' '); // 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 = {}; 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 ` `. 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 = {}; 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? 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 = []; 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, // Missing tags must be false — the old `!= '0'` default treated absence as mod. mod: ircMessage.tags['mod'] == '1', subscriber: ircMessage.tags['subscriber'] == '1', ); } /// 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, );