From b144d622a365b0a2aff63a441fe3ffa39420e27d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 19 Sep 2025 16:04:12 -0700 Subject: [PATCH 1/7] test: Populate eg.initialSnapshot.realmCanDeleteOwnMessageGroup by default But only when we're simulating recent servers, as the comment says. (FL 291 removed realmDeleteOwnMessagePolicy and added realmCanDeleteOwnMessageGroup, so to be representative, tests should always specify one but not the other.) --- test/example_data.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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, From 31fc153f7efa5c062e40c767195e48fb51d24e6a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 18 Sep 2025 17:55:37 -0700 Subject: [PATCH 2/7] icons: Add `trash`, from the Figma Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6141-23859&m=dev --- assets/icons/ZulipIcons.ttf | Bin 17656 -> 17968 bytes assets/icons/trash.svg | 5 +++++ lib/widgets/icons.dart | 7 +++++-- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 assets/icons/trash.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 66d69be4b1764c1a71841d28b8cfe17a18cc75a9..4fa457ba54b862cf9e1a21f1b00a440eeede421b 100644 GIT binary patch delta 2401 zcmbVO-A_|z7=PaOa9W^J%S!2Sl>x0~EBIBQDE!ZI6!&}1ITd!XoAu;(p65L8^Zh>W zIsN-%@!cmvB|${ZbcM`xXmn;arB*-i6Nw{44aLcsgTcA`?%WDS|6XF^W@)(v+brxCquqDF@z+CT(kM%Nroi;tJCk^y07^ zVSDat(+-1#<~m6dSv1Fa&2bWeW$LB??Lx>fCA5%fxJqMv8k%|Pg+`hRu%84qla=aQ+GqqhOi|l#o;D{>lLUZpq?csba;gm>7;h_ zB#N{PaLV=1z3R3iXOzk#ZikU z)}%w4t7(KELwzAQi(;Lhj)0FKbRG^R*zO@e1?e>;pM-=xaLOKNae`d+Uhqj8hleTZ zfK3v`MNy0@lbaMYiTAV!_i@x)fUH5QU>v4gaR)*)8v)~*^Dx#ql~<|prM$jbjp)_@ zyqutA9GFk&3;K%oW6t!-V@Q!pxtS z!f9w{@yvUhr#>XflfkXd(IKQ?w+U)p(1s0TEI}&?ul?XSni|2b?1Az!eTq|Xmwu)v zv?Xl9EqqAGg%uvN34|U)Fm}&r#nG#z7D`{`M4M3ce`#}4+nOyHV}72bh`#?j50Fh8 zlX_5YQhla(87nYD7m`S#ep(ulYCQlaTIcQP)6S%)$N>r4>IX4PY92!)qY(uJ;*qjy zt<%HrHl~2CLh`;9sRvvUfzw(V!$>5F@VbNt)k&}8zI}$?fPM<=dFa~_ zC8BK~zqIrhR&7z1o7urM-lLM~``U+cTnvA`DT`XCMXGID)Pt=s#ZTB!Gjn~QO?b<4 zM;_t12|Y96I!bFZP*?=pJ4Cx{ujmiDoN}*lI4v#0B73{Kdk%;m*m=AC z1EKzXZC!%{{$9~7Tf7dZtG_E48gz(thoX2D<#)b$m92HPxyje2C^r?PM7tvOkk=#_8WFjU)f_9pr%>H@q+XvhZ5Lkl|KWzo$e)|_>|`lc1lbv3Iqan rkLR=J53C?|Q-ElrGx}J<{|z~H-6ZBVZl+!cxnXs=va(S7HZ%SY51VT| delta 2094 zcmbW2O>9(E6vxk-d2OddDFq7FiWMm$R%}a=Lg(|%o0-mZ+UZO?m7Ty?K8m)ql!AOD zjB%&I4>F0mapA{;7-I}I1|v(CEFi&zl@cR6V=!^yLW95a=8mlkH_pxfo%`-RAOCai zx%0lfZ2j?uRS`=>*2rt(m;OxY1^43Kixm-Tzes~qDD@2-xHYv+r12bDho|St3-5pH zd@mB%$(nt9_WZHUeQsK$^{z<&otdNMBWLe_cMaWpU~q6?dt zVT-<1oSmPJmR}z@^A!$uq43#U`P_mvYYnp5!TZ*e<+-Dqt~&!F@y|tk`xfR;FZI0m zX1hq@XA%GUD>u5=PI(!S#m4I&-M;e6ZfmOAWgV~Fk#(|NuF74j(dxGjTGy;!eH(l! z-vhhL?y*0$zm^7RkzN^=gR&$(X_GJ`B2kG+LR?8oidk6FXk?iCDbk9@%6bFqDMm;} z(2HT0R(q{$aFs#abM1&NO`hYd=hz`|qimBNSxd-}#J!L%u3YwuXlCVEG+Y_QejMqS zfOL}BR%A#V$mNm4>^zC@G%I8Hv!xQj$(X0!!NMlOPM{RSO_H$@l{o4tX-7wQl*m?j zgqlR4J&IG!e-if*X0B{zKSE{+o>8oWkO)CO920hJQ?&|5>jePc!v-o;n_uMXtzY`9MCF z=jg0jwoxc*dYZkSzt$;^J{?hEA=)&2+P?ahy`iE7)GmneOnVNMNR0A0 zp7YwSQVWvje`za?70p&!Ro}<)7s1c!JJRCyLOo`D_3eRv}_&pM==eTGhh0F|X*kYyKN3kv7=`m(s?>&sz$G7K%M4 z$7F$C)-%mAYE8D9h}LHZ`+6_5dV1#CmL+73OhTT**T3zP;%feJ={6^om#3NK3G8|_ zh9Kg=y|$vQ*)BVIuH2Mo&`+?RMSq=l!u3C<+MiOEs?Y3-!q*=SX7HC6iBaKq0ElE2P&A%hsS5#|TpgA~xmI3y?J!f!*>OM&h^F~a5u z6Ngrk_RNs?7^Fs_t`(i65?NBp(+^QDF=W{wgnY>$hnzMTK^`%PBaa%m$YTac z + + + + 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 } From b505be2e7f976844f51052e1316c9394526ce051 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 18 Sep 2025 18:01:48 -0700 Subject: [PATCH 3/7] api: Add route deleteMessage --- lib/api/route/messages.dart | 8 ++++++++ test/api/route/messages_test.dart | 12 ++++++++++++ 2 files changed, 20 insertions(+) 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/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, From b78ea35f76e6b58be9d4e1d5914bc127a47fd63c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 19 Sep 2025 15:37:21 -0700 Subject: [PATCH 4/7] dialog: Use "default" and "destructive" styles in iOS alert buttons Fixes #1032. See Apple's HIG document: https://developer.apple.com/design/human-interface-guidelines/alerts And note that Android doesn't vary the button styles in these ways; see the description of #1032. --- lib/widgets/action_sheet.dart | 2 +- lib/widgets/app.dart | 2 +- lib/widgets/compose_box.dart | 2 +- lib/widgets/dialog.dart | 23 +++++++++++++++++++++-- test/widgets/action_sheet_test.dart | 1 + test/widgets/app_test.dart | 1 + test/widgets/compose_box_test.dart | 1 + test/widgets/dialog_checks.dart | 14 ++++++++++++-- 8 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index ce1be46000..65257b0279 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -643,7 +643,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; 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/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/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8f4d614220..fa73173dab 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -576,6 +576,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); 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); From 2dcd55b4190c8992cfb01b2ac21006f8e96305e6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 22 Sep 2025 16:19:38 -0700 Subject: [PATCH 5/7] button: Make ZulipMenuItemButton.style required --- lib/widgets/action_sheet.dart | 1 + lib/widgets/button.dart | 2 +- lib/widgets/subscription_list.dart | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 65257b0279..f48a40a506 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -376,6 +376,7 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); return ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.menu, icon: icon, label: label(zulipLocalizations), onPressed: () => _handlePressed(context), diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 86997c08f2..6f720f0ab4 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, 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, From 5900d7a72821dc1b4e9ea9bedb32d0553c23f4b7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 19 Sep 2025 15:41:04 -0700 Subject: [PATCH 6/7] button: Implement ZulipMenuItemButtonStyle.menuDestructive, from Figma --- lib/widgets/button.dart | 19 +++++++++++++++++-- lib/widgets/theme.dart | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 6f720f0ab4..ef8d0fa07d 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -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/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)!, From f89ecb2d78cc84536c6c7558ba9d102f0483e3ed Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 18 Sep 2025 18:02:14 -0700 Subject: [PATCH 7/7] msglist: Support deleting a message completely Fixes #1548. For the confirmation dialog, we considered following web by including a button linking to /help/delete-a-message#delete-a-message-completely but decided not to because there wasn't a natural way to make it subtle/low-attention. --- assets/l10n/app_en.arb | 20 ++++ lib/generated/l10n/zulip_localizations.dart | 30 ++++++ .../l10n/zulip_localizations_ar.dart | 16 +++ .../l10n/zulip_localizations_de.dart | 16 +++ .../l10n/zulip_localizations_en.dart | 16 +++ .../l10n/zulip_localizations_fr.dart | 16 +++ .../l10n/zulip_localizations_it.dart | 16 +++ .../l10n/zulip_localizations_ja.dart | 16 +++ .../l10n/zulip_localizations_nb.dart | 16 +++ .../l10n/zulip_localizations_pl.dart | 16 +++ .../l10n/zulip_localizations_ru.dart | 16 +++ .../l10n/zulip_localizations_sk.dart | 16 +++ .../l10n/zulip_localizations_sl.dart | 16 +++ .../l10n/zulip_localizations_uk.dart | 16 +++ .../l10n/zulip_localizations_zh.dart | 16 +++ lib/widgets/action_sheet.dart | 99 +++++++++++++---- test/widgets/action_sheet_test.dart | 102 ++++++++++++++++++ 17 files changed, 437 insertions(+), 22 deletions(-) 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/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 f48a40a506..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,7 +377,9 @@ abstract class ActionSheetMenuItemButton extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); return ZulipMenuItemButton( - style: ZulipMenuItemButtonStyle.menu, + style: destructive + ? ZulipMenuItemButtonStyle.menuDestructive + : ZulipMenuItemButtonStyle.menu, icon: icon, label: label(zulipLocalizations), onPressed: () => _handlePressed(context), @@ -1026,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); @@ -1050,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)); } @@ -1603,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/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index fa73173dab..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. @@ -1161,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( @@ -2213,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;