From 1b3c724a90af4b91c23098c79b2d997fed3ce5b6 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 29 Oct 2026 00:53:50 -0400 Subject: [PATCH] Show chat moderation failures in snackbars --- lib/components/chat_history/message.dart | 90 ++++++---- .../chat_history/moderation_test.dart | 169 +++++++++++++++++- 2 files changed, 219 insertions(+), 40 deletions(-) diff --git a/lib/components/chat_history/message.dart b/lib/components/chat_history/message.dart index b6abc0950..ca056c289 100644 --- a/lib/components/chat_history/message.dart +++ b/lib/components/chat_history/message.dart @@ -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; @@ -309,7 +311,8 @@ class _ModerationActions extends StatelessWidget { onTap: () => FocusManager.instance.primaryFocus?.unfocus(), onLongPress: () async { FocusManager.instance.primaryFocus?.unfocus(); - var showTimeoutDialog = await showDialog( + final messenger = ScaffoldMessenger.of(context); + final action = await showDialog<_ModerationAction>( context: context, builder: (context) { return Dialog( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( + 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, diff --git a/test/components/chat_history/moderation_test.dart b/test/components/chat_history/moderation_test.dart index db8add931..d2540dde0 100644 --- a/test/components/chat_history/moderation_test.dart +++ b/test/components/chat_history/moderation_test.dart @@ -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'; @@ -59,8 +62,9 @@ Future pumpMessage( WidgetTester tester, UserModel userModel, MessageModel message, - Channel channel, -) async { + Channel channel, { + ValueNotifier? visible, +}) async { await tester.pumpWidget( MultiProvider( providers: [ @@ -79,8 +83,20 @@ Future 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( + valueListenable: visible, + child: ChatHistoryMessage( + message: message, + channel: channel, + ), + builder: + (context, show, child) => + show ? child! : const SizedBox(), + ), ), ), ), @@ -93,6 +109,33 @@ Future openModeration(WidgetTester tester) async { await tester.pumpAndSettle(); } +class _FailingFunctions extends Fake implements FirebaseFunctions { + final Future Function() fail; + final calls = []; + + _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> call([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); @@ -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(); + 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; @@ -163,6 +305,24 @@ 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); @@ -170,6 +330,7 @@ void main() { 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,