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
2 changes: 1 addition & 1 deletion assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
41 changes: 1 addition & 40 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}

Expand Down
60 changes: 60 additions & 0 deletions lib/widgets/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> unsubscribeFromChannel(BuildContext context, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use a bit of dartdoc, in particular to highlight that it may show a confirmation message.

Otherwise it sounds like it just unconditionally unsubscribes (and then if that request fails shows an error dialog, in common with other ZulipAction methods).

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.
Expand Down
6 changes: 4 additions & 2 deletions lib/widgets/all_channels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/widgets/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ DialogStatus<void> showErrorDialog({
DialogStatus<bool> showSuggestedActionDialog({
required BuildContext context,
required String title,
required String message,
String? message,
required String? actionButtonText,
bool destructiveActionButton = false,
}) {
Expand All @@ -154,7 +154,7 @@ DialogStatus<bool> 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<bool>(context, null),
Expand Down
12 changes: 9 additions & 3 deletions test/widgets/action_sheet_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<http.Request>()
..method.equals('DELETE')
Expand All @@ -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');
Expand Down
84 changes: 84 additions & 0 deletions test/widgets/all_channels_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]);
Expand All @@ -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,
Expand Down Expand Up @@ -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<http.Request>()
..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<http.Request>()
..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<http.Request>()
..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<http.Request>()
..method.equals('DELETE')
..url.path.equals('/api/v1/users/me/subscriptions')
..bodyFields.deepEquals({
'subscriptions': jsonEncode([channel.name]),
});
});
}
14 changes: 9 additions & 5 deletions test/widgets/dialog_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) {
Expand All @@ -121,8 +121,10 @@ void checkNoDialog(WidgetTester tester) {
final dialog = tester.widget<AlertDialog>(find.bySubtype<AlertDialog>());
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')));
Expand All @@ -135,8 +137,10 @@ void checkNoDialog(WidgetTester tester) {
final dialog = tester.widget<CupertinoAlertDialog>(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<CupertinoDialogAction>(
find.descendant(
Expand Down