frosty/lib/screens/settings/stores/user_store.dart
copilot 3fb393166f Fix tag directory sorting and follow Client-Integrity.
Paginate tag (and ASC) lists via GQL with integrity retry; mint follow
integrity from a WebView because Dio /integrity tokens are bad_bot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 07:32:16 +02:00

352 lines
10 KiB
Dart

import 'dart:math';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/apis/twitch_integrity.dart';
import 'package:frosty/models/user.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'user_store.g.dart';
class UserStore = UserStoreBase with _$UserStore;
abstract class UserStoreBase with Store {
final TwitchApi twitchApi;
final TwitchGqlApi twitchGqlApi;
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
final bool Function() hasModeratorScopes;
/// The current user's info.
@readonly
UserTwitch? _details;
/// The user's list of blocked users.
@readonly
var _blockedUsers = ObservableList<UserBlockedTwitch>();
/// The list of channel IDs the user moderates.
@readonly
var _moderatedChannels = ObservableList<String>();
/// Cached follow state for channels queried this session (id → following).
final _followCache = ObservableMap<String, bool>();
static const _deviceIdKey = 'twitch_gql_device_id';
String? _deviceId;
String? _integrityToken;
int _integrityExpirationMs = 0;
ReactionDisposer? _disposeReaction;
UserStoreBase({
required this.twitchApi,
required this.twitchGqlApi,
required this.hasModeratorScopes,
});
@action
Future<void> init() async {
// Get and update the current user's info.
_details = await twitchApi.getUserInfo();
// Get and update non-critical user info.
// Don't use await because having a huge list of blocked users will block the UI.
if (_details?.id != null) {
twitchApi
.getUserBlockedList(id: _details!.id)
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
// Never hit moderated-channels Helix without moderator scopes — that 401
// is what nags non-mod users after an app upgrade.
if (hasModeratorScopes()) {
twitchApi
.getModeratedChannels(id: _details!.id)
.then(
(channels) => _moderatedChannels = channels.asObservable(),
);
} else {
_moderatedChannels = ObservableList<String>();
}
}
_disposeReaction?.call();
_disposeReaction = autorun(
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
);
}
/// Whether we already know follow state for [channelId] this session.
bool? cachedIsFollowing(String channelId) => _followCache[channelId];
@action
Future<bool> isFollowingChannel(String channelId) async {
final cached = _followCache[channelId];
if (cached != null) return cached;
final userId = _details?.id;
if (userId == null) return false;
final following = await twitchApi.isFollowingChannel(
userId: userId,
broadcasterId: channelId,
);
_followCache[channelId] = following;
return following;
}
/// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token.
@action
Future<void> follow({
required String targetId,
required String gqlToken,
}) async {
await _followMutation(
targetId: targetId,
gqlToken: gqlToken,
follow: true,
);
_followCache[targetId] = true;
}
/// Unfollows [targetId] via web GQL.
@action
Future<void> unfollow({
required String targetId,
required String gqlToken,
}) async {
await _followMutation(
targetId: targetId,
gqlToken: gqlToken,
follow: false,
);
_followCache[targetId] = false;
}
Future<void> _followMutation({
required String targetId,
required String gqlToken,
required bool follow,
}) async {
await runWithGqlIntegrity(
gqlToken: gqlToken,
action: ({authToken, integrityToken, deviceId}) {
if (follow) {
return twitchGqlApi.followUser(
targetId: targetId,
authToken: authToken!,
integrityToken: integrityToken,
deviceId: deviceId,
);
}
return twitchGqlApi.unfollowUser(
targetId: targetId,
authToken: authToken!,
integrityToken: integrityToken,
deviceId: deviceId,
);
},
);
}
/// Runs a GQL call with optional Client-Integrity; refreshes once on
/// [IntegrityCheckException] when a web [gqlToken] is available.
///
/// Dio-minted tokens that are `is_bad_bot` are discarded; callers should
/// then capture credentials via a WebView and [applyWebIntegrity].
Future<T> runWithGqlIntegrity<T>({
required String? gqlToken,
required Future<T> Function({
required String? authToken,
required String? integrityToken,
required String? deviceId,
}) action,
}) async {
final deviceId = await _ensureDeviceId();
final authToken =
gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null;
Future<T> run(String? integrityToken) => action(
authToken: authToken,
integrityToken: integrityToken,
deviceId: _deviceId ?? deviceId,
);
try {
return await run(_validIntegrityToken());
} on IntegrityCheckException {
if (authToken == null) {
clearIntegrity();
rethrow;
}
try {
final integrity = await twitchGqlApi.fetchIntegrityToken(
authToken: authToken,
deviceId: _deviceId ?? deviceId,
);
_integrityToken = integrity.token;
_integrityExpirationMs = integrity.expirationMs;
return await run(integrity.token);
} on IntegrityCheckException {
clearIntegrity();
rethrow;
}
}
}
/// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]).
Future<void> applyWebIntegrity(GqlIntegrityCredentials credentials) async {
if (TwitchIntegrity.isBadBot(credentials.integrityToken)) {
clearIntegrity();
throw const IntegrityCheckException(
'Twitch integrity token flagged as bad bot',
);
}
_integrityToken = credentials.integrityToken;
_integrityExpirationMs = credentials.expirationMs;
_deviceId = credentials.deviceId;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_deviceIdKey, credentials.deviceId);
}
void clearIntegrity() {
_integrityToken = null;
_integrityExpirationMs = 0;
}
String? _validIntegrityToken() {
if (_integrityToken == null) return null;
if (TwitchIntegrity.isBadBot(_integrityToken!)) {
clearIntegrity();
return null;
}
if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) {
return null;
}
return _integrityToken;
}
Future<String> _ensureDeviceId() async {
if (_deviceId != null) return _deviceId!;
final prefs = await SharedPreferences.getInstance();
var id = prefs.getString(_deviceIdKey);
if (id == null || id.isEmpty) {
final random = Random.secure();
id = List.generate(
32,
(_) => random.nextInt(16).toRadixString(16),
).join();
await prefs.setString(_deviceIdKey, id);
}
_deviceId = id;
return id;
}
@action
Future<void> block({
required String targetId,
required String displayName,
}) async {
final success = await twitchApi.blockUser(userId: targetId);
if (success) {
_blockedUsers.add(UserBlockedTwitch(targetId, displayName, displayName));
}
}
@action
Future<void> unblock({required String targetId}) async {
final success = await twitchApi.unblockUser(userId: targetId);
if (success) await refreshBlockedUsers();
}
@action
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 || !hasModeratorScopes()) return;
_moderatedChannels =
(await twitchApi.getModeratedChannels(id: _details!.id)).asObservable();
}
bool isModerator(String channelId) {
return _moderatedChannels.contains(channelId);
}
/// Whether the logged-in user can moderate [channelId] — either a moderator
/// of that channel or its broadcaster (the broadcaster isn't in their own
/// moderator list but can still use the moderation endpoints).
///
/// Role only — does not require moderator OAuth scopes. Use
/// [canPerformModeratorActions] before calling Helix.
bool canModerate(String channelId) =>
_details?.id == channelId || isModerator(channelId);
/// Role + moderator OAuth scopes — safe to call Helix mod endpoints.
bool canPerformModeratorActions(String channelId) =>
_details?.id != null &&
hasModeratorScopes() &&
canModerate(channelId);
@action
Future<bool> deleteMessage({
required String broadcasterId,
required String messageId,
}) async {
if (!canPerformModeratorActions(broadcasterId)) return false;
return twitchApi.deleteChatMessage(
broadcasterId: broadcasterId,
moderatorId: _details!.id,
messageId: messageId,
);
}
@action
Future<bool> banOrTimeoutUser({
required String broadcasterId,
required String userIdToBan,
int? duration,
String? reason,
}) async {
if (!canPerformModeratorActions(broadcasterId)) return false;
return twitchApi.banUser(
broadcasterId: broadcasterId,
moderatorId: _details!.id,
userIdToBan: userIdToBan,
duration: duration,
reason: reason,
);
}
/// Removes a ban or timeout for [userIdToUnban] in the channel.
@action
Future<bool> unbanUser({
required String broadcasterId,
required String userIdToUnban,
}) async {
if (!canPerformModeratorActions(broadcasterId)) return false;
return twitchApi.unbanUser(
broadcasterId: broadcasterId,
moderatorId: _details!.id,
userId: userIdToUnban,
);
}
@action
void dispose() {
_details = null;
_blockedUsers.clear();
_moderatedChannels.clear();
_followCache.clear();
clearIntegrity();
_disposeReaction?.call();
_disposeReaction = null;
}
}