Compare commits

...

2 commits

Author SHA1 Message Date
59290b23b1 release v5.3.0
Some checks failed
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Failing after 9m42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:52 +02:00
9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00
8 changed files with 785 additions and 162 deletions

View file

@ -1,3 +1,13 @@
## v5.3.0
October 12, 2026
### New Features
- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels.
---
## v5.2.9
October 7, 2026
@ -16,14 +26,3 @@ 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
### Bug Fixes
- Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page.

View file

@ -6,6 +6,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
import 'package:frosty/screens/channel/video/channel_info_header.dart';
import 'package:frosty/screens/channel/video/native_video.dart';
import 'package:frosty/screens/channel/video/native_video_store.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
@ -746,6 +747,14 @@ class _VideoChatState extends State<VideoChat>
aspectRatio: 16 / 9,
child: SizedBox(),
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
),
],
Expanded(child: chat),
],

View file

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video_player_interface.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/follow_button.dart';
/// Channel metadata + follow control shown under the player (portrait).
class ChannelInfoHeader extends StatelessWidget {
final VideoPlayerInterface videoStore;
final AuthStore authStore;
final String userId;
final String displayName;
final bool isInSharedChatMode;
const ChannelInfoHeader({
super.key,
required this.videoStore,
required this.authStore,
required this.userId,
required this.displayName,
this.isInSharedChatMode = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Observer(
builder: (context) {
final streamInfo = videoStore.streamInfo;
final offline = streamInfo == null;
return Row(
spacing: 8,
children: [
Expanded(
child: StreamInfoBar(
streamInfo: streamInfo,
offlineChannelInfo: videoStore.offlineChannelInfo,
displayName: displayName,
isCompact: true,
isOffline: offline,
isInSharedChatMode: isInSharedChatMode,
showTextShadows: false,
tooltipTriggerMode: TooltipTriggerMode.longPress,
),
),
FollowButton(
authStore: authStore,
userId: userId,
displayName: displayName,
),
],
);
},
),
),
);
}
}

View file

@ -0,0 +1,206 @@
import 'package:flutter/material.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/widgets/alert_message.dart';
import 'package:frosty/widgets/follow_channel_controller.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/integrity_webview_dialog.dart';
/// Compact Follow / Unfollow control for channel headers.
class FollowButton extends StatefulWidget {
final AuthStore authStore;
final String userId;
final String displayName;
final bool? initialIsFollowing;
final ValueChanged<bool>? onFollowChanged;
const FollowButton({
super.key,
required this.authStore,
required this.userId,
required this.displayName,
this.initialIsFollowing,
this.onFollowChanged,
});
@override
State<FollowButton> createState() => _FollowButtonState();
}
class _FollowButtonState extends State<FollowButton> {
late final FollowChannelController _controller = FollowChannelController(
authStore: widget.authStore,
userId: widget.userId,
initialIsFollowing: widget.initialIsFollowing,
onChanged: () {
if (mounted) setState(() {});
},
);
@override
void initState() {
super.initState();
_controller.init();
}
Future<void> _onPressed() async {
if (!_controller.canFollow ||
_controller.isFollowing == null ||
_controller.actionInProgress) {
return;
}
final wasFollowing = _controller.isFollowing!;
try {
final nowFollowing = await _controller.toggle();
if (!mounted) return;
widget.onFollowChanged?.call(nowFollowing);
_showResultSnack(nowFollowing);
} on FollowNeedsWebSessionException {
if (!mounted) return;
await _promptLinkWebSession();
} on IntegrityCheckException {
if (!mounted) return;
await _handleIntegrity(wasFollowing: wasFollowing);
} on ApiException catch (e) {
if (!mounted) return;
_showErrorSnack(e.message);
} catch (_) {
if (!mounted) return;
_showErrorSnack(
wasFollowing
? 'Could not unfollow ${widget.displayName}'
: 'Could not follow ${widget.displayName}',
);
}
}
Future<void> _handleIntegrity({required bool wasFollowing}) async {
final gqlToken = widget.authStore.gqlToken;
if (gqlToken == null) {
_controller.clearActionInProgress();
await _promptLinkWebSession();
return;
}
final credentials = await showIntegrityWebViewDialog(
context: context,
gqlToken: gqlToken,
);
if (!mounted) return;
if (credentials == null) {
_controller.clearActionInProgress();
_showErrorSnack('Follow cancelled — Twitch verification required');
return;
}
try {
final nowFollowing = await _controller.retryAfterIntegrity(
wasFollowing: wasFollowing,
credentials: credentials,
);
if (!mounted) return;
widget.onFollowChanged?.call(nowFollowing);
_showResultSnack(nowFollowing);
} on ApiException catch (e) {
if (!mounted) return;
_showErrorSnack(e.message);
} catch (_) {
if (!mounted) return;
_showErrorSnack(
wasFollowing
? 'Could not unfollow ${widget.displayName}'
: 'Could not follow ${widget.displayName}',
);
}
}
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'),
),
],
),
);
}
void _showResultSnack(bool nowFollowing) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(
message: nowFollowing
? 'Followed ${widget.displayName}'
: 'Unfollowed ${widget.displayName}',
centered: false,
),
),
);
}
void _showErrorSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(message: message, centered: false),
),
);
}
@override
Widget build(BuildContext context) {
if (!_controller.shouldShow) {
return const SizedBox.shrink();
}
final following = _controller.isFollowing == true;
final busy = _controller.loading || _controller.actionInProgress;
return FilledButton.tonal(
onPressed: busy ? null : _onPressed,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Icon(
following
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 16,
),
Text(following ? 'Unfollow' : 'Follow'),
],
),
);
}
}

