Compare commits
2 commits
318dc84dc5
...
383e786479
| Author | SHA1 | Date | |
|---|---|---|---|
| 383e786479 | |||
| 30848e6fe8 |
17 changed files with 846 additions and 122 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
72
test/apis/twitch_gql_follow_test.dart
Normal file
72
test/apis/twitch_gql_follow_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue