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