From 90545b179ef1184728cba6b6895254f1208a9202 Mon Sep 17 00:00:00 2001 From: aringenbach <80891108+aringenbach@users.noreply.github.com> Date: Tue, 5 Sep 2023 17:39:54 +0200 Subject: [PATCH] Add RTE formatting buttons (#1614) * Add RTE formatting buttons * Update UUID as stored properties * Disable autocorrection on URL fields * Alert ids to let * Add ComposerToolbar_Previews * Cleanup * Cleanup * Refactor FormatItem colors * Fix composer layout issue * Fix ui tests * Nest ComposerToolbar A11y ids under RoomScreen * Add composer reply ui tests * Add UTs --------- Co-authored-by: Alfonso Grillo --- ElementX.xcodeproj/project.pbxproj | 4 + .../images/composer/Contents.json | 6 + .../composer/bold.imageset/Contents.json | 15 ++ .../images/composer/bold.imageset/bold.svg | 3 + .../bullet-list.imageset/Contents.json | 15 ++ .../bullet-list.imageset/bullet-list.svg | 3 + .../code-block.imageset/Contents.json | 15 ++ .../code-block.imageset/code-block.svg | 3 + .../composer/indent.imageset/Contents.json | 15 ++ .../composer/indent.imageset/indent.svg | 3 + .../inline-code.imageset/Contents.json | 15 ++ .../inline-code.imageset/inline-code.svg | 5 + .../composer/italic.imageset/Contents.json | 15 ++ .../composer/italic.imageset/italic.svg | 3 + .../composer/link.imageset/Contents.json | 15 ++ .../images/composer/link.imageset/link.svg | 3 + .../numbered-list.imageset/Contents.json | 15 ++ .../numbered-list.imageset/numbered-list.svg | 3 + .../composer/quote.imageset/Contents.json | 15 ++ .../images/composer/quote.imageset/quote.svg | 6 + .../strikethrough.imageset/Contents.json | 15 ++ .../strikethrough.imageset/strikethrough.svg | 3 + .../text-format.imageset/Contents.json | 15 ++ .../text-format.imageset/text-format.svg | 10 + .../composer/underline.imageset/Contents.json | 15 ++ .../composer/underline.imageset/underline.svg | 3 + .../composer/unindent.imageset/Contents.json | 15 ++ .../composer/unindent.imageset/unindent.svg | 3 + ElementX/Sources/Generated/Assets.swift | 13 ++ ElementX/Sources/Mocks/RoomProxyMock.swift | 3 + .../Other/AccessibilityIdentifiers.swift | 22 ++- ElementX/Sources/Other/Extensions/Alert.swift | 34 +++- .../ComposerToolbarModels.swift | 175 +++++++++++++++++- .../ComposerToolbarViewModel.swift | 157 +++++++++++++++- .../View/ComposerToolbar.swift | 76 ++++++-- .../View/FormattingToolbar.swift | 68 +++++++ .../View/MessageComposer.swift | 8 +- .../View/RoomAttachmentPicker.swift | 13 +- .../UITests/UITestsAppCoordinator.swift | 2 +- .../UITests/UITestsScreenIdentifier.swift | 1 + IntegrationTests/Sources/UserFlowTests.swift | 4 +- UITests/Sources/UserSessionScreenTests.swift | 25 ++- ...-9th-generation.userSessionScreenReply.png | 3 + ...en-GB-iPhone-14.userSessionScreenReply.png | 3 + ...-9th-generation.userSessionScreenReply.png | 3 + ...seudo-iPhone-14.userSessionScreenReply.png | 3 + .../ComposerToolbarViewModelTests.swift | 41 +++- 47 files changed, 863 insertions(+), 47 deletions(-) create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/bold.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/bullet-list.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/code-block.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/indent.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/inline-code.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/italic.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/link.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/link.imageset/link.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/numbered-list.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/quote.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/strikethrough.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/text-format.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/underline.svg create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/unindent.svg create mode 100644 ElementX/Sources/Screens/ComposerToolbar/View/FormattingToolbar.swift create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.userSessionScreenReply.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.userSessionScreenReply.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.userSessionScreenReply.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.userSessionScreenReply.png diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b2aa10d6b..484be7a0a 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */; }; 0E8C480700870BB34A2A360F /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4003BC24B24C9E63D3304177 /* DeviceKit */; }; 0EA6537A07E2DC882AEA5962 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 187853A7E643995EE49FAD43 /* Localizable.stringsdict */; }; + 0ED691ADC9C2EA457E7A9427 /* FormattingToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */; }; 0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */; }; 0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFCAA239095A116976E32C4 /* BackgroundTaskTests.swift */; }; 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0376C429FAB1687C3D905F3E /* MockCoder.swift */; }; @@ -870,6 +871,7 @@ 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 09CE2B7AD979BDEE09FEDB08 /* WaitlistScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenModels.swift; sourceTree = ""; }; 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = ""; }; + 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = ""; }; 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSenderAvatarView.swift; sourceTree = ""; }; 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModel.swift; sourceTree = ""; }; @@ -2265,6 +2267,7 @@ isa = PBXGroup; children = ( D121B4FCFC38DBCC17BCC6D6 /* ComposerToolbar.swift */, + 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */, A0A01AECFF54281CF35909A6 /* MessageComposer.swift */, 1756F24C1913F809A0039FD0 /* MessageComposerTextField.swift */, 3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */, @@ -4518,6 +4521,7 @@ 5CE74302A0725F56F1E9D2A0 /* FormRow.swift in Sources */, 4166A7DD2A4E2EFF0EB9369B /* FormRowLabelStyle.swift in Sources */, A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */, + 0ED691ADC9C2EA457E7A9427 /* FormattingToolbar.swift in Sources */, 85AFBB433AD56704A880F8A0 /* FramePreferenceKey.swift in Sources */, 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */, F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */, diff --git a/ElementX/Resources/Assets.xcassets/images/composer/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/Contents.json new file mode 100644 index 000000000..07a9f8edb --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bold.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/bold.svg b/ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/bold.svg new file mode 100644 index 000000000..6c11759eb --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/bold.imageset/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/Contents.json new file mode 100644 index 000000000..c92a0363d --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bullet-list.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/bullet-list.svg b/ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/bullet-list.svg new file mode 100644 index 000000000..7478effee --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/bullet-list.imageset/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/Contents.json new file mode 100644 index 000000000..5096f219f --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "code-block.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/code-block.svg b/ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/code-block.svg new file mode 100644 index 000000000..25c6a5a70 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/code-block.imageset/code-block.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/Contents.json new file mode 100644 index 000000000..55f708cbc --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "indent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/indent.svg b/ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/indent.svg new file mode 100644 index 000000000..28d31ba12 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/indent.imageset/indent.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/Contents.json new file mode 100644 index 000000000..1245395b6 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "inline-code.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/inline-code.svg b/ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/inline-code.svg new file mode 100644 index 000000000..f2a71ba10 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/inline-code.imageset/inline-code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/Contents.json new file mode 100644 index 000000000..00d78a9ad --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "italic.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/italic.svg b/ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/italic.svg new file mode 100644 index 000000000..624e8c4d0 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/italic.imageset/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/link.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/link.imageset/Contents.json new file mode 100644 index 000000000..4676f533a --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/link.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "link.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/link.imageset/link.svg b/ElementX/Resources/Assets.xcassets/images/composer/link.imageset/link.svg new file mode 100644 index 000000000..1174c630a --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/link.imageset/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/Contents.json new file mode 100644 index 000000000..9ebb6d811 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "numbered-list.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/numbered-list.svg b/ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/numbered-list.svg new file mode 100644 index 000000000..cc5b509c8 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/numbered-list.imageset/numbered-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/Contents.json new file mode 100644 index 000000000..7b3b7c058 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "quote.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/quote.svg b/ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/quote.svg new file mode 100644 index 000000000..076a80aac --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/quote.imageset/quote.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/Contents.json new file mode 100644 index 000000000..b5f53e2fd --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "strikethrough.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/strikethrough.svg b/ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/strikethrough.svg new file mode 100644 index 000000000..f23fb95f6 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/strikethrough.imageset/strikethrough.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/Contents.json new file mode 100644 index 000000000..f36cbb199 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "text-format.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/text-format.svg b/ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/text-format.svg new file mode 100644 index 000000000..9a5fc49f2 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/text-format.imageset/text-format.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/Contents.json new file mode 100644 index 000000000..f5c442201 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "underline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/underline.svg b/ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/underline.svg new file mode 100644 index 000000000..9ba004cb4 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/underline.imageset/underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/Contents.json new file mode 100644 index 000000000..33820646a --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "unindent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/unindent.svg b/ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/unindent.svg new file mode 100644 index 000000000..1b2ca3794 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/unindent.imageset/unindent.svg @@ -0,0 +1,3 @@ + + + diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index be6965cdf..e6e44e028 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -32,6 +32,19 @@ internal enum Asset { internal static let appLogo = ImageAsset(name: "images/app-logo") internal static let serverSelectionIcon = ImageAsset(name: "images/server-selection-icon") internal static let closeCircle = ImageAsset(name: "images/close-circle") + internal static let bold = ImageAsset(name: "images/bold") + internal static let bulletList = ImageAsset(name: "images/bullet-list") + internal static let codeBlock = ImageAsset(name: "images/code-block") + internal static let indent = ImageAsset(name: "images/indent") + internal static let inlineCode = ImageAsset(name: "images/inline-code") + internal static let italic = ImageAsset(name: "images/italic") + internal static let link = ImageAsset(name: "images/link") + internal static let numberedList = ImageAsset(name: "images/numbered-list") + internal static let quote = ImageAsset(name: "images/quote") + internal static let strikethrough = ImageAsset(name: "images/strikethrough") + internal static let textFormat = ImageAsset(name: "images/text-format") + internal static let underline = ImageAsset(name: "images/underline") + internal static let unindent = ImageAsset(name: "images/unindent") internal static let encryptionNormal = ImageAsset(name: "images/encryption-normal") internal static let encryptionTrusted = ImageAsset(name: "images/encryption-trusted") internal static let encryptionWarning = ImageAsset(name: "images/encryption-warning") diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 15e91ee07..f7f5ba8bf 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -34,6 +34,7 @@ struct RoomProxyMockConfiguration { var members: [RoomMemberProxyProtocol]? var inviter: RoomMemberProxyMock? var memberForID: RoomMemberProxyMock = .mockMe + var ownUserID = "@alice:somewhere.org" var invitedMembersCount = 100 var joinedMembersCount = 50 @@ -61,6 +62,7 @@ extension RoomProxyMock { invitedMembersCount = configuration.invitedMembersCount joinedMembersCount = configuration.joinedMembersCount activeMembersCount = configuration.activeMembersCount + ownUserID = configuration.ownUserID if let members = configuration.members { membersPublisher = Just(members).eraseToAnyPublisher() @@ -78,5 +80,6 @@ extension RoomProxyMock { setNameClosure = { _ in .success(()) } setTopicClosure = { _ in .success(()) } getMemberUserIDReturnValue = .success(configuration.memberForID) + canUserRedactUserIDReturnValue = .success(false) } } diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index f0aa88bfc..9fa7e4a58 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -106,12 +106,32 @@ struct A11yIdentifiers { struct RoomScreen { let name = "room-name" let avatar = "room-avatar" - let attachmentPicker = "room-attachment_picker" let attachmentPickerPhotoLibrary = "room-attachment_picker_photo_library" let attachmentPickerDocuments = "room-attachment_picker_documents" let attachmentPickerCamera = "room-attachment_picker_camera" let attachmentPickerLocation = "room-attachment_picker_location" + let attachmentPickerPoll = "room-attachment_picker_poll" + let attachmentPickerTextFormatting = "room-attachment_picker_text_formatting" let timelineItemActionMenu = "room-timeline_item_action_menu" + + let composerToolbar = ComposerToolbar() + + struct ComposerToolbar { + let bold = "composer_toolbar-bold" + let italic = "composer_toolbar-italic" + let underline = "composer_toolbar-underline" + let strikethrough = "composer_toolbar-strikethrough" + let unorderedList = "composer_toolbar-unordered_list" + let orderedList = "composer_toolbar-ordered_list" + let indent = "composer_toolbar-indent" + let unindent = "composer_toolbar-unindent" + let inlineCode = "composer_toolbar-inline_code" + let codeBlock = "composer_toolbar-code_block" + let quote = "composer_toolbar-quote" + let link = "composer_toolbar-link" + let openComposeOptions = "composer_toolbar-open_compose_options" + let closeFormattingOptions = "composer_toolbar-close-formatting-options" + } } struct RoomDetailsScreen { diff --git a/ElementX/Sources/Other/Extensions/Alert.swift b/ElementX/Sources/Other/Extensions/Alert.swift index 7f1fcc4a5..a67911b2e 100644 --- a/ElementX/Sources/Other/Extensions/Alert.swift +++ b/ElementX/Sources/Other/Extensions/Alert.swift @@ -52,12 +52,21 @@ extension View { /// .alert(item: $context.alertInfo) /// ``` struct AlertInfo: Identifiable, AlertProtocol { - struct AlertButton { + struct AlertButton: Identifiable { + let id = UUID() let title: String var role: ButtonRole? let action: (() -> Void)? } + struct AlertTextField: Identifiable { + let id = UUID() + let placeholder: String + let text: Binding + let autoCapitalization: TextInputAutocapitalization + let autoCorrectionDisabled: Bool + } + /// An identifier that can be used to distinguish one error from another. let id: T /// The alert's title. @@ -68,6 +77,10 @@ struct AlertInfo: Identifiable, AlertProtocol { var primaryButton = AlertButton(title: L10n.actionOk, action: nil) /// The alert's secondary button title and action. var secondaryButton: AlertButton? + /// The alert's displayed text fields. + var textFields: [AlertTextField]? + /// The alert's additional buttons displayed vertically above the primary button. + var verticalButtons: [AlertButton]? } extension AlertInfo { @@ -95,9 +108,28 @@ extension AlertInfo { extension View { func alert(item: Binding?>) -> some View { alert(item: item) { item in + if let verticalButtons = item.verticalButtons { + ForEach(verticalButtons) { button in + Button(button.title, role: button.role) { + button.action?() + } + } + } + + if let textFields = item.textFields { + VStack(spacing: 24) { + ForEach(textFields) { textField in + TextField(textField.placeholder, text: textField.text) + .textInputAutocapitalization(textField.autoCapitalization) + .autocorrectionDisabled(textField.autoCorrectionDisabled) + } + } + } + Button(item.primaryButton.title, role: item.primaryButton.role) { item.primaryButton.action?() } + if let secondaryButton = item.secondaryButton { Button(secondaryButton.title, role: secondaryButton.role) { secondaryButton.action?() diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index f99b749c8..68a1816a6 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -14,7 +14,9 @@ // limitations under the License. // +import SwiftUI import UIKit +import WysiwygComposer enum ComposerToolbarViewModelAction { case sendMessage(plain: String, html: String, mode: RoomScreenComposerMode) @@ -43,6 +45,8 @@ enum ComposerToolbarViewAction { case displayLocationPicker case displayPollForm case handlePasteOrDrop(provider: NSItemProvider) + case enableTextFormatting + case composerAction(action: ComposerAction) } struct ComposerToolbarViewState: BindableState { @@ -61,8 +65,11 @@ struct ComposerToolbarViewState: BindableState { } struct ComposerToolbarViewStateBindings { - var composerPlainText: String - var composerFocused: Bool + var composerPlainText = "" + var composerFocused = false + var composerActionsEnabled = false + var formatItems: [FormatItem] = .init() + var alertInfo: AlertInfo? var showAttachmentPopover = false { didSet { @@ -72,3 +79,167 @@ struct ComposerToolbarViewStateBindings { } } } + +/// An item in the toolbar +struct FormatItem { + /// The type of the item + let type: FormatType + /// The state of the item + let state: ActionState +} + +/// The types of formatting actions +enum FormatType { + case bold + case italic + case underline + case strikeThrough + case unorderedList + case orderedList + case indent + case unindent + case inlineCode + case codeBlock + case quote + case link +} + +extension FormatType: CaseIterable, Identifiable { + var id: Self { self } +} + +extension FormatItem: Identifiable { + var id: FormatType { type } +} + +extension FormatItem { + /// The icon to display in the formatting toolbar. + var icon: Image { + switch type { + case .bold: + return Image(asset: Asset.Images.bold) + case .italic: + return Image(asset: Asset.Images.italic) + case .underline: + return Image(asset: Asset.Images.underline) + case .strikeThrough: + return Image(asset: Asset.Images.strikethrough) + case .unorderedList: + return Image(asset: Asset.Images.bulletList) + case .orderedList: + return Image(asset: Asset.Images.numberedList) + case .indent: + return Image(asset: Asset.Images.indent) + case .unindent: + return Image(asset: Asset.Images.unindent) + case .inlineCode: + return Image(asset: Asset.Images.inlineCode) + case .codeBlock: + return Image(asset: Asset.Images.codeBlock) + case .quote: + return Image(asset: Asset.Images.quote) + case .link: + return Image(asset: Asset.Images.link) + } + } + + var accessibilityIdentifier: String { + switch type { + case .bold: + return A11yIdentifiers.roomScreen.composerToolbar.bold + case .italic: + return A11yIdentifiers.roomScreen.composerToolbar.italic + case .underline: + return A11yIdentifiers.roomScreen.composerToolbar.underline + case .strikeThrough: + return A11yIdentifiers.roomScreen.composerToolbar.strikethrough + case .unorderedList: + return A11yIdentifiers.roomScreen.composerToolbar.unorderedList + case .orderedList: + return A11yIdentifiers.roomScreen.composerToolbar.orderedList + case .indent: + return A11yIdentifiers.roomScreen.composerToolbar.indent + case .unindent: + return A11yIdentifiers.roomScreen.composerToolbar.unindent + case .inlineCode: + return A11yIdentifiers.roomScreen.composerToolbar.inlineCode + case .codeBlock: + return A11yIdentifiers.roomScreen.composerToolbar.codeBlock + case .quote: + return A11yIdentifiers.roomScreen.composerToolbar.quote + case .link: + return A11yIdentifiers.roomScreen.composerToolbar.link + } + } + + var accessibilityLabel: String { + switch type { + case .bold: + return L10n.richTextEditorFormatBold + case .italic: + return L10n.richTextEditorFormatItalic + case .underline: + return L10n.richTextEditorFormatUnderline + case .strikeThrough: + return L10n.richTextEditorFormatStrikethrough + case .unorderedList: + return L10n.richTextEditorBulletList + case .orderedList: + return L10n.richTextEditorNumberedList + case .indent: + return L10n.richTextEditorIndent + case .unindent: + return L10n.richTextEditorUnindent + case .inlineCode: + return L10n.richTextEditorInlineCode + case .codeBlock: + return L10n.richTextEditorCodeBlock + case .quote: + return L10n.richTextEditorQuote + case .link: + return L10n.richTextEditorLink + } + } +} + +extension FormatType { + /// The associated library composer action. + var composerAction: ComposerAction { + switch self { + case .bold: + return .bold + case .italic: + return .italic + case .underline: + return .underline + case .strikeThrough: + return .strikeThrough + case .unorderedList: + return .unorderedList + case .orderedList: + return .orderedList + case .indent: + return .indent + case .unindent: + return .unindent + case .inlineCode: + return .inlineCode + case .codeBlock: + return .codeBlock + case .quote: + return .quote + case .link: + return .link + } + } + + /// Return true if the format type is an indentation action. + var isIndentType: Bool { + switch self { + case .indent, .unindent: + return true + default: + return false + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index dfd9a92a8..4a0f2e20e 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -15,6 +15,8 @@ // import Combine +import Foundation +import SwiftUI import WysiwygComposer typealias ComposerToolbarViewModelType = StateStoreViewModel @@ -26,10 +28,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool actionsSubject.eraseToAnyPublisher() } + private struct WysiwygLinkData { + let action: LinkAction + let range: NSRange + var url: String + var text: String + } + + private var currentLinkData: WysiwygLinkData? + init(wysiwygViewModel: WysiwygComposerViewModel) { self.wysiwygViewModel = wysiwygViewModel - super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerPlainText: "", composerFocused: false))) + super.init(initialViewState: ComposerToolbarViewState(bindings: .init())) context.$viewState .map(\.composerMode) @@ -46,6 +57,20 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool wysiwygViewModel.$isContentEmpty .weakAssign(to: \.state.composerEmpty, on: self) .store(in: &cancellables) + + wysiwygViewModel.$actionStates + .map { actions in + FormatType + .allCases + // Exclude indent type outside of lists. + .filter { wysiwygViewModel.isInList || !$0.isIndentType } + .map { type in + FormatItem(type: type, + state: actions[type.composerAction] ?? .disabled) + } + } + .weakAssign(to: \.state.bindings.formatItems, on: self) + .store(in: &cancellables) } // MARK: - Public @@ -65,6 +90,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText, mode: state.composerMode)) } + state.bindings.composerActionsEnabled = false case .cancelReply: set(mode: .default) case .cancelEdit: @@ -82,6 +108,15 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool actionsSubject.send(.displayPollForm) case .handlePasteOrDrop(let provider): actionsSubject.send(.handlePasteOrDrop(provider: provider)) + case .enableTextFormatting: + state.bindings.composerActionsEnabled = true + state.bindings.composerFocused = true + case .composerAction(let action): + if action == .link { + createLinkAlert() + } else { + wysiwygViewModel.apply(action) + } } } @@ -128,4 +163,124 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool state.bindings.composerPlainText = text } } + + private func createLinkAlert() { + let linkAction = wysiwygViewModel.getLinkAction() + currentLinkData = WysiwygLinkData(action: linkAction, + range: wysiwygViewModel.attributedContent.selection, + url: linkAction.url ?? "", + text: "") + + let urlBinding: Binding = .init { [weak self] in + self?.currentLinkData?.url ?? "" + } set: { [weak self] value in + self?.currentLinkData?.url = value + } + + let textBinding: Binding = .init { [weak self] in + self?.currentLinkData?.text ?? "" + } set: { [weak self] value in + self?.currentLinkData?.text = value + } + + switch linkAction { + case .createWithText: + state.bindings.alertInfo = makeCreateWithTextAlertInfo(urlBinding: urlBinding, textBinding: textBinding) + case .create: + state.bindings.alertInfo = makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: false) + case .edit: + state.bindings.alertInfo = makeEditChoiceAlertInfo(urlBinding: urlBinding) + case .disabled: + break + } + } + + private func makeCreateWithTextAlertInfo(urlBinding: Binding, textBinding: Binding) -> AlertInfo { + AlertInfo(id: UUID(), + title: L10n.richTextEditorCreateLink, + primaryButton: AlertInfo.AlertButton(title: L10n.actionCancel, action: { + self.restoreComposerSelectedRange() + }), + secondaryButton: AlertInfo.AlertButton(title: L10n.actionSave, action: { + self.restoreComposerSelectedRange() + self.createLinkWithText() + + }), + textFields: [AlertInfo.AlertTextField(placeholder: L10n.commonText, + text: textBinding, + autoCapitalization: .never, + autoCorrectionDisabled: false), + AlertInfo.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder, + text: urlBinding, + autoCapitalization: .never, + autoCorrectionDisabled: true)]) + } + + private func makeSetUrlAlertInfo(urlBinding: Binding, isEdit: Bool) -> AlertInfo { + AlertInfo(id: UUID(), + title: isEdit ? L10n.richTextEditorEditLink : L10n.richTextEditorCreateLink, + primaryButton: AlertInfo.AlertButton(title: L10n.actionCancel, action: { + self.restoreComposerSelectedRange() + }), + secondaryButton: AlertInfo.AlertButton(title: L10n.actionSave, action: { + self.restoreComposerSelectedRange() + self.setLink() + + }), + textFields: [AlertInfo.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder, + text: urlBinding, + autoCapitalization: .never, + autoCorrectionDisabled: true)]) + } + + private func makeEditChoiceAlertInfo(urlBinding: Binding) -> AlertInfo { + AlertInfo(id: UUID(), + title: L10n.richTextEditorEditLink, + primaryButton: AlertInfo.AlertButton(title: L10n.actionRemove, role: .destructive, action: { + self.restoreComposerSelectedRange() + self.removeLinks() + }), + verticalButtons: [AlertInfo.AlertButton(title: L10n.actionEdit, action: { + self.state.bindings.alertInfo = nil + DispatchQueue.main.async { + self.state.bindings.alertInfo = self.makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: true) + } + })]) + } + + private func restoreComposerSelectedRange() { + guard let currentLinkData else { return } + wysiwygViewModel.select(range: currentLinkData.range) + } + + private func setLink() { + guard let currentLinkData else { return } + wysiwygViewModel.applyLinkOperation(.setLink(urlString: currentLinkData.url)) + } + + private func createLinkWithText() { + guard let currentLinkData else { return } + wysiwygViewModel.applyLinkOperation(.createLink(urlString: currentLinkData.url, + text: currentLinkData.text)) + } + + private func removeLinks() { + wysiwygViewModel.applyLinkOperation(.removeLinks) + } +} + +private extension WysiwygComposerViewModel { + /// Return true if the selection of the composer is currently located in a list. + var isInList: Bool { + actionStates[.orderedList] == .reversed || actionStates[.unorderedList] == .reversed + } +} + +private extension LinkAction { + var url: String? { + guard case .edit(let url) = self else { + return nil + } + return url + } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index bce529a29..8cade5e45 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -26,29 +26,65 @@ struct ComposerToolbar: View { @ScaledMetric private var sendButtonIconSize = 16 var body: some View { + VStack(spacing: 8) { + topBar + if context.composerActionsEnabled { + bottomBar + } + } + .alert(item: $context.alertInfo) + } + + private var topBar: some View { HStack(alignment: .bottom, spacing: 10) { - RoomAttachmentPicker(context: context) - .padding(.bottom, 5) // centre align with the send button + if !context.composerActionsEnabled { + RoomAttachmentPicker(context: context) + .padding(.bottom, 5) // centre align with the send button + } messageComposer .environmentObject(context) - Button { - context.send(viewAction: .sendMessage) - } label: { - submitButtonImage - .symbolVariant(.fill) - .font(.compound.bodyLG) - .foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .global.white) - .background { - Circle() - .foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary) - } + if !context.composerActionsEnabled { + sendButton } - .disabled(context.viewState.sendButtonDisabled) - .animation(.linear(duration: 0.1), value: context.viewState.sendButtonDisabled) - .keyboardShortcut(.return, modifiers: [.command]) - .padding([.vertical, .trailing], 6) } } + + private var bottomBar: some View { + HStack(alignment: .bottom, spacing: 10) { + Button { + context.composerActionsEnabled = false + } label: { + Image(systemName: "xmark.circle.fill") + .font(.compound.headingLG) + .foregroundColor(.compound.textActionPrimary) + } + .accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions) + .padding(.bottom, 5) // centre align with the send button + FormattingToolbar(formatItems: context.formatItems) { action in + context.send(viewAction: .composerAction(action: action.composerAction)) + } + sendButton + } + } + + private var sendButton: some View { + Button { + context.send(viewAction: .sendMessage) + } label: { + submitButtonImage + .symbolVariant(.fill) + .font(.compound.bodyLG) + .foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .global.white) + .background { + Circle() + .foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary) + } + } + .disabled(context.viewState.sendButtonDisabled) + .animation(.linear(duration: 0.1), value: context.viewState.sendButtonDisabled) + .keyboardShortcut(.return, modifiers: [.command]) + .padding([.vertical, .trailing], 6) + } private var messageComposer: some View { MessageComposer(plainText: $context.composerPlainText, @@ -109,6 +145,12 @@ struct ComposerToolbar: View { } } +struct ComposerToolbar_Previews: PreviewProvider { + static var previews: some View { + ComposerToolbar.mock() + } +} + // MARK: - Mock extension ComposerToolbar { diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/FormattingToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/FormattingToolbar.swift new file mode 100644 index 000000000..561c3824f --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/FormattingToolbar.swift @@ -0,0 +1,68 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct FormattingToolbar: View { + /// The list of items to render in the toolbar + var formatItems: [FormatItem] + /// The action when an item is selected + var formatAction: (FormatType) -> Void + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 4) { + ForEach(formatItems) { item in + Button { + formatAction(item.type) + } label: { + item.icon + .renderingMode(.template) + .foregroundColor(item.foregroundColor) + } + .disabled(item.state == .disabled) + .frame(width: 44, height: 44) + .background(item.backgroundColor) + .cornerRadius(8) + .accessibilityIdentifier(item.accessibilityIdentifier) + .accessibilityLabel(item.accessibilityLabel) + } + } + } + } +} + +private extension FormatItem { + var foregroundColor: Color { + switch state { + case .reversed: + return .compound.iconOnSolidPrimary + case .enabled: + return .compound.iconSecondary + case .disabled: + return .compound.iconDisabled + } + } + + var backgroundColor: Color { + switch state { + case .reversed: + return .compound.bgActionPrimaryRest + default: + return .compound.bgCanvasDefault + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index d9f3539f6..f509f1bc4 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -59,7 +59,7 @@ struct MessageComposer: View { } } } - .padding([.leading, .trailing], 12.0) + .padding(.horizontal, 12.0) .clipped() .background { ZStack { @@ -107,16 +107,16 @@ private struct MessageComposerReplyHeader: View { .padding(4.0) .background(Color.compound.bgCanvasDefault) .cornerRadius(13.0) - .padding([.trailing, .vertical], 8.0) - .padding([.leading], -4.0) .overlay(alignment: .topTrailing) { Button(action: action) { Image(systemName: "xmark") .font(.compound.bodySM.weight(.medium)) .foregroundColor(.compound.iconTertiary) - .padding(16.0) + .padding(8.0) } } + .padding(.vertical, 8.0) + .padding(.horizontal, -4.0) } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift b/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift index 5ee391a8e..06e51dd8b 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift @@ -31,7 +31,7 @@ struct RoomAttachmentPicker: View { .font(.compound.headingLG) .foregroundColor(.compound.textActionPrimary) } - .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPicker) + .accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions) .popover(isPresented: $context.showAttachmentPopover) { VStack(alignment: .leading, spacing: 0.0) { Button { @@ -72,6 +72,17 @@ struct RoomAttachmentPicker: View { } label: { PickerLabel(title: L10n.screenRoomAttachmentSourcePoll, icon: Image(asset: Asset.Images.timelinePollAttachment)) } + .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll) + + if ServiceLocator.shared.settings.richTextEditorEnabled { + Button { + context.showAttachmentPopover = false + context.send(viewAction: .enableTextFormatting) + } label: { + PickerLabel(title: L10n.screenRoomAttachmentTextFormatting, icon: Image(asset: Asset.Images.textFormat)) + } + .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting) + } } .padding(.top, isPresented ? 20 : 0) .background { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 0984c36ad..c82837815 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -368,7 +368,7 @@ class MockScreen: Identifiable { var sessionVerificationControllerProxy = SessionVerificationControllerProxyMock.configureMock(requestDelay: .seconds(5)) let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationControllerProxy) return SessionVerificationScreenCoordinator(parameters: parameters) - case .userSessionScreen: + case .userSessionScreen, .userSessionScreenReply: let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator()) let clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))) diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index d09d87550..1d95f48e9 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -50,6 +50,7 @@ enum UITestsScreenIdentifier: String { case roomWithUndisclosedPolls case sessionVerification case userSessionScreen + case userSessionScreenReply case roomDetailsScreen case roomDetailsScreenWithRoomAvatar case roomDetailsScreenWithEmptyTopic diff --git a/IntegrationTests/Sources/UserFlowTests.swift b/IntegrationTests/Sources/UserFlowTests.swift index 5f93771ff..c4b80abe8 100644 --- a/IntegrationTests/Sources/UserFlowTests.swift +++ b/IntegrationTests/Sources/UserFlowTests.swift @@ -59,13 +59,13 @@ class UserFlowTests: XCTestCase { for identifier in [A11yIdentifiers.roomScreen.attachmentPickerPhotoLibrary, A11yIdentifiers.roomScreen.attachmentPickerDocuments, A11yIdentifiers.roomScreen.attachmentPickerLocation] { - tapOnButton(A11yIdentifiers.roomScreen.attachmentPicker) + tapOnButton(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions) tapOnButton(identifier) tapOnButton("Cancel") } // Open attachments picker - tapOnButton(A11yIdentifiers.roomScreen.attachmentPicker) + tapOnButton(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions) // Open photo library picker tapOnButton(A11yIdentifiers.roomScreen.attachmentPickerPhotoLibrary) diff --git a/UITests/Sources/UserSessionScreenTests.swift b/UITests/Sources/UserSessionScreenTests.swift index 0bafbc781..9d6d94657 100644 --- a/UITests/Sources/UserSessionScreenTests.swift +++ b/UITests/Sources/UserSessionScreenTests.swift @@ -21,21 +21,28 @@ import XCTest class UserSessionScreenTests: XCTestCase { func testUserSessionFlows() async throws { let roomName = "First room" - let app = Application.launch(.userSessionScreen) - try await app.assertScreenshot(.userSessionScreen, step: 1) - + app.buttons[A11yIdentifiers.homeScreen.roomName(roomName)].tap() - XCTAssert(app.staticTexts[roomName].waitForExistence(timeout: 5.0)) - try await Task.sleep(for: .seconds(1)) - try await app.assertScreenshot(.userSessionScreen, step: 2) - - app.buttons[A11yIdentifiers.roomScreen.attachmentPicker].tap() - + + app.buttons[A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions].tap() try await app.assertScreenshot(.userSessionScreen, step: 3) } + + func testUserSessionReply() async throws { + let roomName = "First room" + let app = Application.launch(.userSessionScreenReply, disableTimelineAccessibility: false) + app.buttons[A11yIdentifiers.homeScreen.roomName(roomName)].tap() + XCTAssert(app.staticTexts[roomName].waitForExistence(timeout: 5.0)) + try await Task.sleep(for: .seconds(1)) + + let cell = app.cells.firstMatch + cell.swipeRight(velocity: .fast) + + try await app.assertScreenshot(.userSessionScreenReply) + } } diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.userSessionScreenReply.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.userSessionScreenReply.png new file mode 100644 index 000000000..81eff68d4 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.userSessionScreenReply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d62a74c1b78bd48824bf06d97344c49f30fb5db1bd1c87b960e8f5ce20a0227 +size 330298 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.userSessionScreenReply.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.userSessionScreenReply.png new file mode 100644 index 000000000..9cd4d3eb6 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.userSessionScreenReply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33826c63e307b6990c1b890699f0aebfc855819c8d9dabeea76d65c2cf6dd64c +size 291077 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.userSessionScreenReply.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.userSessionScreenReply.png new file mode 100644 index 000000000..ea4878fb2 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.userSessionScreenReply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f508eff9ade01d498119147fd543ffdd363a40fa2cc2865710eb59283813e5d0 +size 332447 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.userSessionScreenReply.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.userSessionScreenReply.png new file mode 100644 index 000000000..6c05e31ec --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.userSessionScreenReply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51a6d371dc7b105205e5aea957b084752106c7d046098a84c15f7dcdde9742dc +size 291289 diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index c08f2ac65..3dac7cb5b 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -20,9 +20,20 @@ import XCTest @MainActor class ComposerToolbarViewModelTests: XCTestCase { + private var appSettings: AppSettings! + private var wysiwygViewModel: WysiwygComposerViewModel! + private var viewModel: ComposerToolbarViewModel! + + override func setUp() { + AppSettings.reset() + appSettings = AppSettings() + appSettings.richTextEditorEnabled = true + ServiceLocator.shared.register(appSettings: appSettings) + wysiwygViewModel = WysiwygComposerViewModel() + viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) + } + func testComposerFocus() { - let wysiwygViewModel = WysiwygComposerViewModel() - let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")))) XCTAssertTrue(viewModel.state.bindings.composerFocused) viewModel.process(roomAction: .removeFocus) @@ -30,8 +41,6 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testComposerMode() { - let wysiwygViewModel = WysiwygComposerViewModel() - let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")) viewModel.process(roomAction: .setMode(mode: mode)) XCTAssertEqual(viewModel.state.composerMode, mode) @@ -40,8 +49,6 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testComposerModeIsPublished() { - let wysiwygViewModel = WysiwygComposerViewModel() - let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock")) let expectation = expectation(description: "Composer mode is published") let cancellable = viewModel @@ -62,9 +69,27 @@ class ComposerToolbarViewModelTests: XCTestCase { } func testHandleKeyCommand() { - let wysiwygViewModel = WysiwygComposerViewModel() - let viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) XCTAssertTrue(viewModel.handleKeyCommand(.enter)) XCTAssertFalse(viewModel.handleKeyCommand(.shiftEnter)) } + + func testComposerFocusAfterEnablingRTE() { + viewModel.process(viewAction: .enableTextFormatting) + XCTAssertTrue(viewModel.state.bindings.composerFocused) + } + + func testRTEDisabledAfterSendingMessage() { + viewModel.process(viewAction: .enableTextFormatting) + XCTAssertTrue(viewModel.state.bindings.composerFocused) + viewModel.state.composerEmpty = false + viewModel.process(viewAction: .sendMessage) + XCTAssertFalse(viewModel.state.bindings.composerActionsEnabled) + } + + func testAlertIsShownAfterLinkAction() { + XCTAssertNil(viewModel.state.bindings.alertInfo) + viewModel.process(viewAction: .enableTextFormatting) + viewModel.process(viewAction: .composerAction(action: .link)) + XCTAssertNotNil(viewModel.state.bindings.alertInfo) + } }