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) <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-07-02 18:51:04 -04:00
parent 66932c5d0b
commit 64c748d0b6
5 changed files with 193 additions and 6 deletions

View file

@ -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<List<String>> 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<List<String>> 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<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'},
queryParameters: queryParams,
);
final channels = data['data'] as JsonList;
return channels
final result = channels
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
.whereType<String>()
.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.

View file

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

View file

@ -68,6 +68,16 @@ abstract class UserStoreBase with Store {
Future<void> 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<void> refreshModeratedChannels() async {
if (_details?.id == null) return;
_moderatedChannels =
(await twitchApi.getModeratedChannels(id: _details!.id)).asObservable();
}
bool isModerator(String channelId) {
return _moderatedChannels.contains(channelId);
}

View file

@ -49,6 +49,26 @@ mixin _$UserStore on UserStoreBase, Store {
});
}
late final _$_moderatedChannelsAtom = Atom(
name: 'UserStoreBase._moderatedChannels',
context: context,
);
ObservableList<String> get moderatedChannels {
_$_moderatedChannelsAtom.reportRead();
return super._moderatedChannels;
}
@override
ObservableList<String> get _moderatedChannels => moderatedChannels;
@override
set _moderatedChannels(ObservableList<String> 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<void> refreshModeratedChannels() {
return _$refreshModeratedChannelsAsyncAction.run(
() => super.refreshModeratedChannels(),
);
}
late final _$deleteMessageAsyncAction = AsyncAction(
'UserStoreBase.deleteMessage',
context: context,
);
@override
Future<bool> 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<bool> 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<bool> unbanUser({
required String broadcasterId,
required String userIdToUnban,
}) {
return _$unbanUserAsyncAction.run(
() => super.unbanUser(
broadcasterId: broadcasterId,
userIdToUnban: userIdToUnban,
),
);
}
late final _$UserStoreBaseActionController = ActionController(
name: 'UserStoreBase',
context: context,

View file

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