View file

@ -0,0 +1,150 @@
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_integrity.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Shared follow/unfollow state + GQL mutations for channel UI.
///
/// Owns loading/action flags and the session cache via [AuthStore.user].
/// Callers handle UI (integrity WebView, login prompt, snackbars).
class FollowChannelController {
final AuthStore authStore;
final String userId;
final bool? initialIsFollowing;
final void Function() onChanged;
bool? isFollowing;
bool loading = false;
bool actionInProgress = false;
FollowChannelController({
required this.authStore,
required this.userId,
this.initialIsFollowing,
required this.onChanged,
});
bool get isSelf =>
authStore.user.details?.id != null &&
authStore.user.details!.id == userId;
bool get canFollow => authStore.isLoggedIn && !isSelf;
/// Whether the follow control should be visible (loading or known state).
bool get shouldShow => canFollow && (loading || isFollowing != null);
void init() {
if (!canFollow) return;
if (initialIsFollowing != null) {
isFollowing = initialIsFollowing;
onChanged();
return;
}
loadFollowState();
}
Future<void> loadFollowState() async {
final cached = authStore.user.cachedIsFollowing(userId);
if (cached != null) {
isFollowing = cached;
onChanged();
return;
}
loading = true;
onChanged();
try {
final following = await authStore.user.isFollowingChannel(userId);
isFollowing = following;
} catch (_) {
// Leave isFollowing null so the control stays hidden.
} finally {
loading = false;
onChanged();
}
}
/// Toggles follow. Returns the new following state on success.
///
/// Throws [IntegrityCheckException] when a WebView integrity mint is needed,
/// [ApiException] for API errors, or [StateError] when not ready / no token.
Future<bool> toggle() async {
if (actionInProgress || isFollowing == null) {
throw StateError('Follow toggle not ready');
}
final gqlToken = authStore.gqlToken;
if (gqlToken == null) {
throw const FollowNeedsWebSessionException();
}
final following = isFollowing!;
actionInProgress = true;
onChanged();
try {
await _runMutation(following: following, gqlToken: gqlToken);
} catch (_) {
actionInProgress = false;
onChanged();
rethrow;
}
final nowFollowing = !following;
isFollowing = nowFollowing;
actionInProgress = false;
onChanged();
return nowFollowing;
}
/// After a WebView integrity mint, apply credentials and retry [toggle]'s
/// intended mutation (still based on the pre-toggle [wasFollowing] state).
Future<bool> retryAfterIntegrity({
required bool wasFollowing,
required GqlIntegrityCredentials credentials,
}) async {
final gqlToken = authStore.gqlToken;
if (gqlToken == null) {
throw const FollowNeedsWebSessionException();
}
actionInProgress = true;
onChanged();
try {
await authStore.user.applyWebIntegrity(credentials);
await _runMutation(following: wasFollowing, gqlToken: gqlToken);
} catch (_) {
actionInProgress = false;
onChanged();
rethrow;
}
final nowFollowing = !wasFollowing;
isFollowing = nowFollowing;
actionInProgress = false;
onChanged();
return nowFollowing;
}
Future<void> _runMutation({
required bool following,
required String gqlToken,
}) async {
if (following) {
await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken);
} else {
await authStore.user.follow(targetId: userId, gqlToken: gqlToken);
}
}
void clearActionInProgress() {
if (!actionInProgress) return;
actionInProgress = false;
onChanged();
}
}
/// Thrown when follow needs a linked Twitch web (GQL) session.
class FollowNeedsWebSessionException implements Exception {
const FollowNeedsWebSessionException();
}

View file

@ -6,6 +6,7 @@ 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/follow_channel_controller.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/integrity_webview_dialog.dart';
import 'package:provider/provider.dart';
@ -47,178 +48,119 @@ class UserActionsModal extends StatefulWidget {
}
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;
late final FollowChannelController _follow = FollowChannelController(
authStore: widget.authStore,
userId: widget.userId,
initialIsFollowing: widget.initialIsFollowing,
onChanged: () {
if (mounted) setState(() {});
},
);
@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);
}
}
_follow.init();
}
Future<void> _toggleFollow() async {
if (_followActionInProgress || _isFollowing == null) return;
if (!_follow.canFollow ||
_follow.isFollowing == null ||
_follow.actionInProgress) {
return;
}
final wasFollowing = _follow.isFollowing!;
try {
final nowFollowing = await _follow.toggle();
if (!mounted) return;
widget.onFollowChanged?.call(nowFollowing);
_popWithSnack(
nowFollowing
? 'Followed ${widget.name}'
: 'Unfollowed ${widget.name}',
);
} on FollowNeedsWebSessionException {
if (!mounted) return;
await _promptLinkWebSession();
} on IntegrityCheckException {
if (!mounted) return;
await _handleIntegrity(wasFollowing: wasFollowing);
} on ApiException catch (e) {
if (!mounted) return;
_showErrorSnack(e.message);
} catch (_) {
if (!mounted) return;
_showErrorSnack(
wasFollowing
? 'Could not unfollow ${widget.name}'
: 'Could not follow ${widget.name}',
);
}
}
Future<void> _handleIntegrity({required bool wasFollowing}) async {
final gqlToken = widget.authStore.gqlToken;
if (gqlToken == null) {
_follow.clearActionInProgress();
await _promptLinkWebSession();
return;
}
final following = _isFollowing!;
setState(() => _followActionInProgress = true);
try {
await _runFollowMutation(following: following, gqlToken: gqlToken);
} on IntegrityCheckException {
if (!mounted) return;
// Dio /integrity is almost always bad_bot — mint via WebView like Xtra.
final credentials = await showIntegrityWebViewDialog(
context: context,
gqlToken: gqlToken,
);
if (!mounted) return;
if (credentials == null) {
setState(() => _followActionInProgress = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(
message: 'Follow cancelled — Twitch verification required',
centered: false,
),
),
);
_follow.clearActionInProgress();
_showErrorSnack('Follow cancelled — Twitch verification required');
return;
}
try {
await widget.authStore.user.applyWebIntegrity(credentials);
await _runFollowMutation(following: following, gqlToken: gqlToken);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _followActionInProgress = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(message: e.message, centered: false),
),
final nowFollowing = await _follow.retryAfterIntegrity(
wasFollowing: wasFollowing,
credentials: credentials,
);
return;
} 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,
),
),
);
return;
}
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _followActionInProgress = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(message: e.message, centered: false),
),
);
return;
} 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,
),
),
);
return;
}
if (!mounted) return;
final nowFollowing = !following;
setState(() {
_isFollowing = nowFollowing;
_followActionInProgress = false;
});
widget.onFollowChanged?.call(nowFollowing);
_popWithSnack(
nowFollowing
? 'Followed ${widget.name}'
: 'Unfollowed ${widget.name}',
);
} on ApiException catch (e) {
if (!mounted) return;
_showErrorSnack(e.message);
} catch (_) {
if (!mounted) return;
_showErrorSnack(
wasFollowing
? 'Could not unfollow ${widget.name}'
: 'Could not follow ${widget.name}',
);
}
}
void _popWithSnack(String message) {
final messenger = ScaffoldMessenger.maybeOf(context);
Navigator.pop(context);
messenger?.showSnackBar(
SnackBar(
content: AlertMessage(
message: nowFollowing
? 'Followed ${widget.name}'
: 'Unfollowed ${widget.name}',
centered: false,
),
content: AlertMessage(message: message, centered: false),
),
);
}
Future<void> _runFollowMutation({
required bool following,
required String gqlToken,
}) async {
if (following) {
await widget.authStore.user.unfollow(
targetId: widget.userId,
gqlToken: gqlToken,
void _showErrorSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(message: message, centered: false),
),
);
} else {
await widget.authStore.user.follow(
targetId: widget.userId,
gqlToken: gqlToken,
);
}
}
Future<void> _promptLinkWebSession() {
@ -249,7 +191,7 @@ class _UserActionsModalState extends State<UserActionsModal> {
@override
Widget build(BuildContext context) {
final showFollowTile = _canFollow && (_followLoading || _isFollowing != null);
final showFollowTile = _follow.shouldShow;
return ListView(
primary: false,
@ -280,23 +222,23 @@ class _UserActionsModalState extends State<UserActionsModal> {
),
if (showFollowTile)
ListTile(
leading: _followLoading || _followActionInProgress
leading: _follow.loading || _follow.actionInProgress
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
_isFollowing == true
_follow.isFollowing == true
? Icons.heart_broken_rounded
: Icons.favorite_rounded,
),
title: Text(
_isFollowing == true
_follow.isFollowing == true
? 'Unfollow ${widget.name}'
: 'Follow ${widget.name}',
),
enabled: !_followLoading && !_followActionInProgress,
enabled: !_follow.loading && !_follow.actionInProgress,
onTap: _toggleFollow,
),
if (widget.authStore.isLoggedIn)

View file

@ -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.9+79
version: 5.3.0+80
environment:
sdk: ^3.9.0

View file

@ -0,0 +1,254 @@
import 'dart:convert';
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/apis/twitch_integrity.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/widgets/follow_channel_controller.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockAuthStore extends Mock implements AuthStore {}
class MockTwitchApi extends Mock implements TwitchApi {}
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
void main() {
late MockAuthStore authStore;
late MockTwitchApi api;
late MockTwitchGqlApi gqlApi;
late UserStore userStore;
var notifyCount = 0;
setUp(() async {
SharedPreferences.setMockInitialValues({});
authStore = MockAuthStore();
api = MockTwitchApi();
gqlApi = MockTwitchGqlApi();
notifyCount = 0;
when(() => api.getUserInfo()).thenAnswer(
(_) async => const UserTwitch(
'12345',
'testuser',
'TestUser',
'https://cdn/profile.png',
),
);
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
(_) async => <UserBlockedTwitch>[],
);
userStore = UserStore(
twitchApi: api,
twitchGqlApi: gqlApi,
hasModeratorScopes: () => false,
);
await userStore.init();
when(() => authStore.user).thenReturn(userStore);
when(() => authStore.isLoggedIn).thenReturn(true);
when(() => authStore.gqlToken).thenReturn('web-token');
});
FollowChannelController makeController({
String userId = '999',
bool? initialIsFollowing,
}) {
return FollowChannelController(
authStore: authStore,
userId: userId,
initialIsFollowing: initialIsFollowing,
onChanged: () => notifyCount++,
);
}
group('FollowChannelController', () {
test('hides when logged out', () {
when(() => authStore.isLoggedIn).thenReturn(false);
final controller = makeController(initialIsFollowing: false);
controller.init();
expect(controller.canFollow, isFalse);
expect(controller.shouldShow, isFalse);
});
test('hides for own channel', () {
final controller = makeController(userId: '12345');
controller.init();
expect(controller.isSelf, isTrue);
expect(controller.canFollow, isFalse);
expect(controller.shouldShow, isFalse);
});
test('uses initialIsFollowing without Helix lookup', () {
final controller = makeController(initialIsFollowing: true);
controller.init();
expect(controller.isFollowing, isTrue);
expect(controller.shouldShow, isTrue);
verifyNever(
() => api.isFollowingChannel(
userId: any(named: 'userId'),
broadcasterId: any(named: 'broadcasterId'),
),
);
});
test('loads follow state from Helix and caches', () async {
when(
() => api.isFollowingChannel(
userId: '12345',
broadcasterId: '999',
),
).thenAnswer((_) async => false);
final controller = makeController();
controller.init();
await Future<void>.delayed(Duration.zero);
expect(controller.isFollowing, isFalse);
expect(controller.loading, isFalse);
expect(controller.shouldShow, isTrue);
expect(userStore.cachedIsFollowing('999'), isFalse);
});
test('toggle follows then unfollows', () async {
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
when(
() => gqlApi.unfollowUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
final controller = makeController(initialIsFollowing: false);
controller.init();
expect(await controller.toggle(), isTrue);
expect(controller.isFollowing, isTrue);
expect(userStore.cachedIsFollowing('999'), isTrue);
expect(await controller.toggle(), isFalse);
expect(controller.isFollowing, isFalse);
expect(userStore.cachedIsFollowing('999'), isFalse);
});
test('toggle throws when web session missing', () async {
when(() => authStore.gqlToken).thenReturn(null);
final controller = makeController(initialIsFollowing: false);
controller.init();
await expectLater(
controller.toggle(),
throwsA(isA<FollowNeedsWebSessionException>()),
);
});
test('toggle rethrows integrity and clears action flag', () async {
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenThrow(const IntegrityCheckException('failed integrity check'));
when(
() => gqlApi.fetchIntegrityToken(
authToken: any(named: 'authToken'),
deviceId: any(named: 'deviceId'),
),
).thenThrow(const IntegrityCheckException('bad bot'));
final controller = makeController(initialIsFollowing: false);
controller.init();
await expectLater(
controller.toggle(),
throwsA(isA<IntegrityCheckException>()),
);
expect(controller.actionInProgress, isFalse);
expect(controller.isFollowing, isFalse);
});
test('retryAfterIntegrity applies credentials and follows', () async {
when(
() => gqlApi.followUser(
targetId: any(named: 'targetId'),
authToken: any(named: 'authToken'),
integrityToken: any(named: 'integrityToken'),
deviceId: any(named: 'deviceId'),
),
).thenAnswer((_) async {});
final controller = makeController(initialIsFollowing: false);
controller.init();
final okToken =
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
'is_bad_bot': 'false',
'device_id': 'device-abc',
})))}.sig';
final nowFollowing = await controller.retryAfterIntegrity(
wasFollowing: false,
credentials: GqlIntegrityCredentials(
integrityToken: okToken,
deviceId: 'device-abc',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
);
expect(nowFollowing, isTrue);
expect(controller.isFollowing, isTrue);
verify(
() => gqlApi.followUser(
targetId: '999',
authToken: 'web-token',
integrityToken: okToken,
deviceId: 'device-abc',
),
).called(1);
});
test('retryAfterIntegrity rejects bad_bot integrity', () async {
final controller = makeController(initialIsFollowing: false);
controller.init();
final badToken =
'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
'is_bad_bot': 'true',
'device_id': 'device-abc',
})))}.sig';
await expectLater(
controller.retryAfterIntegrity(
wasFollowing: false,
credentials: GqlIntegrityCredentials(
integrityToken: badToken,
deviceId: 'device-abc',
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
),
),
throwsA(isA<IntegrityCheckException>()),
);
expect(controller.actionInProgress, isFalse);
});
});
}