diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 15fce5b25a..93f3e01596 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg
new file mode 100644
index 0000000000..0c220a4240
--- /dev/null
+++ b/assets/icons/edit.svg
@@ -0,0 +1,4 @@
+
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();
+ });
+ });
+ });
});
}