From aca01d6da44761a43c51c53e101f4481614cdc73 Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Sun, 19 Apr 2026 11:40:53 -0400 Subject: [PATCH] Add basic moderation actions, fix long-press miss bug --- lib/apis/twitch_api.dart | 79 +++++++++++++ .../channel/chat/stores/chat_store.dart | 25 ++++ .../channel/chat/widgets/chat_message.dart | 107 ++++++++++++++++-- lib/screens/settings/stores/auth_store.dart | 2 +- lib/screens/settings/stores/user_store.dart | 50 +++++++- 5 files changed, 251 insertions(+), 12 deletions(-) diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..348b4afd 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -465,4 +465,83 @@ class TwitchApi extends BaseApiClient { return data['messages'] as JsonList; } + + /// Returns a list of broadcaster IDs for channels the authenticated user moderates. + Future> getModeratedChannels({required String id}) async { + try { + final data = await get( + '/moderation/channels', + // Default page size is 20; request the max to avoid hiding mod + // actions for users who moderate many channels. + queryParameters: {'user_id': id, 'first': '100'}, + ); + + final channels = data['data'] as JsonList; + return channels + .map((channel) => (channel as JsonMap)['broadcaster_id'] as String?) + .whereType() + .toList(); + } on ApiException catch (e) { + // Existing users may not have re-authenticated with the new scope yet. + // Treat this as non-fatal and hide moderation actions. + debugPrint('Failed to get moderated channels: $e'); + return []; + } + } + + /// Deletes a specific chat message. + Future deleteChatMessage({ + required String broadcasterId, + required String moderatorId, + required String messageId, + }) async { + try { + await delete( + '/moderation/chat', + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + 'message_id': messageId, + }, + ); + return true; + } on ApiException { + return false; + } + } + + /// Bans or times out a user from a channel. + /// + /// The optional `duration` in seconds will timeout the user. If omitted, the user is banned. + /// The optional `reason` will be displayed to the banned user and other moderators. + Future banUser({ + required String broadcasterId, + required String moderatorId, + required String userIdToBan, + int? duration, + String? reason, + }) async { + final requestBody = { + 'data': { + 'user_id': userIdToBan, + if (duration != null) 'duration': duration, + if (reason != null) 'reason': reason, + }, + }; + + try { + final response = await post( + '/moderation/bans', + data: requestBody, + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + }, + ); + final data = response['data'] as JsonList; + return data.isNotEmpty; + } on ApiException { + return false; + } + } } diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index d0f9ed6e..3e1ec3f7 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -344,6 +344,8 @@ abstract class ChatStoreBase with Store { runInAction(() => _measuredBottomBarHeight.value = height); } + bool _wasAutoScrollingBeforeInteraction = true; + ChatStoreBase({ required this.twitchApi, required this.auth, @@ -852,6 +854,29 @@ abstract class ChatStoreBase with Store { }); } + @action + void pauseAutoScrollForInteraction() { + _wasAutoScrollingBeforeInteraction = _autoScroll; + _autoScroll = false; + } + + @action + void resumeAutoScrollAfterInteraction() { + // Restore the auto-scroll *intent* based on its state before the interaction. + _autoScroll = _wasAutoScrollingBeforeInteraction; + + // If the user was auto-scrolling (i.e., at the bottom) before the interaction, + // ensure they return to the bottom by jumping. + // The scrollController.addListener will then ensure _autoScroll remains true. + if (_wasAutoScrollingBeforeInteraction) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo(0); + } + }); + } + } + @action void listenToSevenTVEmoteSet({required String emoteSetId}) { final subscribePayload = SevenTVEvent( diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 87e313d4..c4384403 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -9,6 +9,7 @@ import 'package:frosty/models/user.dart'; 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/channel/chat/widgets/reply_thread.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:provider/provider.dart'; @@ -112,6 +113,9 @@ class ChatMessage extends StatelessWidget { return; } + final userStore = context.read().user; + final isModerator = userStore.isModerator(chatStore.channelId); + showModalBottomSheetWithProperFocus( context: context, isScrollControlled: true, @@ -199,11 +203,82 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.reply), title: const Text('Reply to message'), ), + if (isModerator && ircMessage.tags['user-id'] != null && ircMessage.tags['id'] != null) ...[ + const Divider(), + ListTile( + onTap: () { + deleteMessageAction(context); + Navigator.pop(context); + }, + leading: const Icon(Icons.delete_outline), + title: const Text('Delete message'), + ), + ListTile( + onTap: () { + timeoutUserAction(context); + Navigator.pop(context); + }, + leading: const Icon(Icons.timer_outlined), + title: const Text('Timeout for 10min'), + ), + ListTile( + onTap: () { + banUserAction(context); + Navigator.pop(context); + }, + leading: const Icon(Icons.block), + title: const Text('Ban user'), + ), + ], ], ), ); } + Future deleteMessageAction(BuildContext context) async { + final userStore = context.read().user; + final success = await userStore.deleteMessage( + broadcasterId: chatStore.channelId, + messageId: ircMessage.tags['id']!, + ); + if (success) { + chatStore.updateNotification('Message deleted'); + } else { + chatStore.updateNotification('Failed to delete message'); + } + } + + Future timeoutUserAction(BuildContext context) async { + final userStore = context.read().user; + final success = await userStore.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: ircMessage.tags['user-id']!, + duration: 600, // 10 minutes + ); + if (success) { + chatStore.updateNotification( + 'User ${ircMessage.tags['display-name'] ?? ircMessage.user} timed out for 10 minutes.', + ); + } else { + chatStore.updateNotification('Failed to timeout user'); + } + } + + Future banUserAction(BuildContext context) async { + final userStore = context.read().user; + final success = await userStore.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: ircMessage.tags['user-id']!, + ); + if (success) { + chatStore.updateNotification( + 'User ${ircMessage.tags['display-name'] ?? ircMessage.user} banned.', + ); + } else { + chatStore.updateNotification('Failed to ban user'); + } + } + @override Widget build(BuildContext context) { final defaultTextStyle = DefaultTextStyle.of(context).style; @@ -624,18 +699,30 @@ class ChatMessage extends StatelessWidget { ? Opacity(opacity: 0.55, child: coloredMessage) : coloredMessage; - final finalMessage = InkWell( - onTap: () { - FocusScope.of(context).unfocus(); - if (_effectiveInputStore.assetsStore.showEmoteMenu) { - _effectiveInputStore.assetsStore.showEmoteMenu = false; - } + return GestureDetector( + // If a new message comes in while long pressing, prevent scrolling + // so that the long press doesn't miss and activate on the wrong message. + onLongPressStart: (_) { + chatStore.pauseAutoScrollForInteraction(); }, - onLongPress: () => onLongPressMessage(context, defaultTextStyle), - child: fadedMessage, + onLongPressEnd: (_) { + chatStore.resumeAutoScrollAfterInteraction(); + onLongPressMessage(context, defaultTextStyle); + }, + onLongPressCancel: () { + chatStore.resumeAutoScrollAfterInteraction(); + }, + // Use an InkWell here to get the ripple effect on tap + child: InkWell( + onTap: () { + FocusScope.of(context).unfocus(); + if (_effectiveInputStore.assetsStore.showEmoteMenu) { + _effectiveInputStore.assetsStore.showEmoteMenu = false; + } + }, + child: fadedMessage, + ), ); - - return finalMessage; }, ); } diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 18548635..88ec669a 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -171,7 +171,7 @@ abstract class AuthBase with Store { 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', 'scope': - 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color', + 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color user:read:moderated_channels moderator:manage:chat_messages moderator:manage:banned_users', 'force_verify': 'true', }, ), diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 39153fe1..98b29b19 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -17,6 +17,10 @@ abstract class UserStoreBase with Store { @readonly var _blockedUsers = ObservableList(); + /// The list of channel IDs the user moderates. + @readonly + var _moderatedChannels = ObservableList(); + ReactionDisposer? _disposeReaction; UserStoreBase({required this.twitchApi}); @@ -26,12 +30,15 @@ abstract class UserStoreBase with Store { // Get and update the current user's info. _details = await twitchApi.getUserInfo(); - // Get and update the current user's list of blocked users. + // Get and update non-critical user info. // Don't use await because having a huge list of blocked users will block the UI. if (_details?.id != null) { twitchApi .getUserBlockedList(id: _details!.id) .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); + twitchApi + .getModeratedChannels(id: _details!.id) + .then((channels) => _moderatedChannels = channels.asObservable()); } _disposeReaction = autorun( @@ -61,10 +68,51 @@ abstract class UserStoreBase with Store { Future refreshBlockedUsers() async => _blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id)).asObservable(); + bool isModerator(String channelId) { + return _moderatedChannels.contains(channelId); + } + + @action + Future deleteMessage({ + required String broadcasterId, + required String messageId, + }) async { + // Need the moderator ID and confirmed mod status for this channel. + if (_details?.id == null || !isModerator(broadcasterId)) { + return false; + } + return twitchApi.deleteChatMessage( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + messageId: messageId, + ); + } + + @action + Future banOrTimeoutUser({ + required String broadcasterId, + required String userIdToBan, + int? duration, + String? reason, + }) async { + // Need the moderator ID and confirmed mod status for this channel. + if (_details?.id == null || !isModerator(broadcasterId)) { + return false; + } + return twitchApi.banUser( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + userIdToBan: userIdToBan, + duration: duration, + reason: reason, + ); + } + @action void dispose() { _details = null; _blockedUsers.clear(); + _moderatedChannels.clear(); if (_disposeReaction != null) _disposeReaction!(); } }