Add chat user message history & blocking

This commit is contained in:
Tommy Chow 2022-01-05 07:23:19 -05:00
parent 5d3b393bf8
commit 58bb1d49d3
15 changed files with 251 additions and 108 deletions

View file

@ -265,37 +265,6 @@ class Twitch {
}
}
/// Returns a user's list of blocked users given their id.
static Future<List<UserBlockedTwitch>> getUserBlockedList({required String id, required Map<String, String>? 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<Channel?> getChannel({required String userId, required Map<String, String>? 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<List<UserBlockedTwitch>> getUserBlockedList({
required String id,
required Map<String, String>? 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<bool> blockUser({required String userId, required Map<String, String> headers}) async {
final uri = Uri.parse('https://api.twitch.tv/helix/users/blocks?target_user_id=$userId');

View file

@ -31,6 +31,9 @@ abstract class _UserStoreBase with Store {
@action
Future<void> block({required String targetId, required Map<String, String> 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);
}

View file

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

View file

@ -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<Chat> {
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,
);
},
),
),
),

View file

@ -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),
);
},

View file

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

View file

@ -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() {

View file

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

View file

@ -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(),
),
)
],
),
),
);
}
}

View file

@ -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),
),
),
],

View file

@ -85,7 +85,6 @@ class _HomeState extends State<Home> {
bottomNavigationBar: Observer(
builder: (_) {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: [
if (authStore.isLoggedIn)
const BottomNavigationBarItem(

View file

@ -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);
}
}

View file

@ -72,6 +72,7 @@ abstract class _SearchStoreBase with Store {
@action
void clearSearch() {
textController.clear();
FocusManager.instance.primaryFocus?.unfocus();
_channelSearchResults = <ChannelQuery>[];
_categorySearchResults = <CategoryTwitch>[];
}

View file

@ -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<void> _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,
),
),
)

View file

@ -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<void> _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),
);
},
);
}
}