diff --git a/lib/models/irc_message.dart b/lib/models/irc_message.dart index d21c604d..122ed6db 100644 --- a/lib/models/irc_message.dart +++ b/lib/models/irc_message.dart @@ -53,7 +53,6 @@ class IRCMessage { /// Returns a list of messages where the gievn CLEARMSG message is applied. static List clearMessage({required List 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 mark the message for deletion. @@ -402,27 +401,27 @@ class IRCMessage { /// The object representation of the IRC ROOMSTATE message. class ROOMSTATE { - final bool emoteOnly; + final String emoteOnly; final String followersOnly; - final bool r9k; + final String r9k; final String slowMode; - final bool subMode; + final String subMode; const ROOMSTATE({ - this.emoteOnly = false, + this.emoteOnly = '0', this.followersOnly = '-1', - this.r9k = false, + this.r9k = '0', this.slowMode = '0', - this.subMode = false, + this.subMode = '0', }); /// Create a new copy with the parameters from the provided [IRCMessage] ROOMSTATE fromIRCMessage(IRCMessage ircMessage) => ROOMSTATE( - emoteOnly: ircMessage.tags['emote-only'] == '0' ? false : true, + emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly, followersOnly: ircMessage.tags['followers-only'] ?? followersOnly, - r9k: ircMessage.tags['r9k'] == '0' ? false : true, + r9k: ircMessage.tags['r9k'] ?? r9k, slowMode: ircMessage.tags['slow'] ?? slowMode, - subMode: ircMessage.tags['subs-only'] == '0' ? false : true, + subMode: ircMessage.tags['subs-only'] ?? subMode, ); } diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 0ddea58f..4eb24573 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -45,7 +45,7 @@ class _ChatState extends State with WidgetsBindingObserver { onTap: () { // If tapping chat, hide the keyboard and emote menu. FocusManager.instance.primaryFocus?.unfocus(); - if (chatStore.showEmoteMenu) chatStore.showEmoteMenu = false; + if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false; }, child: Observer( builder: (context) => ListView.separated( @@ -58,7 +58,7 @@ class _ChatState extends State with WidgetsBindingObserver { itemBuilder: (context, index) => Observer( builder: (context) => ChatMessage( ircMessage: chatStore.messages[index], - chatStore: chatStore, + assetsStore: chatStore.assetsStore, hideMessageIfBanned: context.read().hideBannedMessages, zeroWidth: context.read().zeroWidthEnabled, ), @@ -83,7 +83,13 @@ class _ChatState extends State with WidgetsBindingObserver { ), ), if (context.read().isLoggedIn) ChatBottomBar(chatStore: chatStore), - if (chatStore.showEmoteMenu) Expanded(child: EmoteMenu(chatStore: chatStore)), + if (chatStore.assetsStore.showEmoteMenu) + Expanded( + child: EmoteMenu( + assetsStore: chatStore.assetsStore, + textController: chatStore.textController, + ), + ), ], ), ); diff --git a/lib/screens/channel/chat/chat_assets_store.dart b/lib/screens/channel/chat/chat_assets_store.dart new file mode 100644 index 00000000..0d3932f4 --- /dev/null +++ b/lib/screens/channel/chat/chat_assets_store.dart @@ -0,0 +1,101 @@ +import 'package:frosty/api/bttv_api.dart'; +import 'package:frosty/api/ffz_api.dart'; +import 'package:frosty/api/seventv_api.dart'; +import 'package:frosty/api/twitch_api.dart'; +import 'package:frosty/models/badges.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:mobx/mobx.dart'; + +part 'chat_assets_store.g.dart'; + +class ChatAssetsStore = _ChatAssetsStoreBase with _$ChatAssetsStore; + +abstract class _ChatAssetsStoreBase with Store { + /// The map of emote words to their image or GIF URL. May be used by anyone in the chat. + final emoteToObject = ObservableMap(); + + @computed + List get bttvEmotes => emoteToObject.values.where((emote) => isBTTV(emote)).toList(); + + @computed + List get ffzEmotes => emoteToObject.values.where((emote) => isFFZ(emote)).toList(); + + @computed + List get sevenTvEmotes => emoteToObject.values.where((emote) => is7TV(emote)).toList(); + + /// The emotes that are "owned" and may be used by the current user. + @readonly + var _userEmoteToObject = ObservableMap(); + + /// The map of badges ids to their object representation. + final badgesToObject = {}; + + /// The current index of the emote menu stack. + @observable + var emoteMenuIndex = 0; + + /// Whether or not the emote menu is visible. + @observable + var showEmoteMenu = false; + + bool isBTTV(Emote emote) { + return emote.type == EmoteType.bttvChannel || emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.bttvShared; + } + + bool isFFZ(Emote emote) { + return emote.type == EmoteType.ffzChannel || emote.type == EmoteType.ffzGlobal; + } + + bool is7TV(Emote emote) { + return emote.type == EmoteType.sevenTvChannel || emote.type == EmoteType.sevenTvGlobal; + } + + /// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl] + @action + Future getAssets({required String channelName, required Map headers}) async { + // Fetch the desired channel/user's information. + final channelInfo = await Twitch.getUser(userLogin: channelName, headers: headers); + + 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), + ...await BTTV.getEmotesGlobal(), + ...await BTTV.getEmotesChannel(id: channelInfo.id), + ...await Twitch.getEmotesGlobal(headers: headers), + ...await Twitch.getEmotesChannel(id: channelInfo.id, headers: headers), + ...await SevenTV.getEmotesGlobal(), + ...await SevenTV.getEmotesChannel(user: channelInfo.login) + ]; + + for (final emote in assets) { + emoteToObject[emote.name] = emote; + } + + final badges = [ + await Twitch.getBadgesGlobal(), + await Twitch.getBadgesChannel(id: channelInfo.id), + ]; + + for (final map in badges) { + if (map != null) { + badgesToObject.addAll(map); + } + } + } + } + + @action + Future getUserEmotes({List? emoteSets, required Map headers}) async { + if (emoteSets != null) { + final userEmotes = []; + for (final setId in emoteSets) { + userEmotes.addAll(await Twitch.getEmotesSets(setId: setId, headers: headers)); + } + + _userEmoteToObject = {for (final emote in userEmotes) emote.name: emote}.asObservable(); + } + } +} diff --git a/lib/screens/channel/chat/chat_assets_store.g.dart b/lib/screens/channel/chat/chat_assets_store.g.dart new file mode 100644 index 00000000..a2fb4492 --- /dev/null +++ b/lib/screens/channel/chat/chat_assets_store.g.dart @@ -0,0 +1,112 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_assets_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// 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 _$ChatAssetsStore on _ChatAssetsStoreBase, Store { + Computed>? _$bttvEmotesComputed; + + @override + List get bttvEmotes => + (_$bttvEmotesComputed ??= Computed>(() => super.bttvEmotes, + name: '_ChatAssetsStoreBase.bttvEmotes')) + .value; + Computed>? _$ffzEmotesComputed; + + @override + List get ffzEmotes => + (_$ffzEmotesComputed ??= Computed>(() => super.ffzEmotes, + name: '_ChatAssetsStoreBase.ffzEmotes')) + .value; + Computed>? _$sevenTvEmotesComputed; + + @override + List get sevenTvEmotes => (_$sevenTvEmotesComputed ??= + Computed>(() => super.sevenTvEmotes, + name: '_ChatAssetsStoreBase.sevenTvEmotes')) + .value; + + final _$_userEmoteToObjectAtom = + Atom(name: '_ChatAssetsStoreBase._userEmoteToObject'); + + ObservableMap get userEmoteToObject { + _$_userEmoteToObjectAtom.reportRead(); + return super._userEmoteToObject; + } + + @override + ObservableMap get _userEmoteToObject => userEmoteToObject; + + @override + set _userEmoteToObject(ObservableMap value) { + _$_userEmoteToObjectAtom.reportWrite(value, super._userEmoteToObject, () { + super._userEmoteToObject = value; + }); + } + + final _$emoteMenuIndexAtom = + Atom(name: '_ChatAssetsStoreBase.emoteMenuIndex'); + + @override + int get emoteMenuIndex { + _$emoteMenuIndexAtom.reportRead(); + return super.emoteMenuIndex; + } + + @override + set emoteMenuIndex(int value) { + _$emoteMenuIndexAtom.reportWrite(value, super.emoteMenuIndex, () { + super.emoteMenuIndex = value; + }); + } + + final _$showEmoteMenuAtom = Atom(name: '_ChatAssetsStoreBase.showEmoteMenu'); + + @override + bool get showEmoteMenu { + _$showEmoteMenuAtom.reportRead(); + return super.showEmoteMenu; + } + + @override + set showEmoteMenu(bool value) { + _$showEmoteMenuAtom.reportWrite(value, super.showEmoteMenu, () { + super.showEmoteMenu = value; + }); + } + + final _$getAssetsAsyncAction = AsyncAction('_ChatAssetsStoreBase.getAssets'); + + @override + Future getAssets( + {required String channelName, required Map headers}) { + return _$getAssetsAsyncAction + .run(() => super.getAssets(channelName: channelName, headers: headers)); + } + + final _$getUserEmotesAsyncAction = + AsyncAction('_ChatAssetsStoreBase.getUserEmotes'); + + @override + Future getUserEmotes( + {List? emoteSets, required Map headers}) { + return _$getUserEmotesAsyncAction + .run(() => super.getUserEmotes(emoteSets: emoteSets, headers: headers)); + } + + @override + String toString() { + return ''' +emoteMenuIndex: ${emoteMenuIndex}, +showEmoteMenu: ${showEmoteMenu}, +bttvEmotes: ${bttvEmotes}, +ffzEmotes: ${ffzEmotes}, +sevenTvEmotes: ${sevenTvEmotes} + '''; + } +} diff --git a/lib/screens/channel/chat/chat_bottom_bar.dart b/lib/screens/channel/chat/chat_bottom_bar.dart index 7ebc4b99..915a0071 100644 --- a/lib/screens/channel/chat/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/chat_bottom_bar.dart @@ -15,7 +15,10 @@ class ChatBottomBar extends StatelessWidget { icon: Icon(Icons.adaptive.more), onPressed: () => showModalBottomSheet( context: context, - builder: (_) => ChatDetails(chatStore: chatStore), + builder: (_) => ChatDetails( + chatDetails: chatStore.chatDetailsStore, + userLogin: chatStore.channelName, + ), ), ), Expanded( @@ -24,13 +27,13 @@ class ChatBottomBar extends StatelessWidget { child: TextField( minLines: 1, maxLines: 5, - onTap: () => chatStore.showEmoteMenu = false, + onTap: () => chatStore.assetsStore.showEmoteMenu = false, decoration: InputDecoration( suffixIcon: IconButton( icon: const Icon(Icons.emoji_emotions_outlined), onPressed: () { FocusManager.instance.primaryFocus?.unfocus(); - chatStore.showEmoteMenu = !chatStore.showEmoteMenu; + chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu; }, ), isDense: true, diff --git a/lib/screens/channel/chat/chat_message.dart b/lib/screens/channel/chat/chat_message.dart index 028e8fc4..84143c07 100644 --- a/lib/screens/channel/chat/chat_message.dart +++ b/lib/screens/channel/chat/chat_message.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/irc_message.dart'; -import 'package:frosty/screens/channel/chat/chat_store.dart'; +import 'package:frosty/screens/channel/chat/chat_assets_store.dart'; class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; - final ChatStore chatStore; + final ChatAssetsStore assetsStore; final bool hideMessageIfBanned; final bool zeroWidth; const ChatMessage({ Key? key, required this.ircMessage, - required this.chatStore, + required this.assetsStore, this.hideMessageIfBanned = true, this.zeroWidth = false, }) : super(key: key); @@ -25,8 +25,8 @@ class ChatMessage extends StatelessWidget { return Text.rich( TextSpan( children: ircMessage.generateSpan( - emoteToObject: chatStore.emoteToObject, - badgeToObject: chatStore.badgesToObject, + emoteToObject: assetsStore.emoteToObject, + badgeToObject: assetsStore.badgesToObject, zeroWidthEnabled: zeroWidth, ), ), @@ -43,8 +43,8 @@ class ChatMessage extends StatelessWidget { Text.rich( TextSpan( children: ircMessage.generateSpan( - emoteToObject: chatStore.emoteToObject, - badgeToObject: chatStore.badgesToObject, + emoteToObject: assetsStore.emoteToObject, + badgeToObject: assetsStore.badgesToObject, hideMessage: hideMessageIfBanned, zeroWidthEnabled: zeroWidth, ), @@ -85,8 +85,8 @@ class ChatMessage extends StatelessWidget { Text.rich( TextSpan( children: ircMessage.generateSpan( - emoteToObject: chatStore.emoteToObject, - badgeToObject: chatStore.badgesToObject, + emoteToObject: assetsStore.emoteToObject, + badgeToObject: assetsStore.badgesToObject, zeroWidthEnabled: zeroWidth, ), ), diff --git a/lib/screens/channel/chat/chat_store.dart b/lib/screens/channel/chat/chat_store.dart index 235e3228..64bfb083 100644 --- a/lib/screens/channel/chat/chat_store.dart +++ b/lib/screens/channel/chat/chat_store.dart @@ -2,15 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:frosty/api/bttv_api.dart'; -import 'package:frosty/api/ffz_api.dart'; -import 'package:frosty/api/seventv_api.dart'; -import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/core/settings/settings_store.dart'; -import 'package:frosty/models/badges.dart'; -import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/irc_message.dart'; +import 'package:frosty/screens/channel/chat/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:mobx/mobx.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -20,54 +16,33 @@ part 'chat_store.g.dart'; class ChatStore = _ChatStoreBase with _$ChatStore; abstract class _ChatStoreBase with Store { + /// The provided auth store to determine login status, get the token, and use the headers for requests. + final AuthStore auth; + + /// The provided setting store to account for any user-defined behaviors. + final SettingsStore settings; + + /// The name of the channel to connect to. + final String channelName; + /// The Twitch IRC WebSocket channel. final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443')); - /// The map of emote words to their image or GIF URL. May be used by anyone in the chat. - final emoteToObject = ObservableMap(); - - @computed - List get bttvEmotes => emoteToObject.values.where((emote) => isBTTV(emote)).toList(); - - @computed - List get ffzEmotes => emoteToObject.values.where((emote) => isFFZ(emote)).toList(); - - @computed - List get sevenTvEmotes => emoteToObject.values.where((emote) => is7TV(emote)).toList(); - - /// The emotes that are "owned" and may be used by the current user. - @readonly - var _userEmoteToObject = ObservableMap(); - - /// The map of badges ids to their object representation. - final badgesToObject = {}; - /// The scroll controller that controls auto-scroll and resume-scroll behavior. final scrollController = ScrollController(); /// The text controller that handles the TextField inputs and sending of messages. final textController = TextEditingController(); - /// The name of the channel to connect to. - final String channelName; + /// The chat details store responsible for the chat modes and users in chat. + final chatDetailsStore = ChatDetailsStore(); - /// The provided auth store to determine login status, get the token, and use the headers for requests. - final AuthStore auth; - - /// The provided setting store to account for any user-defined behaviors. - final SettingsStore settings; + /// The assets store responsible for badges, emotes, and the emote menu. + final assetsStore = ChatAssetsStore(); /// Requested message to be sent by the user. Will only be sent on receival of a USERNOTICE command. IRCMessage? toSend; - /// The current index of the emote menu stack. - @observable - var emoteMenuIndex = 0; - - /// Whether or not the emote menu is visible. - @observable - var showEmoteMenu = false; - /// The list of chat messages to render and display. @readonly var _messages = ObservableList(); @@ -80,10 +55,6 @@ abstract class _ChatStoreBase with Store { @readonly var _userState = const USERSTATE(); - /// The rules and modes being used in the chat. - @readonly - var _roomState = const ROOMSTATE(); - _ChatStoreBase({ required this.auth, required this.settings, @@ -92,7 +63,7 @@ abstract class _ChatStoreBase with Store { // Create a reaction where anytime the emote menu is shown or hidden, // scroll to the bottom of the list. This will prevent the emote menu // from covering the latest messages when summoned. - final disposeEmoteMenuReaction = reaction((_) => showEmoteMenu, (_) { + final disposeEmoteMenuReaction = reaction((_) => assetsStore.showEmoteMenu, (_) { SchedulerBinding.instance?.addPostFrameCallback((_) { if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent); }); @@ -115,8 +86,16 @@ abstract class _ChatStoreBase with Store { }, ); + // Fetch the users in chat. + chatDetailsStore.updateChatters(channelName); + // Fetch the assets used in chat including badges and emotes. - getAssets(); + _messages.add(IRCMessage.createNotice(message: 'Fetching channel assets...')); + + assetsStore + .getAssets(channelName: channelName, headers: auth.headersTwitch) + .then((_) => _messages.add(IRCMessage.createNotice(message: 'Channel assets fetched!'))) + .onError((error, stackTrace) => _messages.add(IRCMessage.createNotice(message: 'Failed to fetch assets: ${error.toString()}'))); // The list of messages sent to the IRC WebSocket channel to connect and join. final commands = [ @@ -185,7 +164,7 @@ abstract class _ChatStoreBase with Store { _messages.add(parsedIRCMessage); break; case Command.roomState: - _roomState = _roomState.fromIRCMessage(parsedIRCMessage); + chatDetailsStore.roomState = chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage); continue; case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); @@ -196,7 +175,11 @@ abstract class _ChatStoreBase with Store { break; case Command.globalUserState: final setIds = parsedIRCMessage.tags['emote-sets']?.split(','); - getUserEmotes(emoteSets: setIds); + _messages.add(IRCMessage.createNotice(message: 'Fetching user emotes...')); + assetsStore + .getUserEmotes(emoteSets: setIds, headers: auth.headersTwitch) + .then((_) => _messages.add(IRCMessage.createNotice(message: 'User emotes fetched!'))) + .onError((error, stackTrace) => _messages.add(IRCMessage.createNotice(message: 'Failed to fetch user emotes: ${error.toString()}'))); continue; case Command.none: debugPrint('Unknown command: ${parsedIRCMessage.command}'); @@ -262,7 +245,7 @@ abstract class _ChatStoreBase with Store { // } final userChatMessage = IRCMessage.fromString(userStateString); - userChatMessage.localEmotes.addAll(_userEmoteToObject); + userChatMessage.localEmotes.addAll(assetsStore.userEmoteToObject); userChatMessage.message = message.trim(); toSend = userChatMessage; } @@ -271,65 +254,6 @@ abstract class _ChatStoreBase with Store { textController.clear(); } - /// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl] - @action - Future getAssets() async { - _messages.add(IRCMessage.createNotice(message: 'Fetching channel assets...')); - - // 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), - ...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 SevenTV.getEmotesGlobal(), - ...await SevenTV.getEmotesChannel(user: channelInfo.login) - ]; - - // assets.sort((a, b) => a.id.compareTo(b.id)); - - for (final emote in assets) { - emoteToObject[emote.name] = emote; - } - - final badges = [ - await Twitch.getBadgesGlobal(), - await Twitch.getBadgesChannel(id: channelInfo.id), - ]; - - for (final map in badges) { - if (map != null) { - badgesToObject.addAll(map); - } - } - - _messages.add(IRCMessage.createNotice(message: 'Channel assets fetched!')); - } - } - - @action - Future getUserEmotes({List? emoteSets}) async { - _messages.add(IRCMessage.createNotice(message: 'Fetching user emotes...')); - - if (emoteSets != null) { - final userEmotes = []; - for (final setId in emoteSets) { - userEmotes.addAll(await Twitch.getEmotesSets(setId: setId, headers: auth.headersTwitch)); - } - - _userEmoteToObject = {for (final emote in userEmotes) emote.name: emote}.asObservable(); - } - - _messages.add(IRCMessage.createNotice(message: 'User emotes fetched!')); - } - /// Pauses or resumes the chat subscription depending on the provided state. void handleAppStateChange(AppLifecycleState state) { switch (state) { @@ -344,18 +268,6 @@ abstract class _ChatStoreBase with Store { } } - bool isBTTV(Emote emote) { - return emote.type == EmoteType.bttvChannel || emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.bttvShared; - } - - bool isFFZ(Emote emote) { - return emote.type == EmoteType.ffzChannel || emote.type == EmoteType.ffzGlobal; - } - - bool is7TV(Emote emote) { - return emote.type == EmoteType.sevenTvChannel || emote.type == EmoteType.sevenTvGlobal; - } - /// Closes and disposes all the channels and controllers used by the store. void dispose() { _channel.sink.close(); diff --git a/lib/screens/channel/chat/chat_store.g.dart b/lib/screens/channel/chat/chat_store.g.dart index f6f7f18e..9c37eeea 100644 --- a/lib/screens/channel/chat/chat_store.g.dart +++ b/lib/screens/channel/chat/chat_store.g.dart @@ -9,76 +9,6 @@ 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 { - Computed>? _$bttvEmotesComputed; - - @override - List get bttvEmotes => - (_$bttvEmotesComputed ??= Computed>(() => super.bttvEmotes, - name: '_ChatStoreBase.bttvEmotes')) - .value; - Computed>? _$ffzEmotesComputed; - - @override - List get ffzEmotes => - (_$ffzEmotesComputed ??= Computed>(() => super.ffzEmotes, - name: '_ChatStoreBase.ffzEmotes')) - .value; - Computed>? _$sevenTvEmotesComputed; - - @override - List get sevenTvEmotes => (_$sevenTvEmotesComputed ??= - Computed>(() => super.sevenTvEmotes, - name: '_ChatStoreBase.sevenTvEmotes')) - .value; - - final _$_userEmoteToObjectAtom = - Atom(name: '_ChatStoreBase._userEmoteToObject'); - - ObservableMap get userEmoteToObject { - _$_userEmoteToObjectAtom.reportRead(); - return super._userEmoteToObject; - } - - @override - ObservableMap get _userEmoteToObject => userEmoteToObject; - - @override - set _userEmoteToObject(ObservableMap value) { - _$_userEmoteToObjectAtom.reportWrite(value, super._userEmoteToObject, () { - super._userEmoteToObject = value; - }); - } - - final _$emoteMenuIndexAtom = Atom(name: '_ChatStoreBase.emoteMenuIndex'); - - @override - int get emoteMenuIndex { - _$emoteMenuIndexAtom.reportRead(); - return super.emoteMenuIndex; - } - - @override - set emoteMenuIndex(int value) { - _$emoteMenuIndexAtom.reportWrite(value, super.emoteMenuIndex, () { - super.emoteMenuIndex = value; - }); - } - - final _$showEmoteMenuAtom = Atom(name: '_ChatStoreBase.showEmoteMenu'); - - @override - bool get showEmoteMenu { - _$showEmoteMenuAtom.reportRead(); - return super.showEmoteMenu; - } - - @override - set showEmoteMenu(bool value) { - _$showEmoteMenuAtom.reportWrite(value, super.showEmoteMenu, () { - super.showEmoteMenu = value; - }); - } - final _$_messagesAtom = Atom(name: '_ChatStoreBase._messages'); ObservableList get messages { @@ -130,39 +60,6 @@ mixin _$ChatStore on _ChatStoreBase, Store { }); } - final _$_roomStateAtom = Atom(name: '_ChatStoreBase._roomState'); - - ROOMSTATE get roomState { - _$_roomStateAtom.reportRead(); - return super._roomState; - } - - @override - ROOMSTATE get _roomState => roomState; - - @override - set _roomState(ROOMSTATE value) { - _$_roomStateAtom.reportWrite(value, super._roomState, () { - super._roomState = value; - }); - } - - final _$getAssetsAsyncAction = AsyncAction('_ChatStoreBase.getAssets'); - - @override - Future getAssets() { - return _$getAssetsAsyncAction.run(() => super.getAssets()); - } - - final _$getUserEmotesAsyncAction = - AsyncAction('_ChatStoreBase.getUserEmotes'); - - @override - Future getUserEmotes({List? emoteSets}) { - return _$getUserEmotesAsyncAction - .run(() => super.getUserEmotes(emoteSets: emoteSets)); - } - final _$_ChatStoreBaseActionController = ActionController(name: '_ChatStoreBase'); @@ -202,11 +99,7 @@ mixin _$ChatStore on _ChatStoreBase, Store { @override String toString() { return ''' -emoteMenuIndex: ${emoteMenuIndex}, -showEmoteMenu: ${showEmoteMenu}, -bttvEmotes: ${bttvEmotes}, -ffzEmotes: ${ffzEmotes}, -sevenTvEmotes: ${sevenTvEmotes} + '''; } } diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 098a0dfa..d595c4b8 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/chat/chat_store.dart'; +import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; +import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; +import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; class ChatDetails extends StatelessWidget { - final ChatStore chatStore; + final ChatDetailsStore chatDetails; + final String userLogin; - const ChatDetails({Key? key, required this.chatStore}) : super(key: key); + const ChatDetails({ + Key? key, + required this.chatDetails, + required this.userLogin, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -14,35 +21,20 @@ class ChatDetails extends StatelessWidget { child: Center( child: Observer( builder: (_) { - return ListView( + return Column( children: [ - const ListTile( - title: Text('Modes'), - ), - ListTile( - leading: const Text('Emote-only'), - trailing: Text(chatStore.roomState.emoteOnly ? 'Enabled' : 'Disabled'), - ), - 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 ? 'Enabled' : 'Disabled'), - ), - 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 ? 'Enabled' : 'Disabled'), + Padding( + padding: const EdgeInsets.all(10.0), + child: ChatModes(roomState: chatDetails.roomState), ), + Expanded( + child: RefreshIndicator( + onRefresh: () => chatDetails.updateChatters(userLogin), + child: ChattersList( + chatUsers: chatDetails.chatters, + ), + ), + ) ], ); }, diff --git a/lib/screens/channel/chat/details/chat_details_store.dart b/lib/screens/channel/chat/details/chat_details_store.dart new file mode 100644 index 00000000..aeb6b216 --- /dev/null +++ b/lib/screens/channel/chat/details/chat_details_store.dart @@ -0,0 +1,23 @@ +import 'package:frosty/api/twitch_api.dart'; +import 'package:frosty/models/chatters.dart'; +import 'package:frosty/models/irc_message.dart'; +import 'package:mobx/mobx.dart'; + +part 'chat_details_store.g.dart'; + +class ChatDetailsStore = _ChatDetailsStoreBase with _$ChatDetailsStore; + +abstract class _ChatDetailsStoreBase with Store { + /// The rules and modes being used in the chat. + @observable + var roomState = const ROOMSTATE(); + + /// The list and types of chatters in the chat room. + @readonly + ChatUsers? _chatters; + + @action + Future updateChatters(String userLogin) async { + _chatters = await Twitch.getChatters(userLogin: userLogin); + } +} diff --git a/lib/screens/channel/chat/details/chat_details_store.g.dart b/lib/screens/channel/chat/details/chat_details_store.g.dart new file mode 100644 index 00000000..b58a958d --- /dev/null +++ b/lib/screens/channel/chat/details/chat_details_store.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_details_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// 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 _$ChatDetailsStore on _ChatDetailsStoreBase, Store { + final _$roomStateAtom = Atom(name: '_ChatDetailsStoreBase.roomState'); + + @override + ROOMSTATE get roomState { + _$roomStateAtom.reportRead(); + return super.roomState; + } + + @override + set roomState(ROOMSTATE value) { + _$roomStateAtom.reportWrite(value, super.roomState, () { + super.roomState = value; + }); + } + + final _$_chattersAtom = Atom(name: '_ChatDetailsStoreBase._chatters'); + + ChatUsers? get chatters { + _$_chattersAtom.reportRead(); + return super._chatters; + } + + @override + ChatUsers? get _chatters => chatters; + + @override + set _chatters(ChatUsers? value) { + _$_chattersAtom.reportWrite(value, super._chatters, () { + super._chatters = value; + }); + } + + final _$updateChattersAsyncAction = + AsyncAction('_ChatDetailsStoreBase.updateChatters'); + + @override + Future updateChatters(String userLogin) { + return _$updateChattersAsyncAction + .run(() => super.updateChatters(userLogin)); + } + + @override + String toString() { + return ''' +roomState: ${roomState} + '''; + } +} diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart new file mode 100644 index 00000000..9a61038c --- /dev/null +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/models/irc_message.dart'; + +class ChatModes extends StatelessWidget { + final ROOMSTATE roomState; + + const ChatModes({Key? key, required this.roomState}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Tooltip( + preferBelow: false, + message: 'Emote-Only: ${roomState.emoteOnly != '0' ? 'Enabled' : 'Disabled'}', + child: Icon( + Icons.emoji_emotions_outlined, + color: roomState.emoteOnly != '0' ? Colors.green : Colors.grey, + ), + ), + Tooltip( + preferBelow: false, + message: roomState.followersOnly == '-1' + ? 'Followers-Only: Disabled' + : roomState.followersOnly == '0' + ? 'Followers-Only: Only followed users can chat.' + : roomState.followersOnly == '1' + ? 'Followers-Only: Users followed for at least ${roomState.followersOnly} minute can chat.' + : 'Followers-Only: Users followed for at least ${roomState.followersOnly} minutes can chat', + child: Icon( + Icons.favorite, + color: roomState.followersOnly != '-1' ? Colors.green : Colors.grey, + ), + ), + Tooltip( + preferBelow: false, + message: 'Unique Chat Messages: ${roomState.r9k != '0' ? 'Enabled' : 'Disabled'}', + child: Text( + 'R9K', + style: TextStyle(color: roomState.r9k != '0' ? Colors.green : Colors.grey), + ), + ), + Tooltip( + preferBelow: false, + message: 'Slow: ${roomState.slowMode == '0' ? 'Disabled' : '${roomState.slowMode} seconds'}', + child: Icon( + Icons.history_toggle_off, + color: roomState.slowMode != '0' ? Colors.green : Colors.grey, + ), + ), + Tooltip( + preferBelow: false, + message: 'Subs-Only: ${roomState.subMode != '0' ? 'Enabled' : 'Disabled'}', + child: Icon( + Icons.attach_money, + color: roomState.subMode != '0' ? Colors.green : Colors.grey, + ), + ), + ], + ); + } +} diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart new file mode 100644 index 00000000..f23f9e64 --- /dev/null +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -0,0 +1,93 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/models/chatters.dart'; +import 'package:intl/intl.dart'; + +class ChattersList extends StatelessWidget { + final ChatUsers? chatUsers; + + const ChattersList({Key? key, required this.chatUsers}) : super(key: key); + + @override + Widget build(BuildContext context) { + final chatters = chatUsers?.chatters; + if (chatters != null) { + const headers = [ + 'Moderators', + 'VIPs', + 'Staff', + 'Admins', + 'Global Moderators', + 'Users', + ]; + + final userTypes = [ + chatters.moderators, + chatters.vips, + chatters.staff, + chatters.admins, + chatters.globalMods, + chatters.viewers, + ]; + + const textStyle = TextStyle(fontWeight: FontWeight.bold); + + return CustomScrollView(slivers: [ + SliverPadding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 10.0), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Users', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), + Text( + '${NumberFormat().format(chatUsers?.chatterCount)} users in chat', + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Broadcaster', + style: textStyle, + ), + const SizedBox(height: 5.0), + if (chatters.broadcaster.isNotEmpty) Text(chatters.broadcaster.first), + ], + ), + ), + ), + ...userTypes.expandIndexed((index, type) => [ + if (type.isNotEmpty) ...[ + SliverPadding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0), + sliver: SliverToBoxAdapter( + child: Text( + headers[index], + style: textStyle, + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 5.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => Text(type[index]), + childCount: type.length, + ), + ), + ), + ] + ]) + ]); + } + return const Center(child: Text('Failed to get chatters :(')); + } +} diff --git a/lib/screens/channel/chat/emote_menu/emote_menu.dart b/lib/screens/channel/chat/emote_menu/emote_menu.dart index 688f1c3f..2ac782fd 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu.dart @@ -1,13 +1,14 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/chat/chat_store.dart'; +import 'package:frosty/screens/channel/chat/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; class EmoteMenu extends StatelessWidget { - final ChatStore chatStore; + final ChatAssetsStore assetsStore; + final TextEditingController textController; - const EmoteMenu({Key? key, required this.chatStore}) : super(key: key); + const EmoteMenu({Key? key, required this.assetsStore, required this.textController}) : super(key: key); @override Widget build(BuildContext context) { @@ -24,31 +25,31 @@ class EmoteMenu extends StatelessWidget { children: [ Expanded( child: PageView( - onPageChanged: (index) => chatStore.emoteMenuIndex = index, + onPageChanged: (index) => assetsStore.emoteMenuIndex = index, controller: pageContoller, children: [ Observer( builder: (_) => EmoteMenuPanel( - textController: chatStore.textController, - emotes: chatStore.userEmoteToObject.values.toList(), + textController: textController, + emotes: assetsStore.userEmoteToObject.values.toList(), ), ), Observer( builder: (_) => EmoteMenuPanel( - textController: chatStore.textController, - emotes: chatStore.bttvEmotes, + textController: textController, + emotes: assetsStore.bttvEmotes, ), ), Observer( builder: (_) => EmoteMenuPanel( - textController: chatStore.textController, - emotes: chatStore.ffzEmotes, + textController: textController, + emotes: assetsStore.ffzEmotes, ), ), Observer( builder: (_) => EmoteMenuPanel( - textController: chatStore.textController, - emotes: chatStore.sevenTvEmotes, + textController: textController, + emotes: assetsStore.sevenTvEmotes, ), ), ], @@ -66,9 +67,9 @@ class EmoteMenu extends StatelessWidget { duration: const Duration(milliseconds: 300), curve: Curves.ease, ); - chatStore.emoteMenuIndex = index; + assetsStore.emoteMenuIndex = index; }, - style: index == chatStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey), + style: index == assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey), child: Text(section), ), ),