Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion lib/apis/twitch_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,6 @@ class TwitchApi {
}
}

// Unblocks the user with the given ID and returns true on success or false on failure.
Future<List<dynamic>> getRecentMessages({
required String userLogin,
}) async {
Expand All @@ -581,4 +580,100 @@ class TwitchApi {
return Future.error('Failed to get recent messages for $userLogin');
}
}

/// Returns a list of broadcaster IDs for channels the authenticated user moderates.
Future<List<String>> getModeratedChannels({
required String id,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/moderation/channels?user_id=$id',
);

// This endpoint requires the 'user:read:moderated_channels' scope.
final response = await _client.get(url, headers: headers);

if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;

final List<String> moderatedChannelIds = [];
for (final channelData in data) {
if (channelData is Map && channelData.containsKey('broadcaster_id')) {
moderatedChannelIds.add(channelData['broadcaster_id'] as String);
}
}
return moderatedChannelIds;
} else {
String message = 'Failed to get moderated channels';
try {
final decodedBody = jsonDecode(response.body);
if (decodedBody is Map && decodedBody.containsKey('message')) {
message = decodedBody['message'];
}
} catch (_) {
// Ignore decoding error, use default message
}
return Future.error(
'Failed to get moderated channels for user $id: $message (Status ${response.statusCode})',
);
}
}

/// Deletes a specific chat message.
Future<bool> deleteChatMessage({
required String broadcasterId,
required String moderatorId,
required String messageId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/moderation/chat?broadcaster_id=$broadcasterId&moderator_id=$moderatorId&message_id=$messageId',
);

// This endpoint requires the `moderator:manage:chat_messages` scope.
final response = await _client.delete(url, headers: headers);
// A 204 No Content response indicates success.
return response.statusCode == 204;
}

/// 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,
required Map<String, String> headers,
int? duration,
String? reason,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/moderation/bans?broadcaster_id=$broadcasterId&moderator_id=$moderatorId',
);

final Map<String, dynamic> requestBody = {
'data': {
'user_id': userIdToBan,
if (duration != null) 'duration': duration,
if (reason != null) 'reason': reason,
}
};

// This endpoint requires the `moderator:manage:banned_users` scope.
final response = await _client.post(
url,
headers: {...headers, 'Content-Type': 'application/json'},
body: jsonEncode(requestBody),
);

// A 200 OK response indicates success.
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;
return data.isNotEmpty;
}
return false;
}
}
25 changes: 25 additions & 0 deletions lib/screens/channel/chat/stores/chat_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ abstract class ChatStoreBase with Store {
@observable
IRCMessage? replyingToMessage;

bool _wasAutoScrollingBeforeInteraction = true;

ChatStoreBase({
required this.twitchApi,
required this.auth,
Expand Down Expand Up @@ -464,6 +466,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(
Expand Down
125 changes: 109 additions & 16 deletions lib/screens/channel/chat/widgets/chat_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,6 @@ class ChatMessage extends StatelessWidget {
});
}

Future<void> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));

chatStore.updateNotification('Message copied');
}

void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) {
HapticFeedback.lightImpact();

Expand All @@ -91,6 +85,10 @@ class ChatMessage extends StatelessWidget {
return;
}

final authStore = context.read<AuthStore>();
final userStore = authStore.user;
final isModerator = userStore.isModerator(chatStore.channelId);

showModalBottomSheet(
context: context,
isScrollControlled: true,
Expand Down Expand Up @@ -133,11 +131,94 @@ 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> copyMessage() async {
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));

chatStore.updateNotification('Message copied');
}

Future<void> deleteMessageAction(BuildContext context) async {
final authStore = context.read<AuthStore>();
final userStore = authStore.user;
final success = await userStore.deleteMessage(
broadcasterId: chatStore.channelId,
messageId: ircMessage.tags['id']!,
headers: authStore.headersTwitch,
);
if (success) {
chatStore.updateNotification('Message deleted');
} else {
chatStore.updateNotification('Failed to delete message');
}
}

Future<void> timeoutUserAction(BuildContext context) async {
final authStore = context.read<AuthStore>();
final userStore = authStore.user;
final success = await userStore.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: ircMessage.tags['user-id']!,
headers: authStore.headersTwitch,
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 authStore = context.read<AuthStore>();
final userStore = authStore.user;
final success = await userStore.banOrTimeoutUser(
broadcasterId: chatStore.channelId,
userIdToBan: ircMessage.tags['user-id']!,
headers: authStore.headersTwitch,
);
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;
Expand Down Expand Up @@ -452,18 +533,30 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);

final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
}
return GestureDetector(
// If a new message comes in while long pressing, prevent scolling
// so that the long press doesn't miss and activate on the wrong message.
onLongPressStart: (_) {
chatStore.pauseAutoScrollForInteraction();
},
onLongPressEnd: (_) {
chatStore.resumeAutoScrollAfterInteraction();
onLongPressMessage(context, defaultTextStyle);
},
onLongPressCancel: () {
chatStore.resumeAutoScrollAfterInteraction();
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: coloredMessage,
// Use an InkWell here to get the ripple effect on tap
child: InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
}
},
child: coloredMessage,
),
);

return finalMessage;
},
);
}
Expand Down
12 changes: 10 additions & 2 deletions lib/screens/settings/stores/auth_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,16 @@ abstract class AuthBase with Store {
'client_id': clientId,
'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',
'scope': [
'chat:read',
'chat:edit',
'user:read:follows',
'user:read:blocked_users',
'user:manage:blocked_users',
'user:read:moderated_channels',
'moderator:manage:chat_messages',
'moderator:manage:banned_users',
].join(' '),
'force_verify': 'true',
},
),
Expand Down
Loading