Skip to content

Commit 527032f

Browse files
committed
Add basic moderation actions, fix long-press miss bug
1 parent 40bc8a3 commit 527032f

File tree

5 files changed

+299
-20
lines changed

5 files changed

+299
-20
lines changed

lib/apis/twitch_api.dart

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,6 @@ class TwitchApi {
563563
}
564564
}
565565

566-
// Unblocks the user with the given ID and returns true on success or false on failure.
567566
Future<List<dynamic>> getRecentMessages({
568567
required String userLogin,
569568
}) async {
@@ -581,4 +580,100 @@ class TwitchApi {
581580
return Future.error('Failed to get recent messages for $userLogin');
582581
}
583582
}
583+
584+
/// Returns a list of broadcaster IDs for channels the authenticated user moderates.
585+
Future<List<String>> getModeratedChannels({
586+
required String id,
587+
required Map<String, String> headers,
588+
}) async {
589+
final url = Uri.parse(
590+
'https://api.twitch.tv/helix/moderation/channels?user_id=$id',
591+
);
592+
593+
// This endpoint requires the 'user:read:moderated_channels' scope.
594+
final response = await _client.get(url, headers: headers);
595+
596+
if (response.statusCode == 200) {
597+
final decoded = jsonDecode(response.body);
598+
final data = decoded['data'] as List;
599+
600+
final List<String> moderatedChannelIds = [];
601+
for (final channelData in data) {
602+
if (channelData is Map && channelData.containsKey('broadcaster_id')) {
603+
moderatedChannelIds.add(channelData['broadcaster_id'] as String);
604+
}
605+
}
606+
return moderatedChannelIds;
607+
} else {
608+
String message = 'Failed to get moderated channels';
609+
try {
610+
final decodedBody = jsonDecode(response.body);
611+
if (decodedBody is Map && decodedBody.containsKey('message')) {
612+
message = decodedBody['message'];
613+
}
614+
} catch (_) {
615+
// Ignore decoding error, use default message
616+
}
617+
return Future.error(
618+
'Failed to get moderated channels for user $id: $message (Status ${response.statusCode})',
619+
);
620+
}
621+
}
622+
623+
/// Deletes a specific chat message.
624+
Future<bool> deleteChatMessage({
625+
required String broadcasterId,
626+
required String moderatorId,
627+
required String messageId,
628+
required Map<String, String> headers,
629+
}) async {
630+
final url = Uri.parse(
631+
'https://api.twitch.tv/helix/moderation/chat?broadcaster_id=$broadcasterId&moderator_id=$moderatorId&message_id=$messageId',
632+
);
633+
634+
// This endpoint requires the `moderator:manage:chat_messages` scope.
635+
final response = await _client.delete(url, headers: headers);
636+
// A 204 No Content response indicates success.
637+
return response.statusCode == 204;
638+
}
639+
640+
/// Bans or times out a user from a channel.
641+
///
642+
/// The optional `duration` in seconds will timeout the user. If omitted, the user is banned.
643+
/// The optional `reason` will be displayed to the banned user and other moderators.
644+
Future<bool> banUser({
645+
required String broadcasterId,
646+
required String moderatorId,
647+
required String userIdToBan,
648+
required Map<String, String> headers,
649+
int? duration,
650+
String? reason,
651+
}) async {
652+
final url = Uri.parse(
653+
'https://api.twitch.tv/helix/moderation/bans?broadcaster_id=$broadcasterId&moderator_id=$moderatorId',
654+
);
655+
656+
final Map<String, dynamic> requestBody = {
657+
'data': {
658+
'user_id': userIdToBan,
659+
if (duration != null) 'duration': duration,
660+
if (reason != null) 'reason': reason,
661+
}
662+
};
663+
664+
// This endpoint requires the `moderator:manage:banned_users` scope.
665+
final response = await _client.post(
666+
url,
667+
headers: {...headers, 'Content-Type': 'application/json'},
668+
body: jsonEncode(requestBody),
669+
);
670+
671+
// A 200 OK response indicates success.
672+
if (response.statusCode == 200) {
673+
final decoded = jsonDecode(response.body);
674+
final data = decoded['data'] as List;
675+
return data.isNotEmpty;
676+
}
677+
return false;
678+
}
584679
}

