Add basic moderation actions, fix long-press miss bug
This commit is contained in:
parent
6c969b0d97
commit
aca01d6da4
5 changed files with 251 additions and 12 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue