diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 84e19a9cfa..44770c5270 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/message_square_text.svg b/assets/icons/message_square_text.svg new file mode 100644 index 0000000000..0e8ede8a0b --- /dev/null +++ b/assets/icons/message_square_text.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000000..a5b1b7e078 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index d2d7b53033..3c79dea2ae 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -144,6 +144,10 @@ "@actionSheetOptionUnstarMessage": { "description": "Label for unstar button on action sheet." }, + "actionSheetOptionEditMessage": "Edit message", + "@actionSheetOptionEditMessage": { + "description": "Label for the 'Edit message' button in the message action sheet." + }, "actionSheetOptionMarkTopicAsRead": "Mark topic as read", "@actionSheetOptionMarkTopicAsRead": { "description": "Option to mark a specific topic as read in the action sheet." @@ -168,7 +172,7 @@ "server": {"type": "String", "example": "https://example.com"} } }, - "errorCouldNotFetchMessageSource": "Could not fetch message source", + "errorCouldNotFetchMessageSource": "Could not fetch message source.", "@errorCouldNotFetchMessageSource": { "description": "Error message when the source of a message could not be fetched." }, @@ -219,6 +223,10 @@ "@errorMessageNotSent": { "description": "Error message for compose box when a message could not be sent." }, + "errorMessageEditNotSaved": "Message not saved", + "@errorMessageEditNotSaved": { + "description": "Error message for compose box when a message edit could not be saved." + }, "errorLoginCouldNotConnect": "Failed to connect to server:\n{url}", "@errorLoginCouldNotConnect": { "description": "Error message when the app could not connect to the server.", @@ -309,6 +317,10 @@ "@errorUnstarMessageFailedTitle": { "description": "Error title when unstarring a message failed." }, + "errorCouldNotEditMessageTitle": "Could not edit message", + "@errorCouldNotEditMessageTitle": { + "description": "Error title when an exception prevented us from opening the compose box for editing a message." + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." @@ -329,6 +341,38 @@ "@errorBannerCannotPostInChannelLabel": { "description": "Error-banner text replacing the compose box when you do not have permission to send a message to the channel." }, + "composeBoxBannerLabelEditMessage": "Edit message", + "@composeBoxBannerLabelEditMessage": { + "description": "Label text for the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonCancel": "Cancel", + "@composeBoxBannerButtonCancel": { + "description": "Label text for the 'Cancel' button in the compose-box banner when you are editing a message." + }, + "composeBoxBannerButtonSave": "Save", + "@composeBoxBannerButtonSave": { + "description": "Label text for the 'Save' button in the compose-box banner when you are editing a message." + }, + "savingMessageEditLabel": "SAVING EDIT…", + "@savingMessageEditLabel": { + "description": "Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "savingMessageEditFailedLabel": "EDIT NOT SAVED", + "@savingMessageEditFailedLabel": { + "description": "Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" + }, + "discardDraftConfirmationDialogTitle": "Discard the message you’re writing?", + "@discardDraftConfirmationDialogTitle": { + "description": "Title for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogMessage": "When you edit a message, the content that was previously in the compose box is discarded.", + "@discardDraftConfirmationDialogMessage": { + "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box." + }, + "discardDraftConfirmationDialogConfirmButton": "Discard", + "@discardDraftConfirmationDialogConfirmButton": { + "description": "Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." @@ -341,6 +385,54 @@ "@composeBoxAttachFromCameraTooltip": { "description": "Tooltip for compose box icon to attach an image from the camera to the message." }, + "composeBoxShowSavedSnippetsTooltip": "Show saved snippets", + "@composeBoxShowSavedSnippetsTooltip": { + "description": "Tooltip for compose box icon to show a list of saved snippets." + }, + "noSavedSnippets": "No saved snippets", + "@noSavedSnippets": { + "description": "Text to show on the saved snippets bottom sheet when there are no saved snippets." + }, + "savedSnippetsTitle": "Saved snippets", + "@savedSnippetsTitle": { + "description": "Title for the bottom sheet to display saved snippets." + }, + "newSavedSnippetButton": "New", + "@newSavedSnippetButton": { + "description": "Label for adding a new saved snippet." + }, + "newSavedSnippetTitle": "New snippet", + "@newSavedSnippetTitle": { + "description": "Title for the bottom sheet to add a new saved snippet." + }, + "newSavedSnippetTitleHint": "Title", + "@newSavedSnippetTitleHint": { + "description": "Hint text for the title input when adding a new saved snippet." + }, + "newSavedSnippetContentHint": "Content", + "@newSavedSnippetContentHint": { + "description": "Hint text for the content input when adding a new saved snippet." + }, + "errorFailedToCreateSavedSnippetTitle": "Failed to create saved snippet", + "@errorFailedToCreateSavedSnippetTitle": { + "description": "Error title when the saved snippet failed to be created." + }, + "savedSnippetTitleValidationErrorEmpty": "Title cannot be empty.", + "@savedSnippetTitleValidationErrorEmpty": { + "description": "Validation error message when the title of the saved snippet is empty." + }, + "savedSnippetTitleValidationErrorTooLong": "Title length shouldn't be greater than 60 characters.", + "@savedSnippetTitleValidationErrorTooLong": { + "description": "Validation error message when the title of the saved snippet is too long." + }, + "savedSnippetContentValidationErrorEmpty": "Content cannot be empty.", + "@savedSnippetContentValidationErrorEmpty": { + "description": "Validation error message when the content of the saved snippet is empty." + }, + "savedSnippetContentValidationErrorTooLong": "Content length shouldn't be greater than 10000 characters.", + "@savedSnippetContentValidationErrorTooLong": { + "description": "Validation error message when the content of the saved snippet is too long." + }, "composeBoxGenericContentHint": "Type a message", "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." @@ -367,6 +459,10 @@ "destination": {"type": "String", "example": "#channel name > topic name"} } }, + "composeBoxEditMessageHint": "Message content", + "@composeBoxEditMessageHint": { + "description": "Hint text for content input when editing a message." + }, "composeBoxSendTooltip": "Send", "@composeBoxSendTooltip": { "description": "Tooltip for send button in compose box." @@ -561,7 +657,7 @@ "url": {"type": "String", "example": "http://chat.example.com/"} } }, - "errorInvalidResponse": "The server sent an invalid response", + "errorInvalidResponse": "The server sent an invalid response.", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." }, @@ -591,7 +687,7 @@ "httpStatus": {"type": "int", "example": "500"} } }, - "errorVideoPlayerFailed": "Unable to play the video", + "errorVideoPlayerFailed": "Unable to play the video.", "@errorVideoPlayerFailed": { "description": "Error message when a video fails to play." }, diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0479b0428f..a5174db4f7 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -37,6 +37,13 @@ sealed class Event { case 'update': return RealmUserUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'saved_snippets': + switch (json['op'] as String) { + case 'add': return SavedSnippetsAddEvent.fromJson(json); + case 'update': return SavedSnippetsUpdateEvent.fromJson(json); + case 'remove': return SavedSnippetsRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'stream': switch (json['op'] as String) { case 'create': return ChannelCreateEvent.fromJson(json); @@ -336,6 +343,71 @@ class RealmUserUpdateEvent extends RealmUserEvent { Map toJson() => _$RealmUserUpdateEventToJson(this); } +/// A Zulip event of type `saved_snippets`. +/// +/// The corresponding API docs are in several places for +/// different values of `op`; see subclasses. +sealed class SavedSnippetsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'saved_snippets'; + + String get op; + + SavedSnippetsEvent({required super.id}); +} + +/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsAddEvent extends SavedSnippetsEvent { + @override + String get op => 'add'; + + final SavedSnippet savedSnippet; + + SavedSnippetsAddEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsAddEvent.fromJson(Map json) => + _$SavedSnippetsAddEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsAddEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `update`: https://zulip.com/api/get-events#saved_snippets-update +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsUpdateEvent extends SavedSnippetsEvent { + @override + String get op => 'update'; + + final SavedSnippet savedSnippet; + + SavedSnippetsUpdateEvent({required super.id, required this.savedSnippet}); + + factory SavedSnippetsUpdateEvent.fromJson(Map json) => + _$SavedSnippetsUpdateEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsUpdateEventToJson(this); +} + +/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippetsRemoveEvent extends SavedSnippetsEvent { + @override + String get op => 'remove'; + + final int savedSnippetId; + + SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId}); + + factory SavedSnippetsRemoveEvent.fromJson(Map json) => + _$SavedSnippetsRemoveEventFromJson(json); + + @override + Map toJson() => _$SavedSnippetsRemoveEventToJson(this); +} + /// A Zulip event of type `stream`. /// /// The corresponding API docs are in several places for diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 35206d77b9..94fe288150 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -203,6 +203,55 @@ Json? _$JsonConverterToJson( Json? Function(Value value) toJson, ) => value == null ? null : toJson(value); +SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson( + Map json, +) => SavedSnippetsAddEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsAddEventToJson( + SavedSnippetsAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsUpdateEvent _$SavedSnippetsUpdateEventFromJson( + Map json, +) => SavedSnippetsUpdateEvent( + id: (json['id'] as num).toInt(), + savedSnippet: SavedSnippet.fromJson( + json['saved_snippet'] as Map, + ), +); + +Map _$SavedSnippetsUpdateEventToJson( + SavedSnippetsUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet': instance.savedSnippet, +}; + +SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson( + Map json, +) => SavedSnippetsRemoveEvent( + id: (json['id'] as num).toInt(), + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$SavedSnippetsRemoveEventToJson( + SavedSnippetsRemoveEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'saved_snippet_id': instance.savedSnippetId, +}; + ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) => ChannelCreateEvent( id: (json['id'] as num).toInt(), diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 054230a256..f4cc2fe5fc 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -48,6 +48,8 @@ class InitialSnapshot { final List recentPrivateConversations; + final List? savedSnippets; // TODO(server-10) + final List subscriptions; final UnreadMessagesSnapshot unreadMsgs; @@ -132,6 +134,7 @@ class InitialSnapshot { required this.serverTypingStartedWaitPeriodMilliseconds, required this.realmEmoji, required this.recentPrivateConversations, + required this.savedSnippets, required this.subscriptions, required this.unreadMsgs, required this.streams, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 570d7c2bba..36afb0a39f 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -45,6 +45,10 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['recent_private_conversations'] as List) .map((e) => RecentDmConversation.fromJson(e as Map)) .toList(), + savedSnippets: + (json['saved_snippets'] as List?) + ?.map((e) => SavedSnippet.fromJson(e as Map)) + .toList(), subscriptions: (json['subscriptions'] as List) .map((e) => Subscription.fromJson(e as Map)) @@ -128,6 +132,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => instance.serverTypingStartedWaitPeriodMilliseconds, 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, + 'saved_snippets': instance.savedSnippets, 'subscriptions': instance.subscriptions, 'unread_msgs': instance.unreadMsgs, 'streams': instance.streams, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index a2874c4c44..131a51991b 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -311,6 +311,30 @@ enum UserRole{ } } +/// An item in `saved_snippets` from the initial snapshot. +/// +/// For docs, search for "saved_snippets:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class SavedSnippet { + SavedSnippet({ + required this.id, + required this.title, + required this.content, + required this.dateCreated, + }); + + final int id; + final String title; + final String content; + final int dateCreated; + + factory SavedSnippet.fromJson(Map json) => + _$SavedSnippetFromJson(json); + + Map toJson() => _$SavedSnippetToJson(this); +} + /// As in `streams` in the initial snapshot. /// /// Not called `Stream` because dart:async uses that name. diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index cddf78beb0..67fc606031 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -162,6 +162,21 @@ Map _$ProfileFieldUserDataToJson( 'rendered_value': instance.renderedValue, }; +SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + dateCreated: (json['date_created'] as num).toInt(), +); + +Map _$SavedSnippetToJson(SavedSnippet instance) => + { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'date_created': instance.dateCreated, + }; + ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart new file mode 100644 index 0000000000..b0556b2bf5 --- /dev/null +++ b/lib/api/route/saved_snippets.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../core.dart'; + +part 'saved_snippets.g.dart'; + +/// https://zulip.com/api/create-saved-snippet +Future createSavedSnippet(ApiConnection connection, { + required String title, + required String content, +}) { + return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', { + 'title': RawParameter(title), + 'content': RawParameter(content), + }); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class CreateSavedSnippetResult { + final int savedSnippetId; + + CreateSavedSnippetResult({ + required this.savedSnippetId, + }); + + factory CreateSavedSnippetResult.fromJson(Map json) => + _$CreateSavedSnippetResultFromJson(json); + + Map toJson() => _$CreateSavedSnippetResultToJson(this); +} diff --git a/lib/api/route/saved_snippets.g.dart b/lib/api/route/saved_snippets.g.dart new file mode 100644 index 0000000000..aeb3c2a6c5 --- /dev/null +++ b/lib/api/route/saved_snippets.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'saved_snippets.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateSavedSnippetResult _$CreateSavedSnippetResultFromJson( + Map json, +) => CreateSavedSnippetResult( + savedSnippetId: (json['saved_snippet_id'] as num).toInt(), +); + +Map _$CreateSavedSnippetResultToJson( + CreateSavedSnippetResult instance, +) => {'saved_snippet_id': instance.savedSnippetId}; diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6181c7b39b..3cb7c93b11 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -326,6 +326,12 @@ abstract class ZulipLocalizations { /// **'Unstar message'** String get actionSheetOptionUnstarMessage; + /// Label for the 'Edit message' button in the message action sheet. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get actionSheetOptionEditMessage; + /// Option to mark a specific topic as read in the action sheet. /// /// In en, this message translates to: @@ -359,7 +365,7 @@ abstract class ZulipLocalizations { /// Error message when the source of a message could not be fetched. /// /// In en, this message translates to: - /// **'Could not fetch message source'** + /// **'Could not fetch message source.'** String get errorCouldNotFetchMessageSource; /// Error message when copying the text of a message to the user's system clipboard failed. @@ -414,6 +420,12 @@ abstract class ZulipLocalizations { /// **'Message not sent'** String get errorMessageNotSent; + /// Error message for compose box when a message edit could not be saved. + /// + /// In en, this message translates to: + /// **'Message not saved'** + String get errorMessageEditNotSaved; + /// Error message when the app could not connect to the server. /// /// In en, this message translates to: @@ -526,6 +538,12 @@ abstract class ZulipLocalizations { /// **'Failed to unstar message'** String get errorUnstarMessageFailedTitle; + /// Error title when an exception prevented us from opening the compose box for editing a message. + /// + /// In en, this message translates to: + /// **'Could not edit message'** + String get errorCouldNotEditMessageTitle; + /// Success message after copy link action completed. /// /// In en, this message translates to: @@ -556,6 +574,54 @@ abstract class ZulipLocalizations { /// **'You do not have permission to post in this channel.'** String get errorBannerCannotPostInChannelLabel; + /// Label text for the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Edit message'** + String get composeBoxBannerLabelEditMessage; + + /// Label text for the 'Cancel' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get composeBoxBannerButtonCancel; + + /// Label text for the 'Save' button in the compose-box banner when you are editing a message. + /// + /// In en, this message translates to: + /// **'Save'** + String get composeBoxBannerButtonSave; + + /// Text on a message in the message list saying that a message edit request is processing. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'SAVING EDIT…'** + String get savingMessageEditLabel; + + /// Text on a message in the message list saying that a message edit request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.) + /// + /// In en, this message translates to: + /// **'EDIT NOT SAVED'** + String get savingMessageEditFailedLabel; + + /// Title for a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard the message you’re writing?'** + String get discardDraftConfirmationDialogTitle; + + /// Message for a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'When you edit a message, the content that was previously in the compose box is discarded.'** + String get discardDraftConfirmationDialogMessage; + + /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. + /// + /// In en, this message translates to: + /// **'Discard'** + String get discardDraftConfirmationDialogConfirmButton; + /// Tooltip for compose box icon to attach a file to the message. /// /// In en, this message translates to: @@ -574,6 +640,78 @@ abstract class ZulipLocalizations { /// **'Take a photo'** String get composeBoxAttachFromCameraTooltip; + /// Tooltip for compose box icon to show a list of saved snippets. + /// + /// In en, this message translates to: + /// **'Show saved snippets'** + String get composeBoxShowSavedSnippetsTooltip; + + /// Text to show on the saved snippets bottom sheet when there are no saved snippets. + /// + /// In en, this message translates to: + /// **'No saved snippets'** + String get noSavedSnippets; + + /// Title for the bottom sheet to display saved snippets. + /// + /// In en, this message translates to: + /// **'Saved snippets'** + String get savedSnippetsTitle; + + /// Label for adding a new saved snippet. + /// + /// In en, this message translates to: + /// **'New'** + String get newSavedSnippetButton; + + /// Title for the bottom sheet to add a new saved snippet. + /// + /// In en, this message translates to: + /// **'New snippet'** + String get newSavedSnippetTitle; + + /// Hint text for the title input when adding a new saved snippet. + /// + /// In en, this message translates to: + /// **'Title'** + String get newSavedSnippetTitleHint; + + /// Hint text for the content input when adding a new saved snippet. + /// + /// In en, this message translates to: + /// **'Content'** + String get newSavedSnippetContentHint; + + /// Error title when the saved snippet failed to be created. + /// + /// In en, this message translates to: + /// **'Failed to create saved snippet'** + String get errorFailedToCreateSavedSnippetTitle; + + /// Validation error message when the title of the saved snippet is empty. + /// + /// In en, this message translates to: + /// **'Title cannot be empty.'** + String get savedSnippetTitleValidationErrorEmpty; + + /// Validation error message when the title of the saved snippet is too long. + /// + /// In en, this message translates to: + /// **'Title length shouldn\'t be greater than 60 characters.'** + String get savedSnippetTitleValidationErrorTooLong; + + /// Validation error message when the content of the saved snippet is empty. + /// + /// In en, this message translates to: + /// **'Content cannot be empty.'** + String get savedSnippetContentValidationErrorEmpty; + + /// Validation error message when the content of the saved snippet is too long. + /// + /// In en, this message translates to: + /// **'Content length shouldn\'t be greater than 10000 characters.'** + String get savedSnippetContentValidationErrorTooLong; + /// Hint text for content input when sending a message. /// /// In en, this message translates to: @@ -604,6 +742,12 @@ abstract class ZulipLocalizations { /// **'Message {destination}'** String composeBoxChannelContentHint(String destination); + /// Hint text for content input when editing a message. + /// + /// In en, this message translates to: + /// **'Message content'** + String get composeBoxEditMessageHint; + /// Tooltip for send button in compose box. /// /// In en, this message translates to: @@ -863,7 +1007,7 @@ abstract class ZulipLocalizations { /// Error message when an API call returned an invalid response. /// /// In en, this message translates to: - /// **'The server sent an invalid response'** + /// **'The server sent an invalid response.'** String get errorInvalidResponse; /// Error message when a network request fails. @@ -893,7 +1037,7 @@ abstract class ZulipLocalizations { /// Error message when a video fails to play. /// /// In en, this message translates to: - /// **'Unable to play the video'** + /// **'Unable to play the video.'** String get errorVideoPlayerFailed; /// Error message when URL is empty diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index f26b9d017c..f5c7bde6a8 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +144,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +194,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,32 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -288,6 +323,46 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -307,6 +382,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Message $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Send'; @@ -459,7 +537,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +558,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index bfb14645d5..0ab45cdce8 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +144,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +194,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,32 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -288,6 +323,46 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -307,6 +382,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Message $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Send'; @@ -459,7 +537,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +558,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 35088555e2..0d3412987b 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +144,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +194,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,32 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -288,6 +323,46 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -307,6 +382,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Message $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Send'; @@ -459,7 +537,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +558,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index ae79d99ea9..7d4a183731 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -122,6 +122,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Unstar message'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -141,7 +144,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorCouldNotFetchMessageSource => - 'Could not fetch message source'; + 'Could not fetch message source.'; @override String get errorCopyingFailed => 'Copying failed'; @@ -191,6 +194,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorMessageNotSent => 'Message not sent'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Failed to connect to server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,32 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -288,6 +323,46 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -307,6 +382,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Message $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Send'; @@ -459,7 +537,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorInvalidResponse => 'The server sent an invalid response'; + String get errorInvalidResponse => 'The server sent an invalid response.'; @override String get errorNetworkRequestFailed => 'Network request failed'; @@ -480,7 +558,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { } @override - String get errorVideoPlayerFailed => 'Unable to play the video'; + String get errorVideoPlayerFailed => 'Unable to play the video.'; @override String get serverUrlValidationErrorEmpty => 'Please enter a URL.'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 15f61bd882..b7113e1bcb 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odbierz gwiazdkę'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Oznacz wątek jako przeczytany'; @@ -197,6 +200,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorMessageNotSent => 'Nie wysłano wiadomości'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Nie udało się połączyć z serwerem:\n$url'; @@ -269,6 +275,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Odebranie gwiazdki bez powodzenia'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Skopiowano odnośnik'; @@ -286,6 +295,32 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'Nie masz uprawnień do dodawania wpisów w tym kanale.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Dołącz pliki'; @@ -295,6 +330,46 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Zrób zdjęcie'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Wpisz wiadomość'; @@ -314,6 +389,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Wiadomość do $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Wyślij'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index e4248179e2..8addcabc99 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -127,6 +127,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Снять отметку с сообщения'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Отметить тему как прочитанную'; @@ -197,6 +200,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorMessageNotSent => 'Сообщение не отправлено'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Не удалось подключиться к серверу:\n$url'; @@ -270,6 +276,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Не удалось снять отметку с сообщения'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Ссылка скопирована'; @@ -287,6 +296,32 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'У вас нет права писать в этом канале.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Прикрепить файлы'; @@ -296,6 +331,46 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Сделать снимок'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Ввести сообщение'; @@ -315,6 +390,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Сообщение для $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Отправить'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index baded578ba..b222855fc4 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -123,6 +123,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnstarMessage => 'Odhviezdičkovať správu'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; @@ -192,6 +195,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Správa nebola odoslaná'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Nepodarilo sa pripojiť na server:\n$url'; @@ -262,6 +268,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorUnstarMessageFailedTitle => 'Failed to unstar message'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Link copied'; @@ -279,6 +288,32 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'You do not have permission to post in this channel.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Attach files'; @@ -288,6 +323,46 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -307,6 +382,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Message $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Send'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 50b1029dd7..afac1af0dd 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -128,6 +128,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get actionSheetOptionUnstarMessage => 'Зняти позначку зірочки з повідомлення'; + @override + String get actionSheetOptionEditMessage => 'Edit message'; + @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; @@ -197,6 +200,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorMessageNotSent => 'Повідомлення не надіслано'; + @override + String get errorMessageEditNotSaved => 'Message not saved'; + @override String errorLoginCouldNotConnect(String url) { return 'Не вдалося підключитися до сервера:\n$url'; @@ -270,6 +276,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorUnstarMessageFailedTitle => 'Не вдалося зняти позначку зірочки з повідомлення'; + @override + String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override String get successLinkCopied => 'Посилання скопійовано'; @@ -288,6 +297,32 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorBannerCannotPostInChannelLabel => 'Ви не маєте дозволу на публікацію в цьому каналі.'; + @override + String get composeBoxBannerLabelEditMessage => 'Edit message'; + + @override + String get composeBoxBannerButtonCancel => 'Cancel'; + + @override + String get composeBoxBannerButtonSave => 'Save'; + + @override + String get savingMessageEditLabel => 'SAVING EDIT…'; + + @override + String get savingMessageEditFailedLabel => 'EDIT NOT SAVED'; + + @override + String get discardDraftConfirmationDialogTitle => + 'Discard the message you’re writing?'; + + @override + String get discardDraftConfirmationDialogMessage => + 'When you edit a message, the content that was previously in the compose box is discarded.'; + + @override + String get discardDraftConfirmationDialogConfirmButton => 'Discard'; + @override String get composeBoxAttachFilesTooltip => 'Прикріпити файли'; @@ -297,6 +332,46 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Зробити фото'; + @override + String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets'; + + @override + String get noSavedSnippets => 'No saved snippets'; + + @override + String get savedSnippetsTitle => 'Saved snippets'; + + @override + String get newSavedSnippetButton => 'New'; + + @override + String get newSavedSnippetTitle => 'New snippet'; + + @override + String get newSavedSnippetTitleHint => 'Title'; + + @override + String get newSavedSnippetContentHint => 'Content'; + + @override + String get errorFailedToCreateSavedSnippetTitle => + 'Failed to create saved snippet'; + + @override + String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.'; + + @override + String get savedSnippetTitleValidationErrorTooLong => + 'Title length shouldn\'t be greater than 60 characters.'; + + @override + String get savedSnippetContentValidationErrorEmpty => + 'Content cannot be empty.'; + + @override + String get savedSnippetContentValidationErrorTooLong => + 'Content length shouldn\'t be greater than 10000 characters.'; + @override String get composeBoxGenericContentHint => 'Ввести повідомлення'; @@ -316,6 +391,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Надіслати повідомлення $destination'; } + @override + String get composeBoxEditMessageHint => 'Message content'; + @override String get composeBoxSendTooltip => 'Надіслати'; diff --git a/lib/log.dart b/lib/log.dart index c85d228263..64cb409a0e 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -80,6 +80,7 @@ typedef ReportErrorCallback = void Function( /// /// If `details` is non-null, the [SnackBar] will contain a button that would /// open a dialog containing the error details. +/// Prose in `details` should have final punctuation. // This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` // from importing widget code, because the file is a dependency for the rest of // the app. @@ -91,6 +92,8 @@ ReportErrorCancellablyCallback reportErrorToUserBriefly = defaultReportErrorToUs /// as the body. If called before the app's widget tree is ready /// (see [ZulipApp.ready]), then we give up on showing the message to the user, /// and just log the message to the console. +/// +/// Prose in `message` should have final punctuation. // This gets set in [ZulipApp]. We need this indirection to keep `lib/log.dart` // from importing widget code, because the file is a dependency for the rest of // the app. diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart new file mode 100644 index 0000000000..66b50781dd --- /dev/null +++ b/lib/model/saved_snippet.dart @@ -0,0 +1,35 @@ +import 'package:collection/collection.dart'; + +import '../api/model/events.dart'; +import '../api/model/model.dart'; + +mixin SavedSnippetStore { + Map get savedSnippets; +} + +class SavedSnippetStoreImpl with SavedSnippetStore { + SavedSnippetStoreImpl({required Iterable savedSnippets}) + : _savedSnippets = { + for (final savedSnippet in savedSnippets) + savedSnippet.id: savedSnippet, + }; + + @override + late Map savedSnippets = UnmodifiableMapView(_savedSnippets); + final Map _savedSnippets; + + void handleSavedSnippetsEvent(SavedSnippetsEvent event) { + switch (event) { + case SavedSnippetsAddEvent(:final savedSnippet): + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsUpdateEvent(:final savedSnippet): + assert(_savedSnippets[savedSnippet.id]!.dateCreated + == savedSnippet.dateCreated); // TODO(log) + _savedSnippets[savedSnippet.id] = savedSnippet; + + case SavedSnippetsRemoveEvent(:final savedSnippetId): + _savedSnippets.remove(savedSnippetId); + } + } +} diff --git a/lib/model/store.dart b/lib/model/store.dart index f7a4e0ca4e..97d3af07c1 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -29,6 +29,7 @@ import 'message_list.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; +import 'saved_snippet.dart'; import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; @@ -431,7 +432,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { /// This class does not attempt to poll an event queue /// to keep the data up to date. For that behavior, see /// [UpdateMachine]. -class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, UserStore, ChannelStore, MessageStore { +class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore { /// Construct a store for the user's data, starting from the given snapshot. /// /// The global store must already have been updated with @@ -485,6 +486,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor emailAddressVisibility: initialSnapshot.emailAddressVisibility, emoji: EmojiStoreImpl( core: core, allRealmEmoji: initialSnapshot.realmEmoji), + savedSnippets: SavedSnippetStoreImpl( + savedSnippets: initialSnapshot.savedSnippets ?? []), userSettings: initialSnapshot.userSettings, typingNotifier: TypingNotifier( core: core, @@ -523,6 +526,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.customProfileFields, required this.emailAddressVisibility, required EmojiStoreImpl emoji, + required SavedSnippetStoreImpl savedSnippets, required this.userSettings, required this.typingNotifier, required UserStoreImpl users, @@ -534,6 +538,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor required this.recentSenders, }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName, _emoji = emoji, + _savedSnippets = savedSnippets, _users = users, _channels = channels, _messages = messages; @@ -619,6 +624,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the self-account on the realm. + @override + Map get savedSnippets => _savedSnippets.savedSnippets; + final SavedSnippetStoreImpl _savedSnippets; + final UserSettings? userSettings; // TODO(server-5) final TypingNotifier typingNotifier; @@ -868,6 +877,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor autocompleteViewManager.handleRealmUserUpdateEvent(event); notifyListeners(); + case SavedSnippetsEvent(): + assert(debugLog('server event: saved_snippets/${event.op}')); + _savedSnippets.handleSavedSnippetsEvent(event); + notifyListeners(); + case ChannelEvent(): assert(debugLog("server event: stream/${event.op}")); _channels.handleChannelEvent(event); diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 4beea3db66..7934e1726c 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -12,6 +12,7 @@ import '../api/model/model.dart'; import '../api/route/channels.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; @@ -35,10 +36,6 @@ void _showActionSheet( }) { showModalBottomSheet( context: context, - // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect - // on my iPhone 13 Pro but is marked as "much slower": - // https://api.flutter.dev/flutter/dart-ui/Clip.html - clipBehavior: Clip.antiAlias, useSafeArea: true, isScrollControlled: true, builder: (BuildContext _) { @@ -550,7 +547,12 @@ class MarkTopicAsReadButton extends ActionSheetMenuItemButton { /// Show a sheet of actions you can take on a message in the message list. /// /// Must have a [MessageListPage] ancestor. -void showMessageActionSheet({required BuildContext context, required Message message}) { +void showMessageActionSheet({ + required BuildContext context, + required Message message, + required bool messageListHasComposeBox, + required bool editMessageInProgress, +}) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); @@ -566,6 +568,18 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final now = ZulipBinding.instance.utcNow().millisecondsSinceEpoch ~/ 1000; + final editLimit = store.realmMessageContentEditLimitSeconds; + final outsideEditLimit = + editLimit != null + && editLimit != 0 // TODO(server-6) remove (pre-FL 138, 0 represents no limit) + && now - message.timestamp > editLimit; + final showEditButton = message.senderId == store.selfUserId + && messageListHasComposeBox + && store.realmAllowMessageEditing + && !outsideEditLimit + && !editMessageInProgress; + final optionButtons = [ ReactionButtons(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), @@ -576,6 +590,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), + if (showEditButton) + EditButton(message: message, pageContext: pageContext), ]; _showActionSheet(pageContext, optionButtons: optionButtons); @@ -955,3 +971,37 @@ class ShareButton 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) => + zulipLocalizations.actionSheetOptionEditMessage; + + @override void onPressed() async { + final store = PerAccountStoreWidget.of(pageContext); + final zulipLocalizations = ZulipLocalizations.of(pageContext); + + final composeBoxState = findMessageListPage().composeBoxState; + if (composeBoxState == null) { + throw StateError('Compose box unexpectedly absent when edit-message button pressed'); + } + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + if (editMessageErrorStatus != null) { + throw StateError('Message edit already in progress when edit-message button pressed'); + } + + final rawContent = await ZulipAction.fetchRawContentWithFeedback( + context: pageContext, + messageId: message.id, + errorDialogTitle: zulipLocalizations.errorCouldNotEditMessageTitle, + ); + if (rawContent == null) return; + + composeBoxState.startEditInteraction(messageId: message.id, originalRawContent: rawContent); + } +} diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index bfd91d6afd..0be32572fd 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -9,16 +9,19 @@ import 'package:mime/mime.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; +import '../api/route/saved_snippets.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import '../model/store.dart'; import 'autocomplete.dart'; +import 'button.dart'; import 'color.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'saved_snippet.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -223,13 +226,30 @@ enum ContentValidationError { return zulipLocalizations.contentValidationErrorUploadInProgress; } } + + /// Convert this into message suitable to use in [SavedSnippetComposeBox]. + String messageForSavedSnippet(ZulipLocalizations zulipLocalizations) { + switch (this) { + case ContentValidationError.empty: + return zulipLocalizations.savedSnippetContentValidationErrorEmpty; + case ContentValidationError.tooLong: + return zulipLocalizations.savedSnippetContentValidationErrorTooLong; + case ContentValidationError.quoteAndReplyInProgress: + case ContentValidationError.uploadInProgress: + return message(zulipLocalizations); + } + } } class ComposeContentController extends ComposeController { - ComposeContentController() { + ComposeContentController({this.skipValidationErrorEmpty = false}) { _update(); } + /// Whether to skip producing [ContentValidationError.empty], + /// which is desired for the edit-message compose box. + final bool skipValidationErrorEmpty; + // TODO(#1237) use `max_message_length` instead of hardcoded limit @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; @@ -376,7 +396,7 @@ class ComposeContentController extends ComposeController @override List _computeValidationErrors() { return [ - if (textNormalized.isEmpty) + if (!skipValidationErrorEmpty && textNormalized.isEmpty) ContentValidationError.empty, if ( @@ -394,6 +414,46 @@ class ComposeContentController extends ComposeController } } +enum SavedSnippetTitleValidationError { + empty, + tooLong; + + String message(ZulipLocalizations zulipLocalizations) { + return switch (this) { + SavedSnippetTitleValidationError.empty => zulipLocalizations.savedSnippetTitleValidationErrorEmpty, + SavedSnippetTitleValidationError.tooLong => zulipLocalizations.savedSnippetTitleValidationErrorTooLong, + }; + } +} + +class ComposeSavedSnippetTitleController extends ComposeController { + ComposeSavedSnippetTitleController() { + _update(); + } + + // TODO find the right value for this + @override int get maxLengthUnicodeCodePoints => kMaxTopicLengthCodePoints; + + @override + String _computeTextNormalized() { + return text.trim(); + } + + @override + List _computeValidationErrors() { + return [ + if (textNormalized.isEmpty) + SavedSnippetTitleValidationError.empty, + + if ( + _lengthUnicodeCodePointsIfLong != null + && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints + ) + SavedSnippetTitleValidationError.tooLong, + ]; + } +} + class _TypingNotifier extends StatefulWidget { const _TypingNotifier({ required this.destination, @@ -492,8 +552,12 @@ class _ContentInput extends StatelessWidget { required this.controller, required this.hintText, }); + /// The narrow used for autocomplete. + /// + /// If `null`, autocomplete is disabled. + // TODO support autocomplete without a narrow + final Narrow? narrow; - final Narrow narrow; final ComposeBoxController controller; final String hintText; @@ -526,48 +590,54 @@ class _ContentInput extends StatelessWidget { Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final inputWidget = ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight(context)), + // This [ClipRect] replaces the [TextField] clipping we disable below. + child: ClipRect( + child: InsetShadowBox( + top: _verticalPadding, bottom: _verticalPadding, + color: designVariables.composeBoxBg, + child: TextField( + controller: controller.content, + focusNode: controller.contentFocusNode, + // Let the content show through the `contentPadding` so that + // our [InsetShadowBox] can fade it smoothly there. + clipBehavior: Clip.none, + style: TextStyle( + fontSize: _fontSize, + height: _lineHeightRatio, + color: designVariables.textInput), + // From the spec at + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev + // > Compose box has the height to fit 2 lines. This is [done] to + // > have a bigger hit area for the user to start the input. […] + minLines: 2, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + // This padding ensures that the user can always scroll long + // content entirely out of the top or bottom shadow if desired. + // With this and the `minLines: 2` above, an empty content input + // gets 60px vertical distance (with no text-size scaling) + // between the top of the top shadow and the bottom of the + // bottom shadow. That's a bit more than the 54px given in the + // Figma, and we can revisit if needed, but it's tricky to get + // that 54px distance while also making the scrolling work like + // this and offering two lines of touchable area. + contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), + hintText: hintText, + hintStyle: TextStyle( + color: designVariables.textInput.withFadedAlpha(0.5))))))); + + if (narrow == null) { + return inputWidget; + } + return ComposeAutocomplete( - narrow: narrow, + narrow: narrow!, controller: controller.content, focusNode: controller.contentFocusNode, - fieldViewBuilder: (context) => ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight(context)), - // This [ClipRect] replaces the [TextField] clipping we disable below. - child: ClipRect( - child: InsetShadowBox( - top: _verticalPadding, bottom: _verticalPadding, - color: designVariables.composeBoxBg, - child: TextField( - controller: controller.content, - focusNode: controller.contentFocusNode, - // Let the content show through the `contentPadding` so that - // our [InsetShadowBox] can fade it smoothly there. - clipBehavior: Clip.none, - style: TextStyle( - fontSize: _fontSize, - height: _lineHeightRatio, - color: designVariables.textInput), - // From the spec at - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev - // > Compose box has the height to fit 2 lines. This is [done] to - // > have a bigger hit area for the user to start the input. […] - minLines: 2, - maxLines: null, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - // This padding ensures that the user can always scroll long - // content entirely out of the top or bottom shadow if desired. - // With this and the `minLines: 2` above, an empty content input - // gets 60px vertical distance (with no text-size scaling) - // between the top of the top shadow and the bottom of the - // bottom shadow. That's a bit more than the 54px given in the - // Figma, and we can revisit if needed, but it's tricky to get - // that 54px distance while also making the scrolling work like - // this and offering two lines of touchable area. - contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), - hintText: hintText, - hintStyle: TextStyle( - color: designVariables.textInput.withFadedAlpha(0.5)))))))); + fieldViewBuilder: (context) => inputWidget); } } @@ -819,6 +889,38 @@ class _TopicInputState extends State<_TopicInput> { } } +class _SavedSnippetTitleInput extends StatelessWidget { + const _SavedSnippetTitleInput({required this.controller}); + + final SavedSnippetComposeBoxController controller; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final titleTextStyle = TextStyle( + fontSize: 20, + height: 22 / 20, + color: designVariables.textInput.withFadedAlpha(0.9), + ).merge(weightVariableTextStyle(context, wght: 600)); + + return Container( + padding: const EdgeInsets.only(top: 10, bottom: 9), + decoration: BoxDecoration(border: Border(bottom: BorderSide( + width: 1, + color: designVariables.foreground.withFadedAlpha(0.2)))), + child: TextField( + controller: controller.title, + focusNode: controller.titleFocusNode, + textInputAction: TextInputAction.next, + style: titleTextStyle, + decoration: InputDecoration( + hintText: zulipLocalizations.newSavedSnippetTitleHint, + hintStyle: titleTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5))))); + } +} + class _FixedDestinationContentInput extends StatelessWidget { const _FixedDestinationContentInput({ required this.narrow, @@ -868,6 +970,41 @@ class _FixedDestinationContentInput extends StatelessWidget { } } +class _EditMessageContentInput extends StatelessWidget { + const _EditMessageContentInput({ + required this.narrow, + required this.controller, + }); + + final Narrow narrow; + final EditMessageComposeBoxController controller; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return _ContentInput( + narrow: narrow, + controller: controller, + hintText: zulipLocalizations.composeBoxEditMessageHint); + } +} + +class _SavedSnippetContentInput extends StatelessWidget { + const _SavedSnippetContentInput({required this.controller}); + + final SavedSnippetComposeBoxController controller; + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return _ContentInput( + narrow: null, + controller: controller, + hintText: zulipLocalizations.newSavedSnippetContentHint); + } +} + + /// Data on a file to be uploaded, from any source. /// /// A convenience class to represent data from the generic file picker, @@ -954,14 +1091,32 @@ Future _uploadFiles({ } } -abstract class _AttachUploadsButton extends StatelessWidget { - const _AttachUploadsButton({required this.controller}); +abstract class _ComposeButton extends StatelessWidget { + const _ComposeButton({required this.controller}); final ComposeBoxController controller; IconData get icon; String tooltip(ZulipLocalizations zulipLocalizations); + void handlePress(BuildContext context); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return SizedBox( + width: _composeButtonSize, + child: IconButton( + icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), + tooltip: tooltip(zulipLocalizations), + onPressed: () => handlePress(context))); + } +} + +abstract class _AttachUploadsButton extends _ComposeButton { + const _AttachUploadsButton({required super.controller}); + /// Request files from the user, in the way specific to this upload type. /// /// Subclasses should manage the interaction completely, e.g., by catching and @@ -971,7 +1126,8 @@ abstract class _AttachUploadsButton extends StatelessWidget { /// return an empty [Iterable] after showing user feedback as appropriate. Future> getFiles(BuildContext context); - void _handlePress(BuildContext context) async { + @override + void handlePress(BuildContext context) async { final files = await getFiles(context); if (files.isEmpty) { return; // Nothing to do (getFiles handles user feedback) @@ -989,18 +1145,6 @@ abstract class _AttachUploadsButton extends StatelessWidget { contentFocusNode: controller.contentFocusNode, files: files); } - - @override - Widget build(BuildContext context) { - final designVariables = DesignVariables.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - return SizedBox( - width: _composeButtonSize, - child: IconButton( - icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), - tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); - } } Future> _getFilePickerFiles(BuildContext context, FileType type) async { @@ -1162,6 +1306,23 @@ class _AttachFromCameraButton extends _AttachUploadsButton { } } +class _ShowSavedSnippetsButton extends _ComposeButton { + const _ShowSavedSnippetsButton({required super.controller}) + : assert(controller is! SavedSnippetComposeBoxController); + + @override + void handlePress(BuildContext context) { + showSavedSnippetPickerSheet(context: context, controller: controller); + } + + @override + IconData get icon => ZulipIcons.message_square_text; + + @override + String tooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxShowSavedSnippetsTooltip; +} + class _SendButton extends StatefulWidget { const _SendButton({required this.controller, required this.getDestination}); @@ -1298,6 +1459,98 @@ class _SendButtonState extends State<_SendButton> { } } +class _SavedSnipppetSaveButton extends StatefulWidget { + const _SavedSnipppetSaveButton({required this.controller}); + + final SavedSnippetComposeBoxController controller; + + @override + State<_SavedSnipppetSaveButton> createState() => _SavedSnipppetSaveButtonState(); +} + +class _SavedSnipppetSaveButtonState extends State<_SavedSnipppetSaveButton> { + @override + void initState() { + super.initState(); + widget.controller.title.hasValidationErrors.addListener(_hasErrorsChanged); + widget.controller.content.hasValidationErrors.addListener(_hasErrorsChanged); + } + + @override + void didUpdateWidget(covariant _SavedSnipppetSaveButton oldWidget) { + super.didUpdateWidget(oldWidget); + + final controller = widget.controller; + final oldController = oldWidget.controller; + if (controller == oldController) return; + + oldController.title.hasValidationErrors.removeListener(_hasErrorsChanged); + controller.title.hasValidationErrors.addListener(_hasErrorsChanged); + oldController.content.hasValidationErrors.removeListener(_hasErrorsChanged); + controller.content.hasValidationErrors.addListener(_hasErrorsChanged); + } + + @override + void dispose() { + widget.controller.title.hasValidationErrors.removeListener(_hasErrorsChanged); + widget.controller.content.hasValidationErrors.removeListener(_hasErrorsChanged); + super.dispose(); + } + + void _hasErrorsChanged() { + setState(() { + // The actual state lives in widget.controller. + }); + } + + void _save() async { + if (widget.controller.title.hasValidationErrors.value + || widget.controller.content.hasValidationErrors.value) { + final zulipLocalizations = ZulipLocalizations.of(context); + final validationErrorMessages = [ + for (final error in widget.controller.title.validationErrors) + error.message(zulipLocalizations), + for (final error in widget.controller.content.validationErrors) + error.messageForSavedSnippet(zulipLocalizations), + ]; + showErrorDialog(context: context, + title: zulipLocalizations.errorFailedToCreateSavedSnippetTitle, + message: validationErrorMessages.join('\n\n')); + return; + } + + final store = PerAccountStoreWidget.of(context); + try { + // TODO(#1502) allow saving edits to an existing saved snippet as well + await createSavedSnippet(store.connection, + title: widget.controller.title.textNormalized, + content: widget.controller.content.textNormalized); + if (!mounted) return; + Navigator.pop(context); + } on ApiRequestException catch (e) { + if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.message, + }; + showErrorDialog(context: context, + title: zulipLocalizations.errorFailedToCreateSavedSnippetTitle, + message: message); + } + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return IconButton(onPressed: _save, + icon: Icon(ZulipIcons.check, color: + widget.controller.title.hasValidationErrors.value + || widget.controller.content.hasValidationErrors.value + ? designVariables.icon.withFadedAlpha(0.5) : designVariables.icon)); + } +} + class _ComposeBoxContainer extends StatelessWidget { const _ComposeBoxContainer({ required this.body, @@ -1361,14 +1614,11 @@ class _ComposeBoxContainer extends StatelessWidget { /// The text inputs, compose-button row, and send button for the compose box. abstract class _ComposeBoxBody extends StatelessWidget { - /// The narrow on view in the message list. - Narrow get narrow; - ComposeBoxController get controller; Widget? buildTopicInput(); Widget buildContentInput(); - Widget buildSendButton(); + Widget? buildSendButton(); @override Widget build(BuildContext context) { @@ -1394,13 +1644,18 @@ abstract class _ComposeBoxBody extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4))))); + final store = PerAccountStoreWidget.of(context); final composeButtons = [ _AttachFileButton(controller: controller), _AttachMediaButton(controller: controller), _AttachFromCameraButton(controller: controller), + if (store.zulipFeatureLevel >= 297 // TODO(server-10) simplify + && controller is! SavedSnippetComposeBoxController) + _ShowSavedSnippetsButton(controller: controller), ]; final topicInput = buildTopicInput(); + final sendButton = buildSendButton(); return Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1418,7 +1673,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(children: composeButtons), - buildSendButton(), + if (sendButton != null) sendButton, ]))), ]); } @@ -1431,7 +1686,6 @@ abstract class _ComposeBoxBody extends StatelessWidget { class _StreamComposeBoxBody extends _ComposeBoxBody { _StreamComposeBoxBody({required this.narrow, required this.controller}); - @override final ChannelNarrow narrow; @override @@ -1457,7 +1711,6 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { _FixedDestinationComposeBoxBody({required this.narrow, required this.controller}); - @override final SendableNarrow narrow; @override @@ -1476,6 +1729,40 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { ); } +/// A compose box for editing an already-sent message. +class _EditMessageComposeBoxBody extends _ComposeBoxBody { + _EditMessageComposeBoxBody({required this.narrow, required this.controller}); + + final Narrow narrow; + + @override + final EditMessageComposeBoxController controller; + + @override Widget? buildTopicInput() => null; + + @override Widget buildContentInput() => _EditMessageContentInput( + narrow: narrow, + controller: controller); + + @override Widget? buildSendButton() => null; +} + +class _SavedSnippetComposeBoxBody extends _ComposeBoxBody { + _SavedSnippetComposeBoxBody({required this.controller}); + + @override + final SavedSnippetComposeBoxController controller; + + @override Widget buildTopicInput() => _SavedSnippetTitleInput( + controller: controller); + + @override Widget buildContentInput() => _SavedSnippetContentInput( + controller: controller); + + @override Widget? buildSendButton() => _SavedSnipppetSaveButton( + controller: controller); +} + sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); @@ -1554,6 +1841,44 @@ class StreamComposeBoxController extends ComposeBoxController { class FixedDestinationComposeBoxController extends ComposeBoxController {} +class EditMessageComposeBoxController extends ComposeBoxController { + EditMessageComposeBoxController._({ + required this.messageId, + required this.originalRawContent, + }); + + factory EditMessageComposeBoxController.init({ + required int messageId, + required String originalRawContent, + }) { + return EditMessageComposeBoxController._( + messageId: messageId, + originalRawContent: originalRawContent + ) + ..content.value = TextEditingValue(text: originalRawContent); + } + + @override ComposeContentController get content => _content; + final _content = ComposeContentController(skipValidationErrorEmpty: true); + + final int messageId; + final String originalRawContent; +} + +class SavedSnippetComposeBoxController extends ComposeBoxController { + SavedSnippetComposeBoxController(); + + final title = ComposeSavedSnippetTitleController(); + final titleFocusNode = FocusNode(); + + @override + void dispose() { + super.dispose(); + title.dispose(); + titleFocusNode.dispose(); + } +} + abstract class _Banner extends StatelessWidget { const _Banner(); @@ -1644,6 +1969,60 @@ class _ErrorBanner extends _Banner { } } +class _EditMessageBanner extends _Banner { + const _EditMessageBanner({required this.composeBoxState}); + + final ComposeBoxState composeBoxState; + + @override + String getLabel(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxBannerLabelEditMessage; + + @override + Color getLabelColor(DesignVariables designVariables) => + designVariables.bannerTextIntInfo; + + @override + Color getBackgroundColor(DesignVariables designVariables) => + designVariables.bannerBgIntInfo; + + @override + Widget? buildTrailing(context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: [ + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonCancel, + onPressed: () { + composeBoxState.endEditInteraction(); + }), + // TODO(#1481) disabled appearance when there are validation errors + ZulipWebUiKitButton(label: zulipLocalizations.composeBoxBannerButtonSave, + attention: ZulipWebUiKitButtonAttention.high, + onPressed: () { + final controller = composeBoxState.controller; + if (controller is! EditMessageComposeBoxController) return; // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + + if (controller.content.hasValidationErrors.value) { + List validationErrorMessages = + controller.content.validationErrors.map((error) => + error.message(zulipLocalizations)).toList(); + showErrorDialog(context: context, + title: zulipLocalizations.errorMessageEditNotSaved, + message: validationErrorMessages.join('\n\n')); + return; + } + + store.editMessage( + messageId: controller.messageId, + originalRawContent: controller.originalRawContent, + newContent: controller.content.textNormalized); + composeBoxState.endEditInteraction(); + }), + ]); + } +} + /// The compose box. /// /// Takes the full screen width, covering the horizontal insets with its surface. @@ -1675,12 +2054,59 @@ class ComposeBox extends StatefulWidget { /// The interface for the state of a [ComposeBox]. abstract class ComposeBoxState extends State { ComposeBoxController get controller; + + void startEditInteraction({ + required int messageId, + required String originalRawContent, + }); + + void endEditInteraction(); } class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { @override ComposeBoxController get controller => _controller!; ComposeBoxController? _controller; + @override + void startEditInteraction({ + required int messageId, + required String originalRawContent, + }) async { + final zulipLocalizations = ZulipLocalizations.of(context); + switch (_controller) { + case EditMessageComposeBoxController(): + throw StateError('startEditInteraction called during message-edit interaction'); + case StreamComposeBoxController(): + case FixedDestinationComposeBoxController(): + if (_controller!.content.textNormalized.isNotEmpty) { + final dialog = showSuggestedActionDialog(context: context, + title: zulipLocalizations.discardDraftConfirmationDialogTitle, + message: zulipLocalizations.discardDraftConfirmationDialogMessage, + // TODO(#1032) "destructive" style for action button + actionButtonText: zulipLocalizations.discardDraftConfirmationDialogConfirmButton); + if (await dialog.result != true) return; + if (!context.mounted) return; + } + case SavedSnippetComposeBoxController(): + throw StateError('unexpected controller type'); + case null: // TODO(log) + } + setState(() { + _controller?.dispose(); + _controller = EditMessageComposeBoxController.init( + messageId: messageId, originalRawContent: originalRawContent) + ..contentFocusNode.requestFocus(); + }); + } + + @override + void endEditInteraction() { + final store = PerAccountStoreWidget.of(context); + setState(() { + _setNewController(store); + }); + } + @override void onNewStore() { final newStore = PerAccountStoreWidget.of(context); @@ -1695,7 +2121,11 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case StreamComposeBoxController(): controller.topic.store = newStore; case FixedDestinationComposeBoxController(): + case EditMessageComposeBoxController(): // no reference to the store that needs updating + break; + case SavedSnippetComposeBoxController(): + throw StateError('unexpected controller type'); } } @@ -1750,13 +2180,14 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override Widget build(BuildContext context) { - final Widget? body; - final errorBanner = _errorBannerComposingNotAllowed(context); if (errorBanner != null) { return _ComposeBoxContainer(body: null, banner: errorBanner); } + final Widget? body; + Widget? banner; + final controller = this.controller; final narrow = widget.narrow; switch (controller) { @@ -1768,6 +2199,12 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM narrow as SendableNarrow; body = _FixedDestinationComposeBoxBody(controller: controller, narrow: narrow); } + case EditMessageComposeBoxController(): { + body = _EditMessageComposeBoxBody(controller: controller, narrow: narrow); + banner = _EditMessageBanner(composeBoxState: this); + } + case SavedSnippetComposeBoxController(): + throw StateError('unexpected controller type'); } // TODO(#720) dismissable message-send error, maybe something like: @@ -1775,6 +2212,36 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM // errorBanner = _ErrorBanner(label: // ZulipLocalizations.of(context).errorSendMessageTimeout); // } - return _ComposeBoxContainer(body: body, banner: null); + return _ComposeBoxContainer(body: body, banner: banner); + } +} + +class SavedSnippetComposeBox extends StatefulWidget { + const SavedSnippetComposeBox({super.key}); + + @override + State createState() => _SavedSnippetComposeBoxState(); +} + +class _SavedSnippetComposeBoxState extends State { + // TODO: preserve the controller independent from this state + late SavedSnippetComposeBoxController _controller; + + @override + void initState() { + super.initState(); + _controller = SavedSnippetComposeBoxController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ComposeBoxContainer( + body: _SavedSnippetComposeBoxBody(controller: _controller)); } } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 0d2b4c5a7d..08ce8f08c7 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -49,6 +49,9 @@ class DialogStatus { /// /// The [DialogStatus.result] field of the return value can be used /// for waiting for the dialog to be closed. +/// +/// Prose in [message] should have final punctuation: +/// https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577 // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 0f6d490a97..084a9dbaae 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -413,10 +413,6 @@ void showEmojiPickerSheet({ final store = PerAccountStoreWidget.of(pageContext); showModalBottomSheet( context: pageContext, - // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect - // on my iPhone 13 Pro but is marked as "much slower": - // https://api.flutter.dev/flutter/dart-ui/Clip.html - clipBehavior: Clip.antiAlias, // The bottom inset is left for [builder] to handle; // see [EmojiPicker] and its [CustomScrollView] for how we do that. useSafeArea: true, diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ab5ad446db..4910aaba17 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -293,10 +293,6 @@ void _showMainMenu(BuildContext context, { final accountId = PerAccountStoreWidget.accountIdOf(context); showModalBottomSheet( context: context, - // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect - // on my iPhone 13 Pro but is marked as "much slower": - // https://api.flutter.dev/flutter/dart-ui/Clip.html - clipBehavior: Clip.antiAlias, useSafeArea: true, isScrollControlled: true, // TODO: Fix the issue that the color does not respond when the theme diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index bab7b152de..41e0f369df 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -111,44 +111,50 @@ abstract final class ZulipIcons { /// The Zulip custom icon "message_feed". static const IconData message_feed = IconData(0xf11d, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "message_square_text". + static const IconData message_square_text = IconData(0xf11e, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11f, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "plus". + static const IconData plus = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf12c, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 3e8850f5cc..4e88a9c47d 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -817,16 +817,16 @@ class _TypingStatusWidgetState extends State with PerAccount if (narrow is! SendableNarrow) return const SizedBox(); final store = PerAccountStoreWidget.of(context); - final localizations = ZulipLocalizations.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final typistIds = model!.typistIdsInNarrow(narrow); if (typistIds.isEmpty) return const SizedBox(); final text = switch (typistIds.length) { - 1 => localizations.onePersonTyping( + 1 => zulipLocalizations.onePersonTyping( store.userDisplayName(typistIds.first)), - 2 => localizations.twoPeopleTyping( + 2 => zulipLocalizations.twoPeopleTyping( store.userDisplayName(typistIds.first), store.userDisplayName(typistIds.last)), - _ => localizations.manyPeopleTyping, + _ => zulipLocalizations.manyPeopleTyping, }; return Padding( @@ -1447,18 +1447,31 @@ class MessageWithPossibleSender extends StatelessWidget { final MessageListMessageItem item; + Widget _withRestoreEditMessageGestureDetector(BuildContext context, Widget child) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final store = PerAccountStoreWidget.of(context); + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + if (composeBoxState == null) return; + composeBoxState.startEditInteraction(messageId: item.message.id, + originalRawContent: store.takeFailedMessageEdit(item.message.id)); + }, + child: child); + } + @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); final message = item.message; - final localizations = ZulipLocalizations.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); String? editStateText; switch (message.editState) { case MessageEditState.edited: - editStateText = localizations.messageIsEditedLabel; + editStateText = zulipLocalizations.messageIsEditedLabel; case MessageEditState.moved: - editStateText = localizations.messageIsMovedLabel; + editStateText = zulipLocalizations.messageIsMovedLabel; case MessageEditState.none: } @@ -1473,9 +1486,68 @@ class MessageWithPossibleSender extends StatelessWidget { child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); } + final store = PerAccountStoreWidget.of(context); + final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); + Widget? editMessageErrorStatusRow; + if (editMessageErrorStatus != null) { + final baseTextStyle = TextStyle( + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12), + ); + editMessageErrorStatusRow = switch (editMessageErrorStatus) { + // TODO parse markdown and show new content as local echo? + false => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 1.5, + children: [ + Text( + style: baseTextStyle.copyWith(color: designVariables.btnLabelAttLowIntInfo), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditLabel), + // TODO instead place within outer padding; see Figma: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4026-8775&m=dev + LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withValues(alpha: 0.5), + backgroundColor: designVariables.foreground.withValues(alpha: 0.2), + ), + ]), + true => _withRestoreEditMessageGestureDetector(context, + Text( + style: baseTextStyle.copyWith(color: designVariables.btnLabelAttLowIntDanger), + textAlign: TextAlign.end, + zulipLocalizations.savingMessageEditFailedLabel)), + }; + } + + Widget content = MessageContent(message: message, content: item.content); + + if (editMessageErrorStatus != null) { + content = Opacity(opacity: 0.6, child: content); + switch (editMessageErrorStatus) { + case true: + content = _withRestoreEditMessageGestureDetector(context, content); + case false: + content = IgnorePointer(child: content); + } + } + return GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), + onLongPress: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + final controller = composeBoxState?.controller; + final editMessageInProgress = + controller is EditMessageComposeBoxController + || editMessageErrorStatus != null; + + showMessageActionSheet(context: context, + message: message, + messageListHasComposeBox: composeBoxState != null, + editMessageInProgress: editMessageInProgress); + }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column(children: [ @@ -1489,10 +1561,11 @@ class MessageWithPossibleSender extends StatelessWidget { Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - MessageContent(message: message, content: item.content), + content, if ((message.reactions?.total ?? 0) > 0) ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) + if (editMessageErrorStatusRow != null) editMessageErrorStatusRow + else if (editStateText != null) Text(editStateText, textAlign: TextAlign.end, style: TextStyle( diff --git a/lib/widgets/saved_snippet.dart b/lib/widgets/saved_snippet.dart new file mode 100644 index 0000000000..653c1d4396 --- /dev/null +++ b/lib/widgets/saved_snippet.dart @@ -0,0 +1,265 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import 'compose_box.dart'; +import 'icons.dart'; +import 'inset_shadow.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +void showSavedSnippetPickerSheet({ + required BuildContext context, + required ComposeBoxController controller, +}) async { + final store = PerAccountStoreWidget.of(context); + assert(store.zulipFeatureLevel >= 297); // TODO(server-10) remove + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (BuildContext context) { + return PerAccountStoreWidget( + accountId: store.accountId, + child: _SavedSnippetPicker(controller: controller)); + })); +} + +class _SavedSnippetPicker extends StatelessWidget { + const _SavedSnippetPicker({required this.controller}); + + final ComposeBoxController controller; + + void _handleSelect(BuildContext context, String content) { + if (!content.endsWith('\n')) { + content = '$content\n'; + } + controller.content.insertPadded(content); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + // Usually a user shouldn't have that many saved snippets, so it is + // tolerable to re-sort during builds. + final savedSnippets = store.savedSnippets.values.sortedBy((x) => x.title); // TODO(#1399) + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _SavedSnippetPickerHeader(), + Flexible( + child: InsetShadowBox( + top: 8, + color: designVariables.bgContextMenu, + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final savedSnippet in savedSnippets) + _SavedSnippetItem( + savedSnippet: savedSnippet, + onPressed: + () => _handleSelect(context, savedSnippet.content)), + if (store.savedSnippets.isEmpty) + // TODO(design) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(zulipLocalizations.noSavedSnippets, + textAlign: TextAlign.center)), + ])))), + ]); + } +} + +class _SavedSnippetPickerHeader extends StatelessWidget { + const _SavedSnippetPickerHeader(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + final textStyle = TextStyle( + fontSize: 20, + height: 30 / 20, + color: designVariables.icon); + final overlayColor = WidgetStateColor.fromMap({ + // TODO(design) check if these are the right colors + WidgetState.hovered: designVariables.pressedTint, + WidgetState.pressed: designVariables.pressedTint, + WidgetState.any: Colors.transparent, + }); + + return Material( + color: designVariables.bgContextMenu, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + splashFactory: NoSplash.splashFactory, + overlayColor: overlayColor, + onTap: () => Navigator.of(context).pop(), + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(16, 10, 8, 6), + child: Text(zulipLocalizations.dialogClose, + style: textStyle.merge( + weightVariableTextStyle(context, wght: 400))))), + + // TODO(#1501) support search box + Expanded(child: Padding( + padding: EdgeInsets.only(top: 10, bottom: 6), + child: Text(zulipLocalizations.savedSnippetsTitle, + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.title, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600))))), + + InkWell( + splashFactory: NoSplash.splashFactory, + overlayColor: overlayColor, + onTap: () => showNewSavedSnippetComposeBox(context: context), + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(3, 10, 10, 6), + child: Row( + spacing: 4, + children: [ + Icon(ZulipIcons.plus, size: 24, color: designVariables.icon), + Text(zulipLocalizations.newSavedSnippetButton, + style: textStyle.merge( + weightVariableTextStyle(context, wght: 600))), + ]))), + ]), + ); + } +} + +class _SavedSnippetItem extends StatelessWidget { + const _SavedSnippetItem({ + required this.savedSnippet, + required this.onPressed, + }); + + final SavedSnippet savedSnippet; + final void Function() onPressed; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + // TODO(#xxx): support editing saved snippets + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.fromMap({ + WidgetState.pressed: designVariables.pressedTint, + WidgetState.hovered: designVariables.pressedTint, + WidgetState.any: Colors.transparent, + }), + child: Padding( + // The end padding is 14px to account for the lack of edit button, + // whose visible part would be 14px away from the end of the text. See: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7965-76050&t=IxXomdPIZ5bXvJKA-0 + padding: EdgeInsetsDirectional.fromSTEB(16, 8, 14, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 4, + children: [ + Text(savedSnippet.title, + style: TextStyle( + fontSize: 18, + height: 22 / 18, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 600))), + Text(savedSnippet.content, + style: TextStyle( + fontSize: 17, + height: 18 / 17, + color: designVariables.textMessage + ).merge(weightVariableTextStyle(context, wght: 400))), + ]))); + } +} + +class _NewSavedSnippetHeader extends StatelessWidget { + const _NewSavedSnippetHeader(); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Material( + color: designVariables.bgContextMenu, + child: Stack( + children: [ + Center(child: Padding( + padding: EdgeInsets.only(top: 10, bottom: 6), + child: Text(zulipLocalizations.newSavedSnippetTitle, + style: TextStyle( + color: designVariables.title, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600))))), + PositionedDirectional( + end: 0, + child: InkWell( + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.fromMap({ + // TODO(design) check if these are the right colors + WidgetState.pressed: designVariables.pressedTint, + WidgetState.hovered: designVariables.pressedTint, + WidgetState.any: Colors.transparent, + }), + onTap: () => Navigator.of(context).pop(), + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(8, 10, 16, 6), + child: Text(zulipLocalizations.dialogCancel, + style: TextStyle( + color: designVariables.icon, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 400)))))), + ])); + } +} + +void showNewSavedSnippetComposeBox({ + required BuildContext context, +}) { + final store = PerAccountStoreWidget.of(context); + showModalBottomSheet(context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) { + return PerAccountStoreWidget( + accountId: store.accountId, + child: Padding( + padding: EdgeInsets.only( + // When there is bottom viewInset, part of the bottom sheet would + // be completely obstructed by certain system UI, typically the + // keyboard. For the compose box on message-list page, this is + // handled by [Scaffold]; modal bottom sheet doesn't have that. + // TODO(upstream) https://github.com/flutter/flutter/issues/71418 + bottom: MediaQuery.viewInsetsOf(context).bottom), + child: MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _NewSavedSnippetHeader(), + const SavedSnippetComposeBox(), + ])))); + }); +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index a533311869..cd045237c0 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -105,6 +105,9 @@ ThemeData zulipThemeData(BuildContext context) { scaffoldBackgroundColor: designVariables.mainBackground, tooltipTheme: const TooltipThemeData(preferBelow: false), bottomSheetTheme: BottomSheetThemeData( + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html clipBehavior: Clip.antiAlias, backgroundColor: designVariables.bgContextMenu, modalBarrierColor: designVariables.modalBarrierColor, @@ -130,6 +133,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), @@ -144,6 +149,7 @@ class DesignVariables extends ThemeExtension { 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), btnShadowAttMed: const Color(0xff000000).withValues(alpha: 0.20), @@ -161,6 +167,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), mainBackground: const Color(0xfff0f0f0), + pressedTint: Colors.black.withValues(alpha: 0.04), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), @@ -187,6 +194,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), @@ -201,6 +210,7 @@ class DesignVariables extends ThemeExtension { 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), btnShadowAttMed: const Color(0xffffffff).withValues(alpha: 0.21), @@ -218,6 +228,7 @@ class DesignVariables extends ThemeExtension { labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), mainBackground: const Color(0xff1d1d1d), + pressedTint: Colors.white.withValues(alpha: 0.04), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), @@ -252,6 +263,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, @@ -266,6 +279,7 @@ class DesignVariables extends ThemeExtension { required this.btnBgAttMediumIntInfoNormal, required this.btnLabelAttHigh, required this.btnLabelAttLowIntDanger, + required this.btnLabelAttLowIntInfo, required this.btnLabelAttMediumIntDanger, required this.btnLabelAttMediumIntInfo, required this.btnShadowAttMed, @@ -283,6 +297,7 @@ class DesignVariables extends ThemeExtension { required this.labelEdited, required this.labelMenuButton, required this.mainBackground, + required this.pressedTint, required this.textInput, required this.title, required this.bgSearchInput, @@ -318,6 +333,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; @@ -332,6 +349,7 @@ class DesignVariables extends ThemeExtension { final Color btnBgAttMediumIntInfoNormal; final Color btnLabelAttHigh; final Color btnLabelAttLowIntDanger; + final Color btnLabelAttLowIntInfo; final Color btnLabelAttMediumIntDanger; final Color btnLabelAttMediumIntInfo; final Color btnShadowAttMed; @@ -349,6 +367,7 @@ class DesignVariables extends ThemeExtension { final Color labelEdited; final Color labelMenuButton; final Color mainBackground; + final Color pressedTint; final Color textInput; final Color title; final Color bgSearchInput; @@ -379,6 +398,8 @@ class DesignVariables extends ThemeExtension { DesignVariables copyWith({ Color? background, Color? bannerBgIntDanger, + Color? bannerBgIntInfo, + Color? bannerTextIntInfo, Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, @@ -393,6 +414,7 @@ class DesignVariables extends ThemeExtension { Color? btnBgAttMediumIntInfoNormal, Color? btnLabelAttHigh, Color? btnLabelAttLowIntDanger, + Color? btnLabelAttLowIntInfo, Color? btnLabelAttMediumIntDanger, Color? btnLabelAttMediumIntInfo, Color? btnShadowAttMed, @@ -410,6 +432,7 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? mainBackground, + Color? pressedTint, Color? textInput, Color? title, Color? bgSearchInput, @@ -435,6 +458,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, @@ -449,6 +474,7 @@ class DesignVariables extends ThemeExtension { btnBgAttMediumIntInfoNormal: btnBgAttMediumIntInfoNormal ?? this.btnBgAttMediumIntInfoNormal, btnLabelAttHigh: btnLabelAttHigh ?? this.btnLabelAttHigh, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, + btnLabelAttLowIntInfo: btnLabelAttLowIntInfo ?? this.btnLabelAttLowIntInfo, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, btnLabelAttMediumIntInfo: btnLabelAttMediumIntInfo ?? this.btnLabelAttMediumIntInfo, btnShadowAttMed: btnShadowAttMed ?? this.btnShadowAttMed, @@ -466,6 +492,7 @@ class DesignVariables extends ThemeExtension { labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, mainBackground: mainBackground ?? this.mainBackground, + pressedTint: pressedTint ?? this.pressedTint, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, @@ -498,6 +525,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)!, @@ -512,6 +541,7 @@ class DesignVariables extends ThemeExtension { 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)!, btnShadowAttMed: Color.lerp(btnShadowAttMed, other.btnShadowAttMed, t)!, @@ -529,6 +559,7 @@ class DesignVariables extends ThemeExtension { labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + pressedTint: Color.lerp(pressedTint, other.pressedTint, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 1a17f70f60..d9f42b9911 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -21,6 +21,12 @@ extension UserChecks on Subject { Subject get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot'); } +extension SavedSnippetChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get title => has((x) => x.title, 'title'); + Subject get content => has((x) => x.content, 'content'); +} + extension ZulipStreamChecks on Subject { } diff --git a/test/example_data.dart b/test/example_data.dart index 9577ef0e73..da69dd93dc 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -250,6 +250,28 @@ final User thirdUser = user(fullName: 'Third User'); final User fourthUser = user(fullName: 'Fourth User'); +//////////////////////////////////////////////////////////////// +// Data attached to the self-account on the realm +// + +int _nextSavedSnippetId() => _lastSavedSnippetId++; +int _lastSavedSnippetId = 1; + +SavedSnippet savedSnippet({ + int? id, + String? title, + String? content, + int? dateCreated, +}) { + _checkPositive(id, 'saved snippet ID'); + return SavedSnippet( + id: id ?? _nextSavedSnippetId(), + title: title ?? 'A saved snippet', + content: content ?? 'foo bar baz', + dateCreated: dateCreated ?? 1234567890, // TODO generate timestamp + ); +} + //////////////////////////////////////////////////////////////// // Streams and subscriptions. // @@ -484,7 +506,7 @@ StreamMessage streamMessage({ 'last_edit_timestamp': lastEditTimestamp, 'subject': topic ?? _defaultTopic, 'submessages': submessages ?? [], - 'timestamp': timestamp ?? 1678139636, + 'timestamp': timestamp ?? utcTimestamp(), 'type': 'stream', }) as Map); } @@ -525,7 +547,7 @@ DmMessage dmMessage({ 'last_edit_timestamp': lastEditTimestamp, 'subject': '', 'submessages': submessages ?? [], - 'timestamp': timestamp ?? 1678139636, + 'timestamp': timestamp ?? utcTimestamp(), 'type': 'private', }) as Map); } @@ -674,7 +696,7 @@ UpdateMessageEvent updateMessageEditEvent( messageId: messageId, messageIds: [messageId], flags: flags ?? origMessage.flags, - editTimestamp: editTimestamp ?? 1234567890, // TODO generate timestamp + editTimestamp: editTimestamp ?? utcTimestamp(), moveData: null, origContent: 'some probably-mismatched old Markdown', origRenderedContent: origMessage.content, @@ -705,7 +727,7 @@ UpdateMessageEvent _updateMessageMoveEvent( messageId: messageIds.first, messageIds: messageIds, flags: flags, - editTimestamp: 1234567890, // TODO generate timestamp + editTimestamp: utcTimestamp(), moveData: UpdateMessageMoveData( origStreamId: origStreamId, newStreamId: newStreamId ?? origStreamId, @@ -923,6 +945,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedWaitPeriodMilliseconds, Map? realmEmoji, List? recentPrivateConversations, + List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, @@ -958,6 +981,7 @@ InitialSnapshot initialSnapshot({ serverTypingStartedWaitPeriodMilliseconds ?? 10000, realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], + savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default @@ -971,7 +995,7 @@ InitialSnapshot initialSnapshot({ realmMandatoryTopics: realmMandatoryTopics ?? true, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmAllowMessageEditing: realmAllowMessageEditing ?? true, - realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds ?? 600, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 295dcde7b9..0bfbd2d33a 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -143,6 +143,10 @@ extension TextEditingControllerChecks on Subject { Subject get text => has((t) => t.text, 'text'); } +extension FocusNodeChecks on Subject { + Subject get hasFocus => has((t) => t.hasFocus, 'hasFocus'); +} + extension ScrollMetricsChecks on Subject { Subject get minScrollExtent => has((x) => x.minScrollExtent, 'minScrollExtent'); Subject get maxScrollExtent => has((x) => x.maxScrollExtent, 'maxScrollExtent'); diff --git a/test/model/saved_snippet.dart b/test/model/saved_snippet.dart new file mode 100644 index 0000000000..9272758cdf --- /dev/null +++ b/test/model/saved_snippet.dart @@ -0,0 +1,44 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; + +import '../api/model/model_checks.dart'; +import '../example_data.dart' as eg; +import 'store_checks.dart'; + +void main() { + test('handleSavedSnippetsEvent', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + savedSnippets: [eg.savedSnippet(id: 101)])); + check(store).savedSnippets.values.single.id.equals(101); + + await store.handleEvent(SavedSnippetsAddEvent(id: 1,savedSnippet: + eg.savedSnippet( + id: 102, + title: 'foo title', + content: 'foo content', + ))); + check(store).savedSnippets.values.deepEquals(>[ + (it) => it.isA().id.equals(101), + (it) => it.isA()..id.equals(102) + ..title.equals('foo title') + ..content.equals('foo content') + ]); + + await store.handleEvent(SavedSnippetsRemoveEvent(id: 1, savedSnippetId: 101)); + check(store).savedSnippets.values.single.id.equals(102); + + await store.handleEvent(SavedSnippetsUpdateEvent(id: 1, savedSnippet: + eg.savedSnippet( + id: 102, + title: 'bar title', + content: 'bar content', + dateCreated: store.savedSnippets.values.single.dateCreated, + ))); + check(store).savedSnippets.values.single + ..id.equals(102) + ..title.equals('bar title') + ..content.equals('bar content'); + }); +} diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 32379a6f06..b3d1bc8820 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -55,6 +55,7 @@ extension PerAccountStoreChecks on Subject { Subject get accountId => has((x) => x.accountId, 'accountId'); Subject get account => has((x) => x.account, 'account'); Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId'); + Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets'); Subject get userSettings => has((x) => x.userSettings, 'userSettings'); Subject> get streams => has((x) => x.streams, 'streams'); Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 7d6a5205bb..e298eafbe3 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -23,6 +23,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/action_sheet.dart'; import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji.dart'; @@ -52,11 +53,18 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + bool? realmAllowMessageEditing, + int? realmMessageContentEditLimitSeconds, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add( + eg.selfAccount, + eg.initialSnapshot( + realmAllowMessageEditing: realmAllowMessageEditing, + realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, + )); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([ eg.selfUser, @@ -1421,6 +1429,174 @@ void main() { }); }); + group('EditButton', () { + group('present/absent appropriately', () { + /// Test whether the edit-message button is visible, given params. + /// + /// The message timestamp is 60s before the current time + /// ([TestBinding.utcNow]) as of the start of the test run. + /// + /// The message has streamId: 1 and topic: 'topic'. + /// The message list is for that [TopicNarrow] unless [narrow] is passed. + void testVisibility(bool expected, { + bool self = true, + Narrow? narrow, + bool allowed = true, + int? limit, + bool composing = false, + bool? errorStatus, + }) { + assert(!composing || errorStatus == null); + final description = [ + 'from self: $self', + 'narrow: $narrow', + 'realm allows: $allowed', + 'edit limit: $limit', + 'editing compose box active: $composing', + 'edit-message error status: $errorStatus', + ].join(', '); + + Future tapEditAndPump(WidgetTester tester, { + required String rawContentToPrepare, + }) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.edit, skipOffstage: false)); + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: rawContentToPrepare)).toJson()); + await tester.tap(find.byIcon(ZulipIcons.edit)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + void checkButtonIsPresent(bool expected) { + if (expected) { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsOne(); + } else { + check(find.byIcon(ZulipIcons.edit, skipOffstage: false)).findsNothing(); + } + } + + testWidgets(description, (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final message = eg.streamMessage( + stream: eg.stream(streamId: 1), + topic: 'topic', + sender: self ? eg.selfUser : eg.otherUser, + timestamp: eg.utcTimestamp(testBinding.utcNow()) - 60); + + await setupToMessageActionSheet(tester, + message: message, + narrow: narrow ?? TopicNarrow.ofMessage(message), + realmAllowMessageEditing: allowed, + realmMessageContentEditLimitSeconds: limit, + ); + + if (!composing && errorStatus == null) { + checkButtonIsPresent(expected); + return; + } + + await tapEditAndPump(tester, rawContentToPrepare: 'foo'); + await tester.pump(Duration(seconds: 1)); + await tester.enterText(find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == 'foo'), + 'bar'); + + if (errorStatus == true) { + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + } else if (errorStatus == false) { + connection.prepare( + json: UpdateMessageResult().toJson(), delay: Duration(seconds: 1)); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration(milliseconds: 500)); + } + + await tester.longPress(find.byType(MessageWithPossibleSender)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + checkButtonIsPresent(expected); + + await tester.pump(Duration(milliseconds: 500)); + }); + } + + testVisibility(true); + // TODO(server-6) limit 0 not expected on 6.0+ + testVisibility(true, limit: 0); + testVisibility(true, limit: 600); + testVisibility(true, narrow: ChannelNarrow(1)); + + testVisibility(false, self: false); + testVisibility(false, narrow: CombinedFeedNarrow()); + testVisibility(false, allowed: false); + testVisibility(false, limit: 10); + testVisibility(false, composing: true); + testVisibility(false, errorStatus: false); + testVisibility(false, errorStatus: true); + }); + + group('tap button', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(sender: eg.selfUser); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, + ); + + check(findComposeBoxController(tester)) + .isNotNull() + .isA(); + + await tester.ensureVisible(find.byIcon(ZulipIcons.edit, skipOffstage: false)); + connection.prepare(json: GetMessageResult( + message: eg.streamMessage(content: 'foo')).toJson()); + await tester.tap(find.byIcon(ZulipIcons.edit)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + await tester.pump(Duration.zero); + + check(findComposeBoxController(tester)) + .isNotNull() + .isA() + ..messageId.equals(message.id) + ..originalRawContent.equals('foo'); + }); + + testWidgets('fetchRawContentWithFeedback fails', (tester) async { + final message = eg.streamMessage(sender: eg.selfUser); + await setupToMessageActionSheet(tester, + message: message, + narrow: TopicNarrow.ofMessage(message), + realmAllowMessageEditing: true, + realmMessageContentEditLimitSeconds: null, + ); + + check(findComposeBoxController(tester)) + .isNotNull() + .isA(); + + await tester.ensureVisible(find.byIcon(ZulipIcons.edit, skipOffstage: false)); + connection.prepare( + apiException: eg.apiBadRequest(message: 'Invalid message(s)')); + await tester.tap(find.byIcon(ZulipIcons.edit)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + await tester.pump(Duration.zero); + + check(findComposeBoxController(tester)) + .isNotNull() + .isA(); + }); + }); + }); + group('MessageActionSheetCancelButton', () { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart index 8008b510d3..9be26792f0 100644 --- a/test/widgets/compose_box_checks.dart +++ b/test/widgets/compose_box_checks.dart @@ -1,6 +1,22 @@ import 'package:checks/checks.dart'; +import 'package:flutter/cupertino.dart'; import 'package:zulip/widgets/compose_box.dart'; + +extension ComposeBoxStateChecks on Subject { + Subject get controller => has((c) => c.controller, 'controller'); +} + +extension ComposeBoxControllerChecks on Subject { + Subject get content => has((c) => c.content, 'content'); + Subject get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode'); +} + +extension EditMessageComposeBoxControllerChecks on Subject { + Subject get messageId => has((c) => c.messageId, 'messageId'); + Subject get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent'); +} + extension ComposeContentControllerChecks on Subject { Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index f979c687de..971d3e0208 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,21 +3,24 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/api/route/saved_snippets.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/page.dart'; @@ -32,6 +35,8 @@ import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../model/typing_status_test.dart'; import '../stdlib_checks.dart'; +import '../test_navigation.dart'; +import 'compose_box_checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; @@ -40,6 +45,7 @@ void main() { late PerAccountStore store; late FakeApiConnection connection; + late ComposeBoxState state; late ComposeBoxController? controller; Future prepareComposeBox(WidgetTester tester, { @@ -79,7 +85,8 @@ void main() { ]))); await tester.pumpAndSettle(); - controller = tester.state(find.byType(ComposeBox)).controller; + state = tester.state(find.byType(ComposeBox)); + controller = state.controller; } /// A [Finder] for the topic input. @@ -117,9 +124,11 @@ void main() { .controller.isNotNull().value.text.equals(expected); } + final sendButtonFinder = find.byIcon(ZulipIcons.send); + Future tapSendButton(WidgetTester tester) async { connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); } @@ -232,6 +241,33 @@ void main() { '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); + + group('ContentValidationError.empty', () { + late ComposeContentController controller; + + void checkCountsAsEmpty(String text, bool expected) { + controller.value = TextEditingValue(text: text); + expected + ? check(controller).validationErrors.contains(ContentValidationError.empty) + : check(controller).validationErrors.not((it) => it.contains(ContentValidationError.empty)); + } + + testWidgets('skipValidationErrorEmpty: false (default)', (tester) async { + controller = ComposeContentController(); + addTearDown(controller.dispose); + checkCountsAsEmpty('', true); + checkCountsAsEmpty(' ', true); + checkCountsAsEmpty('a', false); + }); + + testWidgets('skipValidationErrorEmpty: true', (tester) async { + controller = ComposeContentController(skipValidationErrorEmpty: true); + addTearDown(controller.dispose); + checkCountsAsEmpty('', false); + checkCountsAsEmpty(' ', false); + checkCountsAsEmpty('a', false); + }); + }); }); group('length validation', () { @@ -692,7 +728,7 @@ void main() { connection.prepare(json: {}); connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); final requests = connection.takeRequests(); checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]); @@ -856,7 +892,7 @@ void main() { await enterTopic(tester, narrow: narrow, topic: topicInputText); await tester.enterText(contentInputFinder, 'test content'); - await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.tap(sendButtonFinder); await tester.pump(); } @@ -913,7 +949,7 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(ZulipIcons.send), + of: sendButtonFinder, matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; final designVariables = DesignVariables.of(sendButtonElement); @@ -1049,6 +1085,162 @@ void main() { skip: Platform.isWindows); }); + testWidgets('_ShowSavedSnippetsButton', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, narrow: ChannelNarrow(channel.streamId), + streams: [channel]); + + check(find.byIcon(ZulipIcons.message_square_text)).findsOne(); + }); + + testWidgets('legacy: _ShowSavedSnippetsButton', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, narrow: ChannelNarrow(channel.streamId), + streams: [channel], + zulipFeatureLevel: 296); + + check(find.byIcon(ZulipIcons.message_square_text)).findsNothing(); + }); + + // Tests for the bottom sheet that _ShowSavedSnippetsButton leads to + // are in test/widgets/saved_snippet_test.dart. + + group('SavedSnippetComposeBox', () { + final newSavedSnippetInputFinder = find.descendant( + of: find.byType(SavedSnippetComposeBox), matching: find.byType(TextField)); + + late List> poppedRoutes; + + Future prepareSavedSnippetComposeBox(WidgetTester tester, { + required String title, + required String content, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + poppedRoutes = []; + final navigatorObserver = TestNavigatorObserver() + ..onPopped = (route, prevRoute) => poppedRoutes.add(route); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [navigatorObserver], + child: const SavedSnippetComposeBox())); + await tester.pump(); + await tester.enterText(newSavedSnippetInputFinder.first, title); + await tester.enterText(newSavedSnippetInputFinder.last, content); + } + + testWidgets('should not offer _ShowSavedSnippetsButton', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: 'title foo', content: 'content bar'); + check(find.byIcon(ZulipIcons.message_square_text)).findsNothing(); + }); + + testWidgets('add new saved snippet', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: 'title foo', content: 'content bar'); + + connection.prepare(json: + CreateSavedSnippetResult(savedSnippetId: 123).toJson()); + await tester.tap(find.byIcon(ZulipIcons.check)); + await tester.pump(Duration.zero); + check(poppedRoutes).single; + check(connection.takeRequests()).single.isA() + ..bodyFields['title'].equals('title foo') + ..bodyFields['content'].equals('content bar'); + checkNoErrorDialog(tester); + + await store.handleEvent(SavedSnippetsAddEvent(id: 100, + savedSnippet: eg.savedSnippet( + id: 123, title: 'title foo', content: 'content bar'))); + await tester.pump(); + check(find.text('title foo')).findsOne(); + check(find.text('content bar')).findsOne(); + }); + + testWidgets('handle unexpected API exception', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: 'title foo', content: 'content bar'); + + connection.prepare(apiException: eg.apiExceptionUnauthorized()); + await tester.tap(find.byIcon(ZulipIcons.check)); + await tester.pump(Duration.zero); + check(poppedRoutes).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Failed to create saved snippet', + expectedMessage: 'The server said:\n\nInvalid API key'); + }); + + group('client validation errors', () { + testWidgets('empty title', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: '', content: 'content bar'); + + await tester.tap(find.byIcon(ZulipIcons.check)); + await tester.pump(Duration.zero); + check(poppedRoutes).isEmpty(); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Failed to create saved snippet', + expectedMessage: 'Title cannot be empty.'); + }); + + testWidgets('empty content', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: 'title foo', content: ''); + + await tester.tap(find.byIcon(ZulipIcons.check)); + await tester.pump(Duration.zero); + check(poppedRoutes).isEmpty(); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Failed to create saved snippet', + expectedMessage: 'Content cannot be empty.'); + }); + + testWidgets('title is too long', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: 'a' * 61, content: 'content bar'); + + await tester.tap(find.byIcon(ZulipIcons.check)); + await tester.pump(Duration.zero); + check(poppedRoutes).isEmpty(); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Failed to create saved snippet', + expectedMessage: "Title length shouldn't be greater than 60 characters."); + }); + + testWidgets('content is too long', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: 'title foo', content: 'a' * 10001); + + await tester.tap(find.byIcon(ZulipIcons.check)); + await tester.pump(Duration.zero); + check(poppedRoutes).isEmpty(); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Failed to create saved snippet', + expectedMessage: "Content length shouldn't be greater than 10000 characters."); + }); + + testWidgets('disable send button if there are validation errors', (tester) async { + await prepareSavedSnippetComposeBox(tester, + title: '', content: 'content bar'); + final iconElement = tester.element(find.byIcon(ZulipIcons.check)); + final designVariables = DesignVariables.of(iconElement); + check(iconElement.widget).isA().color.isNotNull() + .isSameColorAs(designVariables.icon.withFadedAlpha(0.5)); + + await tester.enterText(newSavedSnippetInputFinder.first, 'title foo'); + await tester.pump(); + check(iconElement.widget).isA().color.isNotNull() + .isSameColorAs(designVariables.icon); + }); + }); + }); + group('error banner', () { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; @@ -1354,4 +1546,195 @@ void main() { checkContentInputValue(tester, 'some content'); }); }); + + group('edit message', () { + final channel = eg.stream(); + final topic = 'topic'; + final message = eg.streamMessage(stream: channel, topic: topic); + final dmMessage = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + + Future prepareEditMessage(WidgetTester tester, {required Narrow narrow}) async { + await prepareComposeBox(tester, + narrow: narrow, + streams: [channel]); + await store.addMessages([message, dmMessage]); + } + + void checkRequest(int messageId, { + required String prevContent, + required String content, + }) { + final prevContentSha256 = sha256.convert(utf8.encode(prevContent)).toString(); + check(connection.takeRequests()).single.isA() + ..method.equals('PATCH') + ..url.path.equals('/api/v1/messages/$messageId') + ..bodyFields.deepEquals({ + 'prev_content_sha256': prevContentSha256, + 'content': content, + }); + } + + // Check that the compose-box controller is the normal one for the narrow, + // not [EditMessageComposeBoxController]. + void checkNotEditMessageController(Narrow narrow, [String expectedContentText = '']) { + switch (narrow) { + case ChannelNarrow(): + check(controller) + .isA() + .content.value.text.equals(expectedContentText); + case TopicNarrow(): + case DmNarrow(): + check(controller).isA(); + default: + throw StateError('unexpected narrow type'); + } + } + + final narrowVariants = ValueVariant({ + ChannelNarrow(channel.streamId), + eg.topicNarrow(channel.streamId, topic), + DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId), + }); + + testWidgets('smoke', (tester) async { + final narrow = narrowVariants.currentValue!; + await prepareEditMessage(tester, narrow: narrow); + + checkNotEditMessageController(narrow); + + state.startEditInteraction(messageId: message.id, originalRawContent: 'foo'); + controller = state.controller; + await tester.pump(); + check(controller) + .isA() + .contentFocusNode.hasFocus.isTrue(); + checkContentInputValue(tester, 'foo'); + // Upload buttons present + check(find.byIcon(ZulipIcons.camera)).findsOne(); + // Send button not present + check(sendButtonFinder).findsNothing(); + + await enterContent(tester, 'bar'); + checkContentInputValue(tester, 'bar'); + + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + checkRequest(message.id, prevContent: 'foo', content: 'bar'); + controller = state.controller; + checkNotEditMessageController(narrow); + checkContentInputValue(tester, ''); + }, variant: narrowVariants); + + testWidgets('cancel edit', (tester) async { + final narrow = narrowVariants.currentValue!; + await prepareEditMessage(tester, narrow: narrow); + checkNotEditMessageController(narrow); + + state.startEditInteraction(messageId: message.id, originalRawContent: 'foo'); + check(state.controller).isA(); + controller = state.controller; + await tester.pump(); + + await enterContent(tester, 'bar'); + checkContentInputValue(tester, 'bar'); + + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Cancel')); + await tester.pump(); + check(connection.takeRequests()).isEmpty(); + controller = state.controller; + checkNotEditMessageController(narrow); + }, variant: narrowVariants); + + testWidgets('edit request fails', (tester) async { + final narrow = narrowVariants.currentValue!; + await prepareEditMessage(tester, narrow: narrow); + + checkNotEditMessageController(narrow); + + state.startEditInteraction(messageId: message.id, originalRawContent: 'foo'); + check(state.controller).isA(); + controller = state.controller; + await tester.pump(); + + await enterContent(tester, 'bar'); + checkContentInputValue(tester, 'bar'); + + connection.prepare(apiException: eg.apiBadRequest()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + checkRequest(message.id, prevContent: 'foo', content: 'bar'); + controller = state.controller; + checkNotEditMessageController(narrow); + + // Error state appears in the message list, not here + }, variant: narrowVariants); + + testWidgets('compose box not empty when edit requested; cancel confirmation dialog', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final narrow = narrowVariants.currentValue!; + await prepareEditMessage(tester, narrow: narrow); + + checkNotEditMessageController(narrow); + await enterContent(tester, 'asdfjkl;'); + + state.startEditInteraction(messageId: message.id, originalRawContent: 'foo'); + await tester.pump(); + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + expectedActionButtonText: 'Discard'); + + await tester.tap(find.byWidget(cancelButton)); + await tester.pump(); + checkNotEditMessageController(narrow, 'asdfjkl;'); + check(controller).identicalTo(state.controller); + }, variant: narrowVariants); + + testWidgets('compose box not empty when edit requested; continue through confirmation dialog', (tester) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final narrow = narrowVariants.currentValue!; + await prepareEditMessage(tester, narrow: narrow); + + checkNotEditMessageController(narrow); + await enterContent(tester, 'asdfjkl;'); + + state.startEditInteraction(messageId: message.id, originalRawContent: 'foo'); + await tester.pump(); + final (actionButton, cancelButton) = checkSuggestedActionDialog(tester, + expectedTitle: 'Discard the message you’re writing?', + expectedMessage: 'When you edit a message, the content that was previously in the compose box is discarded.', + expectedActionButtonText: 'Discard'); + + await tester.tap(find.byWidget(actionButton)); + await tester.pump(); + controller = state.controller; + check(controller) + .isA() + .contentFocusNode.hasFocus.isTrue(); + checkContentInputValue(tester, 'foo'); + + await enterContent(tester, 'bar'); + + connection.prepare(json: UpdateMessageResult().toJson()); + await tester.tap(find.widgetWithText(ZulipWebUiKitButton, 'Save')); + await tester.pump(Duration.zero); + checkRequest(message.id, prevContent: 'foo', content: 'bar'); + controller = state.controller; + checkNotEditMessageController(narrow); + }, variant: narrowVariants); + + testWidgets('calling startEditInteraction twice in a row throws StateError', (tester) async { + final narrow = narrowVariants.currentValue!; + await prepareEditMessage(tester, narrow: narrow); + + state.startEditInteraction(messageId: message.id, originalRawContent: 'foo'); + await check(state.startEditInteraction(messageId: message.id, originalRawContent: 'foo')) + .isA>().throws(); + }, variant: narrowVariants); + }); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f4de7b54ae..ce830aa3be 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -20,6 +20,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; +import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -36,6 +37,7 @@ import '../flutter_checks.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; +import 'compose_box_checks.dart'; import 'content_checks.dart'; import 'dialog_checks.dart'; import 'message_list_checks.dart'; @@ -1424,7 +1426,7 @@ void main() { }); }); - group('edit state label', () { + group('EDITED/MOVED label and edit-message error status', () { void checkMarkersCount({required int edited, required int moved}) { check(find.text('EDITED').evaluate()).length.equals(edited); check(find.text('MOVED').evaluate()).length.equals(moved); @@ -1455,6 +1457,74 @@ void main() { await tester.pump(); checkMarkersCount(edited: 2, moved: 0); }); + + void checkEditInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsOne(); + check(find.byType(LinearProgressIndicator)).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + void checkEditNotInProgress(WidgetTester tester) { + check(find.text('SAVING EDIT…')).findsNothing(); + check(find.byType(LinearProgressIndicator)).findsNothing(); + check(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))).findsNothing(); + } + + void checkEditFailed(WidgetTester tester) { + check(find.text('EDIT NOT SAVED')).findsOne(); + final opacityWidget = tester.widget(find.ancestor( + of: find.byType(MessageContent), + matching: find.byType(Opacity))); + check(opacityWidget.opacity).equals(0.6); + checkMarkersCount(edited: 0, moved: 0); + } + + testWidgets('successful edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(json: UpdateMessageResult().toJson()); + store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar'); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await store.handleEvent(eg.updateMessageEditEvent(message)); + await tester.pump(); + checkEditNotInProgress(tester); + }); + + testWidgets('failed edit', (tester) async { + final message = eg.streamMessage(); + await setupMessageListPage(tester, + narrow: TopicNarrow.ofMessage(message), + messages: [message]); + + connection.prepare(apiException: eg.apiBadRequest(), delay: Duration(seconds: 1)); + store.editMessage(messageId: message.id, + originalRawContent: 'foo', + newContent: 'bar'); + await tester.pump(Duration.zero); + checkEditInProgress(tester); + await tester.pump(Duration(seconds: 1)); + checkEditFailed(tester); + await tester.tap(find.byType(MessageContent)); + await tester.pump(); + checkEditNotInProgress(tester); + + final state = MessageListPage.ancestorOf(tester.element(find.byType(MessageContent))); + check(state.composeBoxState).isNotNull().controller + .isA() + .content.value.text.equals('bar'); + }); }); group('_UnreadMarker animations', () { diff --git a/test/widgets/saved_snippet_test.dart b/test/widgets/saved_snippet_test.dart new file mode 100644 index 0000000000..b3d0b3424f --- /dev/null +++ b/test/widgets/saved_snippet_test.dart @@ -0,0 +1,117 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/icons.dart'; + +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import 'test_app.dart'; + +import '../example_data.dart' as eg; + +void main() { + TestZulipBinding.ensureInitialized(); + + Future prepare(WidgetTester tester, { + required List savedSnippets, + }) async { + addTearDown(testBinding.reset); + final account = eg.account( + user: eg.selfUser, zulipFeatureLevel: eg.futureZulipFeatureLevel); + await testBinding.globalStore.add(account, eg.initialSnapshot( + savedSnippets: savedSnippets, + zulipFeatureLevel: eg.futureZulipFeatureLevel, + )); + final store = await testBinding.globalStore.perAccount(account.id); + final channel = eg.stream(); + await store.addStream(channel); + await store.addSubscription(eg.subscription(channel)); + await store.addUser(eg.selfUser); + + await tester.pumpWidget(TestZulipApp( + accountId: account.id, + child: ComposeBox(narrow: eg.topicNarrow(channel.streamId, 'test')))); + await tester.pumpAndSettle(); + } + + Future tapShowSavedSnippets(WidgetTester tester) async { + await tester.tap(find.byIcon(ZulipIcons.message_square_text)); + await tester.pump(); + await tester.pump(Duration(milliseconds: 250)); // bottom-sheet animation + } + + testWidgets('show placeholder when empty', (tester) async { + await prepare(tester, savedSnippets: []); + + await tapShowSavedSnippets(tester); + check(find.text('No saved snippets')).findsOne(); + }); + + testWidgets('sort saved snippets by title', (tester) async { + const content = 'saved snippet content'; + await prepare(tester, savedSnippets: [ + eg.savedSnippet(title: 'zzz', content: content), + eg.savedSnippet(title: '1abc', content: content), + eg.savedSnippet(title: '1b', content: content), + ]); + Finder findTitleAt(int index) => find.descendant( + of: find.ancestor(of: find.text(content).at(index), + matching: find.byType(Column)), + matching: find.byType(Text)).first; + + await tapShowSavedSnippets(tester); + check( + List.generate(3, (i) => tester.widget(findTitleAt(i))), + ).deepEquals(>[ + (it) => it.isA().data.equals('1abc'), + (it) => it.isA().data.equals('1b'), + (it) => it.isA().data.equals('zzz'), + ]); + }); + + testWidgets('insert into content input', (tester) async { + addTearDown(TypingNotifier.debugReset); + TypingNotifier.debugEnable = false; + await prepare(tester, savedSnippets: [ + eg.savedSnippet( + title: 'saved snippet title', + content: 'saved snippet content'), + ]); + + await tapShowSavedSnippets(tester); + check(find.text('saved snippet title')).findsOne(); + check(find.text('saved snippet content')).findsOne(); + + await tester.tap(find.text('saved snippet content')); + await tester.pump(); + await tester.pump(Duration(milliseconds: 250)); // bottom-sheet animation + check(find.descendant( + of: find.byType(ComposeBox), + matching: find.textContaining('saved snippet content')), + ).findsOne(); + }); + + testWidgets('insert into non-empty content input', (tester) async { + addTearDown(TypingNotifier.debugReset); + TypingNotifier.debugEnable = false; + await prepare(tester, savedSnippets: [ + eg.savedSnippet(content: 'saved snippet content'), + ]); + await tester.enterText(find.byType(TextField), 'some existing content'); + + await tapShowSavedSnippets(tester); + await tester.tap(find.text('saved snippet content')); + await tester.pump(); + await tester.pump(Duration(milliseconds: 250)); // bottom-sheet animation + check(find.descendant( + of: find.byType(ComposeBox), + matching: find.textContaining('some existing content\n\n' + 'saved snippet content')), + ).findsOne(); + }); +}