lib/screens/channel/chat/stores/chat_store.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ abstract class ChatStoreBase with Store {
156156
@observable
157157
IRCMessage? replyingToMessage;
158158

159+
bool _wasAutoScrollingBeforeInteraction = true;
160+
159161
ChatStoreBase({
160162
required this.twitchApi,
161163
required this.auth,
@@ -464,6 +466,29 @@ abstract class ChatStoreBase with Store {
464466
});
465467
}
466468

469+
@action
470+
void pauseAutoScrollForInteraction() {
471+
_wasAutoScrollingBeforeInteraction = _autoScroll;
472+
_autoScroll = false;
473+
}
474+
475+
@action
476+
void resumeAutoScrollAfterInteraction() {
477+
// Restore the auto-scroll *intent* based on its state before the interaction.
478+
_autoScroll = _wasAutoScrollingBeforeInteraction;
479+
480+
// If the user was auto-scrolling (i.e., at the bottom) before the interaction,
481+
// ensure they return to the bottom by jumping.
482+
// The scrollController.addListener will then ensure _autoScroll remains true.
483+
if (_wasAutoScrollingBeforeInteraction) {
484+
WidgetsBinding.instance.addPostFrameCallback((_) {
485+
if (scrollController.hasClients) {
486+
scrollController.jumpTo(0);
487+
}
488+
});
489+
}
490+
}
491+
467492
@action
468493
void listenToSevenTVEmoteSet({required String emoteSetId}) {
469494
final subscribePayload = SevenTVEvent(

lib/screens/channel/chat/widgets/chat_message.dart

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,6 @@ class ChatMessage extends StatelessWidget {
7676
});
7777
}
7878

79-
Future<void> copyMessage() async {
80-
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
81-
82-
chatStore.updateNotification('Message copied');
83-
}
84-
8579
void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
8680
HapticFeedback.lightImpact();
8781

@@ -91,6 +85,10 @@ class ChatMessage extends StatelessWidget {
9185
return;
9286
}
9387

88+
final authStore = context.read<AuthStore>();
89+
final userStore = authStore.user;
90+
final isModerator = userStore.isModerator(chatStore.channelId);
91+
9492
showModalBottomSheet(
9593
context: context,
9694
isScrollControlled: true,
@@ -133,11 +131,94 @@ class ChatMessage extends StatelessWidget {
133131
leading: const Icon(Icons.reply),
134132
title: const Text('Reply to message'),
135133
),
134+
if (isModerator && ircMessage.tags['user-id'] != null && ircMessage.tags['id'] != null) ...[
135+
const Divider(),
136+
ListTile(
137+
onTap: () {
138+
deleteMessageAction(context);
139+
Navigator.pop(context);
140+
},
141+
leading: const Icon(Icons.delete_outline),
142+
title: const Text('Delete message'),
143+
),
144+
ListTile(
145+
onTap: () {
146+
timeoutUserAction(context);
147+
Navigator.pop(context);
148+
},
149+
leading: const Icon(Icons.timer_outlined),
150+
title: const Text('Timeout for 10min'),
151+
),
152+
ListTile(
153+
onTap: () {
154+
banUserAction(context);
155+
Navigator.pop(context);
156+
},
157+
leading: const Icon(Icons.block),
158+
title: const Text('Ban user'),
159+
),
160+
],
136161
],
137162
),
138163
);
139164
}
140165

166+
Future<void> copyMessage() async {
167+
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
168+
169+
chatStore.updateNotification('Message copied');
170+
}
171+
172+
Future<void> deleteMessageAction(BuildContext context) async {
173+
final authStore = context.read<AuthStore>();
174+
final userStore = authStore.user;
175+
final success = await userStore.deleteMessage(
176+
broadcasterId: chatStore.channelId,
177+
messageId: ircMessage.tags['id']!,
178+
headers: authStore.headersTwitch,
179+
);
180+
if (success) {
181+
chatStore.updateNotification('Message deleted');
182+
} else {
183+
chatStore.updateNotification('Failed to delete message');
184+
}
185+
}
186+
187+
Future<void> timeoutUserAction(BuildContext context) async {
188+
final authStore = context.read<AuthStore>();
189+
final userStore = authStore.user;
190+
final success = await userStore.banOrTimeoutUser(
191+
broadcasterId: chatStore.channelId,
192+
userIdToBan: ircMessage.tags['user-id']!,
193+
headers: authStore.headersTwitch,
194+
duration: 600, // 10 minutes
195+
);
196+
if (success) {
197+
chatStore.updateNotification(
198+
'User ${ircMessage.tags['display-name'] ?? ircMessage.user} timed out for 10 minutes.',
199+
);
200+
} else {
201+
chatStore.updateNotification('Failed to timeout user');
202+
}
203+
}
204+
205+
Future<void> banUserAction(BuildContext context) async {
206+
final authStore = context.read<AuthStore>();
207+
final userStore = authStore.user;
208+
final success = await userStore.banOrTimeoutUser(
209+
broadcasterId: chatStore.channelId,
210+
userIdToBan: ircMessage.tags['user-id']!,
211+
headers: authStore.headersTwitch,
212+
);
213+
if (success) {
214+
chatStore.updateNotification(
215+
'User ${ircMessage.tags['display-name'] ?? ircMessage.user} banned.',
216+
);
217+
} else {
218+
chatStore.updateNotification('Failed to ban user');
219+
}
220+
}
221+
141222
@override
142223
Widget build(BuildContext context) {
143224
final defaultTextStyle = DefaultTextStyle.of(context).style;
@@ -452,18 +533,30 @@ class ChatMessage extends StatelessWidget {
452533
child: dividedMessage,
453534
);
454535

455-
final finalMessage = InkWell(
456-
onTap: () {
457-
FocusScope.of(context).unfocus();
458-
if (chatStore.assetsStore.showEmoteMenu) {
459-
chatStore.assetsStore.showEmoteMenu = false;
460-
}
536+
return GestureDetector(
537+
// If a new message comes in while long pressing, prevent scolling
538+
// so that the long press doesn't miss and activate on the wrong message.
539+
onLongPressStart: (_) {
540+
chatStore.pauseAutoScrollForInteraction();
541+
},
542+
onLongPressEnd: (_) {
543+
chatStore.resumeAutoScrollAfterInteraction();
544+
onLongPressMessage(context, defaultTextStyle);
545+
},
546+
onLongPressCancel: () {
547+
chatStore.resumeAutoScrollAfterInteraction();
461548
},
462-
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
463-
child: coloredMessage,
549+
// Use an InkWell here to get the ripple effect on tap
550+
child: InkWell(
551+
onTap: () {
552+
FocusScope.of(context).unfocus();
553+
if (chatStore.assetsStore.showEmoteMenu) {
554+
chatStore.assetsStore.showEmoteMenu = false;
555+
}
556+
},
557+
child: coloredMessage,
558+
),
464559
);
465-
466-
return finalMessage;
467560
},
468561
);
469562
}

lib/screens/settings/stores/auth_store.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,16 @@ abstract class AuthBase with Store {
131131
'client_id': clientId,
132132
'redirect_uri': 'https://twitch.tv/login',
133133
'response_type': 'token',
134-
'scope':
135-
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users',
134+
'scope': [
135+
'chat:read',
136+
'chat:edit',
137+
'user:read:follows',
138+
'user:read:blocked_users',
139+
'user:manage:blocked_users',
140+
'user:read:moderated_channels',
141+
'moderator:manage:chat_messages',
142+
'moderator:manage:banned_users',
143+
].join(' '),
136144
'force_verify': 'true',
137145
},
138146
),

0 commit comments

Comments
 (0)