This commit is contained in:
Mattes 2026-08-28 15:05:51 +05:30 • committed by GitHub
commit 2bafbfe42f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 180 additions and 33 deletions

View file

@ -201,6 +201,23 @@ class TwitchApi extends BaseApiClient {
return StreamsTwitch.fromJson(data); return StreamsTwitch.fromJson(data);
} }
/// Returns a bool indicating if the given [userId] follows the [broadcasterId].
Future<bool> checkUserFollowsChannel({
required String userId,
required String broadcasterId,
}) async {
final data = await get<JsonMap>(
'/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. /// Returns a [FollowedChannels] object containing all followed channels (including offline ones) for the given user ID.
Future<FollowedChannels> getFollowedChannels({ Future<FollowedChannels> getFollowedChannels({
required String userId, required String userId,

View file

@ -59,11 +59,38 @@ class EmoteMenuPanel extends StatelessWidget {
.toList(), .toList(),
children: twitchEmotes!.entries children: twitchEmotes!.entries
.map( .map(
(e) => EmoteMenuSection( (e) {
chatStore: chatStore, final isChannelSection = e.key.contains('Channel');
emotes: e.value, bool sectionDisabled = false;
disabled: e.key.contains('Channel') && !isSubbed,
), 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<Emote>.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(), .toList(),
); );

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
@ -41,35 +42,57 @@ class _EmoteMenuSectionState extends State<EmoteMenuSection>
: 16, : 16,
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemBuilder: (context, index) => InkWell( itemBuilder: (context, index) {
onTap: widget.disabled // Observer wraps emotes: greyscale not available emotes
? null return Observer(
: () => widget.chatStore.addEmote(widget.emotes[index]), builder: (context) {
onLongPress: () { final emote = widget.emotes[index];
HapticFeedback.lightImpact(); 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( // Idea:
context, // 1. Section completly deactivated? -> grey emote
emote: widget.emotes[index], // 2. Sub-emote, but user isn't Sub? -> grey emote
launchExternal: widget.chatStore.settings.launchUrlExternal, // 3. Follower-emote, but user not Sub and not Follower? -> grey emote
); final isEmoteDisabled = widget.disabled ||
}, (isSubEmote && !isSubbed) ||
child: Padding( (isFollowerEmote && !isFollowing && !isSubbed);
padding: const EdgeInsets.all(5.0),
child: Center( return InkWell(
child: FrostyCachedNetworkImage( onTap: isEmoteDisabled
imageUrl: widget.emotes[index].url, ? null
height: : () => widget.chatStore.addEmote(emote),
widget.emotes[index].height?.toDouble() ?? defaultEmoteSize, onLongPress: () {
width: widget.emotes[index].width?.toDouble(), HapticFeedback.lightImpact();
color: widget.disabled
? const Color.fromRGBO(255, 255, 255, 0.5) IRCMessage.showEmoteDetailsBottomSheet(
: null, context,
colorBlendMode: widget.disabled ? BlendMode.modulate : null, 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, itemCount: widget.emotes.length,
), ),
); );

View file

@ -259,6 +259,14 @@ abstract class ChatStoreBase with Store {
@observable @observable
var timeRemaining = const Duration(); 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. /// A notification message to display above the chat.
@readonly @readonly
String? _notification; String? _notification;
@ -1047,6 +1055,9 @@ abstract class ChatStoreBase with Store {
(e) => debugPrint('Failed to fetch chat assets: $e'), (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 // Cancel existing listener to prevent duplicate message processing
_channelListener?.cancel(); _channelListener?.cancel();
_clearPendingDelayedCallbacks(clearSevenTV: false); _clearPendingDelayedCallbacks(clearSevenTV: false);
@ -1564,6 +1575,46 @@ abstract class ChatStoreBase with Store {
} }
} }
/// checks the follow status of the user regarding the currently
/// watched channel
@action
Future<void> 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. /// Closes and disposes all the channels and controllers used by the store.
void dispose() { void dispose() {
_shouldDisconnect = true; _shouldDisconnect = true;

View file

@ -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( late final _$_notificationAtom = Atom(
name: 'ChatStoreBase._notification', name: 'ChatStoreBase._notification',
context: context, context: context,
@ -375,6 +393,16 @@ mixin _$ChatStore on ChatStoreBase, Store {
return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage()); return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage());
} }
late final _$checkFollowStatusAsyncAction = AsyncAction(
'ChatStoreBase.checkFollowStatus',
context: context,
);
@override
Future<void> checkFollowStatus() {
return _$checkFollowStatusAsyncAction.run(() => super.checkFollowStatus());
}
late final _$ChatStoreBaseActionController = ActionController( late final _$ChatStoreBaseActionController = ActionController(
name: 'ChatStoreBase', name: 'ChatStoreBase',
context: context, context: context,
@ -570,6 +598,7 @@ mixin _$ChatStore on ChatStoreBase, Store {
String toString() { String toString() {
return ''' return '''
timeRemaining: ${timeRemaining}, timeRemaining: ${timeRemaining},
isFollowing: ${isFollowing},
expandChat: ${expandChat}, expandChat: ${expandChat},
replyingToMessage: ${replyingToMessage}, replyingToMessage: ${replyingToMessage},
renderMessages: ${renderMessages}, renderMessages: ${renderMessages},