From 64c748d0b634796c50b30cfc02470accf6ad7244 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 18:51:04 -0400 Subject: [PATCH] fix(chat): paginate moderated channels and refresh mod status on entry - getModeratedChannels now follows the `after` cursor so moderators of more than 100 channels aren't silently truncated at the first page (mirrors getUserBlockedList's throttled recursion) - add refreshModeratedChannels() and fire it on channel entry so mod status reflects grants/revokes made since login - regenerate user_store.g.dart, which also restores #576's moderation action wrappers and the _moderatedChannels atom (its generated file was never regenerated when #576 merged) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/apis/twitch_api.dart | 31 +++++-- .../channel/chat/stores/chat_store.dart | 4 + lib/screens/settings/stores/user_store.dart | 10 +++ lib/screens/settings/stores/user_store.g.dart | 90 +++++++++++++++++++ test/apis/twitch_api_test.dart | 64 +++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 9cc5dda5..60401ebe 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -466,21 +466,40 @@ 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 { + /// Returns a list of broadcaster IDs for channels the authenticated user + /// moderates, following pagination so moderators of more than one page of + /// channels aren't silently truncated at the first 100. + Future> getModeratedChannels({ + required String id, + String? cursor, + }) async { try { + // Default page size is 20; request the max to keep the request count low. + final queryParams = {'user_id': id, 'first': '100'}; + if (cursor != null) queryParams['after'] = cursor; + 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'}, + queryParameters: queryParams, ); final channels = data['data'] as JsonList; - return channels + final result = channels .map((channel) => (channel as JsonMap)['broadcaster_id'] as String?) .whereType() .toList(); + + final paginationCursor = data['pagination']['cursor']; + if (paginationCursor != null && channels.isNotEmpty) { + // Throttle between pages to stay under Twitch's rate limit, matching + // getUserBlockedList above. + await Future.delayed(const Duration(milliseconds: 150)); + result.addAll( + await getModeratedChannels(id: id, cursor: paginationCursor), + ); + } + + return result; } 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. diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 1b500b72..d5b60e49 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -467,6 +467,10 @@ abstract class ChatStoreBase with Store { connectToChat(); } + // Refresh which channels the user moderates on entry so mod actions reflect + // status granted/revoked since login. Fire-and-forget, like blocked users. + if (auth.isLoggedIn) auth.user.refreshModeratedChannels(); + // Tell the scrollController to determine when auto-scroll should be enabled or disabled. scrollController.addListener(() { // If the scroll position is at the latest message (maximum possible), enable autoscroll. diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 1322bf5d..70de0c63 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -68,6 +68,16 @@ abstract class UserStoreBase with Store { Future refreshBlockedUsers() async => _blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id)).asObservable(); + /// Re-fetches which channels the user moderates. Mod status is otherwise only + /// loaded once at login, so call this when entering a channel to reflect mod + /// status granted or revoked since then. + @action + Future refreshModeratedChannels() async { + if (_details?.id == null) return; + _moderatedChannels = + (await twitchApi.getModeratedChannels(id: _details!.id)).asObservable(); + } + bool isModerator(String channelId) { return _moderatedChannels.contains(channelId); } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index 2e5659eb..084a0bda 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -49,6 +49,26 @@ mixin _$UserStore on UserStoreBase, Store { }); } + late final _$_moderatedChannelsAtom = Atom( + name: 'UserStoreBase._moderatedChannels', + context: context, + ); + + ObservableList get moderatedChannels { + _$_moderatedChannelsAtom.reportRead(); + return super._moderatedChannels; + } + + @override + ObservableList get _moderatedChannels => moderatedChannels; + + @override + set _moderatedChannels(ObservableList value) { + _$_moderatedChannelsAtom.reportWrite(value, super._moderatedChannels, () { + super._moderatedChannels = value; + }); + } + late final _$initAsyncAction = AsyncAction( 'UserStoreBase.init', context: context, @@ -93,6 +113,76 @@ mixin _$UserStore on UserStoreBase, Store { ); } + late final _$refreshModeratedChannelsAsyncAction = AsyncAction( + 'UserStoreBase.refreshModeratedChannels', + context: context, + ); + + @override + Future refreshModeratedChannels() { + return _$refreshModeratedChannelsAsyncAction.run( + () => super.refreshModeratedChannels(), + ); + } + + late final _$deleteMessageAsyncAction = AsyncAction( + 'UserStoreBase.deleteMessage', + context: context, + ); + + @override + Future deleteMessage({ + required String broadcasterId, + required String messageId, + }) { + return _$deleteMessageAsyncAction.run( + () => super.deleteMessage( + broadcasterId: broadcasterId, + messageId: messageId, + ), + ); + } + + late final _$banOrTimeoutUserAsyncAction = AsyncAction( + 'UserStoreBase.banOrTimeoutUser', + context: context, + ); + + @override + Future banOrTimeoutUser({ + required String broadcasterId, + required String userIdToBan, + int? duration, + String? reason, + }) { + return _$banOrTimeoutUserAsyncAction.run( + () => super.banOrTimeoutUser( + broadcasterId: broadcasterId, + userIdToBan: userIdToBan, + duration: duration, + reason: reason, + ), + ); + } + + late final _$unbanUserAsyncAction = AsyncAction( + 'UserStoreBase.unbanUser', + context: context, + ); + + @override + Future unbanUser({ + required String broadcasterId, + required String userIdToUnban, + }) { + return _$unbanUserAsyncAction.run( + () => super.unbanUser( + broadcasterId: broadcasterId, + userIdToUnban: userIdToUnban, + ), + ); + } + late final _$UserStoreBaseActionController = ActionController( name: 'UserStoreBase', context: context, diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 938c6bb9..c6b2d413 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -290,6 +290,70 @@ void main() { }); }); + group('getModeratedChannels', () { + test('returns broadcaster IDs from a single page', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(200, { + 'data': [ + {'broadcaster_id': '111', 'broadcaster_login': 'a'}, + {'broadcaster_id': '222', 'broadcaster_login': 'b'}, + ], + 'pagination': {'cursor': null}, + }), + queryParameters: {'user_id': '12345', 'first': '100'}, + ); + + final channels = await api.getModeratedChannels(id: '12345'); + + expect(channels, ['111', '222']); + }); + + test('follows the pagination cursor across pages', () async { + dioAdapter + ..onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(200, { + 'data': [ + {'broadcaster_id': '111'}, + ], + 'pagination': {'cursor': 'page2'}, + }), + queryParameters: {'user_id': '12345', 'first': '100'}, + ) + ..onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(200, { + 'data': [ + {'broadcaster_id': '222'}, + ], + 'pagination': {'cursor': null}, + }), + queryParameters: { + 'user_id': '12345', + 'first': '100', + 'after': 'page2', + }, + ); + + final channels = await api.getModeratedChannels(id: '12345'); + + expect(channels, ['111', '222']); + }); + + test('returns an empty list when the scope is missing', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(401, {'message': 'Missing scope'}), + queryParameters: {'user_id': '12345', 'first': '100'}, + ); + + final channels = await api.getModeratedChannels(id: '12345'); + + expect(channels, isEmpty); + }); + }); + group('getTopStreams', () { test('returns streams with pagination', () async { dioAdapter.onGet(