frosty/lib/models/irc.dart
Tommy Chow 96bce87a0e add FrostyColors ThemeExtension, align hardcoded colors with M3 roles
Introduce FrostyColors ThemeExtension with 7 semantic color fields
(highlightedMessage, emoteOnly, followersOnly, slowMode, subOnly,
uniqueMode, overlayOnSurface) and brightness-aware light/dark variants.

Replace hardcoded colors across 15 files:
- Links use colorScheme.primary instead of Colors.blue
- Mentions use colorScheme.error instead of Colors.red
- First-time chatters share highlightedMessage (Twitch purple)
- Chat mode indicators use frostyColors fields
- Overlay text uses frostyColors.overlayOnSurface

Remove Provider<FrostyThemes> pattern from main.dart, channel.dart,
video_overlay.dart, and frosty_photo_view_dialog.dart.

Replace manual onSurface.withValues(alpha: 0.6) with M3's built-in
onSurfaceVariant for secondary text in section headers, chat modes,
alerts, loading indicators, and input hints. Fix double-muted
onSurfaceVariant.withValues(alpha: 0.6) in navigation icons and
notification dismiss button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:01:44 -08:00

1245 lines
38 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;
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) ??
Colors.grey.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,
);
}
factory IRCMessage.createNotice({
required String message,
VoidCallback? actionCallback,
String? actionLabel,
}) => IRCMessage(
raw: '',
tags: {},
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,
);