Add exponential backoff to chat (#35, #36, #63)

Closes #35, closes #36, closes #63
This commit is contained in:
Tommy Chow 2022-01-04 22:45:59 -05:00
parent 1f2b99dfce
commit 5d3b393bf8
2 changed files with 89 additions and 81 deletions

View file

@ -14,19 +14,7 @@ class Chat extends StatefulWidget {
_ChatState createState() => _ChatState();
}
class _ChatState extends State<Chat> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
widget.chatStore.handleAppStateChange(state);
}
class _ChatState extends State<Chat> {
@override
Widget build(BuildContext context) {
final chatStore = widget.chatStore;
@ -92,7 +80,6 @@ class _ChatState extends State<Chat> with WidgetsBindingObserver {
@override
void dispose() {
WidgetsBinding.instance!.removeObserver(this);
widget.chatStore.dispose();
super.dispose();
}

View file

@ -26,7 +26,13 @@ abstract class _ChatStoreBase with Store {
final String channelName;
/// The Twitch IRC WebSocket channel.
final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
WebSocketChannel? _channel;
// The retry counter for exponential backoff.
var _retries = 0;
// The current time to wait between retries for exponential backoff.
var _backoffTime = 0;
/// The scroll controller that controls auto-scroll and resume-scroll behavior.
final scrollController = ScrollController();
@ -55,6 +61,8 @@ abstract class _ChatStoreBase with Store {
@readonly
var _userState = const USERSTATE();
late ReactionDisposer disposeEmoteMenuReaction;
_ChatStoreBase({
required this.auth,
required this.settings,
@ -63,7 +71,7 @@ abstract class _ChatStoreBase with Store {
// Create a reaction where anytime the emote menu is shown or hidden,
// scroll to the bottom of the list. This will prevent the emote menu
// from covering the latest messages when summoned.
final disposeEmoteMenuReaction = reaction((_) => assetsStore.showEmoteMenu, (_) {
disposeEmoteMenuReaction = reaction((_) => assetsStore.showEmoteMenu, (_) {
SchedulerBinding.instance?.addPostFrameCallback((_) {
if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
@ -71,53 +79,7 @@ abstract class _ChatStoreBase with Store {
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
// Listen for new messages and forward them to the handler.
_channel.stream.listen(
(data) => _handleIRCData(data.toString()),
onError: (error) {
debugPrint('Chat error: ${error.toString()}');
_messages.add(IRCMessage.createNotice(message: 'Chat error - ${error.toString()}'));
},
onDone: () {
debugPrint("Disconnected from $channelName's chat.");
_messages.add(IRCMessage.createNotice(message: 'Failed to connect to chat, please try again.'));
disposeEmoteMenuReaction();
},
);
// Fetch the users in chat.
chatDetailsStore.updateChatters(channelName);
// Fetch the assets used in chat including badges and emotes.
_messages.add(IRCMessage.createNotice(message: 'Fetching channel assets...'));
assetsStore
.getAssets(channelName: channelName, headers: auth.headersTwitch)
.then((_) => _messages.add(IRCMessage.createNotice(message: 'Channel assets fetched!')))
.onError((error, stackTrace) => _messages.add(IRCMessage.createNotice(message: 'Failed to fetch assets: ${error.toString()}')));
// The list of messages sent to the IRC WebSocket channel to connect and join.
final commands = [
// Request the tags and commands capabilities.
// This will display tags containing metadata along with each IRC message.
'CAP REQ :twitch.tv/tags twitch.tv/commands',
// The OAuth token in order to connect, default or user token.
'PASS oauth:${auth.token}',
// The nickname for the connecting user. 'justinfan888' is the Twitch default if not logged in.
'NICK ${auth.isLoggedIn ? auth.user.details!.login : 'justinfan888'}',
// Join the desired channel's room.
'JOIN #$channelName',
];
// Send each command in order.
for (final command in commands) {
_channel.sink.add(command);
}
_messages.add(IRCMessage.createNotice(message: "Connected to $channelName's chat."));
reconnect();
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
scrollController.addListener(() {
@ -191,8 +153,25 @@ abstract class _ChatStoreBase with Store {
// Hard upper-limit of 5000 messages to prevent infinite messages being added when scrolling.
if (_messages.length >= 5000) _messages.removeRange(0, 1000);
} else if (message == 'PING :tmi.twitch.tv') {
_channel.sink.add('PONG :tmi.twitch.tv');
_channel?.sink.add('PONG :tmi.twitch.tv');
return;
} else if (message.contains('Welcome, GLHF!')) {
_messages.add(IRCMessage.createNotice(message: "Connected to $channelName's chat."));
// Fetch the users in chat.
chatDetailsStore.updateChatters(channelName);
// Fetch the assets used in chat including badges and emotes.
_messages.add(IRCMessage.createNotice(message: 'Fetching badges and emotes...'));
assetsStore
.getAssets(channelName: channelName, headers: auth.headersTwitch)
.then((_) => _messages.add(IRCMessage.createNotice(message: 'Badges and emotes fetched!')))
.onError((error, stackTrace) => _messages.add(IRCMessage.createNotice(message: 'Failed to fetch assets: ${error.toString()}')));
// Reset exponential backoff if successfully connected.
_retries = 0;
_backoffTime = 0;
}
}
}
@ -227,15 +206,68 @@ abstract class _ChatStoreBase with Store {
});
}
void reconnect() async {
_channel?.sink.close(1001);
_channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
// Listen for new messages and forward them to the handler.
_channel?.stream.listen(
(data) => _handleIRCData(data.toString()),
onError: (error) {
debugPrint('Chat error: ${error.toString()}');
_messages.add(IRCMessage.createNotice(message: 'Chat error - ${error.toString()}'));
},
onDone: () async {
if (_channel == null) return;
// Add notice that chat was disconnected and then wait the backoff time before reconnecting.
_messages.add(IRCMessage.createNotice(message: 'Disconnected from chat, waiting $_backoffTime seconds before reconnecting...'));
await Future.delayed(Duration(seconds: _backoffTime));
// Increase the backoff time for the next retry.
_backoffTime == 0 ? _backoffTime++ : _backoffTime *= 2;
// Increment the retry count and attempt the reconnect.
_retries++;
_messages.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries).'));
reconnect();
},
);
// The list of messages sent to the IRC WebSocket channel to connect and join.
final commands = [
// Request the tags and commands capabilities.
// This will display tags containing metadata along with each IRC message.
'CAP REQ :twitch.tv/tags twitch.tv/commands',
// The OAuth token in order to connect, default or user token.
'PASS oauth:${auth.token}',
// The nickname for the connecting user. 'justinfan888' is the Twitch default if not logged in.
'NICK ${auth.isLoggedIn ? auth.user.details!.login : 'justinfan888'}',
// Join the desired channel's room.
'JOIN #$channelName',
];
// Send each command in order.
for (final command in commands) {
_channel?.sink.add(command);
}
}
/// Sends the given string message by the logged-in user and adds it to [_messages].
void sendMessage(String message) {
// Do not send if the message is blank/empty.
if (message.isEmpty) {
if (message.isEmpty) return;
if (_channel == null || _channel?.closeCode != null) {
_messages.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.'));
return;
}
// Send the message to the IRC chat room.
_channel.sink.add('PRIVMSG #$channelName :$message');
_channel?.sink.add('PRIVMSG #$channelName :$message');
// Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render.
var userStateString = _userState.raw;
@ -256,23 +288,12 @@ abstract class _ChatStoreBase with Store {
textController.clear();
}
/// Pauses or resumes the chat subscription depending on the provided state.
void handleAppStateChange(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
break;
case AppLifecycleState.inactive:
break;
case AppLifecycleState.paused:
break;
case AppLifecycleState.detached:
break;
}
}
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_channel.sink.close();
_channel?.sink.close(1001);
_channel = null;
disposeEmoteMenuReaction();
textController.dispose();
scrollController.dispose();
}