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:
parent
66932c5d0b
commit
64c748d0b6
5 changed files with 193 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue