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