diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 2570578b6f..05598dab42 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -140,7 +140,7 @@ "@unsubscribeConfirmationDialogTitle": { "description": "Title for a confirmation dialog for unsubscribing from a channel.", "placeholders": { - "channelName": {"type": "String", "example": "mobile"} + "channelName": {"type": "String", "example": "#mobile"} } }, "unsubscribeConfirmationDialogMessageCannotResubscribe": "Once you leave this channel, you will not be able to rejoin.", diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 5d233a527f..c01b8b9937 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -17,7 +17,6 @@ import '../model/content.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; -import '../model/realm.dart'; import 'actions.dart'; import 'button.dart'; import 'color.dart'; @@ -634,45 +633,7 @@ class UnsubscribeButton extends ActionSheetMenuItemButton { @override void onPressed() async { - final store = PerAccountStoreWidget.of(pageContext); - final subscription = store.subscriptions[channelId]; - if (subscription == null) return; // TODO could give feedback - - // TODO(future) check if the self-user is a guest and the channel is not web-public - final couldResubscribe = !subscription.inviteOnly - || store.selfHasPermissionForGroupSetting(subscription.canSubscribeGroup, - GroupSettingType.stream, 'can_subscribe_group'); - if (!couldResubscribe) { - // TODO(#1788) warn if org would lose content access (nobody can subscribe) - final zulipLocalizations = ZulipLocalizations.of(pageContext); - - final dialog = showSuggestedActionDialog(context: pageContext, - title: zulipLocalizations.unsubscribeConfirmationDialogTitle(subscription.name), - message: zulipLocalizations.unsubscribeConfirmationDialogMessageCannotResubscribe, - destructiveActionButton: true, - actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton); - if (await dialog.result != true) return; - if (!pageContext.mounted) return; - } - - try { - await unsubscribeFromChannel(PerAccountStoreWidget.of(pageContext).connection, - subscriptions: [subscription.name]); - } catch (e) { - if (!pageContext.mounted) return; - - String? errorMessage; - switch (e) { - case ZulipApiException(): - errorMessage = e.message; - // TODO(#741) specific messages for common errors, like network errors - // (support with reusable code) - default: - } - - final title = ZulipLocalizations.of(pageContext).unsubscribeFailedTitle; - showErrorDialog(context: pageContext, title: title, message: errorMessage); - } + await ZulipAction.unsubscribeFromChannel(pageContext, channelId: channelId); } } diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 4c725f9878..dd34564768 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -7,9 +7,11 @@ import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/model/narrow.dart'; import '../api/route/messages.dart'; +import '../api/route/channels.dart' as channels_api; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/narrow.dart'; +import '../model/realm.dart'; import 'dialog.dart'; import 'store.dart'; @@ -239,6 +241,64 @@ abstract final class ZulipAction { return fetchedMessage?.content; } + + /// Unsubscribe from a channel, possibly after a confirmation dialog, + /// showing an error dialog on failure. + /// + /// A confirmation dialog is shown if the user would not have permission + /// to resubscribe. + /// If [alwaysAsk] is true (the default), + /// a confirmation dialog is shown unconditionally. + static Future unsubscribeFromChannel(BuildContext context, { + required int channelId, + bool alwaysAsk = true, + }) async { + final store = PerAccountStoreWidget.of(context); + final subscription = store.subscriptions[channelId]; + if (subscription == null) return; // TODO could give feedback + + // TODO(future) check if the self-user is a guest and the channel is not web-public + final couldResubscribe = !subscription.inviteOnly + || store.selfHasPermissionForGroupSetting(subscription.canSubscribeGroup, + GroupSettingType.stream, 'can_subscribe_group'); + final zulipLocalizations = ZulipLocalizations.of(context); + if (!couldResubscribe) { + // TODO(#1788) warn if org would lose content access (nobody can subscribe) + + final dialog = showSuggestedActionDialog(context: context, + title: zulipLocalizations.unsubscribeConfirmationDialogTitle('#${subscription.name}'), + message: zulipLocalizations.unsubscribeConfirmationDialogMessageCannotResubscribe, + destructiveActionButton: true, + actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton); + if (await dialog.result != true) return; + if (!context.mounted) return; + } else if (alwaysAsk) { + final dialog = showSuggestedActionDialog(context: context, + title: zulipLocalizations.unsubscribeConfirmationDialogTitle('#${subscription.name}'), + actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton); + if (await dialog.result != true) return; + if (!context.mounted) return; + } + + try { + await channels_api.unsubscribeFromChannel(PerAccountStoreWidget.of(context).connection, + subscriptions: [subscription.name]); + } catch (e) { + if (!context.mounted) return; + + String? errorMessage; + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO(#741) specific messages for common errors, like network errors + // (support with reusable code) + default: + } + + final title = ZulipLocalizations.of(context).unsubscribeFailedTitle; + showErrorDialog(context: context, title: title, message: errorMessage); + } + } } /// Methods that act through platform APIs and show feedback in the UI. diff --git a/lib/widgets/all_channels.dart b/lib/widgets/all_channels.dart index 7acdbe8521..3aaf0d8baf 100644 --- a/lib/widgets/all_channels.dart +++ b/lib/widgets/all_channels.dart @@ -6,6 +6,7 @@ import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; import '../model/channel.dart'; import 'action_sheet.dart'; +import 'actions.dart'; import 'app_bar.dart'; import 'button.dart'; import 'icons.dart'; @@ -137,8 +138,9 @@ class _SubscribeToggle extends StatelessWidget { await subscribeToChannel(store.connection, subscriptions: [channel.name]); } else { - await unsubscribeFromChannel(store.connection, - subscriptions: [channel.name]); + await ZulipAction.unsubscribeFromChannel(context, + channelId: channel.streamId, + alwaysAsk: false); } }, // TODO(#741) interpret API errors for user diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index fb93a3d6cc..8a0164d577 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -145,7 +145,7 @@ DialogStatus showErrorDialog({ DialogStatus showSuggestedActionDialog({ required BuildContext context, required String title, - required String message, + String? message, required String? actionButtonText, bool destructiveActionButton = false, }) { @@ -154,7 +154,7 @@ DialogStatus showSuggestedActionDialog({ context: context, builder: (BuildContext context) => AlertDialog.adaptive( title: Text(title), - content: _adaptiveContent(Text(message)), + content: message != null ? _adaptiveContent(Text(message)) : null, actions: [ _adaptiveAction( onPressed: () => Navigator.pop(context, null), diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index e03e102cf5..22a4b06d8e 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -573,9 +573,15 @@ void main() { connection.prepare(json: {}); await tapButton(tester); - await tester.pump(Duration.zero); + await tester.pump(); - checkNoDialog(tester); + final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Unsubscribe from #${channel.name}?', + expectDestructiveActionButton: false, + expectedActionButtonText: 'Unsubscribe'); + await tester.tap(find.byWidget(unsubscribeButton)); + await tester.pump(); + await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('DELETE') @@ -599,7 +605,7 @@ void main() { await tester.pump(); final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester, - expectedTitle: 'Unsubscribe from ${channel.name}?', + expectedTitle: 'Unsubscribe from #${channel.name}?', expectedMessage: 'Once you leave this channel, you will not be able to rejoin.', expectDestructiveActionButton: true, expectedActionButtonText: 'Unsubscribe'); diff --git a/test/widgets/all_channels_test.dart b/test/widgets/all_channels_test.dart index c824e5fe9b..b47546be40 100644 --- a/test/widgets/all_channels_test.dart +++ b/test/widgets/all_channels_test.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/all_channels.dart'; @@ -10,20 +13,25 @@ import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/remote_settings.dart'; import 'package:zulip/widgets/theme.dart'; +import '../api/fake_api.dart'; import '../api/model/model_checks.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../example_data.dart' as eg; import '../model/test_store.dart'; +import '../stdlib_checks.dart'; import 'checks.dart'; +import 'dialog_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); late PerAccountStore store; + late FakeApiConnection connection; late TransitionDurationObserver transitionDurationObserver; final groupSettingWithSelf = eg.groupSetting(members: [eg.selfUser.userId]); @@ -40,6 +48,7 @@ void main() { ); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; transitionDurationObserver = TransitionDurationObserver(); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, @@ -207,4 +216,79 @@ void main() { check(find.byType(BottomSheet)).findsOne(); }); + + testWidgets('use toggle switch to subscribe/unsubscribe', (tester) async { + final channel = eg.stream(); + await setupAllChannelsPage(tester, channels: [channel]); + + await tester.tap(find.byType(Toggle)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([{'name': channel.name}]), + }); + + await store.addSubscription(eg.subscription(channel)); + await tester.pump(); // Toggle changes state + + await tester.tap(find.byType(Toggle)); + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([channel.name]), + }); + }); + + testWidgets('Toggle "off" to unsubscribe, public channel', (tester) async { + final channel = eg.stream(inviteOnly: false); + final subscription = eg.subscription(channel); + + await setupAllChannelsPage(tester, channels: [subscription]); + + connection.prepare(json: {}); + await tester.tap(find.byType(Toggle)); + await tester.pump(Duration.zero); + checkNoDialog(tester); + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([channel.name]), + }); + }); + + testWidgets('Toggle "off" to unsubscribe, but without resubscribe permission', (tester) async { + final channel = eg.stream( + inviteOnly: true, canSubscribeGroup: eg.groupSetting(members: [])); + final subscription = eg.subscription(channel); + + (Widget, Widget) checkConfirmDialog() => checkSuggestedActionDialog(tester, + expectedTitle: 'Unsubscribe from #${channel.name}?', + expectedMessage: 'Once you leave this channel, you will not be able to rejoin.', + expectDestructiveActionButton: true, + expectedActionButtonText: 'Unsubscribe'); + + await setupAllChannelsPage(tester, channels: [subscription]); + + await tester.tap(find.byType(Toggle)); + await tester.pump(); + final (_, cancelButton) = checkConfirmDialog(); + await tester.tap(find.byWidget(cancelButton)); + await tester.pumpAndSettle(); + check(connection.lastRequest).isNull(); + await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout); + + await tester.tap(find.byType(Toggle)); + await tester.pump(); + final (unsubscribeButton, _) = checkConfirmDialog(); + await tester.tap(find.byWidget(unsubscribeButton)); + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([channel.name]), + }); + }); } diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart index aca3eeffae..b41f6daf79 100644 --- a/test/widgets/dialog_checks.dart +++ b/test/widgets/dialog_checks.dart @@ -109,7 +109,7 @@ void checkNoDialog(WidgetTester tester) { /// Tap the action button by calling `tester.tap(find.byWidget(actionButton))`. (Widget, Widget) checkSuggestedActionDialog(WidgetTester tester, { required String expectedTitle, - required String expectedMessage, + String? expectedMessage, String? expectedActionButtonText, bool expectDestructiveActionButton = false, }) { @@ -121,8 +121,10 @@ void checkNoDialog(WidgetTester tester) { final dialog = tester.widget(find.bySubtype()); tester.widget(find.descendant(matchRoot: true, of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + if (expectedMessage != null) { + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + } final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog), matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue'))); @@ -135,8 +137,10 @@ void checkNoDialog(WidgetTester tester) { final dialog = tester.widget(find.byType(CupertinoAlertDialog)); tester.widget(find.descendant(matchRoot: true, of: find.byWidget(dialog.title!), matching: find.text(expectedTitle))); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + if (expectedMessage != null) { + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); + } final actionButton = tester.widget( find.descendant(