Merge pull request #39 from tommyxchow/tommyxchow/issue6

Implement handling of IRC chat tags
This commit is contained in:
Tommy Chow 2021-11-07 01:10:12 -04:00 • committed by GitHub
commit 4ed18c0666
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 825 additions and 316 deletions

203
lib/api/irc_api.dart Normal file
View file

@ -0,0 +1,203 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/widgets.dart';
import 'package:frosty/models/irc.dart';
class IRC {
/// Applies the given CLEARCHAT message to the given list of messages.
static void clearChat({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
// If there is no message, it means that entire chat was cleared.
if (ircMessage.message == null) {
messages.clear();
// messages.add(IRCMessage(
// tags: {},
// command: Command.clearChat,
// user: null,
// message: 'Chat was cleared by a moderator',
// ));
return;
}
final bannedUser = ircMessage.message;
final banDuration = ircMessage.tags['ban-duration'];
// Search the messages for the banned/timed-out user.
messages.asMap().forEach((i, message) {
if (message.user == bannedUser) {
// Mark the message for removal.
messages[i].command = Command.clearChat;
// If timed-out, indicate the duration.
if (banDuration != null) {
messages[i].tags['ban-duration'] = banDuration;
}
}
});
}
/// Applies the given CLEARMSG message to the given list of messages.
static void clearMessage({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
// final targetUser = ircMessage.tags['login'];
final targetId = ircMessage.tags['target-msg-id'];
// Search for the message associated with the ID and indicate the the message was deleted.
for (var i = 0; i < messages.length; i++) {
if (messages[i].tags['id'] == targetId) {
messages[i].command = Command.clearMessage;
break;
}
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
static List<InlineSpan> generateSpan({required IRCMessage ircMessage, required Map<String, String> assetToUrl}) {
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
// The map containing emotes from the user's tags to their URL.
// This may include sub emotes that they can access but other users cannot.
final localEmoteToUrl = <String, String>{};
final emoteTags = ircMessage.tags['emotes'];
if (emoteTags != null) {
// Emotes and their indices are separated by '/' so split them there.
final emotes = emoteTags.split('/');
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId = emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
// Else, use the one provided indices.
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
} else {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
}
// Extract the word associated with this emoteId by using the provided indices.
final indexSplit = range.split('-');
final startIndex = int.parse(indexSplit[0]);
final endIndex = int.parse(indexSplit[1]);
final emoteWord = ircMessage.message!.substring(startIndex, endIndex + 1);
// Store the emote word and its associated URL for later use.
localEmoteToUrl[emoteWord] = 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0';
}
}
// Add any badges to the span.
final badges = ircMessage.tags['badges'];
if (badges != null) {
for (final badge in badges.split(',')) {
final badgeUrl = assetToUrl[badge];
if (badgeUrl != null) {
span.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: badgeUrl,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: 20,
),
),
);
span.add(const TextSpan(text: ' '));
}
}
}
// Add the username to the span.
span.add(
TextSpan(
text: ircMessage.tags['display-name']!,
style: TextStyle(
color: HexColor.fromHex(ircMessage.tags['color'] ?? '#868686'),
fontWeight: FontWeight.bold,
),
),
);
// Add the colon separator between the username and their message to the span.
span.add(
const TextSpan(text: ':'),
);
// Add the message and any emotes to the span.
final message = ircMessage.message;
if (message != null) {
final words = message.split(' ');
// Discard the last word if it is only the INVALID/UNDEFINED Unicode character.
// Rendering this character on iOS shows a question mark inside inside square.
// This character is used by some clients to bypass restrictions on repeating message.
if (words.last.contains('\u{E0000}')) words.removeLast();
final buffer = StringBuffer();
for (final word in words) {
buffer.write(' ');
final emoteUrl = assetToUrl[word] ?? localEmoteToUrl[word];
if (emoteUrl != null) {
span.add(TextSpan(text: buffer.toString()));
buffer.clear();
span.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: emoteUrl,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: 25,
),
),
);
} else {
buffer.write(word);
}
}
if (buffer.isNotEmpty) {
span.add(TextSpan(text: buffer.toString()));
}
}
return span;
}
}
/// The possible types of Twitch IRC commands.
enum Command {
privateMessage,
clearChat,
clearMessage,
userNotice,
roomState,
userState,
globalUserState,
none,
}
// https://stackoverflow.com/questions/50081213/how-do-i-use-hexadecimal-color-strings-in-flutter
extension HexColor on Color {
/// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
static Color fromHex(String hexString) {
final buffer = StringBuffer();
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
/// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}'
'${alpha.toRadixString(16).padLeft(2, '0')}'
'${red.toRadixString(16).padLeft(2, '0')}'
'${green.toRadixString(16).padLeft(2, '0')}'
'${blue.toRadixString(16).padLeft(2, '0')}';
}

125
lib/models/irc.dart Normal file
View file

@ -0,0 +1,125 @@
import 'package:flutter/foundation.dart';
import 'package:frosty/api/irc_api.dart';
/// The object representation of a Twitch IRC message.
class IRCMessage {
final Map<String, String> tags;
final String? user;
Command command;
String? message;
IRCMessage({required this.tags, required this.command, required this.user, required this.message});
/// Parses an IRC string and returns its corresponding [IRCMessage] object.
factory IRCMessage.fromString(String whole) {
// We have three parts:
// 1. The tags of the IRC message.
// 2. The metadata (user, command, and channel).
// 3. The message itself.
// First, slice the message tags (1) and set aside the rest for later.
// Each part is separated by a space, so we'll start off by breaking off the tags and parsing them.
// Obtain the index to break off the tags.
final tagAndIrcMessageDivider = whole.indexOf(' ');
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags = whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
// Next, parse and map the tags.
final mappedTags = <String, String>{};
// Loop through each tag and store their key value pairs into the map.
for (final tag in tags.split(';')) {
// Skip if the tag has no value.
if (tag.endsWith('=')) continue;
final tagSplit = tag.split('=');
mappedTags[tagSplit[0]] = tagSplit[1];
}
// Now we'll parse the message itself.
// Obtain the entire substring after the tags and excluding the first : (colon)
final ircMessage = whole.substring(tagAndIrcMessageDivider + 2);
// Split each section of the message (part 2 and its subparts and part 3).
// Index 0 contains the username of sender (user) or tmi.twitch.tv depending on the command
// Index 1 is command type
// Index 2 is #channel name that we are currently connected to
// Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command
final splitMessage = ircMessage.split(' ');
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
final String? message = splitMessage.length > 3 ? splitMessage.sublist(3).join(' ').substring(1) : null;
// Check and parse the command.
// The majority of messages will be PRIVMSG, so check that first.
final Command messageCommand;
switch (splitMessage[1]) {
case 'PRIVMSG':
messageCommand = Command.privateMessage;
break;
case 'CLEARCHAT':
messageCommand = Command.clearChat;
break;
case 'CLEARMSG':
messageCommand = Command.clearMessage;
break;
case 'USERNOTICE':
messageCommand = Command.userNotice;
break;
case 'ROOMSTATE':
messageCommand = Command.roomState;
break;
case 'USERSTATE':
messageCommand = Command.userState;
break;
case 'GLOBALUSERSTATE':
messageCommand = Command.globalUserState;
break;
default:
debugPrint('Unknown command: $splitMessage[1]');
messageCommand = Command.none;
}
return IRCMessage(
tags: mappedTags,
command: messageCommand,
user: user,
message: message,
);
}
}
/// The object representation of the IRC ROOMSTATE message.
class ROOMSTATE {
final String emoteOnly;
final String followersOnly;
final String r9k;
final String slowMode;
final String subMode;
const ROOMSTATE({
this.emoteOnly = "0",
this.followersOnly = "0",
this.r9k = "0",
this.slowMode = "0",
this.subMode = "0",
});
/// Create a new copy with the parameters from the provided [IRCMessage]
ROOMSTATE copyWith(IRCMessage ircMessage) {
return ROOMSTATE(
emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly,
followersOnly: ircMessage.tags['followers-only'] ?? followersOnly,
r9k: ircMessage.tags['r9k'] ?? r9k,
slowMode: ircMessage.tags['slow'] ?? slowMode,
subMode: ircMessage.tags['subs-only'] ?? subMode,
);
}
}

View file

@ -41,6 +41,9 @@ class _HomeState extends State<Home> {
controller: _textController,
autocorrect: false,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search for a channel',
),
onSubmitted: (string) async {
if (await Twitch.getUser(userLogin: string, headers: context.read<AuthStore>().headersTwitch) != null) {
Navigator.push(

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/stores/chat_store.dart';
import 'package:frosty/stores/settings_store.dart';
@ -18,17 +19,20 @@ class VideoChat extends StatelessWidget {
body: SafeArea(
child: Column(
children: [
Visibility(
visible: context.read<SettingsStore>().videoEnabled,
child: AspectRatio(
aspectRatio: 16 / 9,
child: Video(channelName: userLogin),
),
Observer(
builder: (_) {
return Visibility(
visible: context.read<SettingsStore>().videoEnabled,
child: AspectRatio(
aspectRatio: 16 / 9,
child: Video(channelName: userLogin),
),
);
},
),
Expanded(
child: Chat(chatStore: ChatStore(auth: context.read<AuthStore>(), channelName: userLogin)),
),
const TextField(),
],
),
),

View file

@ -14,22 +14,21 @@ abstract class _AuthBase with Store {
/// Secure storage to store tokens.
final _storage = const FlutterSecureStorage();
/// The current user's info.
@readonly
UserTwitch? _user;
/// Whether the token is valid or not.
var _tokenIsValid = false;
/// The current token.
@readonly
String? _token;
/// The current user's info.
@readonly
UserTwitch? _user;
/// Whether the user is logged in or not.
@readonly
var _isLoggedIn = false;
/// Whether the token is valid or not.
@observable
var _tokenIsValid = false;
/// Authentication headers for Twitch API requests.
@computed
Map<String, String> get headersTwitch => {'Authorization': 'Bearer $_token', 'Client-Id': clientId};
@ -116,8 +115,26 @@ abstract class _AuthBase with Store {
// Set the login status to logged out.
_isLoggedIn = false;
// If the default token already exists, set it. Otherwise, get a new default token.
_token = await _storage.read(key: 'DEFAULT_TOKEN') ?? await Twitch.getDefaultToken();
// If the default token already exists, set it.
_token = await _storage.read(key: 'DEFAULT_TOKEN');
// If the default token does not already exist, get the new default token and store it.
// Else, validate the existing token.
if (_token == null) {
await Twitch.getDefaultToken();
await _storage.write(key: 'DEFAULT_TOKEN', value: _token);
_tokenIsValid = await Twitch.validateToken(token: _token!);
} else {
// Validate the stored token.
_tokenIsValid = await Twitch.validateToken(token: _token!);
// If the stored token is invalid, get a new default token and store it.
if (!_tokenIsValid) {
await Twitch.getDefaultToken();
await _storage.write(key: 'DEFAULT_TOKEN', value: _token);
_tokenIsValid = await Twitch.validateToken(token: _token!);
}
}
debugPrint('Successfully logged out');
}

View file

@ -17,23 +17,6 @@ mixin _$AuthStore on _AuthBase, Store {
name: '_AuthBase.headersTwitch'))
.value;
final _$_userAtom = Atom(name: '_AuthBase._user');
UserTwitch? get user {
_$_userAtom.reportRead();
return super._user;
}
@override
UserTwitch? get _user => user;
@override
set _user(UserTwitch? value) {
_$_userAtom.reportWrite(value, super._user, () {
super._user = value;
});
}
final _$_tokenAtom = Atom(name: '_AuthBase._token');
String? get token {
@ -51,6 +34,23 @@ mixin _$AuthStore on _AuthBase, Store {
});
}
final _$_userAtom = Atom(name: '_AuthBase._user');
UserTwitch? get user {
_$_userAtom.reportRead();
return super._user;
}
@override
UserTwitch? get _user => user;
@override
set _user(UserTwitch? value) {
_$_userAtom.reportWrite(value, super._user, () {
super._user = value;
});
}
final _$_isLoggedInAtom = Atom(name: '_AuthBase._isLoggedIn');
bool get isLoggedIn {
@ -68,21 +68,6 @@ mixin _$AuthStore on _AuthBase, Store {
});
}
final _$_tokenIsValidAtom = Atom(name: '_AuthBase._tokenIsValid');
@override
bool get _tokenIsValid {
_$_tokenIsValidAtom.reportRead();
return super._tokenIsValid;
}
@override
set _tokenIsValid(bool value) {
_$_tokenIsValidAtom.reportWrite(value, super._tokenIsValid, () {
super._tokenIsValid = value;
});
}
final _$initAsyncAction = AsyncAction('_AuthBase.init');
@override

View file

@ -5,16 +5,17 @@ import 'package:frosty/models/channel.dart';
part 'channel_list_store.g.dart';
// TODO: Fix followed list refresh when log in.
class ChannelListStore = _ChannelListBase with _$ChannelListStore;
abstract class _ChannelListBase with Store {
/// The list of the fetched top channels
@observable
ObservableList<Channel> _topChannels = ObservableList<Channel>();
var _topChannels = ObservableList<Channel>();
/// The list of the fetched followed channels
@observable
ObservableList<Channel> _followedChannels = ObservableList<Channel>();
var _followedChannels = ObservableList<Channel>();
/// The loading status for pagination.
bool _isLoading = false;

View file

@ -1,10 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:frosty/api/bttv_api.dart';
import 'package:frosty/api/ffz_api.dart';
import 'package:frosty/api/irc_api.dart';
import 'package:frosty/api/seventv_api.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/widgets/chat_message.dart';
import 'package:mobx/mobx.dart';
@ -12,53 +14,194 @@ 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 {
@observable
bool autoScroll = true;
/// The Twitch IRC WebSocket channel.
final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
List<String> messages = [];
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 or GIF URL.
final _assetToUrl = <String, String>{};
final _emoteIdToWord = <String, String>{};
final scrollController = ScrollController();
/// 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;
/// 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 = const ROOMSTATE();
_ChatStoreBase({required this.auth, required this.channelName}) {
// 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}',
'NICK justinfan888',
'CAP REQ :twitch.tv/tags',
'CAP REQ :twitch.tv/commands',
// 'CAP REQ :twitch.tv/membership',
'CAP END',
// 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);
}
scrollController.addListener(() {
if (!scrollController.position.atEdge && scrollController.position.pixels < scrollController.position.maxScrollExtent) {
autoScroll = false;
} else if (scrollController.position.atEdge && scrollController.position.pixels != scrollController.position.minScrollExtent) {
autoScroll = true;
// 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) {
_autoScroll = true;
}
});
}
/// 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.trimRight().split('\r\n')) {
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.
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),
@ -72,6 +215,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);
@ -80,188 +225,71 @@ abstract class _ChatStoreBase with Store {
}
}
@action
void handleWebsocketData(Object? data) {
for (final message in data.toString().split('\r\n')) {
if (message.startsWith('@')) {
messages.add(message);
continue;
}
if (message.startsWith('P')) {
channel.sink.add('PONG :tmi.twitch.tv');
continue;
}
}
}
/// Returns a chat message widget for the given [IRCMessage].
Widget renderChatMessage(IRCMessage ircMessage) {
final span = IRC.generateSpan(ircMessage: ircMessage, assetToUrl: _assetToUrl);
Widget parseIrcMessage(String whole) {
var mappedTags = <String, String>{};
final tagAndIrcMessageDivider = whole.indexOf(' ');
final tags = whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
final ircMessage = whole.substring(tagAndIrcMessageDivider + 2);
for (final tag in tags.split(';')) {
if (!tag.endsWith('=')) {
final tagSplit = tag.split('=');
mappedTags[tagSplit[0]] = tagSplit[1];
}
}
final splitMessage = ircMessage.split(' ');
// final user = splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// print(user);
final command = splitMessage[1];
switch (command) {
case 'CLEARCHAT':
break;
case 'CLEARMSG':
break;
case 'GLOBALUSERSTATE':
break;
case 'PRIVMSG':
final message = splitMessage.sublist(3).join(' ').substring(1);
if (autoScroll) {
SchedulerBinding.instance?.addPostFrameCallback((_) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
}
return ChatMessage(
key: Key(mappedTags['id']!),
children: privateMessage(tags: mappedTags, chatMessage: message),
);
case 'ROOMSTATE':
break;
case 'USERNOTICE':
break;
case 'USERSTATE':
break;
}
return const SizedBox();
}
List<InlineSpan> privateMessage({required Map<String, String> tags, required String chatMessage}) {
// debugPrint(chatMessage);
var result = <InlineSpan>[];
final emoteTags = tags['emotes'];
if (emoteTags != null) {
final emotes = emoteTags.split('/');
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId = emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
if (_emoteIdToWord[emoteId] != null) {
continue;
}
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
} else {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
}
final indexSplit = range.split('-');
final startIndex = int.parse(indexSplit[0]);
final endIndex = int.parse(indexSplit[1]);
final emoteWord = chatMessage.substring(startIndex, endIndex + 1);
_emoteIdToWord[emoteId] = emoteWord;
_assetToUrl[emoteWord] = 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0';
}
}
final words = chatMessage.split(' ');
final badges = tags['badges'];
if (badges != null) {
for (final badge in badges.split(',')) {
final badgeUrl = _assetToUrl[badge];
if (badgeUrl != null) {
result.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: badgeUrl,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: 20,
if (ircMessage.command == Command.clearChat || ircMessage.command == Command.clearMessage) {
// Render timeouts and bans
final banDuration = ircMessage.tags['ban-duration'];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Opacity(
opacity: 0.50,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChatMessage(
key: Key(ircMessage.tags['id']!),
children: span,
),
),
);
result.add(const TextSpan(text: ' '));
}
}
}
result.add(
TextSpan(
text: tags['display-name']!,
style: TextStyle(
color: HexColor.fromHex(tags['color'] ?? '#868686'),
fontWeight: FontWeight.bold,
),
),
);
result.add(
const TextSpan(text: ':'),
);
for (final word in words) {
final emoteUrl = _assetToUrl[word];
if (emoteUrl != null) {
result.add(const TextSpan(text: ' '));
result.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: emoteUrl,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: 25,
),
const SizedBox(height: 5),
banDuration == null
? (ircMessage.command == Command.clearMessage)
? const Text('Message deleted.')
: const Text('Permanently Banned.')
: Text(
'Timed out for $banDuration second(s).',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
} else {
result.add(const TextSpan(text: ' '));
result.add(TextSpan(text: word));
}
),
);
} else if (ircMessage.command == Command.userNotice) {
// Render sub alerts
return Container(
color: Colors.purple.withOpacity(0.3),
padding: const EdgeInsets.all(5.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ircMessage.tags['system-msg']!),
const SizedBox(height: 5),
if (ircMessage.message != null)
ChatMessage(
key: Key(ircMessage.tags['id']!),
children: span,
),
],
),
);
} else {
// Render normal chat message (PRIVMSG).
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0),
child: ChatMessage(
key: ircMessage.tags['id'] == null ? null : Key(ircMessage.tags['id']!),
children: span,
),
);
}
return result;
}
@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();
_scrollController.dispose();
}
}
// https://stackoverflow.com/questions/50081213/how-do-i-use-hexadecimal-color-strings-in-flutter
extension HexColor on Color {
/// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
static Color fromHex(String hexString) {
final buffer = StringBuffer();
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
/// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}'
'${alpha.toRadixString(16).padLeft(2, '0')}'
'${red.toRadixString(16).padLeft(2, '0')}'
'${green.toRadixString(16).padLeft(2, '0')}'
'${blue.toRadixString(16).padLeft(2, '0')}';
}

View file

@ -9,37 +9,71 @@ part of 'chat_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$ChatStore on _ChatStoreBase, Store {
final _$autoScrollAtom = Atom(name: '_ChatStoreBase.autoScroll');
final _$_autoScrollAtom = Atom(name: '_ChatStoreBase._autoScroll');
@override
bool get autoScroll {
_$autoScrollAtom.reportRead();
return super.autoScroll;
_$_autoScrollAtom.reportRead();
return super._autoScroll;
}
@override
set autoScroll(bool value) {
_$autoScrollAtom.reportWrite(value, super.autoScroll, () {
super.autoScroll = value;
bool get _autoScroll => autoScroll;
@override
set _autoScroll(bool value) {
_$_autoScrollAtom.reportWrite(value, super._autoScroll, () {
super._autoScroll = value;
});
}
final _$getAssetsAsyncAction = AsyncAction('_ChatStoreBase.getAssets');
final _$_roomStateAtom = Atom(name: '_ChatStoreBase._roomState');
ROOMSTATE get roomState {
_$_roomStateAtom.reportRead();
return super._roomState;
}
@override
Future<void> getAssets() {
return _$getAssetsAsyncAction.run(() => super.getAssets());
ROOMSTATE get _roomState => roomState;
@override
set _roomState(ROOMSTATE value) {
_$_roomStateAtom.reportWrite(value, super._roomState, () {
super._roomState = value;
});
}
final _$_ChatStoreBaseActionController =
ActionController(name: '_ChatStoreBase');
@override
void handleWebsocketData(Object? 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 sendMessage(String message) {
final _$actionInfo = _$_ChatStoreBaseActionController.startAction(
name: '_ChatStoreBase.sendMessage');
try {
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);
}
@ -59,7 +93,7 @@ mixin _$ChatStore on _ChatStoreBase, Store {
@override
String toString() {
return '''
autoScroll: ${autoScroll}
''';
}
}

View file

@ -55,7 +55,7 @@ class ChannelCard extends StatelessWidget {
const SizedBox(height: 5),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(channelInfo.title),
child: Text(channelInfo.title.replaceAll('\n', '')),
),
const SizedBox(height: 5),
Text(

View file

@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/stores/chat_store.dart';
import 'package:frosty/widgets/chat_stats.dart';
import 'package:provider/provider.dart';
class Chat extends StatefulWidget {
final ChatStore chatStore;
@ -18,39 +22,91 @@ class _ChatState extends State<Chat> {
future: widget.chatStore.getAssets(),
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return StreamBuilder(
stream: widget.chatStore.channel.stream,
builder: (context, snapshot) {
widget.chatStore.handleWebsocketData(snapshot.data);
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
ListView.builder(
itemCount: widget.chatStore.messages.length,
controller: widget.chatStore.scrollController,
padding: const EdgeInsets.all(5.0),
itemBuilder: (context, index) {
return widget.chatStore.parseIrcMessage(widget.chatStore.messages[index]);
},
),
Observer(
builder: (_) {
return Visibility(
visible: !widget.chatStore.autoScroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
width: double.infinity,
child: ElevatedButton(
onPressed: () => widget.chatStore.resumeScroll(),
child: const Text('Resume Scroll'),
return Column(
children: [
Expanded(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Observer(
builder: (_) {
return ListView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: widget.chatStore.messages.length,
controller: widget.chatStore.scrollController,
padding: const EdgeInsets.all(5.0),
itemBuilder: (context, index) {
return widget.chatStore.renderChatMessage(widget.chatStore.messages[index]);
},
);
},
),
Observer(
builder: (_) {
return Visibility(
visible: !widget.chatStore.autoScroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
width: double.infinity,
child: ElevatedButton(
onPressed: () => widget.chatStore.resumeScroll(),
child: const Text('Resume Scroll'),
),
),
),
);
},
),
],
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return const Settings();
},
);
},
),
IconButton(
icon: const Icon(Icons.stacked_bar_chart),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return ChatStats(
chatStore: widget.chatStore,
);
},
);
},
),
if (context.read<AuthStore>().isLoggedIn)
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.all(8.0),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(5.0)),
),
hintText: 'Send a message',
),
controller: widget.chatStore.textController,
onSubmitted: (string) => widget.chatStore.sendMessage(string),
),
),
),
],
);
},
)
],
);
}
return const Center(
@ -62,7 +118,7 @@ class _ChatState extends State<Chat> {
@override
void dispose() {
widget.chatStore.channel.sink.close();
widget.chatStore.dispose();
super.dispose();
}
}

View file

@ -7,12 +7,9 @@ class ChatMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0),
child: RichText(
text: TextSpan(
children: children,
),
return RichText(
text: TextSpan(
children: children,
),
);
}

View file

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/stores/chat_store.dart';
class ChatStats extends StatelessWidget {
final ChatStore chatStore;
const ChatStats({Key? key, required this.chatStore}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Center(
child: Observer(
builder: (_) {
return ListView(
children: [
const ListTile(
title: Text('Modes'),
),
ListTile(
leading: const Text('Emote-only'),
trailing: Text(chatStore.roomState.emoteOnly == '0' ? 'Disabled' : 'Enabled'),
),
ListTile(
leading: const Text('Followers-only'),
trailing: Text(chatStore.roomState.followersOnly == '-1'
? 'Disabled'
: chatStore.roomState.followersOnly == '0'
? 'Enabled'
: '${chatStore.roomState.followersOnly} minute(s)'),
),
ListTile(
leading: const Text('R9K'),
trailing: Text(chatStore.roomState.r9k == '0' ? 'Disabled' : 'Enabled'),
),
ListTile(
leading: const Text('Slow'),
trailing: Text(chatStore.roomState.slowMode == '0' ? 'Disabled' : '${chatStore.roomState.slowMode} seconds'),
),
ListTile(
leading: const Text('Sub-only'),
trailing: Text(chatStore.roomState.subMode == '0' ? 'Disabled' : 'Enabled'),
),
],
);
},
),
),
);
}
}

