From 2511ae93fd83139eff82234b3a3ee4617c8e254b Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Thu, 2 Jul 2026 17:43:37 -0400 Subject: [PATCH] feat(chat): moderator actions (delete / timeout / ban / unban) (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable ModerationActions widget shown to channel moderators (and the broadcaster) from both the message long-press menu and the user panel. Supports delete message, timeout (10 min), ban, and remove ban/timeout — the recovery action appears only when the target is currently banned or timed out (tracked via CLEARCHAT). Surfaces Twitch's own error reason when an action is rejected, and forces an immediate chat re-render so a deleted/banned message reflects instantly. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/apis/twitch_api.dart | 98 +++++++++++ .../chat/stores/banned_user_tracker.dart | 31 ++++ .../channel/chat/stores/chat_store.dart | 39 +++++ .../channel/chat/widgets/chat_message.dart | 17 +- .../channel/chat/widgets/chat_user_modal.dart | 8 + .../chat/widgets/moderation_actions.dart | 162 ++++++++++++++++++ lib/screens/settings/stores/auth_store.dart | 2 +- lib/screens/settings/stores/user_store.dart | 72 +++++++- lib/widgets/user_actions_modal.dart | 6 + test/apis/twitch_api_test.dart | 133 ++++++++++++++ .../chat/banned_user_tracker_test.dart | 49 ++++++ 11 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 lib/screens/channel/chat/stores/banned_user_tracker.dart create mode 100644 lib/screens/channel/chat/widgets/moderation_actions.dart create mode 100644 test/screens/channel/chat/banned_user_tracker_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..9cc5dda5 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -465,4 +465,102 @@ 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 { + // Let ApiException propagate so the caller can surface Twitch's reason + // (e.g. you can't delete the broadcaster's or another moderator's message) + // instead of a generic failure. + await delete( + '/moderation/chat', + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + 'message_id': messageId, + }, + ); + return true; + } + + /// 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, + 'duration': ?duration, + 'reason': ?reason, + }, + }; + + // Let ApiException propagate so the caller can surface Twitch's reason + // (e.g. the target is a moderator and can't be banned) instead of a + // generic failure. + final response = await post( + '/moderation/bans', + data: requestBody, + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + }, + ); + final data = response['data'] as JsonList; + return data.isNotEmpty; + } + + /// Removes a ban or timeout for a user in a channel. + /// + /// A timeout is a time-limited ban, so this lifts both. Lets ApiException + /// propagate (e.g. the user isn't currently banned) so the caller can show + /// Twitch's reason. + Future unbanUser({ + required String broadcasterId, + required String moderatorId, + required String userId, + }) async { + await delete( + '/moderation/bans', + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + 'user_id': userId, + }, + ); + return true; + } } diff --git a/lib/screens/channel/chat/stores/banned_user_tracker.dart b/lib/screens/channel/chat/stores/banned_user_tracker.dart new file mode 100644 index 00000000..83a929b2 --- /dev/null +++ b/lib/screens/channel/chat/stores/banned_user_tracker.dart @@ -0,0 +1,31 @@ +/// Tracks which users are currently banned or timed out, learned from IRC +/// CLEARCHAT events, so the chat can offer an "unban / remove timeout" action +/// only when it's actually applicable. +/// +/// Keyed by Twitch user id. A value of null means a permanent ban; a non-null +/// [DateTime] is when a timeout expires. Pure logic (clock passed in) so it can +/// be unit-tested without the chat store. +class BannedUserTracker { + final _expiries = {}; + + /// Record a permanent ban (CLEARCHAT without a ban-duration). + void recordBan(String userId) => _expiries[userId] = null; + + /// Record a timeout (CLEARCHAT with a ban-duration), expiring at [now] + [duration]. + void recordTimeout(String userId, Duration duration, DateTime now) => + _expiries[userId] = now.add(duration); + + /// Forget a user (e.g. after a successful unban). + void clear(String userId) => _expiries.remove(userId); + + /// Whether [userId] is currently banned or within an unexpired timeout. + /// Expired timeouts are pruned on read. + bool isBannedOrTimedOut(String userId, DateTime now) { + if (!_expiries.containsKey(userId)) return false; + final expiry = _expiries[userId]; + if (expiry == null) return true; // permanent ban + if (now.isBefore(expiry)) return true; // timeout still active + _expiries.remove(userId); // expired — clean up + return false; + } +} diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 750315b8..8496a6c5 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -10,6 +10,7 @@ import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/events.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; +import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -254,6 +255,17 @@ abstract class ChatStoreBase with Store { @readonly var _autoScroll = true; + /// Tracks users banned/timed-out via CLEARCHAT so the moderation menu only + /// offers an unban action when it applies. + final _bannedUserTracker = BannedUserTracker(); + + /// Whether [userId] is currently banned or within an unexpired timeout + /// (as observed from CLEARCHAT in this session). + bool isUserBannedOrTimedOut(String userId) => + _bannedUserTracker.isBannedOrTimedOut(userId, DateTime.now()); + + /// Forget a user's ban state after a successful unban so the action hides. + void clearUserBanState(String userId) => _bannedUserTracker.clear(userId); @readonly var _inputText = ''; @@ -674,11 +686,35 @@ abstract class ChatStoreBase with Store { messageBuffer.add(parsedIRCMessage); break; case Command.clearChat: + // Track the banned/timed-out user so the chat can offer an unban + // action only when applicable. target-user-id is absent for a + // whole-chat clear. + final clearedUserId = parsedIRCMessage.tags['target-user-id']; + if (clearedUserId != null) { + final banDuration = parsedIRCMessage.tags['ban-duration']; + final seconds = banDuration != null + ? int.tryParse(banDuration) + : null; + if (seconds != null) { + _bannedUserTracker.recordTimeout( + clearedUserId, + Duration(seconds: seconds), + DateTime.now(), + ); + } else { + _bannedUserTracker.recordBan(clearedUserId); + } + } IRCMessage.clearChat( messages: _messages, bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); + // clearChat marks messages in place, which doesn't notify the + // observable list; reassign so renderMessages recomputes and the + // ban/timeout strike-through shows without waiting for the next + // message. + _messages = _messages.toList().asObservable(); break; case Command.clearMessage: IRCMessage.clearMessage( @@ -686,6 +722,9 @@ abstract class ChatStoreBase with Store { bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); + // Same as clearChat: force a re-render so the deleted message + // updates immediately rather than on the next incoming message. + _messages = _messages.toList().asObservable(); break; case Command.roomState: chatDetailsStore.roomState = chatDetailsStore.roomState diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 87e313d4..3bc8c289 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -8,6 +8,7 @@ import 'package:frosty/models/irc.dart'; 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/moderation_actions.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; @@ -106,8 +107,14 @@ class ChatMessage extends StatelessWidget { void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) { HapticFeedback.lightImpact(); + // Allow the full menu for live messages and for ones marked deleted/cleared + // by a moderation action — those keep their tags (user-id, id), so a + // moderator can still act on them (e.g. remove a timeout). Everything else + // (notices, system messages) just copies. if (ircMessage.command != Command.privateMessage && - ircMessage.command != Command.userState) { + ircMessage.command != Command.userState && + ircMessage.command != Command.clearChat && + ircMessage.command != Command.clearMessage) { copyMessage(); return; } @@ -199,6 +206,14 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.reply), title: const Text('Reply to message'), ), + if (ircMessage.tags['user-id'] != null) + ModerationActions( + chatStore: chatStore, + targetUserId: ircMessage.tags['user-id']!, + targetName: + ircMessage.tags['display-name'] ?? ircMessage.user ?? '', + messageId: ircMessage.tags['id'], + ), ], ), ); diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 07c975a5..6cb2e55a 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; +import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; @@ -80,6 +81,13 @@ class _ChatUserModalState extends State { name: name, userLogin: widget.username, userId: widget.userId, + // Moderator actions for this user (no messageId → no + // Delete); renders nothing for non-moderators. + moderationActions: ModerationActions( + chatStore: widget.chatStore, + targetUserId: widget.userId, + targetName: name, + ), ), ), icon: Icon(Icons.adaptive.more_rounded), diff --git a/lib/screens/channel/chat/widgets/moderation_actions.dart b/lib/screens/channel/chat/widgets/moderation_actions.dart new file mode 100644 index 00000000..2062be4b --- /dev/null +++ b/lib/screens/channel/chat/widgets/moderation_actions.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; + +/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId], +/// reused by the message long-press menu and the user panel. +/// +/// Renders nothing unless the viewer can moderate the channel. [messageId] +/// enables the per-message Delete action; omit it (e.g. the user panel, which +/// isn't tied to one message) to hide Delete. The unban action only appears +/// when the user is currently banned/timed-out (seen via CLEARCHAT). +class ModerationActions extends StatelessWidget { + final ChatStore chatStore; + final String targetUserId; + + /// Display name used in confirmation notifications. + final String targetName; + + /// The message to delete; null hides the Delete action. + final String? messageId; + + const ModerationActions({ + super.key, + required this.chatStore, + required this.targetUserId, + required this.targetName, + this.messageId, + }); + + @override + Widget build(BuildContext context) { + if (!chatStore.auth.user.canModerate(chatStore.channelId)) { + return const SizedBox.shrink(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + // Compact one-row punitive actions, ordered least- to most-destructive. + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Row( + children: [ + if (messageId != null) ...[ + Expanded( + child: _button( + Icons.delete_outline, + 'Delete', + () => _run(context, _delete), + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: _button( + Icons.timer_outlined, + 'Timeout 10min', + () => _run(context, _timeout), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _button( + Icons.block, + 'Ban', + () => _run(context, _ban), + ), + ), + ], + ), + ), + // Recovery action, separate from the punitive row to avoid mistaps and + // only shown when the user is actually banned/timed-out. + if (chatStore.isUserBannedOrTimedOut(targetUserId)) + ListTile( + onTap: () => _run(context, _unban), + leading: const Icon(Icons.lock_open_rounded), + title: const Text('Remove ban or timeout'), + ), + ], + ); + } + + /// Compact icon-over-label button. + Widget _button(IconData icon, String label, VoidCallback onTap) { + return OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ], + ), + ); + } + + /// Closes the sheet, then runs [action], surfacing Twitch's reason on failure. + Future _run( + BuildContext context, + Future Function() action, + ) async { + Navigator.pop(context); + try { + await action(); + } on ApiException catch (e) { + chatStore.updateNotification(e.message); + } + } + + Future _delete() async { + final success = await chatStore.auth.user.deleteMessage( + broadcasterId: chatStore.channelId, + messageId: messageId!, + ); + chatStore.updateNotification( + success ? 'Message deleted' : 'Failed to delete message', + ); + } + + Future _timeout() async { + final success = await chatStore.auth.user.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: targetUserId, + duration: 600, // 10 minutes + ); + chatStore.updateNotification( + success ? '$targetName timed out for 10 minutes.' : 'Failed to timeout user', + ); + } + + Future _ban() async { + final success = await chatStore.auth.user.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: targetUserId, + ); + chatStore.updateNotification( + success ? '$targetName banned.' : 'Failed to ban user', + ); + } + + Future _unban() async { + final success = await chatStore.auth.user.unbanUser( + broadcasterId: chatStore.channelId, + userIdToUnban: targetUserId, + ); + if (success) chatStore.clearUserBanState(targetUserId); + chatStore.updateNotification( + success + ? 'Removed ban/timeout for $targetName.' + : 'Failed to remove ban or timeout', + ); + } +} 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..1322bf5d 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,73 @@ abstract class UserStoreBase with Store { Future refreshBlockedUsers() async => _blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id)).asObservable(); + bool isModerator(String channelId) { + return _moderatedChannels.contains(channelId); + } + + /// Whether the logged-in user can moderate [channelId] — either a moderator + /// of that channel or its broadcaster (the broadcaster isn't in their own + /// moderator list but can still use the moderation endpoints). + bool canModerate(String channelId) => + _details?.id == channelId || isModerator(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 || !canModerate(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 || !canModerate(broadcasterId)) { + return false; + } + return twitchApi.banUser( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + userIdToBan: userIdToBan, + duration: duration, + reason: reason, + ); + } + + /// Removes a ban or timeout for [userIdToUnban] in the channel. + @action + Future unbanUser({ + required String broadcasterId, + required String userIdToUnban, + }) async { + if (_details?.id == null || !canModerate(broadcasterId)) { + return false; + } + return twitchApi.unbanUser( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + userId: userIdToUnban, + ); + } + @action void dispose() { _details = null; _blockedUsers.clear(); + _moderatedChannels.clear(); if (_disposeReaction != null) _disposeReaction!(); } } diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index 099bd1c2..d86c75ae 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -14,6 +14,10 @@ class UserActionsModal extends StatelessWidget { final bool showPinOption; final bool? isPinned; + /// Optional moderation actions (shown at the top) for chat contexts. Kept as + /// an injected widget so this generic modal stays chat-agnostic. + final Widget? moderationActions; + const UserActionsModal({ super.key, required this.authStore, @@ -22,6 +26,7 @@ class UserActionsModal extends StatelessWidget { required this.userId, this.showPinOption = false, this.isPinned, + this.moderationActions, }); @override @@ -30,6 +35,7 @@ class UserActionsModal extends StatelessWidget { primary: false, shrinkWrap: true, children: [ + ?moderationActions, if (showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 5b5df81a..938c6bb9 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -389,4 +389,137 @@ void main() { expect(emotes[2].type, EmoteType.twitchChannel); }); }); + + group('banUser', () { + test('returns true on success', () async { + dioAdapter.onPost( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(200, { + 'data': [ + {'user_id': '3'}, + ], + }), + data: { + 'data': {'user_id': '3'}, + }, + queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'}, + ); + + final result = await api.banUser( + broadcasterId: '1', + moderatorId: '2', + userIdToBan: '3', + ); + + expect(result, isTrue); + }); + + test('propagates the Twitch message when the target cannot be banned ' + '(e.g. another moderator)', () async { + dioAdapter.onPost( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(400, { + 'message': 'The user specified in the user_id field is a moderator.', + }), + data: { + 'data': {'user_id': '3'}, + }, + queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'}, + ); + + expect( + () => api.banUser( + broadcasterId: '1', + moderatorId: '2', + userIdToBan: '3', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'The user specified in the user_id field is a moderator.', + ), + ), + ); + }); + }); + + group('deleteChatMessage', () { + test('propagates the Twitch message when the message cannot be deleted ' + "(e.g. another moderator's or the broadcaster's)", () async { + dioAdapter.onDelete( + 'https://api.twitch.tv/helix/moderation/chat', + (server) => server.reply(400, { + 'message': "You cannot delete the broadcaster's messages.", + }), + queryParameters: { + 'broadcaster_id': '1', + 'moderator_id': '2', + 'message_id': 'm', + }, + ); + + expect( + () => api.deleteChatMessage( + broadcasterId: '1', + moderatorId: '2', + messageId: 'm', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + "You cannot delete the broadcaster's messages.", + ), + ), + ); + }); + }); + + group('unbanUser', () { + test('returns true on success (removes a ban or timeout)', () async { + dioAdapter.onDelete( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(204, null), + queryParameters: { + 'broadcaster_id': '1', + 'moderator_id': '2', + 'user_id': '3', + }, + ); + + final result = await api.unbanUser( + broadcasterId: '1', + moderatorId: '2', + userId: '3', + ); + + expect(result, isTrue); + }); + + test('propagates the Twitch message when the user is not banned', () async { + dioAdapter.onDelete( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(400, { + 'message': 'The user in the user_id query parameter is not banned.', + }), + queryParameters: { + 'broadcaster_id': '1', + 'moderator_id': '2', + 'user_id': '3', + }, + ); + + expect( + () => api.unbanUser(broadcasterId: '1', moderatorId: '2', userId: '3'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'The user in the user_id query parameter is not banned.', + ), + ), + ); + }); + }); } diff --git a/test/screens/channel/chat/banned_user_tracker_test.dart b/test/screens/channel/chat/banned_user_tracker_test.dart new file mode 100644 index 00000000..32093682 --- /dev/null +++ b/test/screens/channel/chat/banned_user_tracker_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; + +void main() { + late BannedUserTracker tracker; + final now = DateTime(2026, 6, 28, 12); + setUp(() => tracker = BannedUserTracker()); + + test('an untracked user is not banned', () { + expect(tracker.isBannedOrTimedOut('1', now), isFalse); + }); + + test('a banned user stays banned (no expiry)', () { + tracker.recordBan('1'); + expect(tracker.isBannedOrTimedOut('1', now), isTrue); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(days: 365))), + isTrue, + ); + }); + + test('a timed-out user is banned until the timeout expires', () { + tracker.recordTimeout('1', const Duration(seconds: 60), now); + expect(tracker.isBannedOrTimedOut('1', now), isTrue); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 59))), + isTrue, + ); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 61))), + isFalse, + ); + }); + + test('clear lifts a ban', () { + tracker.recordBan('1'); + tracker.clear('1'); + expect(tracker.isBannedOrTimedOut('1', now), isFalse); + }); + + test('a later permanent ban overrides an earlier timeout', () { + tracker.recordTimeout('1', const Duration(seconds: 10), now); + tracker.recordBan('1'); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 30))), + isTrue, + ); + }); +}