Obtain chatters based on chat messages (#262)

* Remove chatters API endpoint

* Get chatters based on chat messages
This commit is contained in:
Tommy 2023-04-29 22:49:50 -04:00 • committed by GitHub
parent 3af5406f60
commit 37297870c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 187 additions and 370 deletions

View file

@ -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<ChatUsers> 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<List<UserBlockedTwitch>> getUserBlockedList({
required String id,

View file

@ -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<ChatDetails> createState() => _ChatDetailsState();
}
class _ChatDetailsState extends State<ChatDetails> {
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
context: context,
@ -40,15 +35,15 @@ class _ChatDetailsState extends State<ChatDetails> {
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<ChatDetails> {
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<ChatDetails> {
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<ChatDetails> {
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<ChatDetails> {
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<ChatDetails> {
);
}
Future<void> _showClearDialog() {
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
@ -128,10 +122,7 @@ class _ChatDetailsState extends State<ChatDetails> {
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<ChatDetails> {
);
}
@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<ChatDetails> {
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<ChatDetails> {
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<ChatDetails> {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
Settings(settingsStore: widget.chatStore.settings),
builder: (context) => Settings(settingsStore: chatStore.settings),
),
),
),

View file

@ -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 = <String>{};
@computed
Iterable<List<String>> 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<String> 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<String> 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<void> 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() {

View file

@ -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<Iterable<List<String>>>? _$filteredUsersComputed;
Computed<Iterable<String>>? _$filteredUsersComputed;
@override
Iterable<List<String>> get filteredUsers => (_$filteredUsersComputed ??=
Computed<Iterable<List<String>>>(() => super.filteredUsers,
Iterable<String> get filteredUsers => (_$filteredUsersComputed ??=
Computed<Iterable<String>>(() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers'))
.value;
Computed<List<String>>? _$allChattersComputed;
@override
List<String> get allChatters =>
(_$allChattersComputed ??= Computed<List<String>>(() => 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<void> updateChatters() {
return _$updateChattersAsyncAction.run(() => super.updateChatters());
}
@override
String toString() {
return '''
roomState: ${roomState},
showJumpButton: ${showJumpButton},
filteredUsers: ${filteredUsers},
allChatters: ${allChatters}
filteredUsers: ${filteredUsers}
''';
}
}

View file

@ -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<ChattersList> {
@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<TwitchApi>()
.getUser(
headers: context
.read<AuthStore>()
.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<TwitchApi>()
.getUser(
headers: context
.read<AuthStore>()
.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,
),
),
],
),
],
);
},
),
),
],
);
}

View file

@ -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 &&

View file

@ -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