diff --git a/lib/stores/chat_store.dart b/lib/stores/chat_store.dart index e1778769..bef2db60 100644 --- a/lib/stores/chat_store.dart +++ b/lib/stores/chat_store.dart @@ -14,54 +14,76 @@ import 'package:web_socket_channel/web_socket_channel.dart'; part 'chat_store.g.dart'; +/// The store and view-model for chat-related activities. class ChatStore = _ChatStoreBase with _$ChatStore; abstract class _ChatStoreBase with Store { - @readonly - var _autoScroll = true; - - @readonly - var _roomState = ROOMSTATE(); - - String? _userState; - - // String? _globalUserState; + /// The Twitch IRC WebSocket channel. + final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443')); + /// The list of chat messages to render and display. final messages = ObservableList(); + /// The map of badges and emote words to their image/gif URL. final _assetToUrl = {}; + /// The scroll controller that controls auto-scroll and resume-scroll behavior. final _scrollController = ScrollController(); ScrollController get scrollController => _scrollController; + /// The text controller that handles the TextField inputs and sending of messages. final _textController = TextEditingController(); TextEditingController get textController => _textController; - final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443')); - WebSocketChannel get channel => _channel; - + /// The name of the channel to connect to. final String channelName; + /// The provided auth store to determine login status, get the token, and use the headers for requests. final AuthStore auth; + /// The logged-in user's appearance in chat. + String? _userState; + + /// If the chat should automatically scroll/jump to the latest message. + @readonly + var _autoScroll = true; + + /// The rules and modes being used in the chat. + @readonly + var _roomState = ROOMSTATE(); + _ChatStoreBase({required this.auth, required this.channelName}) { - channel.stream.listen( - (data) => _handleWebsocketData(data.toString()), - onDone: () => debugPrint("DONE"), + // Listen for new messages and forward them to the handler. + _channel.stream.listen( + (data) => _handleIRCData(data.toString()), + onDone: () => debugPrint("Disconnected from $channelName's chat."), ); + // 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!.login : 'justinfan888'}', + + // Join the desired channel's room. 'JOIN #$channelName', ]; + // Send each command in-order. for (final command in commands) { - channel.sink.add(command); + _channel.sink.add(command); } + // Tell the scrollController to determine when auto-scroll should be enabled or disabled. _scrollController.addListener(() { + // If the user scrolls up, auto-scroll will stop, allowing them to freely scroll back to previous messages. + // Else if the user scrolls back to the bottom edge (latest message), auto-scroll will resume. if (!_scrollController.position.atEdge && _scrollController.position.pixels < _scrollController.position.maxScrollExtent) { _autoScroll = false; } else if (_scrollController.position.atEdge && _scrollController.position.pixels != _scrollController.position.minScrollExtent) { @@ -70,10 +92,118 @@ abstract class _ChatStoreBase with Store { }); } + /// Handle and process the provided string-representation of the IRC data. + /// + /// If a message, parses the IRC data into an [IRCMessage] and handles it based on the [Command]. + /// Else if a PING request, sends back the PONG to keep the connection alive. + @action + void _handleIRCData(String data) { + // The IRC data can contain more than one message separated by CRLF. + // To account for this, split by CRLF. Then loop and process each message. + for (final message in data.substring(0, data.length - 2).split('\r\n')) { + // debugPrint(message); + if (message.startsWith('@')) { + final parsedIRCMessage = IRCMessage.fromString(message); + + switch (parsedIRCMessage.command) { + case Command.privateMessage: + messages.add(parsedIRCMessage); + _deleteAndScrollToEnd(); + break; + case Command.clearChat: + IRC.clearChat(messages: messages, ircMessage: parsedIRCMessage); + _deleteAndScrollToEnd(); + break; + case Command.clearMessage: + IRC.clearMessage(messages: messages, ircMessage: parsedIRCMessage); + _deleteAndScrollToEnd(); + break; + case Command.userNotice: + messages.add(parsedIRCMessage); + _deleteAndScrollToEnd(); + break; + case Command.roomState: + _roomState = _roomState.copyWith(parsedIRCMessage); + break; + case Command.userState: + _userState = message; + break; + case Command.globalUserState: + // Updates the current global user state data (it includes user-id), + // Don't really see a use for it when USERSTATE exists, so leaving it unimplemented for now. + // _globalUserState = message; + break; + default: + debugPrint('Unknown command: ${parsedIRCMessage.command}'); + } + } else if (message == 'PING :tmi.twitch.tv') { + _channel.sink.add('PONG :tmi.twitch.tv'); + return; + } + } + } + + /// Sends the given string message by the logged-in user and adds it to [messages]. + @action + void sendMessage(String message) { + // Do not send if the message is blank/empty. + if (message.isEmpty) { + return; + } + + // Send the message to the IRC chat room. + _channel.sink.add('PRIVMSG #$channelName :$message'); + + // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. + final userChatMessage = IRCMessage.fromString(_userState!); + userChatMessage.message = message; + messages.add(userChatMessage); + + // Clear the previous input in the TextField. + _textController.clear(); + } + + /// If [_autoScroll] is enabled, removes messages if [messages] is too large and scrolls to the latest message. + @action + void _deleteAndScrollToEnd() { + if (_autoScroll) { + // If there are over 200 messages, remove at least 20. + if (messages.length > 200) { + messages.removeRange(0, messages.length - 180); + } + + // After the end of the frame, scroll to the bottom of the chat. + // This is a postFrameCallback because the chat should scroll after the widget is built and rendered. + SchedulerBinding.instance?.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + } + + /// Re-enables [_autoScroll] and jumps to the latest message. + @action + void resumeScroll() { + _autoScroll = true; + + // Jump to the latest message (bottom of the list/chat). + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + + // Schedule a postFrameCallback in the event a new message is added at the same time. + SchedulerBinding.instance?.addPostFrameCallback((_) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + /// Fetches global and channel assets (badges and emotes) and stores them in [_assetToUrl] Future getAssets() async { + // Fetch the desired channel/user's information. final channelInfo = await Twitch.getUser(userLogin: channelName, headers: auth.headersTwitch); if (channelInfo != null) { + // Fetch the global and channel's assets (emotes & badges). + // Async awaits are placed in a list so they are performed in parallel. final assets = [ await FFZ.getEmotesGlobal(), await FFZ.getEmotesChannel(id: channelInfo.id), @@ -87,6 +217,8 @@ abstract class _ChatStoreBase with Store { await SevenTV.getEmotesChannel(user: channelInfo.login) ]; + // Add all the assets to the global word-to-asset map. + // 'Global' meaning these assets can be used by any message when rendering. for (final map in assets) { if (map != null) { _assetToUrl.addAll(map); @@ -95,67 +227,7 @@ abstract class _ChatStoreBase with Store { } } - @action - void _handleWebsocketData(String data) { - for (final message in data.substring(0, data.length - 2).split('\r\n')) { - // debugPrint(message); - if (message.startsWith('@')) { - final parsedIRCMessage = IRCMessage.fromString(message); - - switch (parsedIRCMessage.command) { - case Command.privateMessage: - messages.add(parsedIRCMessage); - _scrollToEnd(); - break; - case Command.clearChat: - IRC.clearChat(messages: messages, ircMessage: parsedIRCMessage); - _scrollToEnd(); - break; - case Command.clearMessage: - IRC.clearMessage(messages: messages, ircMessage: parsedIRCMessage); - _scrollToEnd(); - break; - case Command.userNotice: - messages.add(parsedIRCMessage); - _scrollToEnd(); - break; - case Command.roomState: - _roomState = _roomState.copyWith(parsedIRCMessage); - break; - case Command.userState: - // Updates the current user-state data - _userState = message; - break; - case Command.globalUserState: - // Updates the current global user state data (it includes user-id), - // Don't really see a use for it when USERSTATE exists, so leaving it unimplemented for now. - // _globalUserState = message; - break; - default: - debugPrint('Unknown command: ${parsedIRCMessage.command}'); - } - } else if (message == 'PING :tmi.twitch.tv') { - channel.sink.add('PONG :tmi.twitch.tv'); - return; - } - } - } - - @action - void _scrollToEnd() { - if (_autoScroll) { - if (messages.length > 200) { - messages.removeRange(0, messages.length - 180); - } - - SchedulerBinding.instance?.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); - } - } - + /// Returns a chat message widget for the given [IRCMessage]. Widget renderChatMessage(IRCMessage ircMessage) { final span = IRC.generateSpan(ircMessage: ircMessage, assetToUrl: _assetToUrl); @@ -172,6 +244,7 @@ abstract class _ChatStoreBase with Store { key: Key(ircMessage.tags['id']!), children: span, ), + const SizedBox(height: 5), banDuration == null ? (ircMessage.command == Command.clearMessage) ? const Text('Message deleted.') @@ -186,15 +259,15 @@ abstract class _ChatStoreBase with Store { ); } else if (ircMessage.command == Command.userNotice) { return Container( - color: Colors.purple.withOpacity(0.25), - padding: const EdgeInsets.symmetric(vertical: 10.0), + color: Colors.purple.withOpacity(0.3), + padding: const EdgeInsets.all(5.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( ircMessage.tags['system-msg']!, - style: const TextStyle(fontWeight: FontWeight.bold), ), + const SizedBox(height: 5), if (ircMessage.message != null) ChatMessage( key: Key(ircMessage.tags['id']!), @@ -214,29 +287,7 @@ abstract class _ChatStoreBase with Store { } } - void sendMessage(String message) { - if (message.isEmpty) { - return; - } - - _channel.sink.add('PRIVMSG #$channelName :$message'); - - final userChatMessage = IRCMessage.fromString(_userState!); - userChatMessage.message = message; - messages.add(userChatMessage); - - _textController.clear(); - } - - @action - void resumeScroll() { - _autoScroll = true; - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - SchedulerBinding.instance?.addPostFrameCallback((_) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - }); - } - + /// Closes and disposes all the channels and controllers used by the store. void dispose() { _channel.sink.close(); _textController.dispose(); diff --git a/lib/stores/chat_store.g.dart b/lib/stores/chat_store.g.dart index 51418760..4d7a6dd6 100644 --- a/lib/stores/chat_store.g.dart +++ b/lib/stores/chat_store.g.dart @@ -47,22 +47,33 @@ mixin _$ChatStore on _ChatStoreBase, Store { ActionController(name: '_ChatStoreBase'); @override - void _handleWebsocketData(String data) { + void _handleIRCData(String data) { final _$actionInfo = _$_ChatStoreBaseActionController.startAction( - name: '_ChatStoreBase._handleWebsocketData'); + name: '_ChatStoreBase._handleIRCData'); try { - return super._handleWebsocketData(data); + return super._handleIRCData(data); } finally { _$_ChatStoreBaseActionController.endAction(_$actionInfo); } } @override - void _scrollToEnd() { + void sendMessage(String message) { final _$actionInfo = _$_ChatStoreBaseActionController.startAction( - name: '_ChatStoreBase._scrollToEnd'); + name: '_ChatStoreBase.sendMessage'); try { - return super._scrollToEnd(); + return super.sendMessage(message); + } finally { + _$_ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _deleteAndScrollToEnd() { + final _$actionInfo = _$_ChatStoreBaseActionController.startAction( + name: '_ChatStoreBase._deleteAndScrollToEnd'); + try { + return super._deleteAndScrollToEnd(); } finally { _$_ChatStoreBaseActionController.endAction(_$actionInfo); }