diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index b4cca7838c..85221a7e12 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/circle_x.svg b/assets/icons/circle_x.svg
new file mode 100644
index 0000000000..a364d54a81
--- /dev/null
+++ b/assets/icons/circle_x.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index fad1fdfc79..77200c01eb 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -96,6 +96,14 @@
"@permissionsDeniedReadExternalStorage": {
"description": "Message for dialog asking the user to grant permissions for external storage read access."
},
+ "actionSheetOptionSubscribe": "Subscribe",
+ "@actionSheetOptionSubscribe": {
+ "description": "Label in the channel action sheet for subscribing to the channel."
+ },
+ "subscribeFailedTitle": "Failed to subscribe",
+ "@subscribeFailedTitle": {
+ "description": "Error title when subscribing to a channel failed."
+ },
"actionSheetOptionMarkChannelAsRead": "Mark channel as read",
"@actionSheetOptionMarkChannelAsRead": {
"description": "Label for marking a channel as read."
@@ -108,6 +116,29 @@
"@actionSheetOptionListOfTopics": {
"description": "Label for navigating to a channel's topic-list page."
},
+ "actionSheetOptionUnsubscribe": "Unsubscribe",
+ "@actionSheetOptionUnsubscribe": {
+ "description": "Label in the channel action sheet for unsubscribing from the channel."
+ },
+ "unsubscribeConfirmationDialogTitle": "Unsubscribe from {channelName}?",
+ "@unsubscribeConfirmationDialogTitle": {
+ "description": "Title for a confirmation dialog for unsubscribing from a channel.",
+ "placeholders": {
+ "channelName": {"type": "String", "example": "mobile"}
+ }
+ },
+ "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Once you leave this channel, you might not be able to rejoin.",
+ "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": {
+ "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe."
+ },
+ "unsubscribeConfirmationDialogConfirmButton": "Unsubscribe",
+ "@unsubscribeConfirmationDialogConfirmButton": {
+ "description": "Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel."
+ },
+ "unsubscribeFailedTitle": "Failed to unsubscribe",
+ "@unsubscribeFailedTitle": {
+ "description": "Error title when unsubscribing from a channel failed."
+ },
"actionSheetOptionMuteTopic": "Mute topic",
"@actionSheetOptionMuteTopic": {
"description": "Label for muting a topic on action sheet."
diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart
index 8ae2076038..c21a6c8752 100644
--- a/lib/api/route/channels.dart
+++ b/lib/api/route/channels.dart
@@ -4,6 +4,42 @@ import '../core.dart';
import '../model/model.dart';
part 'channels.g.dart';
+/// https://zulip.com/api/subscribe
+///
+/// [subscriptions] is a list of channel names.
+/// (This is one of the few remaining areas where the Zulip API hasn't migrated
+/// to using IDs.)
+Future subscribeToChannel(ApiConnection connection, {
+ // TODO(server-future): This should use a stream ID, not stream name.
+ // (Keep dartdoc up to date.)
+ // Server issue: https://github.com/zulip/zulip/issues/10744
+ required List subscriptions,
+ List? principals,
+}) {
+ return connection.post('subscribeToChannel', (_) {}, 'users/me/subscriptions', {
+ 'subscriptions': subscriptions.map((name) => {'name': name}).toList(),
+ if (principals != null) 'principals': principals,
+ });
+}
+
+/// https://zulip.com/api/unsubscribe
+///
+/// [subscriptions] is a list of channel names.
+/// (This is one of the few remaining areas where the Zulip API hasn't migrated
+/// to using IDs.)
+Future unsubscribeFromChannel(ApiConnection connection, {
+ // TODO(server-future): This should use a stream ID, not stream name.
+ // (Keep dartdoc up to date.)
+ // Server issue: https://github.com/zulip/zulip/issues/10744
+ required List subscriptions,
+ List? principals,
+}) {
+ return connection.delete('unsubscribeFromChannel', (_) {}, 'users/me/subscriptions', {
+ 'subscriptions': subscriptions,
+ if (principals != null) 'principals': principals,
+ });
+}
+
/// https://zulip.com/api/get-stream-topics
Future getStreamTopics(ApiConnection connection, {
required int streamId,
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 6e3d6d8be2..d7e9c25f8f 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -275,6 +275,18 @@ abstract class ZulipLocalizations {
/// **'To upload files, please grant Zulip additional permissions in Settings.'**
String get permissionsDeniedReadExternalStorage;
+ /// Label in the channel context menu for subscribing to the channel.
+ ///
+ /// In en, this message translates to:
+ /// **'Subscribe'**
+ String get actionSheetOptionSubscribe;
+
+ /// Error title when subscribing to a channel failed.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to subscribe'**
+ String get subscribeFailedTitle;
+
/// Label for marking a channel as read.
///
/// In en, this message translates to:
@@ -293,6 +305,36 @@ abstract class ZulipLocalizations {
/// **'List of topics'**
String get actionSheetOptionListOfTopics;
+ /// Label in the channel context menu for unsubscribing from the channel.
+ ///
+ /// In en, this message translates to:
+ /// **'Unsubscribe'**
+ String get actionSheetOptionUnsubscribe;
+
+ /// Title for a confirmation dialog for unsubscribing from a channel.
+ ///
+ /// In en, this message translates to:
+ /// **'Unsubscribe from {channelName}?'**
+ String unsubscribeConfirmationDialogTitle(String channelName);
+
+ /// Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe.
+ ///
+ /// In en, this message translates to:
+ /// **'Once you leave this channel, you might not be able to rejoin.'**
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe;
+
+ /// Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel.
+ ///
+ /// In en, this message translates to:
+ /// **'Unsubscribe'**
+ String get unsubscribeConfirmationDialogConfirmButton;
+
+ /// Error title when unsubscribing from a channel failed.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to unsubscribe'**
+ String get unsubscribeFailedTitle;
+
/// Label for muting a topic on action sheet.
///
/// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index cca05f41e2..5ff9981001 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
@@ -96,6 +102,24 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'List of topics';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index a5ffc40f1d..8d0826bac9 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -88,6 +88,12 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'Bitte gewähre Zulip zusätzliche Berechtigungen in den Einstellungen, um Dateien hochzuladen.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead =>
'Kanal als gelesen markieren';
@@ -98,6 +104,24 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'Themenliste';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Thema stummschalten';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 6191c23018..bffdb9f9a9 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
@@ -96,6 +102,24 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'List of topics';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 5a922da313..5001c79eed 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
@@ -96,6 +102,24 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'List of topics';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index f330fd6f59..25a5c4e999 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -88,6 +88,12 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'Per caricare file, bisogna concedere a Zulip autorizzazioni aggiuntive nelle Impostazioni.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Segna il canale come letto';
@@ -97,6 +103,24 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'Elenco degli argomenti';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Silenzia argomento';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 0a37c8a874..e39ebe4377 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -86,6 +86,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'チャンネルを既読にする';
@@ -95,6 +101,24 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'トピック一覧';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'トピックをミュート';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 66e62b13dd..d08ca0eaf0 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
@@ -96,6 +102,24 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'List of topics';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index c675e00354..9b856a6aae 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -88,6 +88,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'Aby odebrać pliki Zulip musi uzyskać dodatkowe uprawnienia w Ustawieniach.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead =>
'Oznacz kanał jako przeczytany';
@@ -98,6 +104,24 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'Lista wątków';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Wycisz wątek';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 655a2a07db..848b02eefb 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -88,6 +88,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'Для загрузки файлов, пожалуйста, предоставьте Zulip дополнительные разрешения в настройках.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead =>
'Отметить канал как прочитанный';
@@ -98,6 +104,24 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'Список тем';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Отключить тему';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 205bab746b..96e9e0c542 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
@@ -96,6 +102,24 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'List of topics';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Stlmiť tému';
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 6cc6780e46..bdb56cf44d 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'Za nalaganje datotek v nastavitvah omogočite Zulipu dostop do shrambe datotek.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Označi kanal kot prebran';
@@ -96,6 +102,24 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'Seznam tem';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Utišaj temo';
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 9e25589959..0c2f49363e 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -89,6 +89,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'Щоб завантажувати файли, надайте Zulip додаткові дозволи в налаштуваннях.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead =>
'Позначити канал як прочитаний';
@@ -99,6 +105,24 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'Список тем';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Заглушити тему';
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index db563a0434..fdfd2966b5 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -87,6 +87,12 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
String get permissionsDeniedReadExternalStorage =>
'To upload files, please grant Zulip additional permissions in Settings.';
+ @override
+ String get actionSheetOptionSubscribe => 'Subscribe';
+
+ @override
+ String get subscribeFailedTitle => 'Failed to subscribe';
+
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
@@ -96,6 +102,24 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
@override
String get actionSheetOptionListOfTopics => 'List of topics';
+ @override
+ String get actionSheetOptionUnsubscribe => 'Unsubscribe';
+
+ @override
+ String unsubscribeConfirmationDialogTitle(String channelName) {
+ return 'Unsubscribe from $channelName?';
+ }
+
+ @override
+ String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
+ 'Once you leave this channel, you might not be able to rejoin.';
+
+ @override
+ String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
+
+ @override
+ String get unsubscribeFailedTitle => 'Failed to unsubscribe';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index d575735d8c..7adfb2ba24 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -37,7 +37,7 @@ import 'topic_list.dart';
void _showActionSheet(
BuildContext pageContext, {
Widget? header,
- required List optionButtons,
+ required List> buttonSections,
}) {
// Could omit this if we need _showActionSheet outside a per-account context.
final accountId = PerAccountStoreWidget.accountIdOf(pageContext);
@@ -91,7 +91,11 @@ void _showActionSheet(
color: designVariables.bgContextMenu,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
- child: MenuButtonsShape(buttons: optionButtons)))),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 8,
+ children: buttonSections.map((buttons) =>
+ MenuButtonsShape(buttons: buttons)).toList())))),
const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel),
]))),
]))));
@@ -241,21 +245,66 @@ void showChannelActionSheet(BuildContext context, {
final pageContext = PageRoot.contextOf(context);
final store = PerAccountStoreWidget.of(pageContext);
- final optionButtons = [];
-
final unreadCount = store.unreads.countInChannelNarrow(channelId);
- if (unreadCount > 0) {
- optionButtons.add(
- MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId));
+ final isSubscribed = store.subscriptions[channelId] != null;
+ final buttonSections = [
+ if (!isSubscribed)
+ // TODO(#1786) check group-based can-subscribe permission
+ [SubscribeButton(pageContext: pageContext, channelId: channelId)],
+ [
+ if (unreadCount > 0)
+ MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId),
+ TopicListButton(pageContext: pageContext, channelId: channelId),
+ CopyChannelLinkButton(channelId: channelId, pageContext: pageContext)
+ ],
+ if (isSubscribed)
+ [UnsubscribeButton(pageContext: pageContext, channelId: channelId)],
+ ];
+
+ _showActionSheet(pageContext, buttonSections: buttonSections);
+}
+
+class SubscribeButton extends ActionSheetMenuItemButton {
+ const SubscribeButton({
+ super.key,
+ required this.channelId,
+ required super.pageContext,
+ });
+
+ final int channelId;
+
+ @override
+ IconData get icon => ZulipIcons.plus;
+
+ @override
+ String label(ZulipLocalizations zulipLocalizations) {
+ return zulipLocalizations.actionSheetOptionSubscribe;
}
- optionButtons.add(
- TopicListButton(pageContext: pageContext, channelId: channelId));
+ @override
+ void onPressed() async {
+ final store = PerAccountStoreWidget.of(pageContext);
+ final channel = store.streams[channelId];
+ if (channel == null || channel is Subscription) return; // TODO could give feedback
- optionButtons.add(
- CopyChannelLinkButton(channelId: channelId, pageContext: pageContext));
+ try {
+ await subscribeToChannel(store.connection, subscriptions: [channel.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:
+ }
- _showActionSheet(pageContext, optionButtons: optionButtons);
+ final title = ZulipLocalizations.of(pageContext).subscribeFailedTitle;
+ showErrorDialog(context: pageContext, title: title, message: errorMessage);
+ }
+ }
}
class MarkChannelAsReadButton extends ActionSheetMenuItemButton {
@@ -334,6 +383,66 @@ class CopyChannelLinkButton extends ActionSheetMenuItemButton {
}
}
+class UnsubscribeButton extends ActionSheetMenuItemButton {
+ const UnsubscribeButton({
+ super.key,
+ required this.channelId,
+ required super.pageContext,
+ });
+
+ final int channelId;
+
+ @override
+ IconData get icon => ZulipIcons.circle_x;
+
+ @override
+ String label(ZulipLocalizations zulipLocalizations) {
+ return zulipLocalizations.actionSheetOptionUnsubscribe;
+ }
+
+ @override
+ void onPressed() async {
+ final subscription = PerAccountStoreWidget.of(pageContext).subscriptions[channelId];
+ if (subscription == null) return; // TODO could give feedback
+
+ // TODO(#1786) check group-based permission to subscribe, then replace
+ // error message with a new one saying "will not" instead of "might not"
+ // TODO(future) check if the self-user is a guest and the channel is not web-public
+ final couldResubscribe = !subscription.inviteOnly;
+ 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.unsubscribeConfirmationDialogMessageMaybeCannotResubscribe,
+ // TODO(#1032) "destructive" style for action button
+ 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);
+ }
+ }
+}
+
/// Show a sheet of actions you can take on a topic.
///
/// Needs a [PageRoot] ancestor.
@@ -440,7 +549,7 @@ void showTopicActionSheet(BuildContext context, {
narrow: TopicNarrow(channelId, topic, with_: someMessageIdInTopic),
pageContext: context));
- _showActionSheet(pageContext, optionButtons: optionButtons);
+ _showActionSheet(pageContext, buttonSections: [optionButtons]);
}
class UserTopicUpdateButton extends ActionSheetMenuItemButton {
@@ -732,7 +841,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
];
_showActionSheet(pageContext,
- optionButtons: optionButtons,
+ buttonSections: [optionButtons],
header: _MessageActionSheetHeader(message: message));
}
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index a8180cd9ad..4251973ef0 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -63,128 +63,131 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "chevron_right".
static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "circle_x".
+ static const IconData circle_x = IconData(0xf10e, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "clock".
- static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons");
+ static const IconData clock = IconData(0xf10f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "contacts".
- static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons");
+ static const IconData contacts = IconData(0xf110, fontFamily: "Zulip Icons");
/// The Zulip custom icon "copy".
- static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons");
+ static const IconData copy = IconData(0xf111, fontFamily: "Zulip Icons");
/// The Zulip custom icon "edit".
- static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons");
+ static const IconData edit = IconData(0xf112, fontFamily: "Zulip Icons");
/// The Zulip custom icon "eye".
- static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons");
+ static const IconData eye = IconData(0xf113, fontFamily: "Zulip Icons");
/// The Zulip custom icon "eye_off".
- static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons");
+ static const IconData eye_off = IconData(0xf114, fontFamily: "Zulip Icons");
/// The Zulip custom icon "follow".
- static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons");
+ static const IconData follow = IconData(0xf115, fontFamily: "Zulip Icons");
/// The Zulip custom icon "format_quote".
- static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons");
+ static const IconData format_quote = IconData(0xf116, fontFamily: "Zulip Icons");
/// The Zulip custom icon "globe".
- static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons");
+ static const IconData globe = IconData(0xf117, fontFamily: "Zulip Icons");
/// The Zulip custom icon "group_dm".
- static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons");
+ static const IconData group_dm = IconData(0xf118, fontFamily: "Zulip Icons");
/// The Zulip custom icon "hash_italic".
- static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons");
+ static const IconData hash_italic = IconData(0xf119, fontFamily: "Zulip Icons");
/// The Zulip custom icon "hash_sign".
- static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons");
+ static const IconData hash_sign = IconData(0xf11a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "image".
- static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons");
+ static const IconData image = IconData(0xf11b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "inbox".
- static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons");
+ static const IconData inbox = IconData(0xf11c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "info".
- static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons");
+ static const IconData info = IconData(0xf11d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "inherit".
- static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons");
+ static const IconData inherit = IconData(0xf11e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "language".
- static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons");
+ static const IconData language = IconData(0xf11f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "link".
- static const IconData link = IconData(0xf11f, fontFamily: "Zulip Icons");
+ static const IconData link = IconData(0xf120, fontFamily: "Zulip Icons");
/// The Zulip custom icon "lock".
- static const IconData lock = IconData(0xf120, fontFamily: "Zulip Icons");
+ static const IconData lock = IconData(0xf121, fontFamily: "Zulip Icons");
/// The Zulip custom icon "menu".
- static const IconData menu = IconData(0xf121, fontFamily: "Zulip Icons");
+ static const IconData menu = IconData(0xf122, fontFamily: "Zulip Icons");
/// The Zulip custom icon "message_checked".
- static const IconData message_checked = IconData(0xf122, fontFamily: "Zulip Icons");
+ static const IconData message_checked = IconData(0xf123, fontFamily: "Zulip Icons");
/// The Zulip custom icon "message_feed".
- static const IconData message_feed = IconData(0xf123, fontFamily: "Zulip Icons");
+ static const IconData message_feed = IconData(0xf124, fontFamily: "Zulip Icons");
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf124, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf125, fontFamily: "Zulip Icons");
/// The Zulip custom icon "person".
- static const IconData person = IconData(0xf125, fontFamily: "Zulip Icons");
+ static const IconData person = IconData(0xf126, fontFamily: "Zulip Icons");
/// The Zulip custom icon "plus".
- static const IconData plus = IconData(0xf126, fontFamily: "Zulip Icons");
+ static const IconData plus = IconData(0xf127, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf127, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf128, fontFamily: "Zulip Icons");
/// The Zulip custom icon "remove".
- static const IconData remove = IconData(0xf128, fontFamily: "Zulip Icons");
+ static const IconData remove = IconData(0xf129, fontFamily: "Zulip Icons");
/// The Zulip custom icon "search".
- static const IconData search = IconData(0xf129, fontFamily: "Zulip Icons");
+ static const IconData search = IconData(0xf12a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "see_who_reacted".
- static const IconData see_who_reacted = IconData(0xf12a, fontFamily: "Zulip Icons");
+ static const IconData see_who_reacted = IconData(0xf12b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "send".
- static const IconData send = IconData(0xf12b, fontFamily: "Zulip Icons");
+ static const IconData send = IconData(0xf12c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "settings".
- static const IconData settings = IconData(0xf12c, fontFamily: "Zulip Icons");
+ static const IconData settings = IconData(0xf12d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf12d, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf12e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf12e, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf12f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf12f, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf130, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf130, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf131, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf131, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf132, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf132, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf133, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf133, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf134, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topics".
- static const IconData topics = IconData(0xf134, fontFamily: "Zulip Icons");
+ static const IconData topics = IconData(0xf135, fontFamily: "Zulip Icons");
/// The Zulip custom icon "two_person".
- static const IconData two_person = IconData(0xf135, fontFamily: "Zulip Icons");
+ static const IconData two_person = IconData(0xf136, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf136, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf137, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/test/api/route/channels_test.dart b/test/api/route/channels_test.dart
index 011dc508c5..0a73dded59 100644
--- a/test/api/route/channels_test.dart
+++ b/test/api/route/channels_test.dart
@@ -1,3 +1,5 @@
+import 'dart:convert';
+
import 'package:checks/checks.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_test/flutter_test.dart';
@@ -8,6 +10,38 @@ import '../../stdlib_checks.dart';
import '../fake_api.dart';
void main() {
+ test('smoke subscribeToChannel', () {
+ return FakeApiConnection.with_((connection) async {
+ connection.prepare(json: {});
+ await subscribeToChannel(connection,
+ subscriptions: ['foo'],
+ principals: [1]);
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('POST')
+ ..url.path.equals('/api/v1/users/me/subscriptions')
+ ..bodyFields.deepEquals({
+ 'subscriptions': jsonEncode([{'name': 'foo'}]),
+ 'principals': jsonEncode([1]),
+ });
+ });
+ });
+
+ test('smoke unsubscribeFromChannel', () {
+ return FakeApiConnection.with_((connection) async {
+ connection.prepare(json: {});
+ await unsubscribeFromChannel(connection,
+ subscriptions: ['foo'],
+ principals: [1]);
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('DELETE')
+ ..url.path.equals('/api/v1/users/me/subscriptions')
+ ..bodyFields.deepEquals({
+ 'subscriptions': jsonEncode(['foo']),
+ 'principals': jsonEncode([1]),
+ });
+ });
+ });
+
test('smoke updateUserTopic', () {
return FakeApiConnection.with_((connection) async {
connection.prepare(json: {});
diff --git a/test/model/test_store.dart b/test/model/test_store.dart
index 2d9b42e8d8..b25f52ba46 100644
--- a/test/model/test_store.dart
+++ b/test/model/test_store.dart
@@ -307,6 +307,14 @@ extension PerAccountStoreTestExtension on PerAccountStore {
await handleEvent(SubscriptionAddEvent(id: 1, subscriptions: subscriptions));
}
+ Future removeSubscription(int channelId) async {
+ await removeSubscriptions([channelId]);
+ }
+
+ Future removeSubscriptions(List channelIds) async {
+ await handleEvent(SubscriptionRemoveEvent(id: 1, streamIds: channelIds));
+ }
+
Future setUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async {
await handleEvent(eg.userTopicEvent(stream.streamId, topic, visibilityPolicy));
}
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index f5956fdecb..41e0462233 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -244,6 +244,10 @@ void main() {
check(findButtonForLabel(label)).findsOne();
}
+ void checkNoButton(String label) {
+ check(findButtonForLabel(label)).findsNothing();
+ }
+
group('showChannelActionSheet', () {
void checkButtons() {
check(actionSheetFinder).findsOne();
@@ -291,6 +295,47 @@ void main() {
});
});
+ group('SubscribeButton', () {
+ Future tapButton(WidgetTester tester) async {
+ await tester.tap(findButtonForLabel('Subscribe'));
+ await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+ }
+
+ testWidgets('channel not subscribed', (tester) async {
+ await prepare();
+ final narrow = ChannelNarrow(someChannel.streamId);
+ await store.removeSubscription(narrow.streamId);
+ await showFromAppBar(tester, narrow: narrow);
+ checkButton('Subscribe');
+ });
+
+ testWidgets('channel subscribed', (tester) async {
+ await prepare();
+ final narrow = ChannelNarrow(someChannel.streamId);
+ check(store.subscriptions[narrow.streamId]).isNotNull();
+ await showFromAppBar(tester, narrow: narrow);
+ checkNoButton('Subscribe');
+ });
+
+ testWidgets('smoke', (tester) async {
+ await prepare();
+ final narrow = ChannelNarrow(someChannel.streamId);
+ await store.removeSubscription(narrow.streamId);
+ await showFromAppBar(tester, narrow: narrow);
+
+ connection.prepare(json: {});
+ await tapButton(tester);
+ await tester.pump(Duration.zero);
+
+ check(connection.lastRequest).isA()
+ ..method.equals('POST')
+ ..url.path.equals('/api/v1/users/me/subscriptions')
+ ..bodyFields.deepEquals({
+ 'subscriptions': jsonEncode([{'name': someChannel.name}]),
+ });
+ });
+ });
+
group('MarkChannelAsReadButton', () {
void checkRequest(int channelId) {
check(connection.takeRequests()).single.isA()
@@ -375,6 +420,84 @@ void main() {
check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink);
});
});
+
+ group('UnsubscribeButton', () {
+ Future tapButton(WidgetTester tester) async {
+ await tester.ensureVisible(findButtonForLabel('Unsubscribe'));
+ await tester.tap(findButtonForLabel('Unsubscribe'));
+ await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
+ }
+
+ testWidgets('channel subscribed', (tester) async {
+ await prepare();
+ final narrow = ChannelNarrow(someChannel.streamId);
+ check(store.subscriptions[narrow.streamId]).isNotNull();
+ await showFromAppBar(tester, narrow: narrow);
+ checkButton('Unsubscribe');
+ });
+
+ testWidgets('channel not subscribed', (tester) async {
+ await prepare();
+ final narrow = ChannelNarrow(someChannel.streamId);
+ await store.removeSubscription(narrow.streamId);
+ await showFromAppBar(tester, narrow: narrow);
+ checkNoButton('Unsubscribe');
+ });
+
+ testWidgets('smoke, public channel', (tester) async {
+ final channel = eg.stream(inviteOnly: false);
+ final message = eg.streamMessage(stream: channel);
+ await prepare();
+ await store.addStream(channel);
+ await store.addSubscription(eg.subscription(channel));
+ final narrow = ChannelNarrow(channel.streamId);
+ await showFromAppBar(tester,
+ channel: channel, narrow: narrow, messages: [message]);
+
+ connection.prepare(json: {});
+ await tapButton(tester);
+ 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('smoke, private channel', (tester) async {
+ final channel = eg.stream(inviteOnly: true);
+ final message = eg.streamMessage(stream: channel);
+ await prepare();
+ await store.addStream(channel);
+ await store.addSubscription(eg.subscription(channel));
+ final narrow = ChannelNarrow(channel.streamId);
+ await showFromAppBar(tester,
+ channel: channel, narrow: narrow, messages: [message]);
+ connection.takeRequests();
+
+ connection.prepare(json: {});
+ await tapButton(tester);
+ await tester.pump();
+
+ final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester,
+ expectedTitle: 'Unsubscribe from ${channel.name}?',
+ expectedMessage: 'Once you leave this channel, you might not be able to rejoin.',
+ expectedActionButtonText: 'Unsubscribe');
+ await tester.tap(find.byWidget(unsubscribeButton));
+ await tester.pump(Duration.zero);
+
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('DELETE')
+ ..url.path.equals('/api/v1/users/me/subscriptions')
+ ..bodyFields.deepEquals({
+ 'subscriptions': jsonEncode([channel.name]),
+ });
+ });
+ });
});
group('topic action sheet', () {