Skip to content
Merged
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
90 changes: 54 additions & 36 deletions lib/components/chat_history/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ class ChatHistoryMessage extends StatelessWidget {
}
}

enum _ModerationAction { delete, timeout, ban, unban }

/// Long-press moderation for chat messages on channels the user owns.
class _ModerationActions extends StatelessWidget {
final ChatMessage message;
Expand Down Expand Up @@ -309,7 +311,8 @@ class _ModerationActions extends StatelessWidget {
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
onLongPress: () async {
FocusManager.instance.primaryFocus?.unfocus();
var showTimeoutDialog = await showDialog<bool>(
final messenger = ScaffoldMessenger.of(context);
final action = await showDialog<_ModerationAction>(
context: context,
builder: (context) {
return Dialog(
Expand Down Expand Up @@ -368,8 +371,7 @@ class _ModerationActions extends StatelessWidget {
),
title: Text(AppLocalizations.of(context)!.deleteMessage),
onTap: () {
actions.delete(channel, m.messageId);
Navigator.pop(context);
Navigator.pop(context, _ModerationAction.delete);
},
),
ListTile(
Expand All @@ -383,7 +385,7 @@ class _ModerationActions extends StatelessWidget {
)!.timeoutUser(m.author.displayName ?? m.author.login),
),
onTap: () {
Navigator.pop(context, true);
Navigator.pop(context, _ModerationAction.timeout);
},
),
ListTile(
Expand All @@ -397,12 +399,7 @@ class _ModerationActions extends StatelessWidget {
)!.banUser(m.author.displayName ?? m.author.login),
),
onTap: () {
actions.ban(
channel,
m.author.login,
userId: m.author.userId,
);
Navigator.pop(context);
Navigator.pop(context, _ModerationAction.ban);
},
),
ListTile(
Expand All @@ -416,12 +413,7 @@ class _ModerationActions extends StatelessWidget {
)!.unbanUser(m.author.displayName ?? m.author.login),
),
onTap: () {
actions.unban(
channel,
m.author.login,
userId: m.author.userId,
);
Navigator.pop(context);
Navigator.pop(context, _ModerationAction.unban);
},
),
ListTile(
Expand Down Expand Up @@ -455,27 +447,53 @@ class _ModerationActions extends StatelessWidget {
);
},
);
if (showTimeoutDialog == true && context.mounted) {
await showDialog(
context: context,
builder: (context) {
return TimeoutDialog(
title: AppLocalizations.of(
context,
)!.timeoutUser(m.author.displayName ?? m.author.login),
onPressed: (duration) {
actions.timeout(
channel,
m.author.login,
"timed out by streamer",
duration,
userId: m.author.userId,
);
Navigator.pop(context);
},
try {
switch (action) {
case _ModerationAction.delete:
await actions.delete(channel, m.messageId);
case _ModerationAction.ban:
await actions.ban(
channel,
m.author.login,
userId: m.author.userId,
);
case _ModerationAction.unban:
await actions.unban(
channel,
m.author.login,
userId: m.author.userId,
);
case _ModerationAction.timeout:
if (!context.mounted) return;
final duration = await showDialog<Duration>(
context: context,
builder:
(context) => TimeoutDialog(
title: AppLocalizations.of(
context,
)!.timeoutUser(m.author.displayName ?? m.author.login),
onPressed:
(duration) => Navigator.pop(context, duration),
),
);
if (duration == null) return;
await actions.timeout(
channel,
m.author.login,
"timed out by streamer",
duration,
userId: m.author.userId,
);
},
);
case null:
return;
}
} catch (error) {
if (!messenger.mounted) return;
final message =
error is FirebaseFunctionsException
? error.message ?? error.code
: error.toString();
messenger.showSnackBar(SnackBar(content: Text(message)));
}
},
child: child,
Expand Down
169 changes: 165 additions & 4 deletions test/components/chat_history/moderation_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import 'dart:async';

import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
Expand Down Expand Up @@ -59,8 +62,9 @@ Future<void> pumpMessage(
WidgetTester tester,
UserModel userModel,
MessageModel message,
Channel channel,
) async {
Channel channel, {
ValueNotifier<bool>? visible,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
Expand All @@ -79,8 +83,20 @@ Future<void> pumpMessage(
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Material(
child: ChatHistoryMessage(message: message, channel: channel),
home: Scaffold(
body:
visible == null
? ChatHistoryMessage(message: message, channel: channel)
: ValueListenableBuilder<bool>(
valueListenable: visible,
child: ChatHistoryMessage(
message: message,
channel: channel,
),
builder:
(context, show, child) =>
show ? child! : const SizedBox(),
),
),
),
),
Expand All @@ -93,6 +109,33 @@ Future<void> openModeration(WidgetTester tester) async {
await tester.pumpAndSettle();
}

class _FailingFunctions extends Fake implements FirebaseFunctions {
final Future<void> Function() fail;
final calls = <String>[];

_FailingFunctions(this.fail);

@override
HttpsCallable httpsCallableFromUrl(
String url, {
HttpsCallableOptions? options,
}) => _FailingCallable(this, Uri.parse(url).pathSegments.last);
}

class _FailingCallable extends Fake implements HttpsCallable {
final _FailingFunctions functions;
final String name;

_FailingCallable(this.functions, this.name);

@override
Future<HttpsCallableResult<T>> call<T>([dynamic parameters]) async {
functions.calls.add(name);
await functions.fail();
throw StateError('Expected the fake to fail');
}
}

void main() {
testWidgets('owners can moderate their kick chat', (tester) async {
await pump(tester, kickChannel);
Expand Down Expand Up @@ -132,6 +175,105 @@ void main() {
},
);

group('moderation failures', () {
tearDown(Api.debugReset);

for (final channel in [twitchChannel, kickChannel]) {
for (final action
in {
'Ban viewer': 'ban',
'Unban viewer': 'unban',
'Timeout viewer': 'timeout',
'Delete Message': 'deleteMessage',
}.entries) {
testWidgets(
'${channel.provider} ${action.value} shows server rejection',
(tester) async {
final account = fakeAccount(channel);
final functions = _FailingFunctions(() async {
throw FirebaseFunctionsException(
code: 'failed-precondition',
message: 'Twitch does not allow banning this user.',
);
});
Api.debugOverride(account.app, Api(functions));
await pumpMessage(
tester,
FakeUserModel(accounts: [account]),
channel == twitchChannel ? twitchMessage() : kickMessage(),
channel,
);
await openModeration(tester);
await tester.tap(find.text(action.key));
await tester.pumpAndSettle();
if (action.value == 'timeout') {
expect(functions.calls, isEmpty);
await tester.tap(find.byType(TextButton).last);
await tester.pumpAndSettle();
}
expect(functions.calls, [action.value]);
expect(find.byType(Dialog), findsNothing);
expect(
find.descendant(
of: find.byType(SnackBar),
matching: find.text('Twitch does not allow banning this user.'),
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
}
}

for (final disposed in [false, true]) {
testWidgets('delayed failure after message removal, disposed=$disposed', (
tester,
) async {
final pending = Completer<void>();
final visible = ValueNotifier(true);
addTearDown(visible.dispose);
final account = fakeAccount(kickChannel);
Api.debugOverride(
account.app,
Api(
_FailingFunctions(() async {
await pending.future;
throw FirebaseFunctionsException(
code: 'failed-precondition',
message: 'This user is already banned.',
);
}),
),
);
await pumpMessage(
tester,
FakeUserModel(accounts: [account]),
kickMessage(),
kickChannel,
visible: visible,
);
await openModeration(tester);
await tester.tap(find.text('Ban viewer'));
await tester.pumpAndSettle();
if (disposed) {
await tester.pumpWidget(const SizedBox());
} else {
// Keep the page's messenger, but remove the originating chat message.
visible.value = false;
}
await tester.pumpAndSettle();
pending.complete();
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.text('This user is already banned.'),
disposed ? findsNothing : findsOneWidget,
);
});
}
});

group('signed in to twitch and kick', () {
late Account twitch, kick;
late FakeFunctions twitchApi, kickApi;
Expand Down Expand Up @@ -163,13 +305,32 @@ void main() {
expect(kickApi.calls.single.data['provider'], Providers.kick);
});

for (final timeout in [false, true]) {
testWidgets('cancelling moderation, timeout=$timeout, makes no request', (
tester,
) async {
await pumpMessage(tester, userModel, kickMessage(), kickChannel);
await openModeration(tester);
if (timeout) {
await tester.tap(find.text('Timeout viewer'));
await tester.pumpAndSettle();
}
await tester.tapAt(const Offset(1, 1));
await tester.pumpAndSettle();
expect(kickApi.calls, isEmpty);
expect(twitchApi.calls, isEmpty);
expect(find.byType(SnackBar), findsNothing);
});
}

testWidgets('a kick ban calls as the kick account', (tester) async {
await pumpMessage(tester, userModel, kickMessage(), kickChannel);
await openModeration(tester);
await tester.tap(find.textContaining('Ban'));
await tester.pumpAndSettle();

expect(twitchApi.calls, isEmpty);
expect(find.byType(SnackBar), findsNothing);
expect(kickApi.calls.single.name, 'ban');
expect(kickApi.calls.single.data, {
'provider': Providers.kick,
Expand Down
Loading