Add basic moderation actions, fix long-press miss bug

This commit is contained in:
Francis Lavoie 2026-04-19 11:40:53 -04:00
parent 6c969b0d97
commit aca01d6da4
No known key found for this signature in database
5 changed files with 251 additions and 12 deletions

View file

@ -465,4 +465,83 @@ class TwitchApi extends BaseApiClient {
return data['messages'] as JsonList;
}
/// Returns a list of broadcaster IDs for channels the authenticated user moderates.
Future<List<String>> getModeratedChannels({required String id}) async {
try {
final data = await get<JsonMap>(
'/moderation/channels',
// Default page size is 20; request the max to avoid hiding mod
// actions for users who moderate many channels.
queryParameters: {'user_id': id, 'first': '100'},
);
final channels = data['data'] as JsonList;
return channels
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
.whereType<String>()
.toList();
} on ApiException catch (e) {
// Existing users may not have re-authenticated with the new scope yet.
// Treat this as non-fatal and hide moderation actions.
debugPrint('Failed to get moderated channels: $e');
return [];
}
}
/// Deletes a specific chat message.
Future<bool> deleteChatMessage({
required String broadcasterId,
required String moderatorId,
required String messageId,
}) async {
try {
await delete<dynamic>(
'/moderation/chat',
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
'message_id': messageId,
},
);
return true;
} on ApiException {
return false;
}
}
/// Bans or times out a user from a channel.
///
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
/// The optional `reason` will be displayed to the banned user and other moderators.
Future<bool> banUser({
required String broadcasterId,
required String moderatorId,
required String userIdToBan,
int? duration,
String? reason,
}) async {
final requestBody = {
'data': {
'user_id': userIdToBan,
if (duration != null) 'duration': duration,
if (reason != null) 'reason': reason,
},
};
try {
final response = await post<JsonMap>(
'/moderation/bans',
data: requestBody,
queryParameters: {
'broadcaster_id': broadcasterId,
'moderator_id': moderatorId,
},
);
final data = response['data'] as JsonList;
return data.isNotEmpty;
} on ApiException {
return false;
}
}
}

View file

@ -344,6 +344,8 @@ abstract class ChatStoreBase with Store {
runInAction(() => _measuredBottomBarHeight.value = height);
}
bool _wasAutoScrollingBeforeInteraction = true;
ChatStoreBase({
required this.twitchApi,
required this.auth,
@ -852,6 +854,29 @@ abstract class ChatStoreBase with Store {
});
}
@action
void pauseAutoScrollForInteraction() {
_wasAutoScrollingBeforeInteraction = _autoScroll;
_autoScroll = false;
}
@action
void resumeAutoScrollAfterInteraction() {
// Restore the auto-scroll *intent* based on its state before the interaction.
_autoScroll = _wasAutoScrollingBeforeInteraction;
// If the user was auto-scrolling (i.e., at the bottom) before the interaction,
// ensure they return to the bottom by jumping.
// The scrollController.addListener will then ensure _autoScroll remains true.
if (_wasAutoScrollingBeforeInteraction) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(0);
}
});
}
}
@action
void listenToSevenTVEmoteSet({required String emoteSetId}) {
final subscribePayload = SevenTVEvent(

View file

@ -9,6 +9,7 @@ import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';
@ -112,6 +113,9 @@ class ChatMessage extends StatelessWidget {
return;
}
final userStore = context.read<AuthStore>().user;
final isModerator = userStore.isModerator(chatStore.channelId);
showModalBottomSheetWithProperFocus(
context: context,
isScrollControlled: true,
@ -199,11 +203,82 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.reply),
title: const Text('Reply to message'),
),
if (isModerator && ircMessage.tags['user-id'] != null && ircMessage.tags['id'] != null) ...[
const Divider(),
ListTile(
onTap: () {
deleteMessageAction(context);
Navigator.pop(context);
},
leading: const Icon(Icons.delete_outline),
title: const Text('Delete message'),
),
ListTile(
onTap: () {
timeoutUserAction(context);
Navigator.pop(context);
},
leading: const Icon(Icons.timer_outlined),
title: const Text('Timeout for 10min'),
),
ListTile(
onTap: () {
banUserAction(context);
Navigator.pop(context);
},
leading: const Icon(Icons.block),
title: const Text('Ban user'),
),
],
],
),
);
}
Future<void> deleteMessageAction(BuildContext context) async {
final userStore = context.read<AuthStore>().user;
final success = await userStore.deleteMessage(
broadcasterId: chatStore.channelId,
messageId: ircMessage.tags['id']!,
);
if (success) {
chatStore.updateNotification('Message deleted');
} else {
chatStore.updateNotification('Failed to delete message');
}
}
Future<void> timeoutUserAction(BuildContext context) async {
final userStore = context.read<AuthStore>().user;
final success = await userStore.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: ircMessage.tags['user-id']!,
duration: 600, // 10 minutes
);
if (success) {
chatStore.updateNotification(
'User ${ircMessage.tags['display-name'] ?? ircMessage.user} timed out for 10 minutes.',
);
} else {
chatStore.updateNotification('Failed to timeout user');
}
}
Future<void> banUserAction(BuildContext context) async {
final userStore = context.read<AuthStore>().user;
final success = await userStore.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: ircMessage.tags['user-id']!,
);
if (success) {
chatStore.updateNotification(
'User ${ircMessage.tags['display-name'] ?? ircMessage.user} banned.',
);
} else {
chatStore.updateNotification('Failed to ban user');
}
}
@override
Widget build(BuildContext context) {
final defaultTextStyle = DefaultTextStyle.of(context).style;
@ -624,18 +699,30 @@ class ChatMessage extends StatelessWidget {
? Opacity(opacity: 0.55, child: coloredMessage)
: coloredMessage;
final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (_effectiveInputStore.assetsStore.showEmoteMenu) {
_effectiveInputStore.assetsStore.showEmoteMenu = false;
}
return GestureDetector(
// If a new message comes in while long pressing, prevent scrolling
// so that the long press doesn't miss and activate on the wrong message.
onLongPressStart: (_) {
chatStore.pauseAutoScrollForInteraction();
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: fadedMessage,
onLongPressEnd: (_) {
chatStore.resumeAutoScrollAfterInteraction();
onLongPressMessage(context, defaultTextStyle);
},
onLongPressCancel: () {
chatStore.resumeAutoScrollAfterInteraction();
},
// Use an InkWell here to get the ripple effect on tap
child: InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (_effectiveInputStore.assetsStore.showEmoteMenu) {
_effectiveInputStore.assetsStore.showEmoteMenu = false;
}
},
child: fadedMessage,
),
);
return finalMessage;
},
);
}

