diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 66d69be4b1..4fa457ba54 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg new file mode 100644 index 0000000000..8642c16d5a --- /dev/null +++ b/assets/icons/trash.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index bd0d7efdd5..73bcb373b0 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -277,6 +277,26 @@ "@actionSheetOptionEditMessage": { "description": "Label for the 'Edit message' button in the message action sheet." }, + "actionSheetOptionDeleteMessage": "Delete message", + "@actionSheetOptionDeleteMessage": { + "description": "Label for the 'Delete message' button in the message action sheet." + }, + "deleteMessageConfirmationDialogTitle": "Delete message?", + "@deleteMessageConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for deleting a message." + }, + "deleteMessageConfirmationDialogMessage": "Deleting a message permanently removes it for everyone.", + "@deleteMessageConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for deleting a message." + }, + "deleteMessageConfirmationDialogConfirmButton": "Delete", + "@deleteMessageConfirmationDialogConfirmButton": { + "description": "Label for the 'Delete' button on a confirmation dialog for deleting a message." + }, + "errorDeleteMessageFailedTitle": "Failed to delete message", + "@errorDeleteMessageFailedTitle": { + "description": "Error title when deleting a message failed." + }, "actionSheetOptionMarkTopicAsRead": "Mark topic as read", "@actionSheetOptionMarkTopicAsRead": { "description": "Option to mark a specific topic as read in the action sheet." diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 7460a02461..6d0487f0d7 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -250,6 +250,14 @@ class UpdateMessageResult { Map toJson() => _$UpdateMessageResultToJson(this); } +/// https://zulip.com/api/delete-message +Future deleteMessage( + ApiConnection connection, { + required int messageId, +}) { + return connection.delete('deleteMessage', (_) {}, 'messages/$messageId', {}); +} + /// https://zulip.com/api/upload-file Future uploadFile( ApiConnection connection, { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 276ae504f7..44fadaa2ec 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -521,6 +521,36 @@ abstract class ZulipLocalizations { /// **'Edit message'** String get actionSheetOptionEditMessage; + /// Label for the 'Delete message' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'Delete message'** + String get actionSheetOptionDeleteMessage; + + /// Title for a confirmation dialog for deleting a message. + /// + /// In en, this message translates to: + /// **'Delete message?'** + String get deleteMessageConfirmationDialogTitle; + + /// Message for a confirmation dialog for deleting a message. + /// + /// In en, this message translates to: + /// **'Deleting a message permanently removes it for everyone.'** + String get deleteMessageConfirmationDialogMessage; + + /// Label for the 'Delete' button on a confirmation dialog for deleting a message. + /// + /// In en, this message translates to: + /// **'Delete'** + String get deleteMessageConfirmationDialogConfirmButton; + + /// Error title when deleting a message failed. + /// + /// In en, this message translates to: + /// **'Failed to delete message'** + String get errorDeleteMessageFailedTitle; + /// Option to mark a specific topic as read in the 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 133476376f..94eda2e92a 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -237,6 +237,22 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Edit message'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 4516ff21dd..06fa2595dc 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -244,6 +244,22 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Nachricht bearbeiten'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 348fc47890..81ceadcb9c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -237,6 +237,22 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Edit message'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 89f8ee317a..daef9d5945 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -242,6 +242,22 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Modifier le message'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Marquer le sujet comme lu'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 01080f9809..49f4425797 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -241,6 +241,22 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Modifica messaggio'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Segna l\'argomento come letto'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index e652d76c43..48e7d7d423 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -233,6 +233,22 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'メッセージを編集'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'トピックを既読にする'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 7ae25c3d52..8ee2e9c4da 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -237,6 +237,22 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Edit message'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 6624146bc1..7a01bbddf8 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -246,6 +246,22 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Zmień wiadomość'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Oznacz wątek jako przeczytany'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 27bf2a8ab3..e94ff097ea 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -248,6 +248,22 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Редактировать сообщение'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Отметить тему как прочитанную'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index fe2a658c52..4201d37be2 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -238,6 +238,22 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Edit message'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 9bb079a775..b3ccfb0a82 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -241,6 +241,22 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Uredi sporočilo'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Označi temo kot prebrano'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 91fbfc57c8..292f16146c 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -246,6 +246,22 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Редагувати повідомлення'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 46b38ca739..ae0e41822c 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -237,6 +237,22 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get actionSheetOptionEditMessage => 'Edit message'; + @override + String get actionSheetOptionDeleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmationDialogTitle => 'Delete message?'; + + @override + String get deleteMessageConfirmationDialogMessage => + 'Deleting a message permanently removes it for everyone.'; + + @override + String get deleteMessageConfirmationDialogConfirmButton => 'Delete'; + + @override + String get errorDeleteMessageFailedTitle => 'Failed to delete message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index ce1be46000..8191f58069 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -340,6 +340,7 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { IconData get icon; String label(ZulipLocalizations zulipLocalizations); + bool get destructive => false; /// Called when the button is pressed, after dismissing the action sheet. /// @@ -376,6 +377,9 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); return ZulipMenuItemButton( + style: destructive + ? ZulipMenuItemButtonStyle.menuDestructive + : ZulipMenuItemButtonStyle.menu, icon: icon, label: label(zulipLocalizations), onPressed: () => _handlePressed(context), @@ -643,7 +647,7 @@ class UnsubscribeButton extends ActionSheetMenuItemButton { final dialog = showSuggestedActionDialog(context: pageContext, title: zulipLocalizations.unsubscribeConfirmationDialogTitle(subscription.name), message: zulipLocalizations.unsubscribeConfirmationDialogMessageMaybeCannotResubscribe, - // TODO(#1032) "destructive" style for action button + destructiveActionButton: true, actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton); if (await dialog.result != true) return; if (!pageContext.mounted) return; @@ -1025,6 +1029,8 @@ class CopyTopicLinkButton extends ActionSheetMenuItemButton { /// /// Must have a [MessageListPage] ancestor. void showMessageActionSheet({required BuildContext context, required Message message}) { + final now = ZulipBinding.instance.utcNow(); + final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); @@ -1049,30 +1055,34 @@ void showMessageActionSheet({required BuildContext context, required Message mes final isSenderMuted = store.isUserMuted(message.senderId); - final optionButtons = [ - if (popularEmojiLoaded) - ReactionButtons(message: message, pageContext: pageContext), - if (hasReactions) - ViewReactionsButton(message: message, pageContext: pageContext), - if (readReceiptsEnabled) - ViewReadReceiptsButton(message: message, pageContext: pageContext), - StarButton(message: message, pageContext: pageContext), - if (isComposeBoxOffered) - QuoteAndReplyButton(message: message, pageContext: pageContext), - if (showMarkAsUnreadButton) - MarkAsUnreadButton(message: message, pageContext: pageContext), - if (isSenderMuted) - // The message must have been revealed in order to open this action sheet. - UnrevealMutedMessageButton(message: message, pageContext: pageContext), - CopyMessageTextButton(message: message, pageContext: pageContext), - CopyMessageLinkButton(message: message, pageContext: pageContext), - ShareButton(message: message, pageContext: pageContext), - if (_getShouldShowEditButton(pageContext, message)) - EditButton(message: message, pageContext: pageContext), + final buttonSections = [ + [ + if (popularEmojiLoaded) + ReactionButtons(message: message, pageContext: pageContext), + if (hasReactions) + ViewReactionsButton(message: message, pageContext: pageContext), + if (readReceiptsEnabled) + ViewReadReceiptsButton(message: message, pageContext: pageContext), + StarButton(message: message, pageContext: pageContext), + if (isComposeBoxOffered) + QuoteAndReplyButton(message: message, pageContext: pageContext), + if (showMarkAsUnreadButton) + MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + // The message must have been revealed in order to open this action sheet. + UnrevealMutedMessageButton(message: message, pageContext: pageContext), + CopyMessageTextButton(message: message, pageContext: pageContext), + CopyMessageLinkButton(message: message, pageContext: pageContext), + ShareButton(message: message, pageContext: pageContext), + if (_getShouldShowEditButton(pageContext, message)) + EditButton(message: message, pageContext: pageContext), + ], + if (store.selfCanDeleteMessage(message.id, atDate: now)) + [DeleteMessageButton(message: message, pageContext: pageContext)], ]; _showActionSheet(pageContext, - buttonSections: [optionButtons], + buttonSections: buttonSections, header: _MessageActionSheetHeader(message: message)); } @@ -1602,3 +1612,49 @@ class EditButton extends MessageActionSheetMenuItemButton { composeBoxState.startEditInteraction(message.id); } } + +class DeleteMessageButton extends MessageActionSheetMenuItemButton { + DeleteMessageButton({super.key, required super.message, required super.pageContext}); + + @override + IconData get icon => ZulipIcons.trash; + + @override + bool get destructive => true; + + @override + String label(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.actionSheetOptionDeleteMessage; + + @override void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + + final dialog = showSuggestedActionDialog(context: pageContext, + title: zulipLocalizations.deleteMessageConfirmationDialogTitle, + message: zulipLocalizations.deleteMessageConfirmationDialogMessage, + destructiveActionButton: true, + actionButtonText: zulipLocalizations.deleteMessageConfirmationDialogConfirmButton, + ); + if (await dialog.result != true) return; + if (!pageContext.mounted) return; + + final connection = PerAccountStoreWidget.of(pageContext).connection; + try { + await deleteMessage(connection, messageId: message.id); + } 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).errorDeleteMessageFailedTitle; + showErrorDialog(context: pageContext, title: title, message: errorMessage); + } + } +} diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index a3aa1053a8..69dd62d7c9 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -347,7 +347,7 @@ class ChooseAccountPage extends StatelessWidget { final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.logOutConfirmationDialogTitle, message: zulipLocalizations.logOutConfirmationDialogMessage, - // TODO(#1032) "destructive" style for action button + destructiveActionButton: true, actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton); if (await dialog.result == true) { if (!context.mounted) return; diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 86997c08f2..ef8d0fa07d 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -327,7 +327,7 @@ class MenuButtonsShape extends StatelessWidget { class ZulipMenuItemButton extends StatelessWidget { const ZulipMenuItemButton({ super.key, - this.style = ZulipMenuItemButtonStyle.menu, + required this.style, required this.label, this.subLabel, required this.onPressed, @@ -350,7 +350,8 @@ class ZulipMenuItemButton extends StatelessWidget { final Widget? toggle; double get itemSpacingAndEndPadding => switch (style) { - ZulipMenuItemButtonStyle.menu => 16, + ZulipMenuItemButtonStyle.menu + || ZulipMenuItemButtonStyle.menuDestructive => 16, ZulipMenuItemButtonStyle.list => 12, }; @@ -373,6 +374,11 @@ class ZulipMenuItemButton extends StatelessWidget { WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20), ~WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.12), }); + case ZulipMenuItemButtonStyle.menuDestructive: + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.contextMenuItemBgDanger.withFadedAlpha(0.20), + ~WidgetState.pressed: designVariables.contextMenuItemBgDanger.withFadedAlpha(0.12), + }); case ZulipMenuItemButtonStyle.list: return WidgetStateColor.fromMap({ WidgetState.pressed: designVariables.listMenuItemBg.withFadedAlpha(0.7), @@ -384,13 +390,15 @@ class ZulipMenuItemButton extends StatelessWidget { Color _labelColor(DesignVariables designVariables) { return switch (style) { ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemText, + ZulipMenuItemButtonStyle.menuDestructive => designVariables.contextMenuItemTextDanger, ZulipMenuItemButtonStyle.list => designVariables.listMenuItemText, }; } double _labelWght() { return switch (style) { - ZulipMenuItemButtonStyle.menu => 600, + ZulipMenuItemButtonStyle.menu + || ZulipMenuItemButtonStyle.menuDestructive => 600, ZulipMenuItemButtonStyle.list => 500, }; } @@ -398,6 +406,7 @@ class ZulipMenuItemButton extends StatelessWidget { Color _iconColor(DesignVariables designVariables) { return switch (style) { ZulipMenuItemButtonStyle.menu => designVariables.contextMenuItemIcon, + ZulipMenuItemButtonStyle.menuDestructive => designVariables.contextMenuItemIconDanger, ZulipMenuItemButtonStyle.list => designVariables.listMenuItemIcon, }; } @@ -468,6 +477,12 @@ enum ZulipMenuItemButtonStyle { /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3302-20443&m=dev menu, + /// The red, destructive variant of [menu]. + /// + /// See Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6329-127234&m=dev + menuDestructive, + /// The gray "list button" component in Figma, with 12px end padding. /// /// See Figma: diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 76a4e36411..71880b1dc2 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -2024,7 +2024,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM final dialog = showSuggestedActionDialog(context: context, title: zulipLocalizations.discardDraftConfirmationDialogTitle, message: dialogMessage, - // TODO(#1032) "destructive" style for action button + destructiveActionButton: true, actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); if (await dialog.result != true) return true; } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index c5e2e6e562..fb93a3d6cc 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -10,7 +10,15 @@ import 'content.dart'; import 'store.dart'; /// A platform-appropriate action for [AlertDialog.adaptive]'s [actions] param. -Widget _adaptiveAction({required VoidCallback onPressed, required String text}) { +/// +/// [isDefaultAction] and [isDestructiveAction] are ignored on Android +/// because Material Design doesn't specify corresponding styles. +Widget _adaptiveAction({ + required VoidCallback onPressed, + required bool isDefaultAction, + bool isDestructiveAction = false, + required String text, +}) { switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -30,7 +38,11 @@ Widget _adaptiveAction({required VoidCallback onPressed, required String text}) case TargetPlatform.iOS: case TargetPlatform.macOS: - return CupertinoDialogAction(onPressed: onPressed, child: Text(text)); + return CupertinoDialogAction( + onPressed: onPressed, + isDefaultAction: isDefaultAction, + isDestructiveAction: isDestructiveAction, + child: Text(text)); } } @@ -112,9 +124,11 @@ DialogStatus showErrorDialog({ if (learnMoreButtonUrl != null) _adaptiveAction( onPressed: () => PlatformActions.launchUrl(context, learnMoreButtonUrl), + isDefaultAction: false, text: zulipLocalizations.errorDialogLearnMore), _adaptiveAction( onPressed: () => Navigator.pop(context), + isDefaultAction: true, text: zulipLocalizations.errorDialogContinue), ])); return DialogStatus(future); @@ -133,6 +147,7 @@ DialogStatus showSuggestedActionDialog({ required String title, required String message, required String? actionButtonText, + bool destructiveActionButton = false, }) { final zulipLocalizations = ZulipLocalizations.of(context); final future = showDialog( @@ -143,9 +158,12 @@ DialogStatus showSuggestedActionDialog({ actions: [ _adaptiveAction( onPressed: () => Navigator.pop(context, null), + isDefaultAction: false, text: zulipLocalizations.dialogCancel), _adaptiveAction( onPressed: () => Navigator.pop(context, true), + isDefaultAction: true, + isDestructiveAction: destructiveActionButton, text: actionButtonText ?? zulipLocalizations.dialogContinue), ])); return DialogStatus(future); @@ -213,6 +231,7 @@ class UpgradeWelcomeDialog extends StatelessWidget { actions: [ _adaptiveAction( onPressed: () => Navigator.pop(context), + isDefaultAction: true, text: zulipLocalizations.upgradeWelcomeDialogDismiss) ]); } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 54ab74eed3..c9eb68361b 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -189,11 +189,14 @@ abstract final class ZulipIcons { /// The Zulip custom icon "topics". static const IconData topics = IconData(0xf137, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "trash". + static const IconData trash = IconData(0xf138, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf138, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf139, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf139, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf13a, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 0feaeeaa52..ad286cf71f 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -185,6 +185,7 @@ class _SubscriptionListPageBodyState extends State wit sliver: SliverToBoxAdapter( child: MenuButtonsShape(buttons: [ ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.menu, label: zulipLocalizations.navButtonAllChannels, icon: ZulipIcons.chevron_right, onPressed: () => Navigator.push(context, diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 8680640f6b..44517ab2c3 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -161,10 +161,13 @@ class DesignVariables extends ThemeExtension { composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), + contextMenuItemBgDanger: const Color(0xffc0070a), // TODO(#831) red/550 contextMenuItemIcon: const Color(0xff4f42c9), + contextMenuItemIconDanger: const Color(0xffac0508), // TODO(#831) red/600 contextMenuItemLabel: const Color(0xff242631), contextMenuItemMeta: const Color(0xff626573), contextMenuItemText: const Color(0xff381da7), + contextMenuItemTextDanger: const Color(0xffac0508), // TODO(#831) red/600 editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), fabBg: const Color(0xff6e69f3), fabBgPressed: const Color(0xff6159e1), @@ -252,10 +255,13 @@ class DesignVariables extends ThemeExtension { composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), + contextMenuItemBgDanger: const Color(0xffe1392e), // TODO(#831) red/450 contextMenuItemIcon: const Color(0xff9398fd), + contextMenuItemIconDanger: const Color(0xfffd7465), // TODO(#831) red/300 contextMenuItemLabel: const Color(0xffdfe1e8), contextMenuItemMeta: const Color(0xff9194a3), contextMenuItemText: const Color(0xff9398fd), + contextMenuItemTextDanger: const Color(0xfffd7465), // TODO(#831) red/300 editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), fabBg: const Color(0xff4f42c9), fabBgPressed: const Color(0xff4331b8), @@ -352,10 +358,13 @@ class DesignVariables extends ThemeExtension { required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, + required this.contextMenuItemBgDanger, required this.contextMenuItemIcon, + required this.contextMenuItemIconDanger, required this.contextMenuItemLabel, required this.contextMenuItemMeta, required this.contextMenuItemText, + required this.contextMenuItemTextDanger, required this.editorButtonPressedBg, required this.foreground, required this.fabBg, @@ -443,10 +452,13 @@ class DesignVariables extends ThemeExtension { final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; + final Color contextMenuItemBgDanger; final Color contextMenuItemIcon; + final Color contextMenuItemIconDanger; final Color contextMenuItemLabel; final Color contextMenuItemMeta; final Color contextMenuItemText; + final Color contextMenuItemTextDanger; final Color editorButtonPressedBg; final Color fabBg; final Color fabBgPressed; @@ -529,10 +541,13 @@ class DesignVariables extends ThemeExtension { Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, + Color? contextMenuItemBgDanger, Color? contextMenuItemIcon, + Color? contextMenuItemIconDanger, Color? contextMenuItemLabel, Color? contextMenuItemMeta, Color? contextMenuItemText, + Color? contextMenuItemTextDanger, Color? editorButtonPressedBg, Color? fabBg, Color? fabBgPressed, @@ -610,10 +625,13 @@ class DesignVariables extends ThemeExtension { composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, + contextMenuItemBgDanger: contextMenuItemBgDanger ?? this.contextMenuItemBgDanger, contextMenuItemIcon: contextMenuItemIcon ?? this.contextMenuItemIcon, + contextMenuItemIconDanger: contextMenuItemIconDanger ?? this.contextMenuItemIconDanger, contextMenuItemLabel: contextMenuItemLabel ?? this.contextMenuItemLabel, contextMenuItemMeta: contextMenuItemMeta ?? this.contextMenuItemMeta, contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, + contextMenuItemTextDanger: contextMenuItemTextDanger ?? this.contextMenuItemTextDanger, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, fabBg: fabBg ?? this.fabBg, @@ -698,10 +716,13 @@ class DesignVariables extends ThemeExtension { composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, + contextMenuItemBgDanger: Color.lerp(contextMenuItemBgDanger, other.contextMenuItemBgDanger, t)!, contextMenuItemIcon: Color.lerp(contextMenuItemIcon, other.contextMenuItemIcon, t)!, + contextMenuItemIconDanger: Color.lerp(contextMenuItemIconDanger, other.contextMenuItemIconDanger, t)!, contextMenuItemLabel: Color.lerp(contextMenuItemLabel, other.contextMenuItemLabel, t)!, contextMenuItemMeta: Color.lerp(contextMenuItemMeta, other.contextMenuItemMeta, t)!, contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, + contextMenuItemTextDanger: Color.lerp(contextMenuItemTextDanger, other.contextMenuItemTextDanger, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, fabBg: Color.lerp(fabBg, other.fabBg, t)!, diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 121e0ef282..21d4a7de3d 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -450,6 +450,18 @@ void main() { }); }); + group('updateMessage', () { + test('smoke', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + await deleteMessage(connection, messageId: 123321); + check(connection.takeRequests()).single.isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/123321'); + }); + }); + }); + group('uploadFile', () { Future checkUploadFile(FakeApiConnection connection, { required List> content, diff --git a/test/example_data.dart b/test/example_data.dart index b9f9d87f01..9a9eb1099e 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1285,6 +1285,13 @@ InitialSnapshot initialSnapshot({ List? realmNonActiveUsers, List? crossRealmBots, }) { + if (realmDeleteOwnMessagePolicy == null) { + // Set a default for realmCanDeleteOwnMessageGroup, but only if we're + // trying to simulate a modern server without realmDeleteOwnMessagePolicy. + realmCanDeleteOwnMessageGroup ??= GroupSettingValueNamed(nobodyGroup.id); + } + assert((realmCanDeleteOwnMessageGroup != null) ^ (realmDeleteOwnMessagePolicy != null)); + return InitialSnapshot( queueId: queueId ?? '1:2345', lastEventId: lastEventId ?? -1, @@ -1320,7 +1327,6 @@ InitialSnapshot initialSnapshot({ userTopics: userTopics, // no default; allow `null` to simulate servers without this realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, - // no default; allow `null` to simulate servers without this realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8f4d614220..9866b9dc10 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -64,6 +64,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { List? mutedUserIds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, + bool hasDeletePermission = true, bool? realmEnableReadReceipts, bool shouldSetServerEmojiData = true, bool useLegacyServerEmojiData = false, @@ -74,6 +75,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { assert(narrow.containsMessage(message)!); selfUser ??= eg.selfUser; + assert(!(hasDeletePermission && selfUser.role == UserRole.guest)); final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add( selfAccount, @@ -82,6 +84,10 @@ Future setupToMessageActionSheet(WidgetTester tester, { realmAllowMessageEditing: realmAllowMessageEditing, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, realmEnableReadReceipts: realmEnableReadReceipts, + realmCanDeleteAnyMessageGroup: hasDeletePermission + ? eg.groupSetting(members: [selfUser.userId]) + : eg.groupSetting(members: []), + realmCanDeleteOwnMessageGroup: eg.groupSetting(members: []), )); store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUsers([ @@ -117,6 +123,9 @@ Future setupToMessageActionSheet(WidgetTester tester, { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); + check(store.selfCanDeleteMessage(message.id, atDate: testBinding.utcNow())) + .equals(hasDeletePermission); + await beforeLongPress?.call(); // Request the message action sheet. @@ -576,6 +585,7 @@ void main() { final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester, expectedTitle: 'Unsubscribe from ${channel.name}?', expectedMessage: 'Once you leave this channel, you might not be able to rejoin.', + expectDestructiveActionButton: true, expectedActionButtonText: 'Unsubscribe'); await tester.tap(find.byWidget(unsubscribeButton)); await tester.pump(Duration.zero); @@ -1160,6 +1170,10 @@ void main() { }); group('message action sheet', () { + final actionSheetFinder = find.byType(BottomSheet); + Finder findButtonForLabel(String label) => + find.descendant(of: actionSheetFinder, matching: find.text(label)); + group('header', () { void checkSenderAndTimestampShown(WidgetTester tester, {required int senderId}) { check(find.descendant( @@ -2212,6 +2226,95 @@ void main() { }); }); + group('DeleteMessageButton', () { + final findButton = findButtonForLabel('Delete message'); + + group('visibility', () { + testWidgets('shown when user has permission', (tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, + hasDeletePermission: true, + message: message, narrow: TopicNarrow.ofMessage(message)); + + check(findButton).findsOne(); + }); + + testWidgets('not shown when user does not have permission', (tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, + hasDeletePermission: false, + message: message, narrow: TopicNarrow.ofMessage(message)); + + check(findButton).findsNothing(); + }); + }); + + Future tapButton(WidgetTester tester, {bool starred = false}) async { + await tester.ensureVisible(findButton); + await tester.tap(findButton); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + (Widget, Widget) checkConfirmation(WidgetTester tester) => + checkSuggestedActionDialog(tester, + expectedTitle: 'Delete message?', + expectedMessage: 'Deleting a message permanently removes it for everyone.', + expectDestructiveActionButton: true, + expectedActionButtonText: 'Delete'); + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + await tapButton(tester); + await tester.pump(); + + final (deleteButton, cancelButton) = checkConfirmation(tester); + connection.prepare(json: {}); + await tester.tap(find.byWidget(deleteButton)); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}') + ..bodyFields.deepEquals({}); + }); + + testWidgets('cancel confirmation dialog', (tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.takeRequests(); + + await tapButton(tester); + await tester.pump(); + + final (deleteButton, cancelButton) = checkConfirmation(tester); + await tester.tap(find.byWidget(cancelButton)); + await tester.pumpAndSettle(); + + check(connection.lastRequest).isNull(); + }); + + testWidgets('request fails', (tester) async { + final message = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, + message: message, narrow: TopicNarrow.ofMessage(message)); + + await tapButton(tester); + await tester.pump(); + + final (deleteButton, cancelButton) = checkConfirmation(tester); + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.byWidget(deleteButton)); + await tester.pump(Duration.zero); + + checkErrorDialog(tester, expectedTitle: 'Failed to delete message'); + }); + }); + group('MessageActionSheetCancelButton', () { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index efebcc34fa..f486bf168d 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -382,6 +382,7 @@ void main() { return checkSuggestedActionDialog(tester, expectedTitle: 'Log out?', expectedMessage: 'To use this account in the future, you will have to re-enter the URL for your organization and your account information.', + expectDestructiveActionButton: true, expectedActionButtonText: 'Log out'); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 816ade42a7..3bd07653c2 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1645,6 +1645,7 @@ void main() { final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, expectedTitle: 'Discard the message you’re writing?', expectedMessage: expectedMessage, + expectDestructiveActionButton: true, expectedActionButtonText: 'Discard'); if (shouldContinue) { await tester.tap(find.byWidget(actionButton)); diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart index ce7effe2a9..aca3eeffae 100644 --- a/test/widgets/dialog_checks.dart +++ b/test/widgets/dialog_checks.dart @@ -100,6 +100,10 @@ void checkNoDialog(WidgetTester tester) { /// Checks for a suggested-action dialog matching an expected title and message. /// Fails if none is found. /// +/// Use [expectDestructiveActionButton] to check whether +/// the button is "destructive" (see [showSuggestedActionDialog]). +/// This has no effect on Android because the "destructive" style is iOS-only. +/// /// On success, returns a Record with the widget's action button first /// and its cancel button second. /// Tap the action button by calling `tester.tap(find.byWidget(actionButton))`. @@ -107,6 +111,7 @@ void checkNoDialog(WidgetTester tester) { required String expectedTitle, required String expectedMessage, String? expectedActionButtonText, + bool expectDestructiveActionButton = false, }) { switch (defaultTargetPlatform) { case TargetPlatform.android: @@ -133,8 +138,13 @@ void checkNoDialog(WidgetTester tester) { tester.widget(find.descendant(matchRoot: true, of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); - final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog), - matching: find.widgetWithText(CupertinoDialogAction, expectedActionButtonText ?? 'Continue'))); + final actionButton = tester.widget( + find.descendant( + of: find.byWidget(dialog), + matching: find.widgetWithText( + CupertinoDialogAction, + expectedActionButtonText ?? 'Continue'))); + check(actionButton.isDestructiveAction).equals(expectDestructiveActionButton); final cancelButton = tester.widget(find.descendant(of: find.byWidget(dialog), matching: find.widgetWithText(CupertinoDialogAction, 'Cancel'))); return (actionButton, cancelButton);