diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 41f78c50..9c653970 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -5,7 +5,6 @@ import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; -import 'package:frosty/models/chatters.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; @@ -421,21 +420,6 @@ class TwitchApi { } } - /// Returns a [ChatUsers] object containing the names of chatters in the given [userLogin]'s chat. - Future getChatters({required String userLogin}) async { - final uri = - Uri.parse('https://tmi.twitch.tv/group/user/$userLogin/chatters'); - - final response = await _client.get(uri); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - - return ChatUsers.fromJson(decoded); - } else { - return Future.error('Failed to get chatters'); - } - } - /// Returns a user's list of blocked users given their id. Future> getUserBlockedList({ required String id, diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 58cfbccf..544a9705 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -13,7 +13,7 @@ import 'package:frosty/widgets/button.dart'; import 'package:frosty/widgets/dialog.dart'; import 'package:frosty/widgets/list_tile.dart'; -class ChatDetails extends StatefulWidget { +class ChatDetails extends StatelessWidget { final ChatDetailsStore chatDetailsStore; final ChatStore chatStore; final String userLogin; @@ -25,11 +25,6 @@ class ChatDetails extends StatefulWidget { required this.userLogin, }) : super(key: key); - @override - State createState() => _ChatDetailsState(); -} - -class _ChatDetailsState extends State { Future _showSleepTimerDialog(BuildContext context) { return showDialog( context: context, @@ -40,15 +35,15 @@ class _ChatDetailsState extends State { mainAxisSize: MainAxisSize.min, children: [ Opacity( - opacity: widget.chatStore.sleepTimer != null && - widget.chatStore.sleepTimer!.isActive + opacity: chatStore.sleepTimer != null && + chatStore.sleepTimer!.isActive ? 1.0 : 0.5, child: Row( children: [ const Icon(Icons.timer_rounded), Text( - ' ${widget.chatStore.timeRemaining.toString().split('.')[0]}', + ' ${chatStore.timeRemaining.toString().split('.')[0]}', style: const TextStyle( fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()]), @@ -56,7 +51,7 @@ class _ChatDetailsState extends State { const Spacer(), IconButton( tooltip: 'Cancel sleep timer', - onPressed: widget.chatStore.cancelSleepTimer, + onPressed: chatStore.cancelSleepTimer, icon: const Icon(Icons.cancel_rounded), ), ], @@ -65,13 +60,12 @@ class _ChatDetailsState extends State { Row( children: [ DropdownButton( - value: widget.chatStore.sleepHours, + value: chatStore.sleepHours, items: List.generate(24, (index) => index) .map((e) => DropdownMenuItem( value: e, child: Text(e.toString()))) .toList(), - onChanged: (int? hours) => - widget.chatStore.sleepHours = hours!, + onChanged: (int? hours) => chatStore.sleepHours = hours!, menuMaxHeight: 200, ), const SizedBox(width: 10.0), @@ -81,13 +75,13 @@ class _ChatDetailsState extends State { Row( children: [ DropdownButton( - value: widget.chatStore.sleepMinutes, + value: chatStore.sleepMinutes, items: List.generate(60, (index) => index) .map((e) => DropdownMenuItem( value: e, child: Text(e.toString()))) .toList(), onChanged: (int? minutes) => - widget.chatStore.sleepMinutes = minutes!, + chatStore.sleepMinutes = minutes!, menuMaxHeight: 200, ), const SizedBox(width: 10.0), @@ -100,13 +94,13 @@ class _ChatDetailsState extends State { actions: [ Observer( builder: (context) => Button( - onPressed: widget.chatStore.sleepHours == 0 && - widget.chatStore.sleepMinutes == 0 - ? null - : () => widget.chatStore.updateSleepTimer( - onTimerFinished: () => navigatorKey.currentState - ?.popUntil((route) => route.isFirst), - ), + onPressed: + chatStore.sleepHours == 0 && chatStore.sleepMinutes == 0 + ? null + : () => chatStore.updateSleepTimer( + onTimerFinished: () => navigatorKey.currentState + ?.popUntil((route) => route.isFirst), + ), child: const Text('Set timer'), ), ), @@ -120,7 +114,7 @@ class _ChatDetailsState extends State { ); } - Future _showClearDialog() { + Future _showClearDialog(BuildContext context) { return showDialog( context: context, builder: (context) => FrostyDialog( @@ -128,10 +122,7 @@ class _ChatDetailsState extends State { message: 'Are you sure you want to clear your recent emotes?', actions: [ Button( - onPressed: () { - setState(widget.chatStore.assetsStore.recentEmotes.clear); - Navigator.pop(context); - }, + onPressed: Navigator.of(context).pop, child: const Text('Yes'), ), Button( @@ -144,17 +135,11 @@ class _ChatDetailsState extends State { ); } - @override - void initState() { - super.initState(); - widget.chatDetailsStore.updateChatters(); - } - @override Widget build(BuildContext context) { final children = [ ListTile( - title: ChatModes(roomState: widget.chatDetailsStore.roomState), + title: ChatModes(roomState: chatDetailsStore.roomState), ), FrostyListTile( leading: const Icon(Icons.people_outline), @@ -169,9 +154,9 @@ class _ChatDetailsState extends State { child: GestureDetector( onTap: FocusScope.of(context).unfocus, child: ChattersList( - chatDetailsStore: widget.chatDetailsStore, - chatStore: widget.chatStore, - userLogin: widget.userLogin, + chatDetailsStore: chatDetailsStore, + chatStore: chatStore, + userLogin: userLogin, ), ), ), @@ -186,24 +171,24 @@ class _ChatDetailsState extends State { FrostyListTile( leading: const Icon(Icons.delete_outline_rounded), title: 'Clear recent emotes', - onTap: _showClearDialog, + onTap: () => _showClearDialog(context), ), FrostyListTile( leading: const Icon(Icons.refresh_rounded), title: 'Reconnect to chat', onTap: () { - widget.chatStore.updateNotification('Reconnecting to chat...'); + chatStore.updateNotification('Reconnecting to chat...'); - widget.chatStore.connectToChat(); + chatStore.connectToChat(); }, ), FrostyListTile( leading: const Icon(Icons.refresh_rounded), title: 'Refresh badges and emotes', onTap: () async { - await widget.chatStore.getAssets(); + await chatStore.getAssets(); - widget.chatStore.updateNotification('Badges and emotes refreshed'); + chatStore.updateNotification('Badges and emotes refreshed'); }, ), FrostyListTile( @@ -212,8 +197,7 @@ class _ChatDetailsState extends State { onTap: () => Navigator.push( context, MaterialPageRoute( - builder: (context) => - Settings(settingsStore: widget.chatStore.settings), + builder: (context) => Settings(settingsStore: chatStore.settings), ), ), ), diff --git a/lib/screens/channel/chat/details/chat_details_store.dart b/lib/screens/channel/chat/details/chat_details_store.dart index f37716d3..9c5667bb 100644 --- a/lib/screens/channel/chat/details/chat_details_store.dart +++ b/lib/screens/channel/chat/details/chat_details_store.dart @@ -1,8 +1,5 @@ -import 'dart:io'; - import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/models/chatters.dart'; import 'package:frosty/models/irc.dart'; import 'package:mobx/mobx.dart'; @@ -37,33 +34,11 @@ abstract class ChatDetailsStoreBase with Store { var _filterText = ''; /// The list and types of chatters in the chat room. - @readonly - ChatUsers? _chatUsers; + final chatUsers = {}; @computed - Iterable> get filteredUsers => [ - _chatUsers!.chatters.broadcaster, - _chatUsers!.chatters.staff, - _chatUsers!.chatters.admins, - _chatUsers!.chatters.globalMods, - _chatUsers!.chatters.moderators, - _chatUsers!.chatters.vips, - _chatUsers!.chatters.viewers, - ].map((e) => e.where((user) => user.contains(_filterText)).toList()); - - @computed - List get allChatters => [ - ...?_chatUsers?.chatters.broadcaster, - ...?_chatUsers?.chatters.staff, - ...?_chatUsers?.chatters.admins, - ...?_chatUsers?.chatters.globalMods, - ...?_chatUsers?.chatters.moderators, - ...?_chatUsers?.chatters.vips, - ...?_chatUsers?.chatters.viewers, - ]; - - @readonly - String? _error; + Iterable get filteredUsers => + chatUsers.where((user) => user.contains(_filterText)); ChatDetailsStoreBase({required this.twitchApi, required this.channelName}) { scrollController.addListener(() { @@ -76,21 +51,6 @@ abstract class ChatDetailsStoreBase with Store { }); textController.addListener(() => _filterText = textController.text); - - updateChatters(); - } - - @action - Future updateChatters() async { - try { - _chatUsers = await twitchApi.getChatters(userLogin: channelName); - _error = null; - } on SocketException { - _error = 'Failed to connect'; - } catch (e) { - debugPrint(e.toString()); - _error = e.toString(); - } } void dispose() { diff --git a/lib/screens/channel/chat/details/chat_details_store.g.dart b/lib/screens/channel/chat/details/chat_details_store.g.dart index a5fb1827..6cd8e900 100644 --- a/lib/screens/channel/chat/details/chat_details_store.g.dart +++ b/lib/screens/channel/chat/details/chat_details_store.g.dart @@ -9,20 +9,13 @@ part of 'chat_details_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, no_leading_underscores_for_local_identifiers mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { - Computed>>? _$filteredUsersComputed; + Computed>? _$filteredUsersComputed; @override - Iterable> get filteredUsers => (_$filteredUsersComputed ??= - Computed>>(() => super.filteredUsers, + Iterable get filteredUsers => (_$filteredUsersComputed ??= + Computed>(() => super.filteredUsers, name: 'ChatDetailsStoreBase.filteredUsers')) .value; - Computed>? _$allChattersComputed; - - @override - List get allChatters => - (_$allChattersComputed ??= Computed>(() => super.allChatters, - name: 'ChatDetailsStoreBase.allChatters')) - .value; late final _$roomStateAtom = Atom(name: 'ChatDetailsStoreBase.roomState', context: context); @@ -74,57 +67,12 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { }); } - late final _$_chatUsersAtom = - Atom(name: 'ChatDetailsStoreBase._chatUsers', context: context); - - ChatUsers? get chatUsers { - _$_chatUsersAtom.reportRead(); - return super._chatUsers; - } - - @override - ChatUsers? get _chatUsers => chatUsers; - - @override - set _chatUsers(ChatUsers? value) { - _$_chatUsersAtom.reportWrite(value, super._chatUsers, () { - super._chatUsers = value; - }); - } - - late final _$_errorAtom = - Atom(name: 'ChatDetailsStoreBase._error', context: context); - - String? get error { - _$_errorAtom.reportRead(); - return super._error; - } - - @override - String? get _error => error; - - @override - set _error(String? value) { - _$_errorAtom.reportWrite(value, super._error, () { - super._error = value; - }); - } - - late final _$updateChattersAsyncAction = - AsyncAction('ChatDetailsStoreBase.updateChatters', context: context); - - @override - Future updateChatters() { - return _$updateChattersAsyncAction.run(() => super.updateChatters()); - } - @override String toString() { return ''' roomState: ${roomState}, showJumpButton: ${showJumpButton}, -filteredUsers: ${filteredUsers}, -allChatters: ${allChatters} +filteredUsers: ${filteredUsers} '''; } } diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 9ac3929e..5eabc983 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -8,10 +7,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/scroll_to_top_button.dart'; -import 'package:frosty/widgets/section_header.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -34,210 +30,155 @@ class ChattersList extends StatefulWidget { class _ChattersListState extends State { @override Widget build(BuildContext context) { - const headers = [ - 'Broadcaster', - 'Staff', - 'Admins', - 'Global moderators', - 'Moderators', - 'VIPs', - 'Viewers', - ]; - - return Observer( - builder: (context) { - if (widget.chatDetailsStore.error != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AlertMessage( - message: 'Failed to get chatters', - ), - const SizedBox(height: 10.0), - Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - onPressed: widget.chatDetailsStore.updateChatters, - child: const Text('Try again'), - ) - ], - ); - } - - if (widget.chatDetailsStore.chatUsers == null) { - return const LoadingIndicator(subtitle: 'Getting chatters...'); - } - - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(10.0), - child: Observer( - builder: (context) { - return TextField( - controller: widget.chatDetailsStore.textController, - focusNode: widget.chatDetailsStore.textFieldFocusNode, - autocorrect: false, - decoration: InputDecoration( - contentPadding: EdgeInsets.zero, - prefixIcon: const Icon(Icons.filter_list_rounded), - hintText: 'Filter chatters', - suffixIcon: widget.chatDetailsStore.textFieldFocusNode - .hasFocus || - widget.chatDetailsStore.filterText.isNotEmpty - ? IconButton( - tooltip: - widget.chatDetailsStore.filterText.isEmpty - ? 'Cancel' - : 'Clear', - onPressed: () { - if (widget - .chatDetailsStore.filterText.isEmpty) { - widget.chatDetailsStore.textFieldFocusNode - .unfocus(); - } - widget.chatDetailsStore.textController.clear(); - }, - icon: const Icon(Icons.close_rounded), - ) - : null, - ), - ); - }, - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - HapticFeedback.lightImpact(); - - widget.chatDetailsStore.updateChatters(); - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - Observer( - builder: (context) { - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: widget.chatDetailsStore.scrollController, - slivers: [ - if (widget.chatDetailsStore.filterText.isEmpty) - SliverPadding( - padding: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 20.0), - sliver: SliverToBoxAdapter( - child: Text( - '${NumberFormat().format(widget.chatDetailsStore.chatUsers?.chatterCount)} in chat', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, - ), - ), - ), - ), - if (widget - .chatDetailsStore.chatUsers?.chatterCount == - 0) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: AlertMessage( - message: 'No chatters found'), - ), - ) - else if (widget.chatDetailsStore.filteredUsers - .expand((element) => element) - .isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: AlertMessage( - message: 'No matching chatters'), - ), - ) - else - ...widget.chatDetailsStore.filteredUsers - .expandIndexed( - (index, users) => [ - if (users.isNotEmpty) ...[ - SliverPadding( - padding: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 20.0), - sliver: SliverToBoxAdapter( - child: SectionHeader( - headers[index], - padding: const EdgeInsets.symmetric( - vertical: 5.0), - ), - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => InkWell( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, - vertical: 5.0), - child: Text(users[index]), - ), - onLongPress: () async { - HapticFeedback.lightImpact(); - - final userInfo = await context - .read() - .getUser( - headers: context - .read() - .headersTwitch, - userLogin: users[index]); - - if (!mounted) return; - - showModalBottomSheet( - backgroundColor: - Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => - ChatUserModal( - chatStore: widget.chatStore, - username: userInfo.login, - userId: userInfo.id, - displayName: - userInfo.displayName, - ), - ); - }, - ), - childCount: users.length, - ), - ), - ] - ], - ), - ], - ); - }, - ), - Observer( - builder: (context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: widget.chatDetailsStore.showJumpButton - ? ScrollToTopButton( - scrollController: - widget.chatDetailsStore.scrollController) - : null, - ), - ), - ], + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Observer( + builder: (context) { + return TextField( + controller: widget.chatDetailsStore.textController, + focusNode: widget.chatDetailsStore.textFieldFocusNode, + autocorrect: false, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + prefixIcon: const Icon(Icons.filter_list_rounded), + hintText: 'Filter chatters', + suffixIcon: widget + .chatDetailsStore.textFieldFocusNode.hasFocus || + widget.chatDetailsStore.filterText.isNotEmpty + ? IconButton( + tooltip: widget.chatDetailsStore.filterText.isEmpty + ? 'Cancel' + : 'Clear', + onPressed: () { + if (widget.chatDetailsStore.filterText.isEmpty) { + widget.chatDetailsStore.textFieldFocusNode + .unfocus(); + } + widget.chatDetailsStore.textController.clear(); + }, + icon: const Icon(Icons.close_rounded), + ) + : null, ), - ), + ); + }, + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () async { + HapticFeedback.lightImpact(); + + setState(() {}); + }, + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + Observer( + builder: (context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: widget.chatDetailsStore.scrollController, + slivers: [ + if (widget.chatDetailsStore.filterText.isEmpty) + SliverPadding( + padding: const EdgeInsets.only( + left: 10.0, + right: 10.0, + top: 20.0, + bottom: 10.0, + ), + sliver: SliverToBoxAdapter( + child: Text( + '${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + ), + ), + ), + if (widget.chatDetailsStore.chatUsers.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: AlertMessage(message: 'No chatters found'), + ), + ) + else if (widget.chatDetailsStore.filteredUsers.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: + AlertMessage(message: 'No matching chatters'), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => InkWell( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 5.0), + child: Text(widget + .chatDetailsStore.filteredUsers + .elementAt(index)), + ), + onLongPress: () async { + HapticFeedback.lightImpact(); + + final userInfo = await context + .read() + .getUser( + headers: context + .read() + .headersTwitch, + userLogin: widget + .chatDetailsStore.filteredUsers + .elementAt(index)); + + if (!mounted) return; + + showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: widget.chatStore, + username: userInfo.login, + userId: userInfo.id, + displayName: userInfo.displayName, + ), + ); + }, + ), + childCount: + widget.chatDetailsStore.filteredUsers.length, + ), + ), + ], + ); + }, + ), + Observer( + builder: (context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: widget.chatDetailsStore.showJumpButton + ? ScrollToTopButton( + scrollController: + widget.chatDetailsStore.scrollController) + : null, + ), + ), + ], ), - ], - ); - }, + ), + ), + ], ); } diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 926f4b2d..493aebd6 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -169,7 +169,6 @@ abstract class ChatStoreBase with Store { const Duration(milliseconds: 200), (timer) => addMessages()); assetsStore.init(); - chatDetailsStore.updateChatters(); _messageBuffer .add(IRCMessage.createNotice(message: 'Connecting to chat...')); @@ -197,9 +196,6 @@ abstract class ChatStoreBase with Store { if (textFieldFocusNode.hasFocus) { // Hide the emote menu if it is currently shown. if (assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false; - - // Refresh the chatters list to keep autocomplete mentions updated. - if (settings.autocomplete) chatDetailsStore.updateChatters(); } // Un-expand the chat when unfocusing. @@ -234,6 +230,10 @@ abstract class ChatStoreBase with Store { final parsedIRCMessage = IRCMessage.fromString(message, userLogin: auth.user.details?.login); + if (parsedIRCMessage.user != null) { + chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); + } + // Filter messages from any blocked users if not a moderator or not the channel owner. if (!_userState.mod && channelName != auth.user.details?.login && diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index c1de24b1..9c8d7de4 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -43,7 +43,7 @@ class ChatBottomBar extends StatelessWidget { chatStore.textController.text.split(' ').last.toLowerCase())) .toList(); - final matchingChatters = chatStore.chatDetailsStore.allChatters + final matchingChatters = chatStore.chatDetailsStore.chatUsers .where((chatter) => chatter.contains(chatStore.textController.text .split(' ') .last