add message acknowledgment handling and update UI feedback for sending state

This commit is contained in:
Tommy Chow 2026-01-06 16:59:16 -05:00
parent a43e453e7e
commit 7a3f78bc7a
3 changed files with 153 additions and 21 deletions

View file

@ -34,6 +34,35 @@ abstract class ChatStoreBase with Store {
'USERSTATE',
'GLOBALUSERSTATE',
'ROOMSTATE',
'NOTICE', // Rejection notices need immediate feedback
};
/// NOTICE msg-id values indicating message was rejected (from Twitch IRC docs).
/// https://dev.twitch.tv/docs/irc/msg-id/
static const _rejectionMsgIds = {
// Rate limiting
'msg_slowmode',
'msg_ratelimit',
'msg_duplicate',
// User restrictions
'msg_banned',
'msg_timedout',
'msg_channel_blocked',
'msg_suspended',
// Room mode restrictions
'msg_emoteonly',
'msg_subsonly',
'msg_followersonly',
'msg_followersonly_followed',
'msg_followersonly_zero',
'msg_r9k',
// Verification requirements
'msg_verified_email',
'msg_requires_verified_phone_number',
// Moderation
'msg_rejected',
'msg_rejected_mandatory',
'msg_bad_characters',
};
/// Base height of the bottom bar (input field area).
@ -139,6 +168,9 @@ abstract class ChatStoreBase with Store {
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
/// Timer used for resetting the sending state if no acknowledgment is received.
Timer? _sendingTimeoutTimer;
/// Timer used for updating the chat delay countdown message.
Timer? _chatDelayCountdownTimer;
@ -199,6 +231,10 @@ abstract class ChatStoreBase with Store {
@readonly
var _isInSharedChatMode = false;
/// Whether we're waiting for server acknowledgment of a sent message.
@readonly
var _isWaitingForAck = false;
/// The logged-in user's appearance in chat.
@readonly
var _userState = const USERSTATE();
@ -212,6 +248,9 @@ abstract class ChatStoreBase with Store {
/// Public getter for whether the chat is in shared chat mode.
bool get isInSharedChatMode => _isInSharedChatMode;
/// Public getter for whether we're waiting for message acknowledgment.
bool get isWaitingForAck => _isWaitingForAck;
/// Emotes matching the current autocomplete search term.
@computed
List<Emote> get matchingEmotes {
@ -430,6 +469,23 @@ abstract class ChatStoreBase with Store {
case Command.privateMessage:
case Command.notice:
case Command.userNotice:
// Check if this is a rejection notice for a pending message
if (parsedIRCMessage.command == Command.notice &&
_isWaitingForAck) {
final msgId = parsedIRCMessage.tags['msg-id'];
if (msgId != null && _rejectionMsgIds.contains(msgId)) {
// Message was rejected - clear pending but preserve text field for retry
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
toSend = null;
// Show notification with Twitch's rejection message
if (parsedIRCMessage.message != null) {
updateNotification(parsedIRCMessage.message!);
}
}
}
// Update shared chat mode based on source-room-id tag presence
final wasShared = _isInSharedChatMode;
_isInSharedChatMode = parsedIRCMessage.tags.containsKey(
@ -479,10 +535,23 @@ abstract class ChatStoreBase with Store {
case Command.userState:
_userState = _userState.fromIRCMessage(parsedIRCMessage);
if (toSend != null && parsedIRCMessage.tags['id'] != null) {
toSend!.tags['id'] = parsedIRCMessage.tags['id']!;
// USERSTATE arrival confirms message was sent, even without id tag
if (toSend != null) {
final messageId = parsedIRCMessage.tags['id'];
if (messageId != null) {
toSend!.tags['id'] = messageId;
} else {
// Fallback ID for edge cases where Twitch doesn't provide one
toSend!.tags['id'] =
'local-${DateTime.now().millisecondsSinceEpoch}';
}
messageBuffer.add(toSend!);
toSend = null;
// Reset sending state and clear text/reply after successful confirmation
_sendingTimeoutTimer?.cancel();
_isWaitingForAck = false;
textController.clear();
replyingToMessage = null;
}
break;
case Command.globalUserState:
@ -937,6 +1006,12 @@ abstract class ChatStoreBase with Store {
// Do not send if the message is blank/empty.
if (message.isEmpty) return;
// Prevent double-sending while waiting for server acknowledgment
if (_isWaitingForAck) {
updateNotification('Please wait, sending previous message...');
return;
}
if (_channel == null || _channel?.closeCode != null) {
messageBuffer.add(
IRCMessage.createNotice(
@ -947,13 +1022,32 @@ abstract class ChatStoreBase with Store {
return;
}
// Set waiting state for UI feedback
_isWaitingForAck = true;
// Send the message to the IRC chat room.
_channel?.sink.add(
'${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message',
);
// Clear text field immediately (optimistic UI)
textController.clear();
// Text field and reply state cleared on USERSTATE confirmation (not optimistically)
// to preserve message text if Twitch rejects it (e.g., slow mode)
// Start timeout timer in case server doesn't respond (network failure)
// Must be outside userStateString conditional to always have a safety net
_sendingTimeoutTimer?.cancel();
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
// Guard against execution after disposal
if (_shouldDisconnect) return;
if (_isWaitingForAck) {
_isWaitingForAck = false;
toSend = null;
updateNotification(
'Message may not have been sent. Please try again.',
);
}
});
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
var userStateString = _userState.raw;
@ -977,12 +1071,11 @@ abstract class ChatStoreBase with Store {
userChatMessage.tags['reply-parent-msg-id'] =
replyingToMessage!.tags['id']!;
userChatMessage.tags['reply-parent-display-name'] =
replyingToMessage!.tags['display-name']!;
replyingToMessage!.tags['display-name'] ?? '';
userChatMessage.tags['reply-parent-msg-body'] =
replyingToMessage!.message!;
replyingToMessage!.message ?? '';
}
replyingToMessage = null;
toSend = userChatMessage;
}
}
@ -1092,6 +1185,7 @@ abstract class ChatStoreBase with Store {
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
_sendingTimeoutTimer?.cancel();
_cancelChatDelayCountdown();
sleepTimer?.cancel();

View file

@ -227,6 +227,26 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_isWaitingForAckAtom = Atom(
name: 'ChatStoreBase._isWaitingForAck',
context: context,
);
bool get isWaitingForAck {
_$_isWaitingForAckAtom.reportRead();
return super._isWaitingForAck;
}
@override
bool get _isWaitingForAck => isWaitingForAck;
@override
set _isWaitingForAck(bool value) {
_$_isWaitingForAckAtom.reportWrite(value, super._isWaitingForAck, () {
super._isWaitingForAck = value;
});
}
late final _$_userStateAtom = Atom(
name: 'ChatStoreBase._userState',
context: context,

View file

@ -244,6 +244,8 @@ class ChatBottomBar extends StatelessWidget {
child: Observer(
builder: (context) {
final isLoggedIn = chatStore.auth.isLoggedIn;
final isWaitingForAck = chatStore.isWaitingForAck;
final isEnabled = isLoggedIn && !isWaitingForAck;
return GestureDetector(
onTap: !isLoggedIn
@ -258,7 +260,7 @@ class ChatBottomBar extends StatelessWidget {
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 3,
enabled: isLoggedIn,
enabled: isEnabled,
specialTextSpanBuilder: EmoteTextSpanBuilder(
emoteToObject: chatStore.assetsStore.emoteToObject,
userEmoteToObject:
@ -284,11 +286,13 @@ class ChatBottomBar extends StatelessWidget {
),
hintMaxLines: 1,
hintText: isLoggedIn
? chatStore.replyingToMessage != null
? 'Reply'
: hasChatDelay
? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
: 'Chat'
? isWaitingForAck
? 'Sending...'
: chatStore.replyingToMessage != null
? 'Reply'
: hasChatDelay
? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
: 'Chat'
: loginTooltipMessage,
),
controller: chatStore.textController,
@ -306,14 +310,28 @@ class ChatBottomBar extends StatelessWidget {
(chatStore.settings.chatWidth >= 0.3 ||
chatStore.expandChat ||
context.isPortrait)
? IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send_rounded),
onPressed: chatStore.auth.isLoggedIn
? () => chatStore.sendMessage(
chatStore.textController.text,
)
: null,
? Observer(
builder: (context) => IconButton(
tooltip: chatStore.isWaitingForAck
? 'Sending...'
: 'Send',
icon: chatStore.isWaitingForAck
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.send_rounded),
onPressed:
chatStore.auth.isLoggedIn &&
!chatStore.isWaitingForAck
? () => chatStore.sendMessage(
chatStore.textController.text,
)
: null,
),
)
: IconButton(
icon: Icon(Icons.adaptive.more_rounded),