Merge only already-activated tabs; lazy tabs join when tapped. Dedup messages by IRC id for Twitch shared chat overlap. Preserve draft and reply state when switching tabs in merged mode. Merge button appears only when 2+ tabs are loaded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1247 lines
38 KiB
Dart
1247 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: {
|
|
'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,
|
|
);
|