Add documentation to chat store
Resolves #8, resolves #9, resolves #10, resolves #11, resolves #12, resolves #13.
This commit is contained in:
parent
1afc979139
commit
fc315ece27
2 changed files with 171 additions and 109 deletions
|
|
@ -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<IRCMessage>();
|
||||
|
||||
/// The map of badges and emote words to their image/gif URL.
|
||||
final _assetToUrl = <String, String>{};
|
||||
|
||||
/// 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<void> 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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue