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(); /// The list of channel IDs the user moderates. @readonly var _moderatedChannels = ObservableList(); /// Cached follow state for channels queried this session (id → following). final _followCache = ObservableMap(); 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 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(); } } _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 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 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 unfollow({ required String targetId, required String gqlToken, }) async { await _followMutation( targetId: targetId, gqlToken: gqlToken, follow: false, ); _followCache[targetId] = false; } Future _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 runWithGqlIntegrity({ required String? gqlToken, required Future 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 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 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 _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 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 unblock({required String targetId}) async { final success = await twitchApi.unblockUser(userId: targetId); if (success) await refreshBlockedUsers(); } @action 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 || !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 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 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 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; } }