diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index eea8be57..683ca37f 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -563,7 +563,6 @@ class TwitchApi { } } - // Unblocks the user with the given ID and returns true on success or false on failure. Future> getRecentMessages({ required String userLogin, }) async { @@ -581,4 +580,100 @@ class TwitchApi { return Future.error('Failed to get recent messages for $userLogin'); } } + + /// Returns a list of broadcaster IDs for channels the authenticated user moderates. + Future> getModeratedChannels({ + required String id, + required Map headers, + }) async { + final url = Uri.parse( + 'https://api.twitch.tv/helix/moderation/channels?user_id=$id', + ); + + // This endpoint requires the 'user:read:moderated_channels' scope. + final response = await _client.get(url, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final data = decoded['data'] as List; + + final List moderatedChannelIds = []; + for (final channelData in data) { + if (channelData is Map && channelData.containsKey('broadcaster_id')) { + moderatedChannelIds.add(channelData['broadcaster_id'] as String); + } + } + return moderatedChannelIds; + } else { + String message = 'Failed to get moderated channels'; + try { + final decodedBody = jsonDecode(response.body); + if (decodedBody is Map && decodedBody.containsKey('message')) { + message = decodedBody['message']; + } + } catch (_) { + // Ignore decoding error, use default message + } + return Future.error( + 'Failed to get moderated channels for user $id: $message (Status ${response.statusCode})', + ); + } + } + + /// Deletes a specific chat message. + Future deleteChatMessage({ + required String broadcasterId, + required String moderatorId, + required String messageId, + required Map headers, + }) async { + final url = Uri.parse( + 'https://api.twitch.tv/helix/moderation/chat?broadcaster_id=$broadcasterId&moderator_id=$moderatorId&message_id=$messageId', + ); + + // This endpoint requires the `moderator:manage:chat_messages` scope. + final response = await _client.delete(url, headers: headers); + // A 204 No Content response indicates success. + return response.statusCode == 204; + } + + /// 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, + required Map headers, + int? duration, + String? reason, + }) async { + final url = Uri.parse( + 'https://api.twitch.tv/helix/moderation/bans?broadcaster_id=$broadcasterId&moderator_id=$moderatorId', + ); + + final Map requestBody = { + 'data': { + 'user_id': userIdToBan, + if (duration != null) 'duration': duration, + if (reason != null) 'reason': reason, + } + }; + + // This endpoint requires the `moderator:manage:banned_users` scope. + final response = await _client.post( + url, + headers: {...headers, 'Content-Type': 'application/json'}, + body: jsonEncode(requestBody), + ); + + // A 200 OK response indicates success. + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final data = decoded['data'] as List; + return data.isNotEmpty; + } + return false; + } } diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 392f1587..eaa52f07 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -156,6 +156,8 @@ abstract class ChatStoreBase with Store { @observable IRCMessage? replyingToMessage; + bool _wasAutoScrollingBeforeInteraction = true; + ChatStoreBase({ required this.twitchApi, required this.auth, @@ -464,6 +466,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 67a5ebd2..ee583617 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -76,12 +76,6 @@ class ChatMessage extends StatelessWidget { }); } - Future copyMessage() async { - await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); - - chatStore.updateNotification('Message copied'); - } - void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) { HapticFeedback.lightImpact(); @@ -91,6 +85,10 @@ class ChatMessage extends StatelessWidget { return; } + final authStore = context.read(); + final userStore = authStore.user; + final isModerator = userStore.isModerator(chatStore.channelId); + showModalBottomSheet( context: context, isScrollControlled: true, @@ -133,11 +131,94 @@ 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 copyMessage() async { + await Clipboard.setData(ClipboardData(text: ircMessage.message ?? '')); + + chatStore.updateNotification('Message copied'); + } + + Future deleteMessageAction(BuildContext context) async { + final authStore = context.read(); + final userStore = authStore.user; + final success = await userStore.deleteMessage( + broadcasterId: chatStore.channelId, + messageId: ircMessage.tags['id']!, + headers: authStore.headersTwitch, + ); + if (success) { + chatStore.updateNotification('Message deleted'); + } else { + chatStore.updateNotification('Failed to delete message'); + } + } + + Future timeoutUserAction(BuildContext context) async { + final authStore = context.read(); + final userStore = authStore.user; + final success = await userStore.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: ircMessage.tags['user-id']!, + headers: authStore.headersTwitch, + 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 authStore = context.read(); + final userStore = authStore.user; + final success = await userStore.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: ircMessage.tags['user-id']!, + headers: authStore.headersTwitch, + ); + 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; @@ -452,18 +533,30 @@ class ChatMessage extends StatelessWidget { child: dividedMessage, ); - final finalMessage = InkWell( - onTap: () { - FocusScope.of(context).unfocus(); - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - } + return GestureDetector( + // If a new message comes in while long pressing, prevent scolling + // so that the long press doesn't miss and activate on the wrong message. + onLongPressStart: (_) { + chatStore.pauseAutoScrollForInteraction(); + }, + onLongPressEnd: (_) { + chatStore.resumeAutoScrollAfterInteraction(); + onLongPressMessage(context, defaultTextStyle); + }, + onLongPressCancel: () { + chatStore.resumeAutoScrollAfterInteraction(); }, - onLongPress: () => onLongPressMessage(context, defaultTextStyle), - child: coloredMessage, + // Use an InkWell here to get the ripple effect on tap + child: InkWell( + onTap: () { + FocusScope.of(context).unfocus(); + if (chatStore.assetsStore.showEmoteMenu) { + chatStore.assetsStore.showEmoteMenu = false; + } + }, + child: coloredMessage, + ), ); - - return finalMessage; }, ); } diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index c34e28b7..2256650c 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -131,8 +131,16 @@ abstract class AuthBase with Store { 'client_id': clientId, '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', + 'scope': [ + 'chat:read', + 'chat:edit', + 'user:read:follows', + 'user:read:blocked_users', + 'user:manage:blocked_users', + 'user:read:moderated_channels', + 'moderator:manage:chat_messages', + 'moderator:manage:banned_users', + ].join(' '), 'force_verify': 'true', }, ), diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index f6e71348..0e011c6c 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(headers: headers); - // 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, headers: headers) .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); + twitchApi + .getModeratedChannels(id: _details!.id, headers: headers) + .then((channels) => _moderatedChannels = channels.asObservable()); } _disposeReaction = autorun( @@ -73,10 +80,61 @@ abstract class UserStoreBase with Store { )) .asObservable(); + bool isModerator(String channelId) { + return _moderatedChannels.contains(channelId); + } + + @action + Future deleteMessage({ + required String broadcasterId, + required String messageId, + required Map headers, + }) async { + if (_details?.id == null) { + throw Exception('User details not available, cannot get moderator ID.'); + } + if (!isModerator(broadcasterId)) { + // User is not a moderator of this channel + return false; + } + return twitchApi.deleteChatMessage( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + messageId: messageId, + headers: headers, + ); + } + + @action + Future banOrTimeoutUser({ + required String broadcasterId, + required String userIdToBan, + required Map headers, + int? duration, + String? reason, + }) async { + if (_details?.id == null) { + throw Exception('User details not available, cannot get moderator ID.'); + } + if (!isModerator(broadcasterId)) { + // User is not a moderator of this channel + return false; + } + return twitchApi.banUser( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + userIdToBan: userIdToBan, + headers: headers, + duration: duration, + reason: reason, + ); + } + @action void dispose() { _details = null; _blockedUsers.clear(); + _moderatedChannels.clear(); if (_disposeReaction != null) _disposeReaction!(); } }