From 58bb1d49d3b4bfd09748355c383db0992689782f Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 5 Jan 2022 07:23:19 -0500 Subject: [PATCH] Add chat user message history & blocking --- lib/api/twitch_api.dart | 66 ++++++++------- lib/core/user/user_store.dart | 3 + lib/models/irc_message.dart | 4 +- lib/screens/channel/chat/chat.dart | 34 ++++++-- lib/screens/channel/chat/chat_bottom_bar.dart | 2 +- lib/screens/channel/chat/chat_message.dart | 17 ++-- lib/screens/channel/chat/chat_store.dart | 27 ++---- lib/screens/channel/chat/chat_store.g.dart | 11 --- lib/screens/channel/chat/chat_user_modal.dart | 84 +++++++++++++++++++ lib/screens/channel/video_chat.dart | 4 +- lib/screens/home/home.dart | 1 - .../home/search/search_results_channels.dart | 5 +- .../home/search/stores/search_store.dart | 1 + .../settings/account/blocked_users.dart | 32 ++----- lib/widgets/block_button.dart | 68 +++++++++++++++ 15 files changed, 251 insertions(+), 108 deletions(-) create mode 100644 lib/screens/channel/chat/chat_user_modal.dart create mode 100644 lib/widgets/block_button.dart diff --git a/lib/api/twitch_api.dart b/lib/api/twitch_api.dart index f363c318..39e54c1a 100644 --- a/lib/api/twitch_api.dart +++ b/lib/api/twitch_api.dart @@ -265,37 +265,6 @@ class Twitch { } } - /// Returns a user's list of blocked users given their id. - static Future> getUserBlockedList({required String id, required Map? headers, String? cursor}) async { - final uri = cursor == null - ? Uri.parse('https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id') - : Uri.parse('https://api.twitch.tv/helix/users/blocks?first=100&after=$cursor&broadcaster_id=$id'); - - final response = await http.get(uri, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - - final cursor = decoded['pagination']['cursor']; - final blockedList = decoded['data'] as List; - - if (blockedList.isNotEmpty) { - final result = blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList(); - - if (cursor != null) { - result.addAll(await getUserBlockedList(id: id, cursor: cursor, headers: headers)); - } - - return result; - } else { - debugPrint('User does not have anyone blocked'); - return []; - } - } else { - debugPrint('User does not exist'); - return []; - } - } - /// Returns a channels's info associated with the given ID. static Future getChannel({required String userId, required Map? headers}) async { final response = await http.get(Uri.parse('https://api.twitch.tv/helix/channels?broadcaster_id=$userId'), headers: headers); @@ -391,6 +360,41 @@ class Twitch { } } + /// Returns a user's list of blocked users given their id. + static Future> getUserBlockedList({ + required String id, + required Map? headers, + String? cursor, + }) async { + final uri = cursor == null + ? Uri.parse('https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id') + : Uri.parse('https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor'); + + final response = await http.get(uri, headers: headers); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + + final cursor = decoded['pagination']['cursor']; + final blockedList = decoded['data'] as List; + + if (blockedList.isNotEmpty) { + final result = blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList(); + + if (cursor != null) { + result.addAll(await getUserBlockedList(id: id, cursor: cursor, headers: headers)); + } + + return result; + } else { + debugPrint('User does not have anyone blocked'); + return []; + } + } else { + debugPrint('User does not exist'); + return []; + } + } + // Blocks the user with the given ID and returns true on success or false on failure. static Future blockUser({required String userId, required Map headers}) async { final uri = Uri.parse('https://api.twitch.tv/helix/users/blocks?target_user_id=$userId'); diff --git a/lib/core/user/user_store.dart b/lib/core/user/user_store.dart index a9730805..a40dca24 100644 --- a/lib/core/user/user_store.dart +++ b/lib/core/user/user_store.dart @@ -31,6 +31,9 @@ abstract class _UserStoreBase with Store { @action Future block({required String targetId, required Map headers}) async { final success = await Twitch.blockUser(userId: targetId, headers: headers); + // Add a slight delay between requests, otherwise the blocked list won't properly update. + // Weird behavior, might be something to do with PUT request and time to create and update resource? + await Future.delayed(const Duration(milliseconds: 200)); if (success) await refreshBlockedUsers(headers: headers); } diff --git a/lib/models/irc_message.dart b/lib/models/irc_message.dart index 42e9afdb..db4d0476 100644 --- a/lib/models/irc_message.dart +++ b/lib/models/irc_message.dart @@ -413,7 +413,7 @@ class IRCMessage { } /// Parses an IRC string and returns its corresponding [IRCMessage] object. - factory IRCMessage.fromString(String whole, {String? username}) { + factory IRCMessage.fromString(String whole, {String? userLogin}) { // We have three parts: // 1. The tags of the IRC message. // 2. The metadata (user, command, and channel). @@ -472,7 +472,7 @@ class IRCMessage { // Check if the message mentions the logged-in user var mention = false; - if (message != null && username != null) mention = message.toLowerCase().contains(username); + if (message != null && userLogin != null) mention = message.toLowerCase().contains(userLogin); // Now process any Twitch emotes contained in the message tags. // The map containing emotes from the user's tags to their URL. diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 2e468d85..2dcb53a1 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -3,6 +3,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/chat_message.dart'; import 'package:frosty/screens/channel/chat/chat_store.dart'; +import 'package:frosty/screens/channel/chat/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu.dart'; class Chat extends StatefulWidget { @@ -39,11 +40,34 @@ class _ChatState extends State { itemCount: chatStore.messages.length, controller: chatStore.scrollController, itemBuilder: (context, index) => Observer( - builder: (context) => ChatMessage( - ircMessage: chatStore.messages[index], - assetsStore: chatStore.assetsStore, - settingsStore: chatStore.settings, - ), + builder: (context) { + final message = chatStore.messages[index]; + + if (message.user != null && message.user != chatStore.auth.user.details?.login) { + return InkWell( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + onLongPress: () => showModalBottomSheet( + context: context, + builder: (context) => ChatUserModal( + username: message.user!, + chatStore: chatStore, + userId: message.tags['user-id']!, + displayName: message.tags['display-name']!, + ), + ), + child: ChatMessage( + ircMessage: message, + assetsStore: chatStore.assetsStore, + settingsStore: chatStore.settings, + ), + ); + } + return ChatMessage( + ircMessage: message, + assetsStore: chatStore.assetsStore, + settingsStore: chatStore.settings, + ); + }, ), ), ), diff --git a/lib/screens/channel/chat/chat_bottom_bar.dart b/lib/screens/channel/chat/chat_bottom_bar.dart index 124878da..e638a1f7 100644 --- a/lib/screens/channel/chat/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/chat_bottom_bar.dart @@ -34,7 +34,7 @@ class ChatBottomBar extends StatelessWidget { onTap: () { chatStore.assetsStore.showEmoteMenu = false; Future.delayed( - const Duration(milliseconds: 100), + const Duration(milliseconds: 200), () => chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent), ); }, diff --git a/lib/screens/channel/chat/chat_message.dart b/lib/screens/channel/chat/chat_message.dart index 847c0b53..bc374717 100644 --- a/lib/screens/channel/chat/chat_message.dart +++ b/lib/screens/channel/chat/chat_message.dart @@ -9,7 +9,12 @@ class ChatMessage extends StatelessWidget { final ChatAssetsStore assetsStore; final SettingsStore settingsStore; - const ChatMessage({Key? key, required this.ircMessage, required this.assetsStore, required this.settingsStore}) : super(key: key); + const ChatMessage({ + Key? key, + required this.ircMessage, + required this.assetsStore, + required this.settingsStore, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -49,13 +54,11 @@ class ChatMessage extends StatelessWidget { color: const Color(0x4DFF0000), child: span, ); - } else { - return Padding( - padding: padding, - child: span, - ); } - + return Padding( + padding: padding, + child: span, + ); case Command.clearChat: case Command.clearMessage: // Render timeouts and bans diff --git a/lib/screens/channel/chat/chat_store.dart b/lib/screens/channel/chat/chat_store.dart index 59ade52b..b89eb165 100644 --- a/lib/screens/channel/chat/chat_store.dart +++ b/lib/screens/channel/chat/chat_store.dart @@ -104,7 +104,7 @@ abstract class _ChatStoreBase with Store { for (final message in data.trimRight().split('\r\n')) { debugPrint(message); if (message.startsWith('@')) { - final parsedIRCMessage = IRCMessage.fromString(message, username: auth.user.details?.login); + final parsedIRCMessage = IRCMessage.fromString(message, userLogin: auth.user.details?.login); // Filter messages from any blocked users if not a moderator or not the channel owner. if (!_userState.mod && @@ -148,10 +148,15 @@ abstract class _ChatStoreBase with Store { continue; } - if (_autoScroll) _deleteAndScrollToEnd(); + if (_messages.length >= 5000) _messages.removeAt(0); + + if (_autoScroll) { + SchedulerBinding.instance?.addPostFrameCallback((_) { + if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent); + }); + } // Hard upper-limit of 5000 messages to prevent infinite messages being added when scrolling. - if (_messages.length >= 5000) _messages.removeRange(0, 1000); } else if (message == 'PING :tmi.twitch.tv') { _channel?.sink.add('PONG :tmi.twitch.tv'); return; @@ -176,22 +181,6 @@ abstract class _ChatStoreBase with Store { } } - /// If [_autoScroll] is enabled, removes messages if [_messages] is too large and scrolls to the latest message. - @action - void _deleteAndScrollToEnd() { - // If we reach 1000 messages, remove the oldest 200. - if (_messages.length >= 1000) _messages.removeRange(0, 200); - - // Jump to the latest message (bottom of the list/chat). - if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent); - - // 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() { diff --git a/lib/screens/channel/chat/chat_store.g.dart b/lib/screens/channel/chat/chat_store.g.dart index 9c37eeea..915f3162 100644 --- a/lib/screens/channel/chat/chat_store.g.dart +++ b/lib/screens/channel/chat/chat_store.g.dart @@ -74,17 +74,6 @@ mixin _$ChatStore on _ChatStoreBase, Store { } } - @override - void _deleteAndScrollToEnd() { - final _$actionInfo = _$_ChatStoreBaseActionController.startAction( - name: '_ChatStoreBase._deleteAndScrollToEnd'); - try { - return super._deleteAndScrollToEnd(); - } finally { - _$_ChatStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void resumeScroll() { final _$actionInfo = _$_ChatStoreBaseActionController.startAction( diff --git a/lib/screens/channel/chat/chat_user_modal.dart b/lib/screens/channel/chat/chat_user_modal.dart new file mode 100644 index 00000000..3fb41414 --- /dev/null +++ b/lib/screens/channel/chat/chat_user_modal.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:frosty/screens/channel/chat/chat_message.dart'; +import 'package:frosty/screens/channel/chat/chat_store.dart'; +import 'package:frosty/widgets/block_button.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/section_header.dart'; + +class ChatUserModal extends StatelessWidget { + final ChatStore chatStore; + final String username; + final String displayName; + final String userId; + + const ChatUserModal({ + Key? key, + required this.chatStore, + required this.username, + required this.displayName, + required this.userId, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(5.0), + child: ListTile( + leading: ProfilePicture( + userLogin: username, + ), + title: Text( + displayName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + trailing: chatStore.auth.isLoggedIn + ? BlockButton( + authStore: chatStore.auth, + targetUser: username, + targetUserId: userId, + ) + : null, + ), + ), + const SectionHeader( + 'Recent Messages', + padding: EdgeInsets.all(10.0), + ), + Expanded( + child: ListView( + reverse: true, + children: chatStore.messages.reversed + .where((message) => message.user == username) + .map( + (message) => InkWell( + onLongPress: () async { + await Clipboard.setData(ClipboardData(text: message.message!)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Message copied!'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: ChatMessage( + ircMessage: message, + assetsStore: chatStore.assetsStore, + settingsStore: chatStore.settings, + ), + ), + ) + .toList(), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index e1321f3c..27600e8e 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -56,9 +56,7 @@ class VideoChat extends StatelessWidget { icon: const Icon(Icons.settings), onPressed: () => showModalBottomSheet( context: context, - builder: (_) { - return Settings(settingsStore: settingsStore); - }, + builder: (_) => Settings(settingsStore: settingsStore), ), ), ], diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 38eeb3d7..423f4cbc 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -85,7 +85,6 @@ class _HomeState extends State { bottomNavigationBar: Observer( builder: (_) { return BottomNavigationBar( - type: BottomNavigationBarType.fixed, items: [ if (authStore.isLoggedIn) const BottomNavigationBarItem( diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index a692d2d6..fe0628c6 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -24,7 +24,10 @@ class SearchResultsChannels extends StatelessWidget { ), ); } catch (e) { - const snackBar = SnackBar(content: Text('Failed to get channel info :(')); + const snackBar = SnackBar( + content: Text('Failed to get channel info :('), + behavior: SnackBarBehavior.floating, + ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } } diff --git a/lib/screens/home/search/stores/search_store.dart b/lib/screens/home/search/stores/search_store.dart index a1745a6c..3b7b7ae1 100644 --- a/lib/screens/home/search/stores/search_store.dart +++ b/lib/screens/home/search/stores/search_store.dart @@ -72,6 +72,7 @@ abstract class _SearchStoreBase with Store { @action void clearSearch() { textController.clear(); + FocusManager.instance.primaryFocus?.unfocus(); _channelSearchResults = []; _categorySearchResults = []; } diff --git a/lib/screens/settings/account/blocked_users.dart b/lib/screens/settings/account/blocked_users.dart index f88bd5be..bc732c4d 100644 --- a/lib/screens/settings/account/blocked_users.dart +++ b/lib/screens/settings/account/blocked_users.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/core/auth/auth_store.dart'; +import 'package:frosty/widgets/block_button.dart'; class BlockedUsers extends StatelessWidget { final AuthStore authStore; @@ -11,29 +12,6 @@ class BlockedUsers extends StatelessWidget { @override Widget build(BuildContext context) { - Future _showDialog(BuildContext context, {required String targetUser, required String targetUserId}) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Unblock'), - content: Text('Are you sure you want to unblock $targetUser?'), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch); - Navigator.of(context).pop(); - }, - child: const Text('Yes'), - ), - ], - ), - ); - } - return Scaffold( appBar: AppBar( title: const Text('Blocked Users'), @@ -52,10 +30,10 @@ class BlockedUsers extends StatelessWidget { .map( (user) => ListTile( title: Text(user.displayName), - trailing: OutlinedButton( - child: const Text('Unblock'), - onPressed: () => _showDialog(context, targetUser: user.displayName, targetUserId: user.userId), - style: OutlinedButton.styleFrom(primary: Colors.red), + trailing: BlockButton( + authStore: authStore, + targetUser: user.displayName, + targetUserId: user.userId, ), ), ) diff --git a/lib/widgets/block_button.dart b/lib/widgets/block_button.dart new file mode 100644 index 00000000..ebe6c0f0 --- /dev/null +++ b/lib/widgets/block_button.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/core/auth/auth_store.dart'; + +class BlockButton extends StatelessWidget { + final AuthStore authStore; + final String targetUser; + final String targetUserId; + + const BlockButton({ + Key? key, + required this.authStore, + required this.targetUser, + required this.targetUserId, + }) : super(key: key); + + Future _showDialog( + BuildContext context, { + required bool isBlocked, + required String targetUser, + required String targetUserId, + }) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: isBlocked ? const Text('Unblock') : const Text('Block'), + content: Text('Are you sure you want to ${isBlocked ? 'unblock $targetUser' : 'block $targetUser'}?'), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (isBlocked) { + authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch); + } else { + authStore.user.block(targetId: targetUserId, headers: authStore.headersTwitch); + } + Navigator.of(context).pop(); + }, + child: const Text('Yes'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final isBlocked = authStore.user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty; + + return OutlinedButton( + child: isBlocked ? const Text('Unblock') : const Text('Block'), + onPressed: () => _showDialog( + context, + isBlocked: isBlocked, + targetUser: targetUser, + targetUserId: targetUserId, + ), + style: OutlinedButton.styleFrom(primary: Colors.red), + ); + }, + ); + } +}