fix chat delay message sending ux
refactor chat store to bypass delay for specific IRC commands and remove sending state management
This commit is contained in:
parent
fc56c3347a
commit
bd194e91c2
3 changed files with 83 additions and 128 deletions
|
|
@ -28,6 +28,28 @@ abstract class ChatStoreBase with Store {
|
|||
/// The maximum ammount of messages to render when autoscroll is enabled.
|
||||
static const _renderMessageLimit = 100;
|
||||
|
||||
/// IRC commands that should bypass chat delay.
|
||||
/// These are confirmations/state updates rather than chat messages to sync.
|
||||
static const _delayBypassCommands = {
|
||||
'USERSTATE',
|
||||
'GLOBALUSERSTATE',
|
||||
'ROOMSTATE',
|
||||
};
|
||||
|
||||
/// Checks if IRC data contains a command that should bypass the chat delay.
|
||||
/// Returns true if any message in the data contains a bypass command.
|
||||
bool _shouldBypassDelay(String data) {
|
||||
for (final message in data.trimRight().split('\r\n')) {
|
||||
if (message.startsWith('@')) {
|
||||
final parts = message.split(' ');
|
||||
if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final TwitchApi twitchApi;
|
||||
|
||||
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
|
||||
|
|
@ -108,9 +130,6 @@ abstract class ChatStoreBase with Store {
|
|||
/// Timer used for dismissing the notification.
|
||||
Timer? _notificationTimer;
|
||||
|
||||
/// Timer used for resetting the sending state if no USERSTATE is received.
|
||||
Timer? _sendingTimeoutTimer;
|
||||
|
||||
/// Timer used for updating the chat delay countdown message.
|
||||
Timer? _chatDelayCountdownTimer;
|
||||
|
||||
|
|
@ -167,10 +186,6 @@ abstract class ChatStoreBase with Store {
|
|||
@readonly
|
||||
var _showMentionAutocomplete = false;
|
||||
|
||||
/// Whether a message is currently being sent and waiting for server confirmation.
|
||||
@readonly
|
||||
var _isSendingMessage = false;
|
||||
|
||||
/// Whether the chat is currently in shared chat mode (based on source-room-id tag presence).
|
||||
@readonly
|
||||
var _isInSharedChatMode = false;
|
||||
|
|
@ -185,9 +200,6 @@ abstract class ChatStoreBase with Store {
|
|||
@observable
|
||||
IRCMessage? replyingToMessage;
|
||||
|
||||
/// Public getter for whether a message is currently being sent.
|
||||
bool get isSendingMessage => _isSendingMessage;
|
||||
|
||||
/// Public getter for whether the chat is in shared chat mode.
|
||||
bool get isInSharedChatMode => _isInSharedChatMode;
|
||||
|
||||
|
|
@ -413,12 +425,8 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
if (toSend != null && parsedIRCMessage.tags['id'] != null) {
|
||||
toSend!.tags['id'] = parsedIRCMessage.tags['id']!;
|
||||
textController.clear();
|
||||
messageBuffer.add(toSend!);
|
||||
toSend = null;
|
||||
// Reset sending state since message was successfully sent
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_isSendingMessage = false;
|
||||
}
|
||||
break;
|
||||
case Command.globalUserState:
|
||||
|
|
@ -662,14 +670,19 @@ abstract class ChatStoreBase with Store {
|
|||
// Listen for new messages and forward them to the handler.
|
||||
_channelListener = _channel?.stream.listen(
|
||||
(data) {
|
||||
if (!settings.showVideo || settings.chatDelay == 0) {
|
||||
_handleIRCData(data.toString());
|
||||
final dataStr = data.toString();
|
||||
|
||||
// Process immediately if delay is disabled or command should bypass delay
|
||||
if (!settings.showVideo ||
|
||||
settings.chatDelay == 0 ||
|
||||
_shouldBypassDelay(dataStr)) {
|
||||
_handleIRCData(dataStr);
|
||||
} else {
|
||||
final capturedConnectionId = currentConnectionId;
|
||||
Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () {
|
||||
// Only process if this is still the active connection
|
||||
if (capturedConnectionId == currentConnectionId) {
|
||||
_handleIRCData(data.toString());
|
||||
_handleIRCData(dataStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -868,12 +881,6 @@ abstract class ChatStoreBase with Store {
|
|||
// Do not send if the message is blank/empty.
|
||||
if (message.isEmpty) return;
|
||||
|
||||
// Prevent sending multiple messages simultaneously
|
||||
if (_isSendingMessage) {
|
||||
updateNotification('Please wait, sending previous message...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_channel == null || _channel?.closeCode != null) {
|
||||
messageBuffer.add(
|
||||
IRCMessage.createNotice(
|
||||
|
|
@ -881,56 +888,46 @@ abstract class ChatStoreBase with Store {
|
|||
'Cannot send message - chat is disconnected. Reconnecting...',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Set sending state to true
|
||||
_isSendingMessage = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a timeout timer to reset sending state if no USERSTATE is received
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
||||
if (_isSendingMessage) {
|
||||
_isSendingMessage = false;
|
||||
updateNotification(
|
||||
'Message failed to send. Try again or check connection.',
|
||||
);
|
||||
}
|
||||
});
|
||||
// Send the message to the IRC chat room.
|
||||
_channel?.sink.add(
|
||||
'${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message',
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
|
||||
var userStateString = _userState.raw;
|
||||
if (userStateString != null) {
|
||||
if (message.length > 3 && message.substring(0, 3) == '/me') {
|
||||
userStateString +=
|
||||
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
||||
} else {
|
||||
userStateString +=
|
||||
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
||||
}
|
||||
|
||||
final userChatMessage = IRCMessage.fromString(userStateString);
|
||||
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
||||
if (auth.isLoggedIn && auth.user.details != null) {
|
||||
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
||||
}
|
||||
|
||||
if (replyingToMessage != null &&
|
||||
replyingToMessage!.tags['id'] != null) {
|
||||
userChatMessage.tags['reply-parent-msg-id'] =
|
||||
replyingToMessage!.tags['id']!;
|
||||
userChatMessage.tags['reply-parent-display-name'] =
|
||||
replyingToMessage!.tags['display-name']!;
|
||||
userChatMessage.tags['reply-parent-msg-body'] =
|
||||
replyingToMessage!.message!;
|
||||
}
|
||||
|
||||
replyingToMessage = null;
|
||||
toSend = userChatMessage;
|
||||
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
|
||||
var userStateString = _userState.raw;
|
||||
if (userStateString != null) {
|
||||
if (message.length > 3 && message.substring(0, 3) == '/me') {
|
||||
userStateString +=
|
||||
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
|
||||
} else {
|
||||
userStateString +=
|
||||
' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}';
|
||||
}
|
||||
|
||||
final userChatMessage = IRCMessage.fromString(userStateString);
|
||||
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
|
||||
if (auth.isLoggedIn && auth.user.details != null) {
|
||||
userChatMessage.tags['user-id'] = auth.user.details!.id;
|
||||
}
|
||||
|
||||
if (replyingToMessage != null &&
|
||||
replyingToMessage!.tags['id'] != null) {
|
||||
userChatMessage.tags['reply-parent-msg-id'] =
|
||||
replyingToMessage!.tags['id']!;
|
||||
userChatMessage.tags['reply-parent-display-name'] =
|
||||
replyingToMessage!.tags['display-name']!;
|
||||
userChatMessage.tags['reply-parent-msg-body'] =
|
||||
replyingToMessage!.message!;
|
||||
}
|
||||
|
||||
replyingToMessage = null;
|
||||
toSend = userChatMessage;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1039,7 +1036,6 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
_messageBufferTimer?.cancel();
|
||||
_notificationTimer?.cancel();
|
||||
_sendingTimeoutTimer?.cancel();
|
||||
_cancelChatDelayCountdown();
|
||||
sleepTimer?.cancel();
|
||||
|
||||
|
|
|
|||
|
|
@ -184,26 +184,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
);
|
||||
}
|
||||
|
||||
late final _$_isSendingMessageAtom = Atom(
|
||||
name: 'ChatStoreBase._isSendingMessage',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isSendingMessage {
|
||||
_$_isSendingMessageAtom.reportRead();
|
||||
return super._isSendingMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isSendingMessage => isSendingMessage;
|
||||
|
||||
@override
|
||||
set _isSendingMessage(bool value) {
|
||||
_$_isSendingMessageAtom.reportWrite(value, super._isSendingMessage, () {
|
||||
super._isSendingMessage = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_isInSharedChatModeAtom = Atom(
|
||||
name: 'ChatStoreBase._isInSharedChatMode',
|
||||
context: context,
|
||||
|
|
|
|||
|
|
@ -246,12 +246,10 @@ class ChatBottomBar extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
final isDisabled =
|
||||
!chatStore.auth.isLoggedIn ||
|
||||
chatStore.isSendingMessage;
|
||||
final isLoggedIn = chatStore.auth.isLoggedIn;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isDisabled && !chatStore.isSendingMessage
|
||||
onTap: !isLoggedIn
|
||||
? () {
|
||||
chatStore.updateNotification(
|
||||
loginTooltipMessage,
|
||||
|
|
@ -263,8 +261,7 @@ class ChatBottomBar extends StatelessWidget {
|
|||
focusNode: chatStore.textFieldFocusNode,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
// Disable text field when sending message or when not logged in
|
||||
enabled: !isDisabled,
|
||||
enabled: isLoggedIn,
|
||||
specialTextSpanBuilder: EmoteTextSpanBuilder(
|
||||
emoteToObject: chatStore.assetsStore.emoteToObject,
|
||||
userEmoteToObject:
|
||||
|
|
@ -289,14 +286,12 @@ class ChatBottomBar extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
hintMaxLines: 1,
|
||||
hintText: chatStore.auth.isLoggedIn
|
||||
? chatStore.isSendingMessage
|
||||
? 'Sending...'
|
||||
: chatStore.replyingToMessage != null
|
||||
? 'Reply'
|
||||
: hasChatDelay
|
||||
? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
|
||||
: 'Chat'
|
||||
hintText: isLoggedIn
|
||||
? chatStore.replyingToMessage != null
|
||||
? 'Reply'
|
||||
: hasChatDelay
|
||||
? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
|
||||
: 'Chat'
|
||||
: loginTooltipMessage,
|
||||
),
|
||||
controller: chatStore.textController,
|
||||
|
|
@ -313,30 +308,14 @@ class ChatBottomBar extends StatelessWidget {
|
|||
(chatStore.settings.chatWidth >= 0.3 ||
|
||||
chatStore.expandChat ||
|
||||
context.isPortrait))
|
||||
Observer(
|
||||
builder: (context) {
|
||||
return IconButton(
|
||||
tooltip: chatStore.isSendingMessage
|
||||
? 'Sending...'
|
||||
: 'Send',
|
||||
icon: chatStore.isSendingMessage
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
onPressed:
|
||||
chatStore.auth.isLoggedIn &&
|
||||
!chatStore.isSendingMessage
|
||||
? () => chatStore.sendMessage(
|
||||
chatStore.textController.text,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
IconButton(
|
||||
tooltip: 'Send',
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
onPressed: chatStore.auth.isLoggedIn
|
||||
? () => chatStore.sendMessage(
|
||||
chatStore.textController.text,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue