From 2618fbf8b530c28b4607a4662ba6c280afec6617 Mon Sep 17 00:00:00 2001 From: lakshya1goel Date: Mon, 3 Feb 2025 20:24:47 +0530 Subject: [PATCH] compose: Support editing an already-sent message Fixes: #126 --- assets/icons/ZulipIcons.ttf | Bin 12136 -> 12404 bytes assets/icons/edit.svg | 4 + assets/l10n/app_en.arb | 28 +++ lib/api/model/model.dart | 22 +- lib/generated/l10n/zulip_localizations.dart | 42 ++++ .../l10n/zulip_localizations_ar.dart | 21 ++ .../l10n/zulip_localizations_en.dart | 21 ++ .../l10n/zulip_localizations_ja.dart | 21 ++ .../l10n/zulip_localizations_nb.dart | 21 ++ .../l10n/zulip_localizations_pl.dart | 21 ++ .../l10n/zulip_localizations_ru.dart | 21 ++ .../l10n/zulip_localizations_sk.dart | 21 ++ lib/model/store.dart | 11 + lib/widgets/action_sheet.dart | 38 ++++ lib/widgets/compose_box.dart | 207 ++++++++++++++++-- lib/widgets/icons.dart | 55 ++--- lib/widgets/message_list.dart | 62 ++++-- lib/widgets/theme.dart | 56 +++++ test/widgets/action_sheet_test.dart | 144 ++++++++++++ 19 files changed, 751 insertions(+), 65 deletions(-) create mode 100644 assets/icons/edit.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 15fce5b25a31060652d65f3290a9436f48f6d7f5..93f3e0159664ddf5b10c681d2d0873d08549138c 100644 GIT binary patch delta 1860 zcmb7FT}+!*7=F*!_UnIvmck0ip#QX`{l2ze%L*+-6WyYT3y9N&Y;?c?2Lq*UkPu52 zua_*1yD(lD;}zS58D1=A7haHgWn#SWhGkh6B*rYpMPs-a=yMKdx?9^k=RM~==Xu_r z^Y!a5=l-0m6C)xgZIFqy*T%+QK3RSJ1`!(|GQIKUSSI`S4`Z7|=8th|a%!=`xtZ$pm7llo;4Jp3Fb9W?nTIjI2ZZMqYpadzzYoCO z#roO8(p0%R@!iZfM2>Ia*jTKtF0(VN3ke*Bo=vj*N>~|HYRWa`y0T4X za$vtir%6HiF?{3HM+w+|N>he3oN!{)C`wtZ6O_YFo(zO@yl_4#cqz2cin9sQ1wY06 zYNk$dgAk>p5H=uWV7r8ax=5fyi2FfJA%r1+~65B06$OX#mlmz7u_}3A7++0f-vtf-cFtVZ7Bz+~`sLW$M7JEE3>1-AZAMDWrGgi(pp= zBL;nb3I9E^I?Bt5UfK)dxLJ9pv1%S>k;Yx~xrW#BeP$0N{&|Fi_>4cf4*cHsAo?}d z4y38zd!FWL?P;XpQA2tHK?;ygB4|$hI{Hy-9uZO~t%QEXkdTJ-2GFGn4K^;Qrz{bu zstw&*?F7G&O&Ym$2E2SR6)z3NbgewlR~f|qMq|Tbt-E+q%03_QsQ~9C60lW?DC`A^ z0_>Co-Vih`k%XO*FkmlAHg3HLt54^|bnx)ok6jP1x?+N9>>5|8i)K+s+Z^p3Cd{)b*SDBllxZkLTB> zLep;Z`v3oKbvdop`i^Ob+UPi)!nFrgD^r`8+N}(;LXlNiKCM^_*|t_IvldN3kK%7> z?N)NV*@|McgcNt)E=Hs_|x#%_B3{)j&0iFJ7n zSI)LS>&S$CzHp{P84qY@!oEN#5bz${@?vBOKALv?-oy87u1#lKvua8zcJ_%+H}r^q z+S3(-MEhBG(-+nP2e+Op9q|r-;%sFcV`wYjt##r*<$C|R!bTd7^t%@G%=CP1YbtGF GjQ#;sbObB_ delta 1601 zcmb7EOK%%h7(Fxb`;i9P(zIz)lf)i-#*eW*wqwVRLKmg18Z{7sgwmQMO$u?6rXeH< z#jGlJr3_0{A^HbUQ=~4kC@A6~v4{}Ms;W?nDik3Vu|Pry&b_{YZt>{Mx%18UJlAu7 zzwmgf4GlmT@1qO$ixaP$I{n+3DL@+rx@u=9a)rs=iJO3Dm0lO7Tg~O8j~(Al5rS+niZ@v~!ET4~X(S4FSzF%JnueG~ZfXzqa%DUyS{c&wnf| zO*fja-aLB)2z^4sW~;frtW9c6ruFhUw%BaV9=gl0exIw(Kq$m@F0W%)G!X6 z!zXwdNFqf}BZDk*^a%5wLmYWNr%>RVA{-VAJ>_r8IE&)>>6?Nr<1J`yCOF0x940Vj zVuy(wzOK^ImJJ+Zq5=*v)M5!o7SG`scu{6lhL&f!woqb`1`aaDqQAkGedt9u0*o+F zXT}t3lv&No77hA4XbY!)9N-jjmJ<`CRJ5O&`kXi^^XS;;HZ6AA#Gr8zL&et7j@)+j+ zdE^{5DP)W#B79Cr(ZwE)P*Q`b98TrqswhQGD%lS+SDd9X^y+NVb26XEta0V#e}U0$ zQh<`Bxl<9QvUzpfg+xMJ6G!MSCa&WsS9MuZY{DT7@@}!Aj%|o$Wj(3tWqcO&&aXK3OA{Nn!DzE$^Ftd)5pkC z-gF9?EF+3#%czbd>|DK)Y}DKr{8!slgg<>x-1%z|=M_@qrb3+jhC+!vt&k_rD466~ z1s)N&sK6rwa|#*qyh4`TzQmhAns`fr2OfB)NEFE}g)(_j!6NhQkub>13RUuDg%R?K zLV|on!6u7Clo+LGz}pIS@|r@0{EkABysjV=+EA!%>A_o?`&H=3+)ti}_lEa@Z_M|x z{{#PCU^1{9w1ZcJcSH5iZg?&HS;UNNM!Tac(a*cz?f&|}YER_ + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1060027553..37c83db9a4 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -685,6 +685,14 @@ "@messageIsMovedLabel": { "description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, + "messageIsEditingLabel": "SAVING EDIT...", + "@messageIsEditingLabel": { + "description": "Label shown while a message edit is being saved. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "messageIsEditErrorLabel": "EDIT ISN'T SAVED. CHECK YOUR CONNECTION.", + "@messageIsEditErrorLabel": { + "description": "Label shown when a message edit failed to save. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, "pollWidgetQuestionMissing": "No question.", "@pollWidgetQuestionMissing": { "description": "Text to display for a poll when the question is missing" @@ -716,5 +724,25 @@ "emojiPickerSearchEmoji": "Search emoji", "@emojiPickerSearchEmoji": { "description": "Hint text for the emoji picker search text field." + }, + "actionSheetOptionEditMessage": "Edit", + "@actionSheetOptionEditMessage": { + "description": "Label for action sheet button to edit a message." + }, + "errorEditingFailed": "Failed to edit message", + "@errorEditingFailed": { + "description": "Error title when editing a message fails." + }, + "editMessageSaveTooltip": "Save changes", + "@editMessageSaveTooltip": { + "description": "Tooltip for the save button when editing a message" + }, + "editMessageTitle": "Edit the message", + "@editMessageTitle": { + "description": "Title shown in the edit message banner" + }, + "dialogSave": "Save", + "@dialogSave": { + "description": "Button label to save changes" } } diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 03af104baf..69fd617e87 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:flutter/foundation.dart'; import 'events.dart'; import 'initial_snapshot.dart'; @@ -541,8 +542,15 @@ sealed class Message { final String contentType; // final List editHistory; // TODO handle - @JsonKey(readValue: MessageEditState._readFromMessage, fromJson: Message._messageEditStateFromJson) - MessageEditState editState; + @JsonKey(readValue: MessageEditState._readFromMessage, fromJson: _messageEditStateFromJson) + final ValueNotifier _editStateNotifier; + + MessageEditState get editState => _editStateNotifier.value; + set editState(MessageEditState value) { + _editStateNotifier.value = value; + } + + ValueNotifier get editStateNotifier => _editStateNotifier; final int id; bool isMeMessage; @@ -572,7 +580,7 @@ sealed class Message { @JsonKey(name: 'match_subject') final String? matchTopic; - static MessageEditState _messageEditStateFromJson(Object? json) { + static MessageEditState _messageEditStateFromJson(dynamic json) { // This is a no-op so that [MessageEditState._readFromMessage] // can return the enum value directly. return json as MessageEditState; @@ -603,7 +611,7 @@ sealed class Message { required this.client, required this.content, required this.contentType, - required this.editState, + required MessageEditState editState, required this.id, required this.isMeMessage, required this.lastEditTimestamp, @@ -617,7 +625,7 @@ sealed class Message { required this.flags, required this.matchContent, required this.matchTopic, - }); + }) : _editStateNotifier = ValueNotifier(editState); factory Message.fromJson(Map json) { final type = json['type'] as String; @@ -863,7 +871,9 @@ class DmMessage extends Message { enum MessageEditState { none, edited, - moved; + moved, + editing, + editError; /// Whether the given topic move reflected either a "resolve topic" /// or "unresolve topic" operation. diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 501eb577bf..76bca6282d 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1023,6 +1023,18 @@ abstract class ZulipLocalizations { /// **'MOVED'** String get messageIsMovedLabel; + /// Label shown while a message edit is being saved. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'SAVING EDIT...'** + String get messageIsEditingLabel; + + /// Label shown when a message edit failed to save. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'** + String get messageIsEditErrorLabel; + /// Text to display for a poll when the question is missing /// /// In en, this message translates to: @@ -1070,6 +1082,36 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Search emoji'** String get emojiPickerSearchEmoji; + + /// Label for action sheet button to edit a message. + /// + /// In en, this message translates to: + /// **'Edit'** + String get actionSheetOptionEditMessage; + + /// Error title when editing a message fails. + /// + /// In en, this message translates to: + /// **'Failed to edit message'** + String get errorEditingFailed; + + /// Tooltip for the save button when editing a message + /// + /// In en, this message translates to: + /// **'Save changes'** + String get editMessageSaveTooltip; + + /// Title shown in the edit message banner + /// + /// In en, this message translates to: + /// **'Edit the message'** + String get editMessageTitle; + + /// Button label to save changes + /// + /// In en, this message translates to: + /// **'Save'** + String get dialogSave; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 721b20ac02..96c0fd31fd 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 6936cfe736..3753b6048b 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c431471645..b5f7c5b277 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index fc530fccaa..03cc33f654 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get messageIsMovedLabel => 'MOVED'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'No question.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index f817d400a8..4f117c8e55 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRZENIESIONO'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'Brak pytania.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Szukaj emoji'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index f6d8f1e41c..6de99253e2 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get messageIsMovedLabel => 'ПЕРЕМЕЩЕНО'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'Нет вопроса.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Поиск эмодзи'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index d6e04126d3..3216bc1fc1 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -541,6 +541,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get messageIsMovedLabel => 'PRESUNUTÉ'; + @override + String get messageIsEditingLabel => 'SAVING EDIT...'; + + @override + String get messageIsEditErrorLabel => 'EDIT ISN\'T SAVED. CHECK YOUR CONNECTION.'; + @override String get pollWidgetQuestionMissing => 'Bez otázky.'; @@ -564,4 +570,19 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Hľadať emotikon'; + + @override + String get actionSheetOptionEditMessage => 'Edit'; + + @override + String get errorEditingFailed => 'Failed to edit message'; + + @override + String get editMessageSaveTooltip => 'Save changes'; + + @override + String get editMessageTitle => 'Edit the message'; + + @override + String get dialogSave => 'Save'; } diff --git a/lib/model/store.dart b/lib/model/store.dart index 7603c7f452..fdd337f729 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -721,6 +721,16 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess ); } + Future editMessage({required int messageId, required String content}) { + assert(!_disposed); + + return _apiUpdateMessage(connection, + messageId: messageId, + content: content, + propagateMode: PropagateMode.changeOne, + ); + } + static List _sortCustomProfileFields(List initialCustomProfileFields) { // TODO(server): The realm-wide field objects have an `order` property, // but the actual API appears to be that the fields should be shown in @@ -741,6 +751,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess } const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 +const _apiUpdateMessage = updateMessage; const _tryResolveUrl = tryResolveUrl; /// Like [Uri.resolve], but on failure return null instead of throwing. diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6a90b61e64..4b8148a9f6 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -390,6 +390,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final canEditMessage = message.senderId == store.selfUserId + && messageListPage.narrow is! CombinedFeedNarrow; + final optionButtons = [ ReactionButtons(message: message, pageContext: context), StarButton(message: message, pageContext: context), @@ -397,6 +400,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes QuoteAndReplyButton(message: message, pageContext: context), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: context), + if (canEditMessage) + EditButton(message: message, pageContext: context), CopyMessageTextButton(message: message, pageContext: context), CopyMessageLinkButton(message: message, pageContext: context), ShareButton(message: message, pageContext: context), @@ -708,6 +713,39 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } } +class EditButton extends MessageActionSheetMenuItemButton { + EditButton({super.key, required super.message, required super.pageContext}); + + @override + IconData get icon => ZulipIcons.edit; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionEditMessage; + } + + @override + void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); + final rawContent = await fetchRawContentWithFeedback( + context: pageContext, + messageId: message.id, + errorDialogTitle: zulipLocalizations.errorEditingFailed, + ); + + if (rawContent == null) return; + if (!pageContext.mounted) return; + + final messageListPage = findMessageListPage(); + final composeBoxController = messageListPage.composeBoxController; + + if (composeBoxController == null) return; + + composeBoxController.startEditing(message); + composeBoxController.content.text = rawContent; + } +} + class CopyMessageTextButton extends MessageActionSheetMenuItemButton { CopyMessageTextButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 1233964afb..20a09a08e0 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1022,6 +1022,10 @@ class _SendButtonState extends State<_SendButton> { final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + if (widget.controller.isEditing) { + return const SizedBox(width: _composeButtonSize); + } + final iconColor = _hasValidationErrors ? designVariables.icon.withFadedAlpha(0.5) : designVariables.icon; @@ -1040,10 +1044,96 @@ class _SendButtonState extends State<_SendButton> { } } +class _EditHeader extends StatelessWidget { + const _EditHeader({ + required this.onCancel, + required this.onSave, + }); + + final VoidCallback onCancel; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Container( + height: 40, + padding: const EdgeInsetsDirectional.fromSTEB(16, 5, 8, 5), + decoration: BoxDecoration( + color: designVariables.bannerBgIntInfo, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + zulipLocalizations.editMessageTitle, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: designVariables.bannerTextIntInfo, + ).merge(weightVariableTextStyle(context, wght: 600)), + ), + Row( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: designVariables.btnShadowAttMedium, + blurRadius: 1, + spreadRadius: 1, + offset: Offset(0, 0), + ), + ], + ), + child: FilledButton( + onPressed: onCancel, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + backgroundColor: designVariables.btnBgAttMediumIntInfoNormal, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: Text(zulipLocalizations.dialogCancel, style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: designVariables.btnLabelAttMediumIntInfo, + ),), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: onSave, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + backgroundColor: designVariables.btnBgAttHighIntInfoNormal, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: Text(zulipLocalizations.dialogSave, style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: designVariables.btnLabelAttHigh, + ),), + ), + ], + ), + ], + ), + ); + } +} + class _ComposeBoxContainer extends StatelessWidget { const _ComposeBoxContainer({ required this.body, this.errorBanner, + this.editHeader, }) : assert(body != null || errorBanner != null); /// The text inputs, compose-button row, and send button. @@ -1062,6 +1152,7 @@ class _ComposeBoxContainer extends StatelessWidget { /// and bottom device insets. /// (A bottom inset may occur if [body] is null.) final Widget? errorBanner; + final Widget? editHeader; Widget _paddedBody() { assert(body != null); @@ -1073,17 +1164,30 @@ class _ComposeBoxContainer extends StatelessWidget { Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); - final List children = switch ((errorBanner, body)) { - (Widget(), Widget()) => [ + final List children = switch ((errorBanner, editHeader, body)) { + (Widget(), Widget(), Widget()) => [ // _paddedBody() already pads the bottom inset, // so make sure the error banner doesn't double-pad it. + MediaQuery.removePadding(context: context, removeBottom: true, + child: errorBanner!), + editHeader!, + _paddedBody(), + ], + (Widget(), Widget(), null) => [ + MediaQuery.removePadding(context: context, removeBottom: true, + child: errorBanner!), + editHeader!, + ], + (Widget(), null, Widget()) => [ MediaQuery.removePadding(context: context, removeBottom: true, child: errorBanner!), _paddedBody(), ], - (Widget(), null) => [errorBanner!], - (null, Widget()) => [_paddedBody()], - (null, null) => throw UnimplementedError(), // not allowed, see dartdoc + (Widget(), null, null) => [errorBanner!], + (null, Widget(), Widget()) => [editHeader!, _paddedBody()], + (null, Widget(), null) => [editHeader!], + (null, null, Widget()) => [_paddedBody()], + (null, null, null) => throw UnimplementedError(), }; // TODO(design): Maybe put a max width on the compose box, like we do on @@ -1147,7 +1251,14 @@ abstract class _ComposeBoxBody extends StatelessWidget { child: Theme( data: inputThemeData, child: Column(children: [ - if (topicInput != null) topicInput, + ValueListenableBuilder( + valueListenable: controller.isEditingNotifier, + builder: (context, isEditing, _) { + return (!isEditing && topicInput != null) + ? topicInput + : const SizedBox.shrink(); + }, + ), buildContentInput(), ]))), SizedBox( @@ -1177,10 +1288,13 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { @override final StreamComposeBoxController controller; - @override Widget buildTopicInput() => _TopicInput( - streamId: narrow.streamId, - controller: controller, - ); + @override + Widget? buildTopicInput() => controller.isEditing + ? null + : _TopicInput( + streamId: narrow.streamId, + controller: controller, + ); @override Widget buildContentInput() => _StreamContentInput( narrow: narrow, @@ -1220,10 +1334,29 @@ sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); + final ValueNotifier isEditingNotifier = ValueNotifier(false); + bool get isEditing => _messageBeingEdited != null; + + Message? _messageBeingEdited; + Message? get messageBeingEdited => _messageBeingEdited; + + void startEditing(Message message) { + _messageBeingEdited = message; + contentFocusNode.requestFocus(); + isEditingNotifier.value = true; + } + + void cancelEditing() { + _messageBeingEdited = null; + content.clear(); + isEditingNotifier.value = false; + } + @mustCallSuper void dispose() { content.dispose(); contentFocusNode.dispose(); + isEditingNotifier.dispose(); } } @@ -1391,11 +1524,53 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } - return _ComposeBoxContainer(body: body, errorBanner: null); + return ValueListenableBuilder( + valueListenable: controller.isEditingNotifier, + builder: (context, isEditing, child) { + final Widget? editHeader = isEditing ? _EditHeader( + onCancel: () { + controller.cancelEditing(); + }, + onSave: () { + final store = PerAccountStoreWidget.of(context); + final content = controller.content.textNormalized; + final message = controller.messageBeingEdited!; + + controller.content.clear(); + controller.cancelEditing(); + + setState(() { + message.editState = MessageEditState.editing; + }); + + store.editMessage( + messageId: message.id, + content: content, + ).then((_) { + if (!mounted) return; + setState(() { + message.editState = MessageEditState.edited; + }); + }).catchError((e) { + if (!mounted) return; + setState(() { + message.editState = MessageEditState.editError; + }); + }); + }, + ) : null; + + // TODO(#720) dismissable message-send error, maybe something like: + // if (controller.sendMessageError.value != null) { + // errorBanner = _ErrorBanner(label: + // ZulipLocalizations.of(context).errorSendMessageTimeout); + // } + return _ComposeBoxContainer( + body: body, + errorBanner: errorBanner, + editHeader: editHeader, + ); + }, + ); } } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 7d58305fb4..c6c3fefb3d 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -54,83 +54,86 @@ abstract final class ZulipIcons { /// The Zulip custom icon "copy". static const IconData copy = IconData(0xf10a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "edit". + static const IconData edit = IconData(0xf10b, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf125, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index bd85c03c05..6c21932c4b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1354,14 +1354,6 @@ class MessageWithPossibleSender extends StatelessWidget { } final localizations = ZulipLocalizations.of(context); - String? editStateText; - switch (message.editState) { - case MessageEditState.edited: - editStateText = localizations.messageIsEditedLabel; - case MessageEditState.moved: - editStateText = localizations.messageIsMovedLabel; - case MessageEditState.none: - } return GestureDetector( behavior: HitTestBehavior.translucent, @@ -1383,15 +1375,51 @@ class MessageWithPossibleSender extends StatelessWidget { MessageContent(message: message, content: item.content), if ((message.reactions?.total ?? 0) > 0) ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), + ValueListenableBuilder( + valueListenable: message.editStateNotifier, + builder: (context, editState, _) { + String? editStateText; + switch (editState) { + case MessageEditState.edited: + editStateText = localizations.messageIsEditedLabel; + case MessageEditState.moved: + editStateText = localizations.messageIsMovedLabel; + case MessageEditState.editing: + editStateText = localizations.messageIsEditingLabel; + case MessageEditState.editError: + editStateText = localizations.messageIsEditErrorLabel; + case MessageEditState.none: + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (editStateText != null) + Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: (editState == MessageEditState.editError) + ? designVariables.btnLabelAttLowIntDanger + : (editState == MessageEditState.editing) + ? designVariables.btnLabelAttLowIntInfo + : designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))), + if (editState == MessageEditState.editing) + Padding( + padding: const EdgeInsets.only(top: 4), + child: LinearProgressIndicator( + backgroundColor: designVariables.foreground.withAlpha(20), + color: designVariables.foreground.withAlpha(50), + minHeight: 2, + ), + ), + ], + ); + }, + ), ])), SizedBox(width: 16, child: message.flags.contains(MessageFlag.starred) diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 5358f73c42..8e579dcaf8 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -120,6 +120,8 @@ class DesignVariables extends ThemeExtension { static final light = DesignVariables._( background: const Color(0xffffffff), bannerBgIntDanger: const Color(0xfff2e4e4), + bannerBgIntInfo: const Color(0xffddecf6), + bannerTextIntInfo: const Color(0xff06037c), bgBotBar: const Color(0xfff6f6f6), bgContextMenu: const Color(0xfff2f2f2), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), @@ -128,8 +130,14 @@ class DesignVariables extends ThemeExtension { bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), + btnBgAttHighIntInfoNormal: const Color(0xff3c6bff), + btnBgAttMediumIntInfoNormal: const Color(0xff3c6bff).withValues(alpha: 0.12), + btnLabelAttHigh: const Color(0xffffffff), btnLabelAttLowIntDanger: const Color(0xffc0070a), + btnLabelAttLowIntInfo: const Color(0xff2347c6), btnLabelAttMediumIntDanger: const Color(0xffac0508), + btnLabelAttMediumIntInfo: const Color(0xff1027a6), + btnShadowAttMedium: const Color(0xff000000).withValues(alpha: 0.2), composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), @@ -169,6 +177,8 @@ class DesignVariables extends ThemeExtension { static final dark = DesignVariables._( background: const Color(0xff000000), bannerBgIntDanger: const Color(0xff461616), + bannerBgIntInfo: const Color(0xff00253d), + bannerTextIntInfo: const Color(0xffcbdbfd), bgBotBar: const Color(0xff222222), bgContextMenu: const Color(0xff262626), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), @@ -177,8 +187,14 @@ class DesignVariables extends ThemeExtension { bgTopBar: const Color(0xff242424), borderBar: Colors.black.withValues(alpha: 0.5), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), + btnBgAttHighIntInfoNormal: const Color(0xff1e41d3), + btnBgAttMediumIntInfoNormal: const Color(0xff97b6fe).withValues(alpha: 0.12), + btnLabelAttHigh: const Color(0xffffffff).withValues(alpha: 0.85), btnLabelAttLowIntDanger: const Color(0xffff8b7c), + btnLabelAttLowIntInfo: const Color(0xff84a8fd), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), + btnLabelAttMediumIntInfo: const Color(0xff97b6fe), + btnShadowAttMedium: const Color(0xff000000).withValues(alpha: 0.2), composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), @@ -226,6 +242,8 @@ class DesignVariables extends ThemeExtension { DesignVariables._({ required this.background, required this.bannerBgIntDanger, + required this.bannerBgIntInfo, + required this.bannerTextIntInfo, required this.bgBotBar, required this.bgContextMenu, required this.bgCounterUnread, @@ -234,8 +252,14 @@ class DesignVariables extends ThemeExtension { required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, + required this.btnBgAttHighIntInfoNormal, + required this.btnBgAttMediumIntInfoNormal, + required this.btnLabelAttHigh, required this.btnLabelAttLowIntDanger, + required this.btnLabelAttLowIntInfo, required this.btnLabelAttMediumIntDanger, + required this.btnLabelAttMediumIntInfo, + required this.btnShadowAttMedium, required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, @@ -284,6 +308,8 @@ class DesignVariables extends ThemeExtension { final Color background; final Color bannerBgIntDanger; + final Color bannerBgIntInfo; + final Color bannerTextIntInfo; final Color bgBotBar; final Color bgContextMenu; final Color bgCounterUnread; @@ -292,8 +318,14 @@ class DesignVariables extends ThemeExtension { final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; + final Color btnBgAttHighIntInfoNormal; + final Color btnBgAttMediumIntInfoNormal; + final Color btnLabelAttHigh; final Color btnLabelAttLowIntDanger; + final Color btnLabelAttLowIntInfo; final Color btnLabelAttMediumIntDanger; + final Color btnLabelAttMediumIntInfo; + final Color btnShadowAttMedium; final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; @@ -337,6 +369,8 @@ class DesignVariables extends ThemeExtension { DesignVariables copyWith({ Color? background, Color? bannerBgIntDanger, + Color? bannerBgIntInfo, + Color? bannerTextIntInfo, Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, @@ -345,8 +379,14 @@ class DesignVariables extends ThemeExtension { Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, + Color? btnBgAttHighIntInfoNormal, + Color? btnBgAttMediumIntInfoNormal, + Color? btnLabelAttHigh, Color? btnLabelAttLowIntDanger, + Color? btnLabelAttLowIntInfo, Color? btnLabelAttMediumIntDanger, + Color? btnLabelAttMediumIntInfo, + Color? btnShadowAttMedium, Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, @@ -385,6 +425,8 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: background ?? this.background, bannerBgIntDanger: bannerBgIntDanger ?? this.bannerBgIntDanger, + bannerBgIntInfo: bannerBgIntInfo ?? this.bannerBgIntInfo, + bannerTextIntInfo: bannerTextIntInfo ?? this.bannerTextIntInfo, bgBotBar: bgBotBar ?? this.bgBotBar, bgContextMenu: bgContextMenu ?? this.bgContextMenu, bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, @@ -393,8 +435,14 @@ class DesignVariables extends ThemeExtension { bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, + btnBgAttHighIntInfoNormal: btnBgAttHighIntInfoNormal ?? this.btnBgAttHighIntInfoNormal, + btnBgAttMediumIntInfoNormal: btnBgAttMediumIntInfoNormal ?? this.btnBgAttMediumIntInfoNormal, + btnLabelAttHigh: btnLabelAttHigh ?? this.btnLabelAttHigh, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, + btnLabelAttLowIntInfo: btnLabelAttLowIntInfo ?? this.btnLabelAttLowIntInfo, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, + btnLabelAttMediumIntInfo: btnLabelAttMediumIntInfo ?? this.btnLabelAttMediumIntInfo, + btnShadowAttMedium: btnShadowAttMedium ?? this.btnShadowAttMedium, composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, @@ -440,6 +488,8 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: Color.lerp(background, other.background, t)!, bannerBgIntDanger: Color.lerp(bannerBgIntDanger, other.bannerBgIntDanger, t)!, + bannerBgIntInfo: Color.lerp(bannerBgIntInfo, other.bannerBgIntInfo, t)!, + bannerTextIntInfo: Color.lerp(bannerTextIntInfo, other.bannerTextIntInfo, t)!, bgBotBar: Color.lerp(bgBotBar, other.bgBotBar, t)!, bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!, bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, @@ -448,8 +498,14 @@ class DesignVariables extends ThemeExtension { bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, + btnBgAttHighIntInfoNormal: Color.lerp(btnBgAttHighIntInfoNormal, other.btnBgAttHighIntInfoNormal, t)!, + btnBgAttMediumIntInfoNormal: Color.lerp(btnBgAttMediumIntInfoNormal, other.btnBgAttMediumIntInfoNormal, t)!, + btnLabelAttHigh: Color.lerp(btnLabelAttHigh, other.btnLabelAttHigh, t)!, btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, + btnLabelAttLowIntInfo: Color.lerp(btnLabelAttLowIntInfo, other.btnLabelAttLowIntInfo, t)!, btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, + btnLabelAttMediumIntInfo: Color.lerp(btnLabelAttMediumIntInfo, other.btnLabelAttMediumIntInfo, t)!, + btnShadowAttMedium: Color.lerp(btnShadowAttMedium, other.btnShadowAttMedium, t)!, composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index e6b48384b8..9973cfe568 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1083,6 +1083,150 @@ void main() { checkActionSheet(tester, isShown: false); }); }); + + group('EditMessageButton', () { + Future setupToMessageActionSheetWithEditableMessage(WidgetTester tester) async { + final message = eg.streamMessage(sender: eg.selfUser, flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message)); + } + + Future setupToMessageActionSheetWithNonEditableMessage(WidgetTester tester) async { + final message = eg.streamMessage(sender: eg.otherUser, flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message)); + } + + Future setupToMessageActionSheetInCombinedFeed(WidgetTester tester) async { + final message = eg.streamMessage(sender: eg.selfUser, flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow()); + } + + group('visibility', () { + testWidgets('shows button when message is editable', (tester) async { + await setupToMessageActionSheetWithEditableMessage(tester); + + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + check(find.text(zulipLocalizations.actionSheetOptionEditMessage)).findsOne(); + }); + + testWidgets('hides button when message is from another user', (tester) async { + await setupToMessageActionSheetWithNonEditableMessage(tester); + + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + check(find.text(zulipLocalizations.actionSheetOptionEditMessage)).findsNothing(); + }); + + testWidgets('hides button in combined feed narrow', (tester) async { + await setupToMessageActionSheetInCombinedFeed(tester); + + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + check(find.text(zulipLocalizations.actionSheetOptionEditMessage)).findsNothing(); + }); + }); + + Future tapEditButton(WidgetTester tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final editButton = find.text(zulipLocalizations.actionSheetOptionEditMessage); + check(editButton).findsOne(); + await tester.tap(editButton); + await tester.pump(); + } + + group('onPress', () { + testWidgets('shows edit UI with original message content', (tester) async { + final message = eg.streamMessage(content: 'Hello world', sender: eg.selfUser, flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, + message: message, + narrow: ChannelNarrow(message.streamId)); + + connection.prepare(json: { + 'message': message.toJson(), + 'raw_content': 'Hello world', + }); + + connection.prepare(json: {'result': 'success'}); + connection.prepare(json: {'result': 'success'}); + connection.prepare(json: {'result': 'success'}); + + await tapEditButton(tester); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + check(find.text(zulipLocalizations.editMessageTitle)).findsOne(); + check(find.text(zulipLocalizations.dialogSave)).findsOne(); + check(find.text(zulipLocalizations.dialogCancel)).findsOne(); + + final textField = find.byType(TextField); + check(textField).findsOne(); + check(tester.widget(textField).controller?.text) + .equals('Hello world'); + }); + + testWidgets('shows progress while saving edit', (tester) async { + final message = eg.streamMessage(content: 'Hello world', sender: eg.selfUser, flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, + message: message, + narrow: ChannelNarrow(message.streamId)); + + connection.prepare(json: { + 'message': message.toJson(), + 'raw_content': 'Hello world', + }); + + connection.prepare(json: {'result': 'success'}); + connection.prepare(json: {'result': 'success'}); + connection.prepare(json: {'result': 'success'}); + + await tapEditButton(tester); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + connection.prepare(delay: const Duration(milliseconds: 500), json: {'result': 'success'}); + + await tester.tap(find.text(GlobalLocalizations.zulipLocalizations.dialogSave)); + await tester.pump(); + + check(find.byType(LinearProgressIndicator)).findsOne(); + check(find.text(GlobalLocalizations.zulipLocalizations.messageIsEditingLabel)) + .findsOne(); + await tester.pumpAndSettle(); + }); + + testWidgets('cancels editing and resets state', (tester) async { + final message = eg.streamMessage(content: 'Hello world', sender: eg.selfUser, flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, + message: message, + narrow: ChannelNarrow(message.streamId)); + + connection.prepare(json: { + 'message': message.toJson(), + 'raw_content': 'Hello world', + }); + + connection.prepare(json: {'result': 'success'}); + connection.prepare(json: {'result': 'success'}); + connection.prepare(json: {'result': 'success'}); + + await tapEditButton(tester); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + await tester.tap(find.text(GlobalLocalizations.zulipLocalizations.dialogCancel)); + await tester.pump(); + check(find.text(GlobalLocalizations.zulipLocalizations.editMessageTitle)) + .findsNothing(); + }); + }); + }); }); }