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

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