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:
parent
b8648426f8
commit
2511ae93fd
11 changed files with 614 additions and 3 deletions
|
|
@ -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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
49
test/screens/channel/chat/banned_user_tracker_test.dart
Normal file
49
test/screens/channel/chat/banned_user_tracker_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue