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', () {