frosty/lib/screens/channel/chat/widgets/chat_message.dart
Tommy Chow 9e81b2d80a make auto-synced chat delay session-only and improve video store
- split chatDelay into persisted (manual) and session-only (synced) fields
  so auto-synced latency values don't carry over between channels
- make latency tracker singleton/idempotent to prevent overlapping JS pollers
- add isCycling guard to prevent re-entrant latency cycles
- deduplicate concurrent stream info API requests
- tie JS cleanup timer to video mode only (skip in chat-only)
- update UI to reflect effective delay instead of persisted value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:11:21 -08:00

570 lines
21 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
final ChatStore chatStore;
final bool isModal;
final bool showReplyHeader;
final bool isInReplyThread;
const ChatMessage({
super.key,
required this.ircMessage,
required this.chatStore,
this.isModal = false,
this.showReplyHeader = true,
this.isInReplyThread = false,
});
void onTapName(BuildContext context) {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
// Ignore if long-pressing own username.
if (ircMessage.user == null ||
ircMessage.user == chatStore.auth.user.details?.login) {
return;
}
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: ircMessage.user!,
userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!,
),
);
}
void onTapPingedUser(BuildContext context, {required String nickname}) {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
final twitchApi = context.read<TwitchApi>();
twitchApi.getUser(userLogin: nickname).then((user) {
if (context.mounted) {
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: user.login,
userId: user.id,
displayName: user.displayName,
),
);
}
});
}
Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
chatStore.updateNotification('Message copied');
}
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
HapticFeedback.lightImpact();
if (ircMessage.command != Command.privateMessage &&
ircMessage.command != Command.userState) {
copyMessage();
return;
}
showModalBottomSheetWithProperFocus(
context: context,
isScrollControlled: true,
builder: (context) => ListView(
shrinkWrap: true,
primary: false,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
style: defaultTextStyle,
),
),
),
const Divider(indent: 12, endIndent: 12),
ListTile(
onTap: () {
copyMessage();
Navigator.pop(context);
},
leading: const Icon(Icons.copy),
title: const Text('Copy message'),
),
ListTile(
onTap: () async {
await copyMessage();
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
final hasChatDelay =
chatStore.settings.showVideo && effectiveChatDelay > 0;
if (hasChatDelay) {
chatStore.updateNotification(
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
);
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus();
if (context.mounted) {
Navigator.pop(context);
}
},
leading: const Icon(Icons.content_paste),
title: const Text('Copy message and paste'),
),
ListTile(
onTap: () {
chatStore.replyingToMessage = ircMessage;
chatStore.safeRequestFocus();
Navigator.pop(context);
},
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final defaultTextStyle = DefaultTextStyle.of(context).style;
final messageHeaderIconSize =
defaultBadgeSize * chatStore.settings.badgeScale;
final messageHeaderTextColor = defaultTextStyle.color?.withValues(
alpha: 0.5,
);
const messageHeaderFontWeight = FontWeight.w600;
return Observer(
builder: (context) {
Color? highlightColor;
final Widget renderMessage;
switch (ircMessage.command) {
case Command.privateMessage:
case Command.userState:
final shouldHighlightFirstMessage =
chatStore.settings.highlightFirstTimeChatter &&
ircMessage.tags['first-msg'] == '1';
final shouldHighlightMessage =
chatStore.settings.showUserNotices &&
ircMessage.tags['msg-id'] == 'highlighted-message';
final messageSpan = Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
onTapName: () => onTapName(context),
onTapPingedUser: (nickname) =>
onTapPingedUser(context, nickname: nickname),
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
),
);
// Check if the message is replying to another message.
final replyUser = ircMessage.tags['reply-parent-display-name'];
final replyBody = ircMessage.tags['reply-parent-msg-body'];
Widget? messageHeaderIcon;
Widget? messageHeader;
if (replyUser != null && replyBody != null && showReplyHeader) {
messageHeaderIcon = Icon(
Icons.chat_rounded,
size: messageHeaderIconSize,
color: messageHeaderTextColor,
);
messageHeader = GestureDetector(
onTap: isModal
? null
: () => showModalBottomSheetWithProperFocus(
context: context,
builder: (context) {
return ReplyThread(
selectedMessage: ircMessage,
chatStore: chatStore,
);
},
),
child: Text.rich(
maxLines: 1,
overflow: TextOverflow.ellipsis,
TextSpan(
children: [
TextSpan(
text: replyUser,
style: TextStyle(
fontWeight: FontWeight.w600,
color: messageHeaderTextColor,
),
),
TextSpan(
text: ': $replyBody',
style: TextStyle(color: messageHeaderTextColor),
),
],
),
),
);
} else if (shouldHighlightFirstMessage) {
highlightColor = context.frostyColors.highlightedMessage;
messageHeaderIcon = Icon(
Icons.auto_awesome_rounded,
size: messageHeaderIconSize,
color: messageHeaderTextColor,
);
messageHeader = Text(
'First message',
style: TextStyle(
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
);
} else if (shouldHighlightMessage) {
highlightColor = context.frostyColors.highlightedMessage;
messageHeader = Text(
'Highlighted message',
style: TextStyle(
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
);
}
// If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) {
highlightColor = context.colorScheme.error;
}
if (messageHeader != null) {
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (messageHeaderIcon != null)
Row(
spacing: 4,
children: [
messageHeaderIcon,
Flexible(child: messageHeader),
],
)
else
messageHeader,
messageSpan,
],
);
} else {
renderMessage = messageSpan;
}
break;
case Command.clearChat:
case Command.clearMessage:
// Render timeouts and bans
final banDuration = ircMessage.tags['ban-duration'];
renderMessage = Opacity(
opacity: 0.5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (banDuration == null)
if (ircMessage.command == Command.clearMessage)
const Text(
'Message deleted',
style: TextStyle(fontWeight: messageHeaderFontWeight),
)
else
const Text(
'Permanently banned',
style: TextStyle(fontWeight: messageHeaderFontWeight),
)
else
Text(
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}',
style: const TextStyle(
fontWeight: messageHeaderFontWeight,
),
),
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
onTapName: () => onTapName(context),
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
showMessage:
chatStore.settings.showDeletedMessages ||
chatStore.revealedMessageIds.contains(
ircMessage.tags['id'],
),
onTapDeletedMessage: () {
if (ircMessage.tags['id'] != null) {
chatStore.revealMessage(ircMessage.tags['id']!);
}
},
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
),
),
],
),
);
break;
case Command.notice:
// Use tabular figures so countdown numbers don't shift horizontally
final noticeStyle = TextStyle(
color: messageHeaderTextColor,
fontFeatures: const [FontFeature.tabularFigures()],
);
if (ircMessage.actionCallback != null &&
ircMessage.actionLabel != null) {
renderMessage = Text.rich(
TextSpan(
children: [
TextSpan(
text: ircMessage.message ?? '',
style: noticeStyle,
),
TextSpan(text: ' '),
TextSpan(
text: ircMessage.actionLabel!,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = ircMessage.actionCallback,
),
],
),
);
} else {
renderMessage = Text.rich(
TextSpan(text: ircMessage.message),
style: noticeStyle,
);
}
break;
case Command.userNotice:
if (chatStore.settings.showUserNotices) {
highlightColor = context.frostyColors.highlightedMessage;
Widget? messageHeaderIcon;
Widget? messageHeader;
if (ircMessage.tags.containsKey('system-msg')) {
final messageId = ircMessage.tags['msg-id'];
final isGift = messageId?.contains('gift') == true;
final isSub = messageId?.contains('sub') == true;
final isRaid = messageId?.contains('raid') == true;
final isPrime =
ircMessage.tags['msg-param-sub-plan'] == 'Prime';
IconData? icon;
if (isGift) {
icon = Icons.card_giftcard_rounded;
} else if (isSub) {
icon = Icons.star_rounded;
} else if (isRaid) {
icon = Icons.people_rounded;
} else if (isPrime) {
icon = Icons.workspace_premium_rounded;
}
if (icon != null) {
messageHeaderIcon = Icon(
icon,
size: messageHeaderIconSize,
color: messageHeaderTextColor,
);
}
messageHeader = Text(
ircMessage.tags['system-msg']!,
style: TextStyle(
fontWeight: messageHeaderFontWeight,
color: messageHeaderTextColor,
),
);
} else if (ircMessage.tags['msg-id'] == 'announcement') {
messageHeaderIcon = Icon(
Icons.campaign_rounded,
size: messageHeaderIconSize,
);
messageHeader = const Text(
'Announcement',
style: TextStyle(fontWeight: messageHeaderFontWeight),
);
}
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (messageHeader != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (messageHeaderIcon != null) ...[messageHeaderIcon],
Expanded(child: messageHeader),
],
),
if (ircMessage.message != null) ...[
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
onTapName: () => onTapName(context),
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
),
),
],
],
);
} else {
renderMessage = const SizedBox();
}
break;
default:
renderMessage = const SizedBox();
}
// Check if this is a reply message in reply thread context for indentation
final isReplyInThread =
isInReplyThread &&
ircMessage.tags['reply-parent-display-name'] != null &&
ircMessage.tags['reply-parent-msg-body'] != null;
// Add reply icon for messages in reply thread
final messageWithIcon = isReplyInThread
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0, top: 2.0),
child: Icon(
Icons.subdirectory_arrow_right_rounded,
size: 16,
color: messageHeaderTextColor,
),
),
Expanded(child: renderMessage),
],
)
: renderMessage;
final paddedMessage = Padding(
padding: EdgeInsets.only(
top: chatStore.settings.messageSpacing / 2,
bottom: chatStore.settings.messageSpacing / 2,
left: isReplyInThread ? 16 : (highlightColor == null ? 12 : 0),
right: highlightColor == null ? 12 : 0,
),
child: messageWithIcon,
);
// Add a divider above the message if dividers are enabled.
final dividedMessage = chatStore.settings.showChatMessageDividers
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [paddedMessage, const Divider()],
)
: paddedMessage;
// Color the message if the color has been set.
final coloredMessage = highlightColor == null
? dividedMessage
: Container(
padding: const EdgeInsets.only(left: 8, right: 12),
decoration: BoxDecoration(
color: highlightColor.withValues(alpha: 0.1),
border: Border(
left: BorderSide(color: highlightColor, width: 4),
),
),
child: dividedMessage,
);
final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
}
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: coloredMessage,
);
return finalMessage;
},
);
}
}