fix: load complete Twitch user emote inventory

This commit is contained in:
Seritias 2026-08-19 11:52:17 +02:00
parent 340367d387
commit 4d889349ad
7 changed files with 190 additions and 74 deletions

View file

@ -56,27 +56,44 @@ class TwitchApi extends BaseApiClient {
}).toList();
}
/// Returns a list of Twitch emotes under the provided [setId].
Future<List<Emote>> getEmotesSets({required String setId}) async {
final data = await get<JsonMap>(
'/chat/emotes/set',
queryParameters: {'emote_set_id': setId},
);
/// Returns every Twitch emote the user may use in the broadcaster's chat.
Future<List<Emote>> getUserEmotes({
required String userId,
required String broadcasterId,
}) async {
final result = <Emote>[];
String? cursor;
final decoded = data['data'] as JsonList;
final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
do {
final data = await get<JsonMap>(
'/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.

View file

@ -404,54 +404,47 @@ abstract class ChatAssetsStoreBase with Store {
@action
Future<void> userEmotesFuture({
required List<String> emoteSets,
required Map<String, String> 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<String>()
.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 = <String, List<Emote>>{};
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);
}
}

View file

@ -307,14 +307,14 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
@override
Future<void> userEmotesFuture({
required List<String> emoteSets,
required Map<String, String> 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,
),
);

View file

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

View file

@ -9,6 +9,7 @@ const twitchUserScopes = <String>[
'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 = <String, String>{
'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',

View file

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

View file

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