From 4d889349ad4dd93b5dd767cf971ef445cc91db8d Mon Sep 17 00:00:00 2001 From: Seritias <23217891+Seritias@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:52:17 +0200 Subject: [PATCH] fix: load complete Twitch user emote inventory --- lib/apis/twitch_api.dart | 55 +++++---- .../chat/stores/chat_assets_store.dart | 81 ++++++------- .../chat/stores/chat_assets_store.g.dart | 8 +- .../channel/chat/stores/chat_store.dart | 9 +- lib/utils/twitch_oauth_scopes.dart | 2 + test/apis/twitch_api_test.dart | 2 - .../channel/chat/chat_assets_store_test.dart | 107 ++++++++++++++++++ 7 files changed, 190 insertions(+), 74 deletions(-) create mode 100644 test/screens/channel/chat/chat_assets_store_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 148b7efa..c4c99887 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -56,27 +56,44 @@ class TwitchApi extends BaseApiClient { }).toList(); } - /// Returns a list of Twitch emotes under the provided [setId]. - Future> getEmotesSets({required String setId}) async { - final data = await get( - '/chat/emotes/set', - queryParameters: {'emote_set_id': setId}, - ); + /// Returns every Twitch emote the user may use in the broadcaster's chat. + Future> getUserEmotes({ + required String userId, + required String broadcasterId, + }) async { + final result = []; + String? cursor; - final decoded = data['data'] as JsonList; - final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + do { + final data = await get( + '/chat/emotes/user', + queryParameters: { + 'user_id': userId, + 'broadcaster_id': broadcasterId, + 'after': ?cursor, + }, + ); - return emotes.map((emote) { - switch (emote.emoteType) { - case 'globals': - case 'smilies': - return Emote.fromTwitch(emote, EmoteType.twitchGlobal); - case 'subscriptions': - return Emote.fromTwitch(emote, EmoteType.twitchSub); - default: - return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); - } - }).toList(); + final decoded = data['data'] as JsonList; + result.addAll( + decoded.map((json) { + final emote = EmoteTwitch.fromJson(json); + switch (emote.emoteType) { + case 'globals': + case 'smilies': + return Emote.fromTwitch(emote, EmoteType.twitchGlobal); + case 'subscriptions': + return Emote.fromTwitch(emote, EmoteType.twitchSub); + default: + return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); + } + }), + ); + + cursor = (data['pagination'] as JsonMap?)?['cursor'] as String?; + } while (cursor != null && cursor.isNotEmpty); + + return result; } /// Returns a map of global Twitch badges to their [Emote] object. diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index 6a2187ca..9c426930 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -404,54 +404,47 @@ abstract class ChatAssetsStoreBase with Store { @action Future userEmotesFuture({ - required List emoteSets, - required Map headers, + required String userId, + required String broadcasterId, required Function onError, }) async { - final userEmotes = await Future.wait( - emoteSets.map( - (setId) => twitchApi.getEmotesSets(setId: setId).catchError(onError), - ), - ); + try { + final emotes = await twitchApi.getUserEmotes( + userId: userId, + broadcasterId: broadcasterId, + ); + final ownerIds = emotes + .where((emote) => emote.type == EmoteType.twitchSub) + .map((emote) => emote.ownerId) + .whereType() + .where((ownerId) => ownerId != 'twitch') + .toSet(); + final owners = await Future.wait( + ownerIds.map((ownerId) => twitchApi.getUser(id: ownerId)), + ); + final ownerNames = { + for (final owner in owners) owner.id: owner.displayName, + }; + final sections = >{}; - for (final emoteSet in userEmotes) { - if (emoteSet.isNotEmpty) { - if (emoteSet.first.type == EmoteType.twitchSub) { - final ownerId = emoteSet.first.ownerId; - - // Check for tuurbo emote sets (e.g., monkey set). - if (ownerId == 'twitch') { - _userEmoteSectionToEmotes.update( - 'Global Emotes', - (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], - ifAbsent: () => emoteSet, - ); - } else { - final owner = await twitchApi.getUser(id: ownerId); - _userEmoteSectionToEmotes.update( - owner.displayName, - (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], - ifAbsent: () => emoteSet, - ); - } - } else if (emoteSet.first.type == EmoteType.twitchGlobal) { - _userEmoteSectionToEmotes.update( - 'Global Emotes', - (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], - ifAbsent: () => emoteSet, - ); - } else if (emoteSet.first.type == EmoteType.twitchUnlocked) { - _userEmoteSectionToEmotes.update( - 'Unlocked Emotes', - (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], - ifAbsent: () => emoteSet, - ); - } - - for (final emote in emoteSet) { - _userEmoteToObject[emote.name] = emote; - } + for (final emote in emotes) { + final section = switch (emote.type) { + EmoteType.twitchGlobal => 'Global Emotes', + EmoteType.twitchSub when emote.ownerId == 'twitch' => 'Global Emotes', + EmoteType.twitchSub => ownerNames[emote.ownerId] ?? 'Unlocked Emotes', + _ => 'Unlocked Emotes', + }; + sections.update( + section, + (existing) => [...existing, emote], + ifAbsent: () => [emote], + ); } + + _userEmoteToObject = {for (final emote in emotes) emote.name: emote}; + _userEmoteSectionToEmotes = sections; + } catch (error) { + onError(error); } } diff --git a/lib/screens/channel/chat/stores/chat_assets_store.g.dart b/lib/screens/channel/chat/stores/chat_assets_store.g.dart index 1c1e1b51..fb8f080b 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.g.dart @@ -307,14 +307,14 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { @override Future userEmotesFuture({ - required List emoteSets, - required Map headers, + required String userId, + required String broadcasterId, required Function onError, }) { return _$userEmotesFutureAsyncAction.run( () => super.userEmotesFuture( - emoteSets: emoteSets, - headers: headers, + userId: userId, + broadcasterId: broadcasterId, onError: onError, ), ); diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e7baa1c1..18b5935e 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -800,14 +800,13 @@ abstract class ChatStoreBase with Store { } break; case Command.globalUserState: - final setIds = parsedIRCMessage.tags['emote-sets']?.split(','); - if (setIds != null) { + final userId = auth.user.details?.id; + if (auth.isLoggedIn && userId != null) { assetsStore.userEmotesFuture( - emoteSets: setIds, - headers: auth.headersTwitch, + userId: userId, + broadcasterId: channelId, onError: (error) { debugPrint(error.toString()); - return []; }, ); } diff --git a/lib/utils/twitch_oauth_scopes.dart b/lib/utils/twitch_oauth_scopes.dart index 8e0677f9..ca4947ca 100644 --- a/lib/utils/twitch_oauth_scopes.dart +++ b/lib/utils/twitch_oauth_scopes.dart @@ -9,6 +9,7 @@ const twitchUserScopes = [ 'user:read:blocked_users', 'user:manage:blocked_users', 'user:manage:chat_color', + 'user:read:emotes', ]; /// Moderator-only scopes requested via an explicit opt-in upgrade. @@ -38,6 +39,7 @@ const twitchScopeLabels = { 'user:read:blocked_users': 'View blocked users', 'user:manage:blocked_users': 'Block and unblock users', 'user:manage:chat_color': 'Change chat name color', + 'user:read:emotes': 'View emotes you can use', 'user:read:moderated_channels': 'View channels you moderate', 'moderator:manage:chat_messages': 'Delete chat messages as a moderator', 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 9e568585..8ac2de44 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -54,7 +54,6 @@ void main() { queryParameters: { 'user_id': '12345', 'broadcaster_id': '67890', - 'first': 100, }, ); dioAdapter.onGet( @@ -74,7 +73,6 @@ void main() { queryParameters: { 'user_id': '12345', 'broadcaster_id': '67890', - 'first': 100, 'after': 'next-page', }, ); diff --git a/test/screens/channel/chat/chat_assets_store_test.dart b/test/screens/channel/chat/chat_assets_store_test.dart new file mode 100644 index 00000000..af4acc75 --- /dev/null +++ b/test/screens/channel/chat/chat_assets_store_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/bttv_api.dart'; +import 'package:frosty/apis/ffz_api.dart'; +import 'package:frosty/apis/seventv_api.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/stores/global_assets_store.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTwitchApi extends Mock implements TwitchApi {} + +class MockBTTVApi extends Mock implements BTTVApi {} + +class MockFFZApi extends Mock implements FFZApi {} + +class MockSevenTVApi extends Mock implements SevenTVApi {} + +class MockGlobalAssetsStore extends Mock implements GlobalAssetsStore {} + +void main() { + late MockTwitchApi twitchApi; + late ChatAssetsStore store; + + const hypeEmote = Emote( + name: 'HypeLol', + zeroWidth: false, + url: 'https://cdn/hype', + type: EmoteType.twitchUnlocked, + ownerId: 'twitch', + ); + const subEmote = Emote( + name: 'ChannelLove', + zeroWidth: false, + url: 'https://cdn/sub', + type: EmoteType.twitchSub, + ownerId: '67890', + ); + + setUp(() { + twitchApi = MockTwitchApi(); + store = ChatAssetsStore( + twitchApi: twitchApi, + bttvApi: MockBTTVApi(), + ffzApi: MockFFZApi(), + sevenTVApi: MockSevenTVApi(), + globalAssetsStore: MockGlobalAssetsStore(), + ); + }); + + test( + 'indexes Hype Train emotes and preserves subscription sections', + () async { + when( + () => twitchApi.getUserEmotes(userId: '12345', broadcasterId: '67890'), + ).thenAnswer((_) async => [hypeEmote, subEmote]); + when(() => twitchApi.getUser(id: '67890')).thenAnswer( + (_) async => const UserTwitch( + '67890', + 'channel', + 'Channel', + 'https://cdn/profile', + ), + ); + + await store.userEmotesFuture( + userId: '12345', + broadcasterId: '67890', + onError: fail, + ); + + expect(store.userEmoteToObject['HypeLol'], hypeEmote); + expect(store.userEmoteSectionToEmotes['Unlocked Emotes'], [hypeEmote]); + expect(store.userEmoteSectionToEmotes['Channel'], [subEmote]); + verify(() => twitchApi.getUser(id: '67890')).called(1); + }, + ); + + test('failed refresh preserves the last complete inventory', () async { + when( + () => twitchApi.getUserEmotes(userId: '12345', broadcasterId: '67890'), + ).thenAnswer((_) async => [hypeEmote]); + + await store.userEmotesFuture( + userId: '12345', + broadcasterId: '67890', + onError: fail, + ); + + final error = Exception('network failure'); + when( + () => twitchApi.getUserEmotes(userId: '12345', broadcasterId: '67890'), + ).thenThrow(error); + Object? reportedError; + + await store.userEmotesFuture( + userId: '12345', + broadcasterId: '67890', + onError: (value) => reportedError = value, + ); + + expect(reportedError, same(error)); + expect(store.userEmoteToObject, {'HypeLol': hypeEmote}); + expect(store.userEmoteSectionToEmotes['Unlocked Emotes'], [hypeEmote]); + }); +}