Add chat user message history & blocking
This commit is contained in:
parent
5d3b393bf8
commit
58bb1d49d3
15 changed files with 251 additions and 108 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
84
lib/screens/channel/chat/chat_user_modal.dart
Normal file
84
lib/screens/channel/chat/chat_user_modal.dart
Normal 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(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ class _HomeState extends State<Home> {
|
|||
bottomNavigationBar: Observer(
|
||||
builder: (_) {
|
||||
return BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
items: [
|
||||
if (authStore.isLoggedIn)
|
||||
const BottomNavigationBarItem(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ abstract class _SearchStoreBase with Store {
|
|||
@action
|
||||
void clearSearch() {
|
||||
textController.clear();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
_channelSearchResults = <ChannelQuery>[];
|
||||
_categorySearchResults = <CategoryTwitch>[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
68
lib/widgets/block_button.dart
Normal file
68
lib/widgets/block_button.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue