From 78590ce09337bb313419c2aaabaaf15c43c639e2 Mon Sep 17 00:00:00 2001 From: Mattes Kraus Date: Sat, 28 Mar 2026 23:14:33 +0100 Subject: [PATCH] fix: enable follower emotes for followers but non-subs Fixes #514 --- lib/apis/twitch_api.dart | 17 ++++ .../chat/emote_menu/emote_menu_panel.dart | 37 +++++++-- .../chat/emote_menu/emote_menu_section.dart | 79 ++++++++++++------- .../channel/chat/stores/chat_store.dart | 51 ++++++++++++ .../channel/chat/stores/chat_store.g.dart | 29 +++++++ 5 files changed, 180 insertions(+), 33 deletions(-) diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..1960a85e 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -189,6 +189,23 @@ class TwitchApi extends BaseApiClient { return StreamsTwitch.fromJson(data); } + /// Returns a bool indicating if the given [userId] follows the [broadcasterId]. + Future checkUserFollowsChannel({ + required String userId, + required String broadcasterId, + }) async { + final data = await get( + '/channels/followed', + queryParameters: { + 'user_id': userId, + 'broadcaster_id': broadcasterId, + }, + ); + + final channelsData = data['data'] as JsonList; + return channelsData.isNotEmpty; + } + /// Returns a [FollowedChannels] object containing all followed channels (including offline ones) for the given user ID. Future getFollowedChannels({ required String userId, diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index 20c1f2bc..b5f218f6 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -59,11 +59,38 @@ class EmoteMenuPanel extends StatelessWidget { .toList(), children: twitchEmotes!.entries .map( - (e) => EmoteMenuSection( - chatStore: chatStore, - emotes: e.value, - disabled: e.key.contains('Channel') && !isSubbed, - ), + (e) { + final isChannelSection = e.key.contains('Channel'); + bool sectionDisabled = false; + + if (isChannelSection && !isSubbed) { + final hasAccessibleEmotes = e.value.any((emote) => + emote.type == EmoteType.twitchFollower || + emote.type == EmoteType.twitchBits || + emote.type == EmoteType.twitchUnlocked); + + sectionDisabled = !hasAccessibleEmotes; + } + + // We sort the emotes: first the follower, then subscriber emotes + final sortedEmotes = List.from(e.value); + sortedEmotes.sort((a, b) { + int getWeight(EmoteType type) { + if (type == EmoteType.twitchFollower) return 0; + if (type == EmoteType.twitchUnlocked) return 1; + if (type == EmoteType.twitchBits) return 2; + if (type == EmoteType.twitchSub) return 3; + return 4; // fallback + } + return getWeight(a.type).compareTo(getWeight(b.type)); + }); + + return EmoteMenuSection( + chatStore: chatStore, + emotes: sortedEmotes, + disabled: sectionDisabled, + ); + }, ) .toList(), ); diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart index 52005c0c..d0fb6ef6 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/irc.dart'; @@ -41,35 +42,57 @@ class _EmoteMenuSectionState extends State : 16, ), padding: EdgeInsets.zero, - itemBuilder: (context, index) => InkWell( - onTap: widget.disabled - ? null - : () => widget.chatStore.addEmote(widget.emotes[index]), - onLongPress: () { - HapticFeedback.lightImpact(); + itemBuilder: (context, index) { + // Observer wraps emotes: greyscale not available emotes + return Observer( + builder: (context) { + final emote = widget.emotes[index]; + final isSubbed = widget.chatStore.userState.subscriber; + final isFollowing = widget.chatStore.isFollowing; + + final isSubEmote = emote.type == EmoteType.twitchSub || emote.type == EmoteType.twitchChannel; + final isFollowerEmote = emote.type == EmoteType.twitchFollower; - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: widget.emotes[index], - launchExternal: widget.chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: widget.emotes[index].url, - height: - widget.emotes[index].height?.toDouble() ?? defaultEmoteSize, - width: widget.emotes[index].width?.toDouble(), - color: widget.disabled - ? const Color.fromRGBO(255, 255, 255, 0.5) - : null, - colorBlendMode: widget.disabled ? BlendMode.modulate : null, - ), - ), - ), - ), + // Idea: + // 1. Section completly deactivated? -> grey emote + // 2. Sub-emote, but user isn't Sub? -> grey emote + // 3. Follower-emote, but user not Sub and not Follower? -> grey emote + final isEmoteDisabled = widget.disabled || + (isSubEmote && !isSubbed) || + (isFollowerEmote && !isFollowing && !isSubbed); + + return InkWell( + onTap: isEmoteDisabled + ? null + : () => widget.chatStore.addEmote(emote), + onLongPress: () { + HapticFeedback.lightImpact(); + + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: emote, + launchExternal: widget.chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: emote.url, + height: emote.height?.toDouble() ?? defaultEmoteSize, + width: emote.width?.toDouble(), + // Color to grey-ish when disabled + color: isEmoteDisabled + ? const Color.fromRGBO(255, 255, 255, 0.5) + : null, + colorBlendMode: isEmoteDisabled ? BlendMode.modulate : null, + ), + ), + ), + ); + }, + ); + }, itemCount: widget.emotes.length, ), ); diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e8b5d607..1bd39595 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -205,6 +205,14 @@ abstract class ChatStoreBase with Store { @observable var timeRemaining = const Duration(); + /// A check if the user follows the current channel. + /// + /// Architectural note: While it might seem convenient to pack this into [_userState] + /// alongside subscriber and mod, [_userState] is strictly populated from + /// IRC messages which do not include follower status. Therefore, it is managed separately here + @observable + bool isFollowing = false; + /// A notification message to display above the chat. @readonly String? _notification; @@ -889,6 +897,9 @@ abstract class ChatStoreBase with Store { (e) => debugPrint('Failed to fetch chat assets: $e'), ); + // Check if the current user follows the channel + checkFollowStatus(); + // Cancel existing listener to prevent duplicate message processing _channelListener?.cancel(); _clearPendingDelayedCallbacks(clearSevenTV: false); @@ -1339,6 +1350,46 @@ abstract class ChatStoreBase with Store { } } + /// checks the follow status of the user regarding the currently + /// watched channel + @action + Future checkFollowStatus() async { + // Sanity Check 1: Is user logged in + final currentUserId = auth.user.details?.id; + + if (currentUserId == null || currentUserId.isEmpty) { + isFollowing = false; + return; + } + + // Sanity Check 2: Is the user watching a valid channel + if (channelId.isEmpty) { + isFollowing = false; + return; + } + + // API Call + try { + isFollowing = await twitchApi.checkUserFollowsChannel( + userId: currentUserId, + broadcasterId: channelId, + ); + + debugPrint('Follow status for channel $channelId: $isFollowing'); + } catch (e, stackTrace) { + debugPrint('❌ Follow Status Check failed: $e'); + + // Log the failing check to FirebaseCrashlytics + FirebaseCrashlytics.instance.recordError( + e, + stackTrace, + reason: 'Failed to check follow status for channel $channelId', + ); + + isFollowing = false; + } + } + /// Closes and disposes all the channels and controllers used by the store. void dispose() { _shouldDisconnect = true; diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index 826915be..e68a53c5 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -59,6 +59,24 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } + late final _$isFollowingAtom = Atom( + name: 'ChatStoreBase.isFollowing', + context: context, + ); + + @override + bool get isFollowing { + _$isFollowingAtom.reportRead(); + return super.isFollowing; + } + + @override + set isFollowing(bool value) { + _$isFollowingAtom.reportWrite(value, super.isFollowing, () { + super.isFollowing = value; + }); + } + late final _$_notificationAtom = Atom( name: 'ChatStoreBase._notification', context: context, @@ -375,6 +393,16 @@ mixin _$ChatStore on ChatStoreBase, Store { return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage()); } + late final _$checkFollowStatusAsyncAction = AsyncAction( + 'ChatStoreBase.checkFollowStatus', + context: context, + ); + + @override + Future checkFollowStatus() { + return _$checkFollowStatusAsyncAction.run(() => super.checkFollowStatus()); + } + late final _$ChatStoreBaseActionController = ActionController( name: 'ChatStoreBase', context: context, @@ -570,6 +598,7 @@ mixin _$ChatStore on ChatStoreBase, Store { String toString() { return ''' timeRemaining: ${timeRemaining}, +isFollowing: ${isFollowing}, expandChat: ${expandChat}, replyingToMessage: ${replyingToMessage}, renderMessages: ${renderMessages},