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);
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue