Merge pull request #39 from tommyxchow/tommyxchow/issue6
Implement handling of IRC chat tags
This commit is contained in:
commit
4ed18c0666
16 changed files with 825 additions and 316 deletions
203
lib/api/irc_api.dart
Normal file
203
lib/api/irc_api.dart
Normal 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
125
lib/models/irc.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
54
lib/widgets/chat_stats.dart
Normal file
54
lib/widgets/chat_stats.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ class DrawerMenu extends StatelessWidget {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) {
|
||||
return const Settings();
|
||||
},
|
||||
|
|
|
|||
36
pubspec.lock
36
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue