frosty/lib/stores/chat_store.dart

314 lines
10 KiB
Dart
Raw Normal View History

2021-09-09 22:30:29 -04:00
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
2021-10-03 22:14:55 -04:00
import 'package:frosty/api/bttv_api.dart';
import 'package:frosty/api/ffz_api.dart';
import 'package:frosty/api/irc_api.dart';
2021-10-03 22:14:55 -04:00
import 'package:frosty/api/seventv_api.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/models/irc_message.dart';
2021-10-01 00:08:19 -04:00
import 'package:frosty/stores/auth_store.dart';
2021-09-09 22:30:29 -04:00
import 'package:frosty/widgets/chat_message.dart';
2021-10-01 00:08:19 -04:00
import 'package:mobx/mobx.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
2021-10-01 00:08:19 -04:00
part 'chat_store.g.dart';
class ChatStore = _ChatStoreBase with _$ChatStore;
abstract class _ChatStoreBase with Store {
@readonly
var _autoScroll = true;
2021-09-06 00:59:05 -04:00
@readonly
var _messages = <IRCMessage>[];
2021-09-06 00:59:05 -04:00
final _assetToUrl = <String, String>{};
2021-09-06 19:03:39 -04:00
final _emoteIdToWord = <String, String>{};
final _scrollController = ScrollController();
ScrollController get scrollController => _scrollController;
final _textController = TextEditingController();
TextEditingController get textController => _textController;
2021-10-03 22:14:55 -04:00
final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
WebSocketChannel get channel => _channel;
String? userState;
String? globalUserState;
String? roomState;
final String channelName;
final AuthStore auth;
_ChatStoreBase({required this.auth, required this.channelName}) {
channel.stream.listen(
(event) => handleWebsocketData(event.toString()),
onDone: () => debugPrint("DONE"),
);
final commands = [
'CAP REQ :twitch.tv/tags twitch.tv/commands',
'CAP END',
2021-10-03 22:14:55 -04:00
'PASS oauth:${auth.token}',
'NICK ${auth.isLoggedIn ? auth.user!.login : 'justinfan888'}',
'JOIN #$channelName',
];
for (final command in commands) {
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;
2021-09-06 19:03:39 -04:00
}
});
}
2021-10-03 22:14:55 -04:00
Future<void> getAssets() async {
final channelInfo = await Twitch.getUser(userLogin: channelName, headers: auth.headersTwitch);
if (channelInfo != null) {
final assets = [
await FFZ.getEmotesGlobal(),
await FFZ.getEmotesChannel(id: channelInfo.id),
await BTTV.getEmotesGlobal(),
await BTTV.getEmotesChannel(id: channelInfo.id),
await Twitch.getEmotesGlobal(headers: auth.headersTwitch),
await Twitch.getEmotesChannel(id: channelInfo.id, headers: auth.headersTwitch),
await Twitch.getBadgesGlobal(headers: auth.headersTwitch),
await Twitch.getBadgesChannel(id: channelInfo.id, headers: auth.headersTwitch),
await SevenTV.getEmotesGlobal(),
await SevenTV.getEmotesChannel(user: channelInfo.login)
];
for (final map in assets) {
if (map != null) {
_assetToUrl.addAll(map);
}
2021-10-01 03:02:14 -04:00
}
}
}
void handleWebsocketData(String ircMessages) {
for (final message in ircMessages.substring(0, ircMessages.length - 2).split('\r\n')) {
// debugPrint(message);
2021-09-11 02:17:09 -04:00
if (message.startsWith('@')) {
final parsedIRCMessage = IRC.parse(message);
switch (parsedIRCMessage.command) {
case 'CLEARCHAT':
_messages = IRC.CLEARCHAT(messages: _messages, ircMessage: parsedIRCMessage);
return;
case 'CLEARMSG':
_messages = IRC.CLEARMSG(messages: _messages, ircMessage: parsedIRCMessage);
return;
case 'GLOBALUSERSTATE':
// Updates the current global user state data (it includes user-id)
globalUserState = message;
return;
case 'PRIVMSG':
_messages = IRC.PRIVMSG(messages: _messages, ircMessage: parsedIRCMessage);
break;
case 'ROOMSTATE':
roomState = message;
return;
case 'USERNOTICE':
debugPrint(message);
// _messages = IRC.USERNOTICE(messages: _messages, ircMessage: parsedIRCMessage);
break;
case 'USERSTATE':
// Updates the current user-state data
userState = message;
return;
}
} else if (message.startsWith('P')) {
2021-09-11 02:17:09 -04:00
channel.sink.add('PONG :tmi.twitch.tv');
return;
}
}
if (_autoScroll) {
if (_messages.length > 200) {
_messages.removeRange(0, _messages.length - 180);
}
SchedulerBinding.instance?.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
2021-09-11 02:17:09 -04:00
}
Widget renderChatMessage(IRCMessage ircMessage) {
final result = <InlineSpan>[];
final emoteTags = ircMessage.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 = ircMessage.message!.substring(startIndex, endIndex + 1);
_emoteIdToWord[emoteId] = emoteWord;
_assetToUrl[emoteWord] = 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0';
}
}
final words = ircMessage.message!.split(' ');
final badges = ircMessage.tags['badges'];
if (badges != null) {
for (final badge in badges.split(',')) {
final badgeUrl = _assetToUrl[badge];
if (badgeUrl != null) {
result.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
2021-09-09 22:30:29 -04:00
child: CachedNetworkImage(
imageUrl: badgeUrl,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: 20,
),
),
);
result.add(const TextSpan(text: ' '));
}
}
}
result.add(
TextSpan(
text: ircMessage.tags['display-name']!,
style: TextStyle(
color: HexColor.fromHex(ircMessage.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,
2021-09-09 22:30:29 -04:00
child: CachedNetworkImage(
imageUrl: emoteUrl,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: 25,
),
),
);
} else {
result.add(const TextSpan(text: ' '));
result.add(TextSpan(text: word));
}
}
if (ircMessage.command == 'CLEARCHAT' || ircMessage.command == 'CLEARMSG') {
final banDuration = ircMessage.tags['ban-duration'];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0),
child: Opacity(
opacity: 0.50,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChatMessage(
key: Key(ircMessage.tags['id']!),
children: result,
),
banDuration == null
? (ircMessage.command == 'CLEARMSG')
? const Text('Message deleted.')
: const Text('Permanently Banned.')
: Text(
'Timed out for $banDuration seconds.',
style: const TextStyle(fontWeight: FontWeight.bold),
)
],
),
),
);
} else {
return ChatMessage(
key: ircMessage.tags['id'] == null ? null : Key(ircMessage.tags['id']!),
children: result,
);
}
}
void sendMessage(String message) {
if (message.isEmpty) {
return;
}
_channel.sink.add('PRIVMSG #$channelName :$message');
_messages = IRC.addMessage(messages: _messages, userState: userState!, sentMessage: message);
_textController.clear();
}
2021-10-01 00:08:19 -04:00
@action
2021-09-11 02:17:09 -04:00
void resumeScroll() {
_autoScroll = true;
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
2021-09-11 02:17:09 -04:00
SchedulerBinding.instance?.addPostFrameCallback((_) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
2021-09-11 02:17:09 -04:00
});
}
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')}';
}