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 <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-10-07 03:06:05 +02:00
parent 318dc84dc5
commit 30848e6fe8
15 changed files with 835 additions and 41 deletions

View file

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

View file

@ -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<bool> isFollowingChannel({
required String userId,
required String broadcasterId,
}) async {
final data = await get<JsonMap>(
'/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<StreamsTwitch> getStreamsUnderCategory({
required String gameId,

View file

@ -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<void> 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<void> 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<JsonMap>(
'/integrity',
data: <String, dynamic>{},
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<void> _mutateFollow({
required String operationName,
required String sha256Hash,
required String authToken,
required JsonMap variables,
required List<String> successPath,
String? integrityToken,
String? deviceId,
}) async {
final headers = <String, String>{
'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<dynamic>(
'/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<String, dynamic>.from(response.first as Map);
}
if (response is Map) {
return Map<String, dynamic>.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?;

View file

@ -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));

View file

@ -20,6 +20,8 @@ class LargeStreamCard extends StatelessWidget {
final bool showCategory;
final bool showPinOption;
final bool? isPinned;
final bool? initialIsFollowing;
final ValueChanged<bool>? 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,
),
);
},

View file

@ -15,6 +15,8 @@ class OfflineChannelCard extends StatelessWidget {
final bool showPinOption;
final bool? isPinned;
final bool showOfflineStatus;
final bool? initialIsFollowing;
final ValueChanged<bool>? 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,
),
);
},

View file

@ -26,6 +26,8 @@ class StreamCard extends StatelessWidget {
final bool showCategory;
final bool showPinOption;
final bool? isPinned;
final bool? initialIsFollowing;
final ValueChanged<bool>? 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,
),
);
},

View file

@ -260,6 +260,12 @@ class _StreamsListState extends State<StreamsList>
: _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<StreamsList>
widget.categoryId == null,
showPinOption: true,
isPinned: true,
initialIsFollowing: true,
onFollowChanged:
onFollowChanged,
)
: StreamCard(
key: ValueKey(stream.userId),
@ -331,6 +340,9 @@ class _StreamsListState extends State<StreamsList>
widget.categoryId == null,
showPinOption: true,
isPinned: true,
initialIsFollowing: true,
onFollowChanged:
onFollowChanged,
);
},
);
@ -373,6 +385,9 @@ class _StreamsListState extends State<StreamsList>
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<StreamsList>
showCategory: widget.categoryId == null,
showPinOption: isFollowingTab,
isPinned: false,
initialIsFollowing:
isFollowingTab ? true : null,
onFollowChanged: onFollowChanged,
),
);
},
@ -432,6 +450,8 @@ class _StreamsListState extends State<StreamsList>
_listStore.offlineChannels[index],
showPinOption: false,
isPinned: false,
initialIsFollowing: true,
onFollowChanged: onFollowChanged,
),
);
},

View file

@ -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,
);
}

View file

@ -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<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,
});
@ -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<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 {
final deviceId = await _ensureDeviceId();
Future<void> 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<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,
@ -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;
}

View file

@ -79,6 +79,42 @@ mixin _$UserStore on UserStoreBase, Store {
return _$initAsyncAction.run(() => super.init());
}
late final _$isFollowingChannelAsyncAction = AsyncAction(
'UserStoreBase.isFollowingChannel',
context: context,
);
@override
Future<bool> isFollowingChannel(String channelId) {
return _$isFollowingChannelAsyncAction.run(
() => super.isFollowingChannel(channelId),
);
}
late final _$followAsyncAction = AsyncAction(
'UserStoreBase.follow',
context: context,
);
@override
Future<void> 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<void> unfollow({required String targetId, required String gqlToken}) {
return _$unfollowAsyncAction.run(
() => super.unfollow(targetId: targetId, gqlToken: gqlToken),
);
}
late final _$blockAsyncAction = AsyncAction(
'UserStoreBase.block',
context: context,

View file

@ -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<bool>? 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<UserActionsModal> createState() => _UserActionsModalState();
}
class _UserActionsModalState extends State<UserActionsModal> {
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<void> _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<void> _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<void> _promptLinkWebSession() {
return showDialog<void>(
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<SettingsStore>().pinnedChannelIds = [
...context.read<SettingsStore>().pinnedChannelIds
..remove(userId),
..remove(widget.userId),
];
} else {
context.read<SettingsStore>().pinnedChannelIds = [
...context.read<SettingsStore>().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}'),
),
],
);

View file

@ -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': <String, dynamic>{},
}),
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': <dynamic>[],
'pagination': <String, dynamic>{},
}),
queryParameters: {
'user_id': '12345',
'broadcaster_id': '999',
'first': '1',
},
);
expect(
await api.isFollowingChannel(userId: '12345', broadcasterId: '999'),
isFalse,
);
});
});
}

View file

@ -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<IntegrityCheckException>()),
);
});
});
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');
});
});
}

View file

@ -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() {
);
});
group('UserStore moderated channels gating', () {
test('init skips moderated-channels without moderator scopes', () async {
final store = UserStore(
UserStore makeStore({required bool hasModeratorScopes}) => UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
twitchGqlApi: gqlApi,
hasModeratorScopes: () => hasModeratorScopes,
);
await store.init();
group('UserStore moderated channels gating', () {
test('init skips moderated-channels without moderator scopes', () async {
final userStore = makeStore(hasModeratorScopes: false);
await userStore.init();
await Future<void>.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<void>.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);
});
});
}