Merge 78590ce093 into bb150ce42a
This commit is contained in:
commit
2bafbfe42f
5 changed files with 180 additions and 33 deletions
|
|
@ -201,6 +201,23 @@ class TwitchApi extends BaseApiClient {
|
|||
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.
|
||||
Future<FollowedChannels> getFollowedChannels({
|
||||
required String userId,
|
||||
|
|
|
|||
|
|
@ -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<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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<EmoteMenuSection>
|
|||
: 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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -259,6 +259,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;
|
||||
|
|
@ -1047,6 +1055,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);
|
||||
|
|
@ -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.
|
||||
void dispose() {
|
||||
_shouldDisconnect = true;
|
||||
|
|
|
|||
|
|
@ -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<void> 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},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue