fix: load complete Twitch user emote inventory
This commit is contained in:
parent
340367d387
commit
4d889349ad
7 changed files with 190 additions and 74 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
|
|
|
|||
107
test/screens/channel/chat/chat_assets_store_test.dart
Normal file
107
test/screens/channel/chat/chat_assets_store_test.dart
Normal 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]);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue