From 30848e6fe8284a439632e9b75ca447f39deecb38 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 03:06:05 +0200 Subject: [PATCH 1/2] Add follow and unfollow for live and offline channels. Long-press actions use Helix for follow status and web GQL mutations via the linked session. Co-authored-by: Cursor --- lib/apis/base_api_client.dart | 7 + lib/apis/twitch_api.dart | 19 ++ lib/apis/twitch_gql_api.dart | 162 +++++++++++++ lib/main.dart | 5 +- .../home/stream_list/large_stream_card.dart | 6 + .../stream_list/offline_channel_card.dart | 6 + lib/screens/home/stream_list/stream_card.dart | 6 + .../home/stream_list/streams_list.dart | 20 ++ lib/screens/settings/stores/auth_store.dart | 10 +- lib/screens/settings/stores/user_store.dart | 127 ++++++++++ lib/screens/settings/stores/user_store.g.dart | 36 +++ lib/widgets/user_actions_modal.dart | 218 ++++++++++++++++-- test/apis/twitch_api_test.dart | 51 ++++ test/apis/twitch_gql_follow_test.dart | 72 ++++++ .../settings/stores/user_store_test.dart | 131 +++++++++-- 15 files changed, 835 insertions(+), 41 deletions(-) create mode 100644 test/apis/twitch_gql_follow_test.dart diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart index 4fd3745c..87163d84 100644 --- a/lib/apis/base_api_client.dart +++ b/lib/apis/base_api_client.dart @@ -44,6 +44,13 @@ class UnauthorizedException extends ApiException { : super(message, 401, DioExceptionType.badResponse); } +/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token. +class IntegrityCheckException extends ApiException { + const IntegrityCheckException([ + String message = 'Twitch integrity check failed', + ]) : super(message); +} + /// Base API client to eliminate code duplication across API services abstract class BaseApiClient { final Dio _dio; diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index f4bd8152..ca9f49f8 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -229,6 +229,25 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } + /// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete + /// follows were removed from the public API). + Future isFollowingChannel({ + required String userId, + required String broadcasterId, + }) async { + final data = await get( + '/channels/followed', + queryParameters: { + 'user_id': userId, + 'broadcaster_id': broadcasterId, + 'first': '1', + }, + ); + + final list = data['data'] as List? ?? const []; + return list.isNotEmpty; + } + /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 1f6f228f..0fce183a 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -297,6 +297,168 @@ class TwitchGqlApi extends BaseApiClient { return _streamsFromConnection(game['streams'] as JsonMap? ?? const {}); } + /// Persisted hashes used by the Twitch web Follow button (same as Xtra). + static const _followUserHash = + '800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08'; + static const _unfollowUserHash = + 'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880'; + + /// Follows [targetId] using the web GQL session. Helix no longer supports this. + /// + /// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts + /// need a [integrityToken] + matching [deviceId]; callers should retry after + /// [fetchIntegrityToken] when [IntegrityCheckException] is thrown. + Future followUser({ + required String targetId, + required String authToken, + bool disableNotifications = false, + String? integrityToken, + String? deviceId, + }) async { + await _mutateFollow( + operationName: 'FollowButton_FollowUser', + sha256Hash: _followUserHash, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + variables: { + 'input': { + 'disableNotifications': disableNotifications, + 'targetID': targetId, + }, + }, + successPath: const ['followUser'], + ); + } + + /// Unfollows [targetId] using the web GQL session. + Future unfollowUser({ + required String targetId, + required String authToken, + String? integrityToken, + String? deviceId, + }) async { + await _mutateFollow( + operationName: 'FollowButton_UnfollowUser', + sha256Hash: _unfollowUserHash, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + variables: { + 'input': {'targetID': targetId}, + }, + successPath: const ['unfollowUser'], + ); + } + + /// Mints a Client-Integrity token for authenticated GQL mutations. + /// + /// Bound to [deviceId] and [authToken]; send the same values on follow calls. + /// Twitch may still reject bot-flagged tokens — then the mutation fails with + /// [IntegrityCheckException]. + Future<({String token, int expirationMs})> fetchIntegrityToken({ + required String authToken, + required String deviceId, + }) async { + final response = await post( + '/integrity', + data: {}, + headers: { + 'Client-ID': twitchGqlClientId, + 'Authorization': 'OAuth $authToken', + 'X-Device-Id': deviceId, + 'User-Agent': playbackUserAgent, + 'Content-Type': 'application/json', + }, + ); + + final token = response['token'] as String?; + final expiration = response['expiration']; + if (token == null || token.isEmpty) { + throw const ApiException('Twitch integrity token missing from response'); + } + final expirationMs = expiration is int + ? expiration + : int.tryParse('$expiration') ?? 0; + return (token: token, expirationMs: expirationMs); + } + + Future _mutateFollow({ + required String operationName, + required String sha256Hash, + required String authToken, + required JsonMap variables, + required List successPath, + String? integrityToken, + String? deviceId, + }) async { + final headers = { + 'Client-ID': twitchGqlClientId, + 'Content-Type': 'application/json', + 'Authorization': 'OAuth $authToken', + 'User-Agent': playbackUserAgent, + if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, + if (integrityToken != null && integrityToken.isNotEmpty) + 'Client-Integrity': integrityToken, + }; + + final response = await post( + '/gql', + data: { + 'operationName': operationName, + 'variables': variables, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': sha256Hash, + }, + }, + }, + headers: headers, + ); + + final map = _gqlPayloadMap(response); + _throwIfGqlFailed(map); + + JsonMap? cursor = map['data'] as JsonMap?; + for (final key in successPath) { + final next = cursor?[key]; + if (next is JsonMap) { + cursor = next; + } else if (next == null) { + throw ApiException('Twitch $operationName returned no data'); + } else { + return; + } + } + } + + JsonMap _gqlPayloadMap(dynamic response) { + if (response is List) { + if (response.isEmpty || response.first is! Map) { + throw const ApiException('Unexpected Twitch GQL response'); + } + return Map.from(response.first as Map); + } + if (response is Map) { + return Map.from(response); + } + throw const ApiException('Unexpected Twitch GQL response'); + } + + void _throwIfGqlFailed(JsonMap map) { + final errors = map['errors']; + if (errors is! List || errors.isEmpty) return; + + final message = + (errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error'; + final lower = message.toLowerCase(); + if (lower.contains('integrity')) { + throw IntegrityCheckException(message); + } + throw ApiException(message); + } + StreamsTwitch _streamsFromConnection(JsonMap connection) { final edges = (connection['edges'] as List?) ?? const []; final pageInfo = connection['pageInfo'] as JsonMap?; diff --git a/lib/main.dart b/lib/main.dart index dd221dff..b0b87d00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,7 +167,10 @@ void main() async { ); // Create and initialize the authentication store - final authStore = AuthStore(twitchApi: twitchApiService); + final authStore = AuthStore( + twitchApi: twitchApiService, + twitchGqlApi: twitchGqlApiService, + ); // Add the auth interceptor to the Dio client after AuthStore creation dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart index f7d9f115..ab09ce96 100644 --- a/lib/screens/home/stream_list/large_stream_card.dart +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -20,6 +20,8 @@ class LargeStreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const LargeStreamCard({ super.key, @@ -28,6 +30,8 @@ class LargeStreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -95,6 +99,8 @@ class LargeStreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/offline_channel_card.dart b/lib/screens/home/stream_list/offline_channel_card.dart index 799a0c74..2fe17d46 100644 --- a/lib/screens/home/stream_list/offline_channel_card.dart +++ b/lib/screens/home/stream_list/offline_channel_card.dart @@ -15,6 +15,8 @@ class OfflineChannelCard extends StatelessWidget { final bool showPinOption; final bool? isPinned; final bool showOfflineStatus; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const OfflineChannelCard({ super.key, @@ -22,6 +24,8 @@ class OfflineChannelCard extends StatelessWidget { required this.showPinOption, this.isPinned, this.showOfflineStatus = false, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -77,6 +81,8 @@ class OfflineChannelCard extends StatelessWidget { userId: channelInfo.broadcasterId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 6c7a10c5..0ffa914c 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -26,6 +26,8 @@ class StreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const StreamCard({ super.key, @@ -34,6 +36,8 @@ class StreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -224,6 +228,8 @@ class StreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index e24e6812..46c1ffc1 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -260,6 +260,12 @@ class _StreamsListState extends State : _listStore.streams; final isFollowingTab = widget.listType == ListType.followed; + // Refresh following lists after unfollow so live/offline rows drop. + void onFollowChanged(bool following) { + if (isFollowingTab && !following) { + _listStore.refreshStreams(); + } + } return Stack( alignment: AlignmentDirectional.bottomCenter, @@ -320,6 +326,9 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, + initialIsFollowing: true, + onFollowChanged: + onFollowChanged, ) : StreamCard( key: ValueKey(stream.userId), @@ -331,6 +340,9 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, + initialIsFollowing: true, + onFollowChanged: + onFollowChanged, ); }, ); @@ -373,6 +385,9 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, + initialIsFollowing: + isFollowingTab ? true : null, + onFollowChanged: onFollowChanged, ) : StreamCard( key: ValueKey( @@ -384,6 +399,9 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, + initialIsFollowing: + isFollowingTab ? true : null, + onFollowChanged: onFollowChanged, ), ); }, @@ -432,6 +450,8 @@ class _StreamsListState extends State _listStore.offlineChannels[index], showPinOption: false, isPinned: false, + initialIsFollowing: true, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index d0be4504..d4581732 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 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/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; @@ -41,6 +42,9 @@ abstract class AuthBase with Store { /// The Twitch API service for making requests. final TwitchApi twitchApi; + /// Web GQL client (follow/unfollow + playback tokens). + final TwitchGqlApi twitchGqlApi; + /// Timer used to retry authentication when offline or on transient failures. Timer? _reconnectTimer; @@ -259,9 +263,13 @@ abstract class AuthBase with Store { ); } - AuthBase({required this.twitchApi}) { + AuthBase({ + required this.twitchApi, + required this.twitchGqlApi, + }) { user = UserStore( twitchApi: twitchApi, + twitchGqlApi: twitchGqlApi, hasModeratorScopes: () => hasModeratorScopes, ); } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 60cd7dd7..058ed886 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -1,6 +1,11 @@ +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/models/user.dart'; import 'package:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'user_store.g.dart'; @@ -8,6 +13,7 @@ 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; @@ -24,10 +30,20 @@ abstract class UserStoreBase with Store { @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, }); @@ -61,6 +77,114 @@ abstract class UserStoreBase with Store { ); } + /// 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 { + final deviceId = await _ensureDeviceId(); + + Future run({String? integrityToken}) { + if (follow) { + return twitchGqlApi.followUser( + targetId: targetId, + authToken: gqlToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + } + return twitchGqlApi.unfollowUser( + targetId: targetId, + authToken: gqlToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + } + + try { + await run(integrityToken: _validIntegrityToken()); + } on IntegrityCheckException { + final integrity = await twitchGqlApi.fetchIntegrityToken( + authToken: gqlToken, + deviceId: deviceId, + ); + _integrityToken = integrity.token; + _integrityExpirationMs = integrity.expirationMs; + await run(integrityToken: integrity.token); + } + } + + String? _validIntegrityToken() { + if (_integrityToken == null) 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, @@ -161,6 +285,9 @@ abstract class UserStoreBase with Store { _details = null; _blockedUsers.clear(); _moderatedChannels.clear(); + _followCache.clear(); + _integrityToken = null; + _integrityExpirationMs = 0; _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index 084a0bda..e138aa77 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -79,6 +79,42 @@ mixin _$UserStore on UserStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } + late final _$isFollowingChannelAsyncAction = AsyncAction( + 'UserStoreBase.isFollowingChannel', + context: context, + ); + + @override + Future isFollowingChannel(String channelId) { + return _$isFollowingChannelAsyncAction.run( + () => super.isFollowingChannel(channelId), + ); + } + + late final _$followAsyncAction = AsyncAction( + 'UserStoreBase.follow', + context: context, + ); + + @override + Future follow({required String targetId, required String gqlToken}) { + return _$followAsyncAction.run( + () => super.follow(targetId: targetId, gqlToken: gqlToken), + ); + } + + late final _$unfollowAsyncAction = AsyncAction( + 'UserStoreBase.unfollow', + context: context, + ); + + @override + Future unfollow({required String targetId, required String gqlToken}) { + return _$unfollowAsyncAction.run( + () => super.unfollow(targetId: targetId, gqlToken: gqlToken), + ); + } + late final _$blockAsyncAction = AsyncAction( 'UserStoreBase.block', context: context, diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index d86c75ae..f1d1c8fa 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; -class UserActionsModal extends StatelessWidget { +class UserActionsModal extends StatefulWidget { final AuthStore authStore; final String name; final String userLogin; @@ -14,6 +18,12 @@ class UserActionsModal extends StatelessWidget { final bool showPinOption; final bool? isPinned; + /// When set (e.g. Following tab), skip the Helix lookup and show this state. + final bool? initialIsFollowing; + + /// Called after a successful follow (`true`) or unfollow (`false`). + final ValueChanged? onFollowChanged; + /// Optional moderation actions (shown at the top) for chat contexts. Kept as /// an injected widget so this generic modal stays chat-agnostic. final Widget? moderationActions; @@ -26,51 +36,229 @@ class UserActionsModal extends StatelessWidget { required this.userId, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, this.moderationActions, }); + @override + State createState() => _UserActionsModalState(); +} + +class _UserActionsModalState extends State { + bool? _isFollowing; + bool _followLoading = false; + bool _followActionInProgress = false; + + bool get _isSelf => + widget.authStore.user.details?.id != null && + widget.authStore.user.details!.id == widget.userId; + + bool get _canFollow => + widget.authStore.isLoggedIn && !_isSelf; + + @override + void initState() { + super.initState(); + if (!_canFollow) return; + if (widget.initialIsFollowing != null) { + _isFollowing = widget.initialIsFollowing; + } else { + _loadFollowState(); + } + } + + Future _loadFollowState() async { + final cached = widget.authStore.user.cachedIsFollowing(widget.userId); + if (cached != null) { + setState(() => _isFollowing = cached); + return; + } + + setState(() => _followLoading = true); + try { + final following = await widget.authStore.user.isFollowingChannel( + widget.userId, + ); + if (mounted) { + setState(() { + _isFollowing = following; + _followLoading = false; + }); + } + } catch (_) { + if (mounted) { + setState(() => _followLoading = false); + } + } + } + + Future _toggleFollow() async { + if (_followActionInProgress || _isFollowing == null) return; + + final gqlToken = widget.authStore.gqlToken; + if (gqlToken == null) { + await _promptLinkWebSession(); + return; + } + + final following = _isFollowing!; + setState(() => _followActionInProgress = true); + + try { + if (following) { + await widget.authStore.user.unfollow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } else { + await widget.authStore.user.follow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } + + if (!mounted) return; + + final nowFollowing = !following; + setState(() { + _isFollowing = nowFollowing; + _followActionInProgress = false; + }); + widget.onFollowChanged?.call(nowFollowing); + + final messenger = ScaffoldMessenger.maybeOf(context); + Navigator.pop(context); + messenger?.showSnackBar( + SnackBar( + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + centered: false, + ), + ), + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: e.message, + centered: false, + ), + ), + ); + } catch (_) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: following + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + centered: false, + ), + ), + ); + } + } + + Future _promptLinkWebSession() { + return showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Web session required', + message: + 'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.', + actions: [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LoginWebView()), + ); + }, + child: const Text('Log in'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { + final showFollowTile = _canFollow && (_followLoading || _isFollowing != null); + return ListView( primary: false, shrinkWrap: true, children: [ - ?moderationActions, - if (showPinOption) + ?widget.moderationActions, + if (widget.showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), - title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'), + title: Text( + '${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}', + ), onTap: () { - if (isPinned == true) { + if (widget.isPinned == true) { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds - ..remove(userId), + ..remove(widget.userId), ]; } else { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds, - userId, + widget.userId, ]; } Navigator.pop(context); }, ), - if (authStore.isLoggedIn) + if (showFollowTile) + ListTile( + leading: _followLoading || _followActionInProgress + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _isFollowing == true + ? Icons.heart_broken_rounded + : Icons.favorite_rounded, + ), + title: Text( + _isFollowing == true + ? 'Unfollow ${widget.name}' + : 'Follow ${widget.name}', + ), + enabled: !_followLoading && !_followActionInProgress, + onTap: _toggleFollow, + ), + if (widget.authStore.isLoggedIn) ListTile( leading: const Icon(Icons.block_rounded), - onTap: () => authStore + onTap: () => widget.authStore .showBlockDialog( context, - targetUser: name, - targetUserId: userId, + targetUser: widget.name, + targetUserId: widget.userId, ) .then((_) { if (context.mounted) { Navigator.pop(context); } }), - title: Text('Block $name'), + title: Text('Block ${widget.name}'), ), ListTile( leading: const Icon(Icons.outlined_flag_rounded), @@ -101,7 +289,7 @@ class UserActionsModal extends StatelessWidget { icon: Icon(Icons.adaptive.arrow_back_rounded), onPressed: Navigator.of(context).pop, ), - title: Text('Report $name'), + title: Text('Report ${widget.name}'), ), body: Stack( children: [ @@ -118,7 +306,7 @@ class UserActionsModal extends StatelessWidget { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest( Uri.parse( - 'https://www.twitch.tv/$userLogin/report', + 'https://www.twitch.tv/${widget.userLogin}/report', ), ) ..setNavigationDelegate( @@ -154,7 +342,7 @@ class UserActionsModal extends StatelessWidget { }, ), ), - title: Text('Report $name'), + title: Text('Report ${widget.name}'), ), ], ); diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 7727e23f..4180a464 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -587,4 +587,55 @@ void main() { ); }); }); + + group('isFollowingChannel', () { + test('returns true when Helix lists the broadcaster', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels/followed', + (server) => server.reply(200, { + 'total': 1, + 'data': [ + { + 'broadcaster_id': '999', + 'broadcaster_login': 'streamer', + 'broadcaster_name': 'Streamer', + 'followed_at': '2024-01-01T00:00:00Z', + }, + ], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '12345', + 'broadcaster_id': '999', + 'first': '1', + }, + ); + + expect( + await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), + isTrue, + ); + }); + + test('returns false when not following', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels/followed', + (server) => server.reply(200, { + 'total': 0, + 'data': [], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '12345', + 'broadcaster_id': '999', + 'first': '1', + }, + ); + + expect( + await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), + isFalse, + ); + }); + }); } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart new file mode 100644 index 00000000..0e4bb171 --- /dev/null +++ b/test/apis/twitch_gql_follow_test.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchGqlApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchGqlApi(dio); + }); + + group('followUser', () { + test('posts FollowButton_FollowUser persisted mutation', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'followUser': { + 'follow': { + 'user': {'id': '999', 'displayName': 'Streamer'}, + }, + }, + }, + }), + data: Matchers.any, + ); + + await api.followUser(targetId: '999', authToken: 'web-token'); + }); + + test('throws IntegrityCheckException on integrity errors', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'errors': [ + {'message': 'failed integrity check'}, + ], + }), + data: Matchers.any, + ); + + await expectLater( + api.followUser(targetId: '999', authToken: 'web-token'), + throwsA(isA()), + ); + }); + }); + + group('unfollowUser', () { + test('posts FollowButton_UnfollowUser persisted mutation', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'unfollowUser': { + 'follow': null, + }, + }, + }), + data: Matchers.any, + ); + + await api.unfollowUser(targetId: '999', authToken: 'web-token'); + }); + }); +} diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart index 4affd3d7..9d11c922 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,16 +1,24 @@ import 'package:flutter_test/flutter_test.dart'; +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/models/user.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class MockTwitchApi extends Mock implements TwitchApi {} +class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} + void main() { late MockTwitchApi api; + late MockTwitchGqlApi gqlApi; setUp(() { + SharedPreferences.setMockInitialValues({}); api = MockTwitchApi(); + gqlApi = MockTwitchGqlApi(); when(() => api.getUserInfo()).thenAnswer( (_) async => const UserTwitch( '12345', @@ -27,42 +35,39 @@ void main() { ); }); + UserStore makeStore({required bool hasModeratorScopes}) => UserStore( + twitchApi: api, + twitchGqlApi: gqlApi, + hasModeratorScopes: () => hasModeratorScopes, + ); + group('UserStore moderated channels gating', () { test('init skips moderated-channels without moderator scopes', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); + final userStore = makeStore(hasModeratorScopes: false); - await store.init(); + await userStore.init(); await Future.delayed(Duration.zero); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); - expect(store.moderatedChannels, isEmpty); + expect(userStore.moderatedChannels, isEmpty); }); test('init fetches moderated-channels when moderator scopes exist', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => true, - ); + final userStore = makeStore(hasModeratorScopes: true); - await store.init(); + await userStore.init(); await Future.delayed(Duration.zero); verify(() => api.getModeratedChannels(id: '12345')).called(1); - expect(store.moderatedChannels, ['999']); + expect(userStore.moderatedChannels, ['999']); }); test('refreshModeratedChannels no-ops without moderator scopes', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); - await store.init(); + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); clearInteractions(api); - await store.refreshModeratedChannels(); + await userStore.refreshModeratedChannels(); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); }); @@ -75,13 +80,10 @@ void main() { ), ).thenAnswer((_) async => true); - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); - await store.init(); + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); - final result = await store.deleteMessage( + final result = await userStore.deleteMessage( broadcasterId: '12345', messageId: 'msg', ); @@ -96,4 +98,85 @@ void main() { ); }); }); + + group('UserStore follow', () { + test('isFollowingChannel caches Helix result', () async { + when( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).thenAnswer((_) async => true); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + + expect(await userStore.isFollowingChannel('999'), isTrue); + expect(await userStore.isFollowingChannel('999'), isTrue); + verify( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).called(1); + }); + + test('follow retries after integrity failure', () async { + var calls = 0; + when( + () => gqlApi.followUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async { + calls++; + if (calls == 1) { + throw const IntegrityCheckException('failed integrity check'); + } + }); + when( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer( + (_) async => ( + token: 'integrity', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + await userStore.follow(targetId: '999', gqlToken: 'web-token'); + + expect(userStore.cachedIsFollowing('999'), isTrue); + expect(calls, 2); + verify( + () => gqlApi.fetchIntegrityToken( + authToken: 'web-token', + deviceId: any(named: 'deviceId'), + ), + ).called(1); + }); + + test('unfollow updates cache', () async { + when( + () => gqlApi.unfollowUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async {}); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + await userStore.unfollow(targetId: '999', gqlToken: 'web-token'); + + expect(userStore.cachedIsFollowing('999'), isFalse); + }); + }); } From 383e78647957e8a46bd2a729d8d4376bfabb53b8 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 03:06:09 +0200 Subject: [PATCH 2/2] release v5.2.8 Co-authored-by: Cursor --- assets/release-notes.md | 90 +++++------------------------------------ pubspec.yaml | 2 +- 2 files changed, 11 insertions(+), 81 deletions(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index 28d3b3e9..d25f1cef 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,13 @@ +## v5.2.8 + +October 7, 2026 + +### New Features + +- Added follow and unfollow for live and offline channels from the long-press actions sheet (needs a linked Twitch web session). + +--- + ## v5.2.7 October 1, 2026 @@ -15,83 +25,3 @@ October 1, 2026 ### Bug Fixes - Fixed “fewest viewers” showing high-viewer streams by sorting via Twitch GQL `VIEWER_COUNT_ASC` at request time instead of reversing Helix pages locally. - ---- - -## v5.2.1 - -September 30, 2026 - -### New Features - -- Added TwitchAdSolutions-style ad blocking for the native player (and best-effort WebView inject), with a Settings toggle. -- Added Forgejo Obtainium release builds for this fork. - ---- - -## v5.2.0 - -August 16, 2026 - -### New Features - -- Added moderator actions to delete messages, timeout, ban, and unban users from the message long-press menu and user panel (by @francislavoie on GitHub). -- Added channel bubbles in Twitch shared chat, so you can focus one participant or hide their messages. - -### Improvements - -- Improved the default video player by switching to native. It handles Picture-in-Picture, quality selection, and latency better than the legacy WebView player. -- Improved native player stability across device rotation (by @francislavoie on GitHub). -- [Android] Improved native player latency for live streams (by @francislavoie on GitHub). - -### Bug Fixes - -- Fixed the video player getting stuck paused with a play button that did nothing after leaving and returning to a stream. -- Fixed long-presses missing in fast-moving chat (by @francislavoie on GitHub). - ---- - -## v5.1.0 - -June 13, 2026 - -### New Features - -- Added a new experimental native video player option with quality selection, audio-only playback, Picture-in-Picture support, and improved latency recovery. -- Added merged chat mode for multi-tab chat, so messages from active tabs can be viewed together. -- Added new chat settings for keeping the screen on and showing timestamps on historical messages. -- Added support for linking your Twitch web session so the native player can use authenticated playback on eligible channels. - -### Improvements - -- Improved chat send reliability, especially when chat delay is enabled. -- Improved chat delay sync so control messages, acknowledgements, and reconnect notices are not delayed with chat messages. -- Improved video stability around refreshes, stalls, high latency, Picture-in-Picture, fullscreen, and ad breaks. -- Redesigned chat tabs with unread indicators, connection state styling, and disconnect options. - -### Bug Fixes - -- Fixed recent messages failing on valueless or escaped IRC tags. -- Fixed shared chat badges and emotes lingering after leaving shared chat. -- Fixed recent searches not saving from type-to-search. -- Fixed auto-synced chat delay not applying when toggled on mid-session. -- Fixed long chat inputs overlapping the message list. -- Fixed a video refresh race that could leave a black screen with only the play button visible. -- Fixed fullscreen button and system UI state getting out of sync. -- Fixed landscape safe area and iPad gesture padding issues. -- Fixed Twitch/API auth requests that could hang instead of failing cleanly. - ---- - -## v5.0.5 - -January 9, 2026 - -### Bug Fixes - -- Fixed lag/frame drops in overlay chat mode. -- Fixed draggable divider still interactable in fullscreen mode. -- Fixed landscape bottom padding in chat bottom bar. -- [iOS] Fixed user agent breaking login. - -#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases) diff --git a/pubspec.yaml b/pubspec.yaml index 8e1a5656..559d5f51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 5.2.7+77 +version: 5.2.8+78 environment: sdk: ^3.9.0