View file

@ -19,6 +19,7 @@ class DrawerMenu extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) {
return const Settings();
},

View file

@ -7,14 +7,14 @@ packages:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "28.0.0"
version: "30.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.0"
version: "2.7.0"
args:
dependency: transitive
description:
@ -91,14 +91,14 @@ packages:
name: built_value
url: "https://pub.dartlang.org"
source: hosted
version: "8.1.2"
version: "8.1.3"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
version: "3.1.0+1"
cached_network_image_platform_interface:
dependency: transitive
description:
@ -140,7 +140,7 @@ packages:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.4"
version: "0.3.5"
clock:
dependency: transitive
description:
@ -182,7 +182,7 @@ packages:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
version: "1.0.4"
dart_style:
dependency: transitive
description:
@ -236,7 +236,7 @@ packages:
name: flutter_cache_manager
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.2"
version: "3.1.3"
flutter_lints:
dependency: "direct dev"
description:
@ -379,14 +379,14 @@ packages:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
version: "4.3.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.2"
version: "6.0.1"
lints:
dependency: transitive
description:
@ -421,7 +421,7 @@ packages:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
version: "1.0.1"
mobx:
dependency: "direct main"
description:
@ -470,7 +470,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.0.6"
path_provider_linux:
dependency: transitive
description:
@ -533,7 +533,7 @@ packages:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.3"
version: "4.2.4"
provider:
dependency: "direct main"
description:
@ -701,7 +701,7 @@ packages:
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
dependency: "direct main"
description:
name: web_socket_channel
url: "https://pub.dartlang.org"
@ -713,35 +713,35 @@ packages:
name: webview_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.3.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.15"
version: "2.2.1"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.4.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.14"
version: "2.2.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.9"
version: "2.2.10"
xdg_directories:
dependency: transitive
description:

View file

@ -29,13 +29,14 @@ environment:
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.0.1
json_annotation: ^4.3.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
http: ^0.13.3
web_socket_channel: ^2.1.0
flutter_secure_storage: ^5.0.0-beta.5
flutter_web_auth: ^0.3.1
intl: ^0.17.0
@ -56,7 +57,7 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^1.0.0
build_runner: ^2.1.1
json_serializable: ^5.0.1
json_serializable: ^6.0.0
mobx_codegen: ^2.0.0
# For information on the generic Dart part of this file, see the