feat(chat): moderator actions (delete / timeout / ban / unban) (#576)

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) <noreply@anthropic.com>
This commit is contained in:
Francis Lavoie 2026-07-02 17:43:37 -04:00 • committed by GitHub
parent b8648426f8
commit 2511ae93fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 614 additions and 3 deletions

View file

@ -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<List<String>> getModeratedChannels({required String id}) async {
try {
final data = await get<JsonMap>(
'/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<String>()
.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<bool> 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<dynamic>(
'/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<bool> 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<JsonMap>(
'/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<bool> unbanUser({
required String broadcasterId,
required String moderatorId,
required String userId,
}) async {
await delete<dynamic>(
'/moderation/bans',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'user_id': userId,
},
);
return true;
}
}

View file

@ -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 = <String, DateTime?>{};
/// 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;
}
}

View file

@ -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

View file

@ -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'],
),
],
),
);

View file

@ -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<ChatUserModal> {
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),

View file

@ -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<void> _run(
BuildContext context,
Future<void> Function() action,
) async {
Navigator.pop(context);
try {
await action();
} on ApiException catch (e) {
chatStore.updateNotification(e.message);
}
}
Future<void> _delete() async {
final success = await chatStore.auth.user.deleteMessage(
broadcasterId: chatStore.channelId,
messageId: messageId!,
);
chatStore.updateNotification(
success ? 'Message deleted' : 'Failed to delete message',
);
}
Future<void> _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<void> _ban() async {
final success = await chatStore.auth.user.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: targetUserId,
);
chatStore.updateNotification(
success ? '$targetName banned.' : 'Failed to ban user',
);
}
Future<void> _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',
);
}
}

View file

@ -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',
},
),

View file

@ -17,6 +17,10 @@ abstract class UserStoreBase with Store {
@readonly
var _blockedUsers = ObservableList<UserBlockedTwitch>();
/// The list of channel IDs the user moderates.
@readonly
var _moderatedChannels = ObservableList<String>();
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<void> 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<bool> 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<bool> 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<bool> 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!();
}
}

View file

@ -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),

View file

@ -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<ApiException>().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<ApiException>().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<ApiException>().having(
(e) => e.message,
'message',
'The user in the user_id query parameter is not banned.',
),
),
);
});
});
}

View file

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