frosty/lib/models/irc.dart
Tommy Chow 8f4ebde155
v5 (#463)
* migrate to dio

* ignore folders

* simplify twitch api headers handling

* clean up main

* simplify logs

* optimize profile picture fetching
fixes refetches on tab switch

* update claude md

* refactor irc.dart

* update copilot instructions with essential commands and project structure details

* implement message sending state management and UI updates

* refactor notification handling and add copy-paste functionality in chat

* refactor AlertMessage to support vertical layout and optional padding

* refactor error messages for consistency and clarity across various components

* refactor chat layout to use draggable divider and improve chat width settings

* refactor Uptime widget to use SharedTimerService for synchronized updates

* refactor search results components to handle null future states gracefully

* refactor loading indicators to use skeleton loaders for improved user experience

* refactor UI components for improved layout and responsiveness, enhance skeleton loaders, and streamline padding adjustments

* refactor: replace manual blur effects with BlurredContainer for consistency and reusability

* refactor: enhance JavaScript execution and error handling in WebView components

* refactor: replace app bar with blurred container for improved UI consistency across screens

* refactor: streamline video chat layout and enhance PiP drag gesture functionality

* revert some app bar changes

* refactor: implement Picture-in-Picture drag functionality in VideoChat

* refactor: pass currentChannelId to IRCMessage and ChatMessage for improved context

* fix large thubmnail width

* chore: update Gradle and Kotlin plugin versions for improved compatibility

* changes from v4.10.0 branch

* feat: integrate AnimatedScrollBorder for improved UI responsiveness across various screens

* refactor: update SectionHeader styling and improve theme management

* feat: add pinned section to StreamsList and enhance skeleton loader padding

* feat: add repository guidelines for project structure, coding style, testing, and security

* feat: enhance StreamCard UI with BlurredContainer and adjust border width in theme

* feat: implement unfocusInput and safeRequestFocus methods to manage text field focus behavior

* feat: enhance Picture-in-Picture drag interactions with haptic feedback and smooth animations

* refactor: improve code formatting and readability across multiple files

* feat: implement picture-in-picture mode toggle and state management for video playback

* feat: reset PiP mode state when pausing video playback

* feat: add custom app bar with profile picture and stream info for video chat

* feat: convert multiple settings screens to use StatefulWidget and add animated scroll borders

* feat: update blur configuration for adaptive alpha based on theme

* fix: adjust default font size in SectionHeader widget

* feat: enhance PiP mode with drag overlay instruction and UI improvements

* fix: improve badge rendering with consistent margin and border for current channel

* refactor: replace BackdropFilter with BlurredContainer for improved clarity and maintainability

* fix: reduce background transparency in Uptime widget for better visibility

* feat: enhance photo view dialog with full resolution toggle and blurred background

* fix: reset chat delay to 0 when auto sync is enabled and update settings title for clarity

* fix: adjust border width in theme to improve consistency

* feat: add forceDarkMode option to BlurredContainer for improved theme adaptability

* feat: implement offline followed channels feature with UI integration and settings option

* fix: increase font size and weight for offline status text for better readability

* feat: remove offline pinned channels feature and update related UI components

* feat: optimize offline channels loading behavior based on expansion state

* fix: improve clarity and consistency in repository guidelines formatting

* feat: update full resolution URL handling and button visibility logic

* feat: replace app bar with blurred version in chat-only mode and add animated scroll border

* feat: refactor video chat layout for improved chat and video display in landscape mode

* feat: remove bottom bar visibility setting and adjust chat layout accordingly

* fix: simplify condition for displaying offline channels section in following tab

* feat: enhance reply thread display with indentation and reply icon

* feat: implement showModalBottomSheetWithProperFocus for improved focus handling

* feat: add gradient direction support to BlurredContainer for enhanced visual effects

* feat: enhance NavigationBar and Theme settings for improved visual consistency

* feat: add gradient direction support to BlurredContainer in VideoChat for enhanced visual effects

* chore: update .gitignore to include .cursor file for better environment management

* fix: update PiP drag gesture conditions to include video paused state for better user experience

* refactor: replace ScrollController with SettingsPageLayout in settings screens for improved layout management

* refactor: centralize orientation handling by introducing OrientationUtils for improved readability and maintainability

* refactor: replace OrientationUtils with ContextExtensions for streamlined context-based access to theme, layout, and API dependencies

* refactor: improve video chat UI by adjusting title layout and adding stream info tooltips for better user experience

* refactor: adjust titleSpacing in theme and navigation components for improved layout consistency

* refactor: update stream info timer handling and overlay management for improved UI responsiveness

* refactor: enhance stream info timer logic and overlay visibility management for chat-only mode

* refactor: enhance input decoration theme in FrostyThemes for improved visual consistency and responsiveness

* refactor: adjust inputDecorationTheme padding in FrostyThemes for improved visual consistency

* refactor: enhance video overlay management by including video preview overlay in hideElements for improved UI consistency

* refactor: improve chat bottom bar padding and video chat layout for enhanced UI consistency

* refactor: adjust chat padding to account for device bottom insets for improved layout consistency

* refactor: update text styles in FrostyThemes for improved visual consistency across UI components

* refactor: update iOS minimum deployment target to 13.0 for improved compatibility

* refactor: adjust chat bottom padding based on emote menu visibility for improved layout consistency

* refactor: adjust padding values in StreamsList for improved layout consistency

* refactor: replace Padding with AnimatedPadding in Chat for smoother layout transitions
refactor: replace Container with AnimatedContainer in BlurredContainer for smoother animations

* refactor: remove unnecessary radius property from ProfilePicture in OfflineChannelCard for cleaner code

* refactor: enhance chat bottom bar suffix icon to display message delay with tooltip for improved user experience

* refactor: update hint text and tooltips in chat bottom bar and search for improved clarity

* refactor: simplify notification display logic and enhance chat input behavior for improved user experience

* refactor: enhance layout consistency by adjusting spacing in various components across the channel and home screens

* refactor: introduce LiveIndicator widget and adjust spacing in channel and video components for improved layout consistency

* refactor: remove monospace font family from chat bottom bar for improved text rendering

* refactor: reduce notification animation duration and change dismiss direction for improved user experience

* refactor: replace VideoBar with StreamInfoBar in video components for improved layout and consistency

* refactor: adjust font sizes and spacing in StreamInfoBar for improved layout consistency

* refactor: standardize font sizes and update gradient stops in video components for improved visual consistency

* refactor: update tooltip padding and background color in theme for improved visual consistency

* refactor: enhance tooltip behavior and standardize font sizes in StreamInfoBar and related components for improved visual consistency

* refactor: add showThumbnail option to LargeStreamCardSkeletonLoader for improved layout flexibility

* refactor: add StreamInfoBarSkeletonLoader for improved layout consistency in stream components

* refactor: add shadow effects to icons and text in StreamInfoBar and VideoOverlay for improved visual depth

* refactor: update defaultOverlayOpacity to 0.0 for improved overlay behavior

* fix: correct stream info text color on overlay

* refactor: implement a new color picker dialog with random color generation for enhanced user experience

* refactor: adjust gradient colors and stops in VideoOverlay for improved visual consistency

* refactor: refine gradient colors and stops in VideoOverlay for enhanced visual clarity

* refactor: add isCompact parameter to StreamInfoBar for responsive text sizing

* fix: update tooltip background color to use surface color for better contrast

* feat: add shared chat mode support in chat store and update StreamInfoBar for visual indication

* refactor: disable debug print for IRC messages to reduce log clutter

* feat: implement chat color selection feature with user color management in chat settings

* refactor: replace color adjustment logic with new contrast-aware utility for chat names

* refactor: simplify chat details UI by replacing list tiles with action cards and removing unused clear dialog

* refactor: enhance chat details UI by improving time formatting and adding label styling to action cards

* refactor: update chat color terminology to 'Username color' for clarity in UI

* refactor: enhance chat details UI by introducing active chat modes display and improving layout structure

* refactor: improve chat details layout by replacing ListTile with Padding and adding a Divider for better visual separation

* refactor: remove chat notifications positioning setting and adjust notification alignment in video chat

* refactor: streamline StreamInfoBar component by consolidating offline handling and improving layout for better readability

* refactor: enhance StreamInfoBar by adding tooltip for streamer name and improving text overflow handling

* refactor: adjust tooltip display duration in StreamInfoBar and update theme configuration

* refactor: adjust margin and padding in StreamCard for improved layout consistency

* chore: update dependencies in pubspec.yaml and pubspec.lock for Firebase and other packages

* template

* chore: update iOS platform version to 15.0 and Firebase dependencies to 12.2.0

* refactor: simplify StreamInfoBar layout by reducing nested widgets and improving text color handling

* format

* feat: enhance Channel model and StreamInfoBar to support offline channel details

* feat: add offline indicator and improve StreamInfoBar layout for better offline channel display

* fix: adjust icon color opacity in StreamInfoBar for improved visibility

* feat: enhance dismissal animation in FrostyPhotoViewDialog to track swipe direction

* docs: update copilot instructions for clarity and additional error handling details

* refactor: optimize latency tooltip layout and improve offline indicator styling in VideoOverlay

* feat: implement shared chat asset fetching and participant management in ChatAssetsStore

* refactor: replace Observer with Builder for improved widget rebuilding in VideoOverlay

* refactor: update StreamInfoBar border styling and adjust layout in VideoOverlay for improved visual consistency

* feat: add showTextShadows property to StreamInfoBar for customizable text shadow display

* refactor: adjust layout in VideoOverlay by adding padding to improve spacing and visual consistency

* refactor: enhance VideoOverlay gradients and layout for improved visual hierarchy and user experience

* refactor: reduce vertical padding in VideoOverlay for improved layout consistency

* refactor: remove overlay opacity settings from VideoSettings and SettingsStore for simplification

* refactor: improve text overlay transition in VideoChat for better user experience

* Revert "refactor: improve text overlay transition in VideoChat for better user experience"

This reverts commit fd16ff5ec3.

* refactor: wrap videoOverlay in ColoredBox for improved layout handling

* refactor: optimize animation duration and transition curves in VideoChat for smoother interactions

* refactor: dispose searchScrollController in HomeStoreBase to prevent memory leaks

* refactor: simplify gradient definitions in VideoOverlay for improved readability and maintainability

* refactor: remove 'Offline' text overlay from VideoOverlay for cleaner UI

* refactor: add refresh functionality for assets in ChatDetails with loading indicators

* refactor: update visibility conditions for uptime, viewer count, and category display in StreamInfoBar

* refactor: replace AlertDialog with FrostyDialog for consistent dialog styling across the application

* refactor: enhance layout handling in FrostyPageView to prevent overflow during height transitions

* refactor: replace ColorPickerDialog with AccentColorPickerDialog for improved color selection experience

* refactor: replace theme and accent color settings with dedicated components for improved modularity and readability

* refactor: replace ElevatedButton with TextButton for consistent button styling across dialogs

* refactor: integrate MobX Observer for reactive state management in settings widgets

* refactor: update theme and dialog components for improved styling and consistency

* refactor: enhance dialog styling with rounded corners and border in theme configuration

* refactor: improve dialog messages for unauthorized access handling

* refactor: update bodyLarge text style in theme configuration to include font weight

* refactor: standardize Divider usage and adjust theme styles for improved consistency

* refactor: adjust Divider properties and theme styles for enhanced visual consistency

* chore: remove GEMINI.md file as it is no longer needed for project documentation

* refactor: enhance token validation and reconnect logic in AuthBase for improved error handling

* format

* refactor: integrate landscape cutout settings into VideoChat layout for improved responsiveness

* refactor: implement app lifecycle handling in VideoChat and enhance stream info update logic in VideoStore

* refactor: update landscape cutout conditionals in VideoChat layout for improved responsiveness

* update AGENTS.md

* add scrollbar to search

* fix scrolling exception

* update CLAUDE.md

* 5 auth reconnect attemps then log out

* increase reply max lines to 2

* simplify and improve reply formatting

* add prime icon

* better draggable divider

* support draggable divider in fullscreen overlay chat

* add text shadow in fullscreen chat overlay mode

* format

* add landscape checks

* video store optimization attempts
* Dispose WebViewController and remove JavaScript channels properly
  * Track and cleanup all MutationObservers to prevent accumulation
  * Throttle latency updates to once per 5 seconds (reduces CPU load)
  * Add timeout protection and null checks for robustness
  * Prevent duplicate event listeners on page reloads

* double tap divider to reset

* Revert "video store optimization attempts"

This reverts commit b1df7b0de4.

* optimize video store attempt 2

* Revert "double tap divider to reset"

This reverts commit aeb26a4335.

* optimize latency listener to open the menu and check every 20 secconds

* more optimizations

* hard refresh

* abbreviate latency

* fix lint

* improve hide default overlay

* fix chat no longer reconnecting with chat delay

* refine chat alerts

* fix historical message missing badges and emotes on initial connection

* parallelize

* revert

* add reconnect button inline action

* refactor: replace Scrollbar with FrostyScrollbar across chat and settings screens for consistent styling

* tweak chat details menu

* add scrollbar to release notes, renames for consistency

* fix duplicate message case

* fix chat delay alerts in auto sync mode

* add regex for numeric strings and enhance display name formatting logic

* exterminate borders

* refactor: simplify active chat modes display in chat details screen

* feat: enhance chat input button with delay notification and disable state

* fix: wrap chat in SafeArea to prevent UI overflow issues

* refactor: remove divider line from video chat and update draggable divider visibility logic

* persist overlay in pip mode

* fix: update chat delay handling and notifications in chat bottom bar

* fix: update chat overlay visibility logic to respect fullscreen settings

* tap to show deleted message

* fix: prevent accidental chat scrolling when accessing system UI in landscape mode

* show latency in portrait

* fix: enhance chat delay notifications with dynamic delay time

* docs: update CLAUDE.md for improved environment variable instructions and architecture overview

* fix: improve chat delay countdown management and message handling

* add preliminary release notes

* remove agents.md

* update lockfile

* add initial multi chat feature

* add option to persist multi chat tabs

* add remove confirmation dialog

* improve add chat design

* lazy-load tabs

* create global assets store to dedupe global asset fetching

* improve tab design/styles

* update notes

* use frosty dialog

* update claude md

* tweak

* add ability to reorder tabs

* resolve conflict

* merge cleanup

* format

* update release notes

* add chat delay sync completion tracking to prevent countdown re-appearance

* add tests

* fix

* update release notes

* bump
2026-01-01 22:28:17 -05:00

1237 lines
37 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,
) {
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,
);
} 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,
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,
) {
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,
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;
// 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,
);
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,
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: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
),
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,
);