View file

@ -171,7 +171,7 @@ abstract class AuthBase with Store {
'redirect_uri': 'https://twitch.tv/login',
'response_type': 'token',
'scope':
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color',
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color user:read:moderated_channels moderator:manage:chat_messages moderator:manage:banned_users',
'force_verify': 'true',
},
),

View file

@ -17,6 +17,10 @@ abstract class UserStoreBase with Store {
@readonly
var _blockedUsers = ObservableList<UserBlockedTwitch>();
/// The list of channel IDs the user moderates.
@readonly
var _moderatedChannels = ObservableList<String>();
ReactionDisposer? _disposeReaction;
UserStoreBase({required this.twitchApi});
@ -26,12 +30,15 @@ abstract class UserStoreBase with Store {
// Get and update the current user's info.
_details = await twitchApi.getUserInfo();
// Get and update the current user's list of blocked users.
// Get and update non-critical user info.
// Don't use await because having a huge list of blocked users will block the UI.
if (_details?.id != null) {
twitchApi
.getUserBlockedList(id: _details!.id)
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
twitchApi
.getModeratedChannels(id: _details!.id)
.then((channels) => _moderatedChannels = channels.asObservable());
}
_disposeReaction = autorun(
@ -61,10 +68,51 @@ abstract class UserStoreBase with Store {
Future<void> refreshBlockedUsers() async => _blockedUsers =
(await twitchApi.getUserBlockedList(id: _details!.id)).asObservable();
bool isModerator(String channelId) {
return _moderatedChannels.contains(channelId);
}
@action
Future<bool> deleteMessage({
required String broadcasterId,
required String messageId,
}) async {
// Need the moderator ID and confirmed mod status for this channel.
if (_details?.id == null || !isModerator(broadcasterId)) {
return false;
}
return twitchApi.deleteChatMessage(
broadcasterId: broadcasterId,
moderatorId: _details!.id,
messageId: messageId,
);
}
@action
Future<bool> banOrTimeoutUser({
required String broadcasterId,
required String userIdToBan,
int? duration,
String? reason,
}) async {
// Need the moderator ID and confirmed mod status for this channel.
if (_details?.id == null || !isModerator(broadcasterId)) {
return false;
}
return twitchApi.banUser(
broadcasterId: broadcasterId,
moderatorId: _details!.id,
userIdToBan: userIdToBan,
duration: duration,
reason: reason,
);
}
@action
void dispose() {
_details = null;
_blockedUsers.clear();
_moderatedChannels.clear();
if (_disposeReaction != null) _disposeReaction!();
}
}