diff --git a/crates/wysiwyg/src/composer_model/menu_action.rs b/crates/wysiwyg/src/composer_model/menu_action.rs index ac1f589658..4d0edc7908 100644 --- a/crates/wysiwyg/src/composer_model/menu_action.rs +++ b/crates/wysiwyg/src/composer_model/menu_action.rs @@ -32,7 +32,7 @@ where return MenuAction::None; } let (raw_text, start, end) = self.extended_text(range); - if let Some((key, text)) = Self::pattern_for_text(raw_text) { + if let Some((key, text)) = Self::pattern_for_text(raw_text, start) { MenuAction::Suggestion(SuggestionPattern { key, text, @@ -71,7 +71,10 @@ where /// Compute at/hash/slash pattern for a given text. /// Return pattern key and associated text, if it exists. - fn pattern_for_text(mut text: S) -> Option<(PatternKey, String)> { + fn pattern_for_text( + mut text: S, + start_location: usize, + ) -> Option<(PatternKey, String)> { let Some(first_char) = text.pop_first() else { return None; }; @@ -79,8 +82,11 @@ where return None; }; - // Exclude if there is inner whitespaces. - if text.chars().any(|c| c.is_whitespace()) { + // Exclude slash patterns that are not at the beginning of the document + // and any selection that contains inner whitespaces. + if (key == PatternKey::Slash && start_location > 0) + || text.chars().any(|c| c.is_whitespace()) + { None } else { Some((key, text.to_string())) diff --git a/crates/wysiwyg/src/tests/test_menu_action.rs b/crates/wysiwyg/src/tests/test_menu_action.rs index 812ee7ffc5..ffcc076a33 100644 --- a/crates/wysiwyg/src/tests/test_menu_action.rs +++ b/crates/wysiwyg/src/tests/test_menu_action.rs @@ -115,6 +115,12 @@ fn slash_pattern_is_detected() { assert_eq!(model.compute_menu_action(), sp(Slash, "invi", 0, 5)); } +#[test] +fn slash_pattern_is_not_detected_if_not_at_the_beginning_of_dom() { + let model = cm("abc /invi|"); + assert_eq!(model.compute_menu_action(), MenuAction::None); +} + // MenuAction update tests. #[test] fn at_pattern_is_updated_on_character_input() { diff --git a/platforms/ios/example/Shared/View+Accessibility.swift b/platforms/ios/example/Shared/View+Accessibility.swift index 08f5730de2..14aa26f576 100644 --- a/platforms/ios/example/Shared/View+Accessibility.swift +++ b/platforms/ios/example/Shared/View+Accessibility.swift @@ -46,6 +46,19 @@ public enum WysiwygSharedAccessibilityIdentifier: String { case showTreeButton = "WysiwygShowTreeButton" case treeText = "WysiwygTreeText" case forceCrashButton = "WysiwygForceCrashButton" + case setHtmlButton = "WysiwygSetHtmlButton" + case setHtmlField = "WysiwygSetHtmlField" + + // Mock buttons for menu + case aliceButton = "WysiwygMenuAliceButton" + case bobButton = "WysiwygMenuBobButton" + case charlieButton = "WysiwygMenuCharlieButton" + case room1Button = "WysiwygMenuRoom1Button" + case room2Button = "WysiwygMenuRoom2Button" + case room3Button = "WysiwygMenuRoom3Button" + case joinCommandButton = "WysiwygMenuJoinButton" + case inviteCommandButton = "WysiwygMenuInviteButton" + case meCommandButton = "WysiwygMenuMeButton" } public extension View { diff --git a/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj b/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj index 6af89353b1..fc37a745d3 100644 --- a/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj +++ b/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ A64AB144296C747C00F08494 /* WysiwygUITests+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64AB143296C747C00F08494 /* WysiwygUITests+Format.swift */; }; A64AB146296C759A00F08494 /* WysiwygUITests+Quotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64AB145296C759A00F08494 /* WysiwygUITests+Quotes.swift */; }; A64AB148296C769000F08494 /* WysiwygUITests+CodeBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64AB147296C769000F08494 /* WysiwygUITests+CodeBlocks.swift */; }; + A661FDA729B0ACB400E799A6 /* WysiwygUITests+Suggestions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A661FDA629B0ACB400E799A6 /* WysiwygUITests+Suggestions.swift */; }; A6852F032981643900632252 /* WysiwygUITests+Lists.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6852F022981643900632252 /* WysiwygUITests+Lists.swift */; }; A6852F052981661000632252 /* WysiwygUITests+Indent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6852F042981661000632252 /* WysiwygUITests+Indent.swift */; }; A68E713C291D34A50023CC04 /* View+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157428C0E95C00C8E727 /* View+Accessibility.swift */; }; @@ -27,10 +28,21 @@ A6C2157528C0E95C00C8E727 /* View+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157428C0E95C00C8E727 /* View+Accessibility.swift */; }; A6C2157928C0F62000C8E727 /* WysiwygActionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157828C0F62000C8E727 /* WysiwygActionToolbar.swift */; }; A6C2157C28C0FAAD00C8E727 /* WysiwygAction+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157B28C0FAAD00C8E727 /* WysiwygAction+Utils.swift */; }; + A6E13E4829A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4729A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift */; }; + A6E13E4B29A8EE6D00A85A55 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4A29A8EE6D00A85A55 /* AppDelegate.swift */; }; + A6E13E4D29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4C29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift */; }; + A6E13E4F29A8F00200A85A55 /* WysiwygTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4E29A8F00200A85A55 /* WysiwygTextAttachment.swift */; }; + A6E13E5129A8F06E00A85A55 /* SerializationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E5029A8F06E00A85A55 /* SerializationService.swift */; }; + A6E13E5329A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E5229A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift */; }; + A6E13E5529A8F1C400A85A55 /* WysiwygAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E5429A8F1C400A85A55 /* WysiwygAttachmentView.swift */; }; A6E6B26F2886D9AA009596F2 /* WysiwygComposer in Frameworks */ = {isa = PBXBuildFile; productRef = A6E6B26E2886D9AA009596F2 /* WysiwygComposer */; }; A6F3FC0128D4658000C170E8 /* AlertHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F3FC0028D4658000C170E8 /* AlertHelper.swift */; }; A6F3FC0428D465AF00C170E8 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F3FC0328D465AF00C170E8 /* View.swift */; }; A6F3FC0628DA123900C170E8 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F3FC0528DA123900C170E8 /* UIAlertController.swift */; }; + A6F4D0CC29AE082900087A3E /* WysiwygSuggestionList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F4D0CB29AE082900087A3E /* WysiwygSuggestionList.swift */; }; + A6F4D0CF29AE0C1500087A3E /* Users.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F4D0CE29AE0C1500087A3E /* Users.swift */; }; + A6F4D0D129AE0C3200087A3E /* Rooms.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F4D0D029AE0C3200087A3E /* Rooms.swift */; }; + A6F4D0D329AE0C5100087A3E /* Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F4D0D229AE0C5100087A3E /* Commands.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,16 +70,29 @@ A64AB143296C747C00F08494 /* WysiwygUITests+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygUITests+Format.swift"; sourceTree = ""; }; A64AB145296C759A00F08494 /* WysiwygUITests+Quotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygUITests+Quotes.swift"; sourceTree = ""; }; A64AB147296C769000F08494 /* WysiwygUITests+CodeBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygUITests+CodeBlocks.swift"; sourceTree = ""; }; + A661FDA629B0ACB400E799A6 /* WysiwygUITests+Suggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygUITests+Suggestions.swift"; sourceTree = ""; }; A6852F022981643900632252 /* WysiwygUITests+Lists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygUITests+Lists.swift"; sourceTree = ""; }; A6852F042981661000632252 /* WysiwygUITests+Indent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygUITests+Indent.swift"; sourceTree = ""; }; A68E713F291D40710023CC04 /* WysiwygSharedConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygSharedConstants.swift; sourceTree = ""; }; A6C2157428C0E95C00C8E727 /* View+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Accessibility.swift"; sourceTree = ""; }; A6C2157828C0F62000C8E727 /* WysiwygActionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygActionToolbar.swift; sourceTree = ""; }; A6C2157B28C0FAAD00C8E727 /* WysiwygAction+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygAction+Utils.swift"; sourceTree = ""; }; + A6E13E4729A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygPermalinkReplacer.swift; sourceTree = ""; }; + A6E13E4929A8EC0200A85A55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + A6E13E4A29A8EE6D00A85A55 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A6E13E4C29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygAttachmentViewProvider.swift; sourceTree = ""; }; + A6E13E4E29A8F00200A85A55 /* WysiwygTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygTextAttachment.swift; sourceTree = ""; }; + A6E13E5029A8F06E00A85A55 /* SerializationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerializationService.swift; sourceTree = ""; }; + A6E13E5229A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygTextAttachmentData.swift; sourceTree = ""; }; + A6E13E5429A8F1C400A85A55 /* WysiwygAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygAttachmentView.swift; sourceTree = ""; }; A6E6B2712886DA6E009596F2 /* WysiwygComposer */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = WysiwygComposer; path = ../lib/WysiwygComposer; sourceTree = ""; }; A6F3FC0028D4658000C170E8 /* AlertHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertHelper.swift; sourceTree = ""; }; A6F3FC0328D465AF00C170E8 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; A6F3FC0528DA123900C170E8 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; + A6F4D0CB29AE082900087A3E /* WysiwygSuggestionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygSuggestionList.swift; sourceTree = ""; }; + A6F4D0CE29AE0C1500087A3E /* Users.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Users.swift; sourceTree = ""; }; + A6F4D0D029AE0C3200087A3E /* Rooms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rooms.swift; sourceTree = ""; }; + A6F4D0D229AE0C5100087A3E /* Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Commands.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -113,10 +138,14 @@ A6472CA92886CF830021A0E8 /* Wysiwyg */ = { isa = PBXGroup; children = ( + A6E13E4929A8EC0200A85A55 /* Info.plist */, + A6E13E4A29A8EE6D00A85A55 /* AppDelegate.swift */, A6472CAA2886CF830021A0E8 /* WysiwygApp.swift */, A6472CAE2886CF840021A0E8 /* Assets.xcassets */, A6F3FC0228D4659E00C170E8 /* Extensions */, + A6F4D0CD29AE0BE100087A3E /* Pills */, A6472CB02886CF840021A0E8 /* Preview Content */, + A6E13E4629A7AB3600A85A55 /* Mocks */, A6C2157A28C0F63400C8E727 /* Views */, ); path = Wysiwyg; @@ -141,6 +170,7 @@ A64AB13F296C73CE00F08494 /* WysiwygUITests+Links.swift */, A6852F022981643900632252 /* WysiwygUITests+Lists.swift */, A64AB145296C759A00F08494 /* WysiwygUITests+Quotes.swift */, + A661FDA629B0ACB400E799A6 /* WysiwygUITests+Suggestions.swift */, A64AB13D296C732500F08494 /* WysiwygUITests+Typing.swift */, ); path = WysiwygUITests; @@ -162,10 +192,21 @@ A6472CAC2886CF830021A0E8 /* ContentView.swift */, 66F8D0C828E34D4E00CFA145 /* Composer.swift */, A6C2157828C0F62000C8E727 /* WysiwygActionToolbar.swift */, + A6F4D0CB29AE082900087A3E /* WysiwygSuggestionList.swift */, ); path = Views; sourceTree = ""; }; + A6E13E4629A7AB3600A85A55 /* Mocks */ = { + isa = PBXGroup; + children = ( + A6F4D0D229AE0C5100087A3E /* Commands.swift */, + A6F4D0D029AE0C3200087A3E /* Rooms.swift */, + A6F4D0CE29AE0C1500087A3E /* Users.swift */, + ); + path = Mocks; + sourceTree = ""; + }; A6E6B26D2886D9AA009596F2 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -191,6 +232,19 @@ path = Extensions; sourceTree = ""; }; + A6F4D0CD29AE0BE100087A3E /* Pills */ = { + isa = PBXGroup; + children = ( + A6E13E5029A8F06E00A85A55 /* SerializationService.swift */, + A6E13E5429A8F1C400A85A55 /* WysiwygAttachmentView.swift */, + A6E13E4C29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift */, + A6E13E4729A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift */, + A6E13E4E29A8F00200A85A55 /* WysiwygTextAttachment.swift */, + A6E13E5229A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift */, + ); + path = Pills; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -339,16 +393,27 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A6E13E4B29A8EE6D00A85A55 /* AppDelegate.swift in Sources */, + A6E13E4F29A8F00200A85A55 /* WysiwygTextAttachment.swift in Sources */, + A6E13E4D29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift in Sources */, A6C2157528C0E95C00C8E727 /* View+Accessibility.swift in Sources */, A6472CAD2886CF830021A0E8 /* ContentView.swift in Sources */, + A6E13E5329A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift in Sources */, + A6E13E4829A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift in Sources */, + A6F4D0CF29AE0C1500087A3E /* Users.swift in Sources */, A6472CAB2886CF830021A0E8 /* WysiwygApp.swift in Sources */, A6C2157928C0F62000C8E727 /* WysiwygActionToolbar.swift in Sources */, + A6E13E5529A8F1C400A85A55 /* WysiwygAttachmentView.swift in Sources */, A68E7140291D40710023CC04 /* WysiwygSharedConstants.swift in Sources */, + A6F4D0D129AE0C3200087A3E /* Rooms.swift in Sources */, + A6F4D0D329AE0C5100087A3E /* Commands.swift in Sources */, + A6F4D0CC29AE082900087A3E /* WysiwygSuggestionList.swift in Sources */, A6F3FC0628DA123900C170E8 /* UIAlertController.swift in Sources */, A6F3FC0128D4658000C170E8 /* AlertHelper.swift in Sources */, A6F3FC0428D465AF00C170E8 /* View.swift in Sources */, A6C2157C28C0FAAD00C8E727 /* WysiwygAction+Utils.swift in Sources */, 66F8D0C928E34D4E00CFA145 /* Composer.swift in Sources */, + A6E13E5129A8F06E00A85A55 /* SerializationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -360,6 +425,7 @@ A64AB13E296C732500F08494 /* WysiwygUITests+Typing.swift in Sources */, A6472CC62886CF840021A0E8 /* WysiwygUITests.swift in Sources */, A64AB140296C73CE00F08494 /* WysiwygUITests+Links.swift in Sources */, + A661FDA729B0ACB400E799A6 /* WysiwygUITests+Suggestions.swift in Sources */, A68E7141291D40710023CC04 /* WysiwygSharedConstants.swift in Sources */, A64AB144296C747C00F08494 /* WysiwygUITests+Format.swift in Sources */, A6852F032981643900632252 /* WysiwygUITests+Lists.swift in Sources */, @@ -506,6 +572,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Wysiwyg/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -535,6 +602,7 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Wysiwyg/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/platforms/ios/example/Wysiwyg/AppDelegate.swift b/platforms/ios/example/Wysiwyg/AppDelegate.swift new file mode 100644 index 0000000000..9369f0f0a8 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/AppDelegate.swift @@ -0,0 +1,30 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 UIKit + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + if #available(iOS 15.0, *) { + NSTextAttachment.registerViewProviderClass(WysiwygAttachmentViewProvider.self, + forFileType: WysiwygAttachmentViewProvider.pillUTType) + } else { + // Fallback on earlier versions + } + return true + } +} diff --git a/platforms/ios/example/Wysiwyg/Info.plist b/platforms/ios/example/Wysiwyg/Info.plist new file mode 100644 index 0000000000..3eb94e1eca --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Info.plist @@ -0,0 +1,36 @@ + + + + + LSSupportsOpeningDocumentsInPlace + + CFBundleDocumentTypes + + + CFBundleTypeName + Mention Pills + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + org.matrix.rte.pills + + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.text + + UTTypeDescription + Mention Pills + UTTypeIdentifier + org.matrix.rte.pills + + + + diff --git a/platforms/ios/example/Wysiwyg/Mocks/Commands.swift b/platforms/ios/example/Wysiwyg/Mocks/Commands.swift new file mode 100644 index 0000000000..2c751ef4e6 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Mocks/Commands.swift @@ -0,0 +1,57 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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. +// + +enum Commands: Identifiable, CaseIterable { + case join + case invite + case me + + var id: String { + name + } + + var iconSystemName: String { + "terminal" + } + + var name: String { + switch self { + case .join: + return "/join" + case .invite: + return "/invite" + case .me: + return "/me" + } + } + + var accessibilityIdentifier: WysiwygSharedAccessibilityIdentifier { + switch self { + case .join: + return .joinCommandButton + case .invite: + return .inviteCommandButton + case .me: + return .meCommandButton + } + } + + static let title = "Commands" + + static func filtered(with text: String) -> [Commands] { + allCases.filter { $0.name.lowercased().contains("/" + text.lowercased()) } + } +} diff --git a/platforms/ios/example/Wysiwyg/Mocks/Rooms.swift b/platforms/ios/example/Wysiwyg/Mocks/Rooms.swift new file mode 100644 index 0000000000..c05d84b062 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Mocks/Rooms.swift @@ -0,0 +1,68 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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. +// + +enum Rooms: Identifiable, CaseIterable { + case room1 + case room2 + case room3 + + var id: String { + url + } + + var iconSystemName: String { + "character.bubble" + } + + var name: String { + switch self { + case .room1: + return "Room 1" + case .room2: + return "Room 2" + case .room3: + return "Room 3" + } + } + + var url: String { + switch self { + case .room1: + return "https://matrix.to/#/#room1:matrix.org" + case .room2: + return "https://matrix.to/#/#room2:matrix.org" + case .room3: + return "https://matrix.to/#/#room3:matrix.org" + } + } + + var accessiblityIdentifier: WysiwygSharedAccessibilityIdentifier { + switch self { + case .room1: + return .room1Button + case .room2: + return .room2Button + case .room3: + return .room3Button + } + } + + static let title = "Rooms" + + static func filtered(with text: String) -> [Rooms] { + allCases.filter { $0.name.lowercased().contains(text.lowercased()) } + } +} diff --git a/platforms/ios/example/Wysiwyg/Mocks/Users.swift b/platforms/ios/example/Wysiwyg/Mocks/Users.swift new file mode 100644 index 0000000000..3f8e5c9202 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Mocks/Users.swift @@ -0,0 +1,68 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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. +// + +enum Users: Identifiable, CaseIterable { + case alice + case bob + case charlie + + var id: String { + url + } + + var iconSystemName: String { + "person.circle" + } + + var name: String { + switch self { + case .alice: + return "Alice" + case .bob: + return "Bob" + case .charlie: + return "Charlie" + } + } + + var url: String { + switch self { + case .alice: + return "https://matrix.to/#/@alice:matrix.org" + case .bob: + return "https://matrix.to/#/@bob:matrix.org" + case .charlie: + return "https://matrix.to/#/@charlie:matrix.org" + } + } + + var accessibilityIdentifier: WysiwygSharedAccessibilityIdentifier { + switch self { + case .alice: + return .aliceButton + case .bob: + return .bobButton + case .charlie: + return .charlieButton + } + } + + static let title = "Users" + + static func filtered(with text: String) -> [Users] { + allCases.filter { $0.name.lowercased().contains(text.lowercased()) } + } +} diff --git a/platforms/ios/example/Wysiwyg/Pills/SerializationService.swift b/platforms/ios/example/Wysiwyg/Pills/SerializationService.swift new file mode 100644 index 0000000000..9dc73106d4 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Pills/SerializationService.swift @@ -0,0 +1,45 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 Foundation + +final class SerializationService { + // MARK: - Properties + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + // MARK: - Public + + func deserialize(_ data: Data) throws -> T { + try decoder.decode(T.self, from: data) + } + + func deserialize(_ object: Any) throws -> T { + let jsonData: Data + + if let data = object as? Data { + jsonData = data + } else { + jsonData = try JSONSerialization.data(withJSONObject: object, options: []) + } + return try decoder.decode(T.self, from: jsonData) + } + + func serialize(_ object: T) throws -> Data { + try encoder.encode(object) + } +} diff --git a/platforms/ios/example/Wysiwyg/Pills/WysiwygAttachmentView.swift b/platforms/ios/example/Wysiwyg/Pills/WysiwygAttachmentView.swift new file mode 100644 index 0000000000..28f853fd83 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Pills/WysiwygAttachmentView.swift @@ -0,0 +1,106 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 UIKit + +/// Base view class for mention Pills. +@available(iOS 15.0, *) +@objcMembers +class WysiwygAttachmentView: UIView { + // MARK: - Internal Structs + + /// Sizes provided alongside frame to build `PillAttachmentView` layout. + struct Sizes { + var verticalMargin: CGFloat + var horizontalMargin: CGFloat + var avatarSideLength: CGFloat + + var pillBackgroundHeight: CGFloat { + avatarSideLength + 2 * verticalMargin + } + + var pillHeight: CGFloat { + pillBackgroundHeight + 2 * verticalMargin + } + + var displaynameLabelLeading: CGFloat { + avatarSideLength + 2 * horizontalMargin + } + + var totalWidthWithoutLabel: CGFloat { + displaynameLabelLeading + 2 * horizontalMargin + } + } + + // MARK: - Init + + /// Create a Mention Pill view for given data. + /// + /// - Parameters: + /// - frame: the frame of the view + /// - sizes: additional size parameters + /// - pillData: the pill data + convenience init(frame: CGRect, + sizes: Sizes, + andPillData pillData: WysiwygTextAttachmentData) { + self.init(frame: frame) + let label = UILabel(frame: .zero) + label.text = pillData.displayName + label.font = pillData.font + label.textColor = UIColor.label + let labelSize = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, + height: sizes.pillBackgroundHeight)) + label.frame = CGRect(x: sizes.displaynameLabelLeading, + y: 0, + width: labelSize.width, + height: sizes.pillBackgroundHeight) + + let pillBackgroundView = UIView(frame: CGRect(x: 0, + y: sizes.verticalMargin, + width: labelSize.width + sizes.totalWidthWithoutLabel, + height: sizes.pillBackgroundHeight)) + + let avatarView = UIImageView(frame: CGRect(x: sizes.horizontalMargin, + y: sizes.verticalMargin, + width: sizes.avatarSideLength, + height: sizes.avatarSideLength)) + avatarView.image = UIImage(systemName: "person.circle")?.withRenderingMode(.alwaysTemplate) + avatarView.tintColor = UIColor.label + + avatarView.isUserInteractionEnabled = false + + pillBackgroundView.addSubview(avatarView) + pillBackgroundView.addSubview(label) + + pillBackgroundView.backgroundColor = UIColor(red: 227 / 255, green: 232 / 255, blue: 240 / 255, alpha: 1.0) + pillBackgroundView.layer.cornerRadius = sizes.pillBackgroundHeight / 2.0 + + addSubview(pillBackgroundView) + } + + // MARK: - Override + + override var isHidden: Bool { + get { + false + } + // swiftlint:disable:next unused_setter_value + set { + // Disable isHidden for pills, fixes a bug where the system sometimes + // hides attachment views for undisclosed reasons. Pills never needs to be hidden. + } + } +} diff --git a/platforms/ios/example/Wysiwyg/Pills/WysiwygAttachmentViewProvider.swift b/platforms/ios/example/Wysiwyg/Pills/WysiwygAttachmentViewProvider.swift new file mode 100644 index 0000000000..41826f34d5 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Pills/WysiwygAttachmentViewProvider.swift @@ -0,0 +1,81 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 UIKit +import WysiwygComposer + +/// Provider for mention Pills attachment view. +@available(iOS 15.0, *) +@objc class WysiwygAttachmentViewProvider: NSTextAttachmentViewProvider { + // MARK: - Properties + + static let pillUTType = "org.matrix.rte.pills" + + private static let pillAttachmentViewSizes = WysiwygAttachmentView.Sizes(verticalMargin: 2.0, + horizontalMargin: 4.0, + avatarSideLength: 16.0) + private weak var textView: WysiwygTextView? + + // MARK: - Override + + override init(textAttachment: NSTextAttachment, + parentView: UIView?, + textLayoutManager: NSTextLayoutManager?, + location: NSTextLocation) { + super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location) + + textView = parentView?.superview as? WysiwygTextView + } + + override func loadView() { + super.loadView() + + guard let textAttachment = textAttachment as? WysiwygTextAttachment else { + return + } + + guard let pillData = textAttachment.data else { + return + } + + let pillView = WysiwygAttachmentView(frame: CGRect(origin: .zero, size: Self.size(forDisplayText: pillData.displayName, + andFont: pillData.font)), + sizes: Self.pillAttachmentViewSizes, + andPillData: pillData) + view = pillView + textView?.registerPillView(pillView) + } +} + +@available(iOS 15.0, *) +extension WysiwygAttachmentViewProvider { + /// Computes size required to display a pill for given display text. + /// + /// - Parameters: + /// - displayText: display text for the pill + /// - font: the text font + /// - Returns: required size for pill + static func size(forDisplayText displayText: String, andFont font: UIFont) -> CGSize { + let label = UILabel(frame: .zero) + label.text = displayText + label.font = font + let labelSize = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, + height: pillAttachmentViewSizes.pillBackgroundHeight)) + + return CGSize(width: labelSize.width + pillAttachmentViewSizes.totalWidthWithoutLabel, + height: pillAttachmentViewSizes.pillHeight) + } +} diff --git a/platforms/ios/example/Wysiwyg/Pills/WysiwygPermalinkReplacer.swift b/platforms/ios/example/Wysiwyg/Pills/WysiwygPermalinkReplacer.swift new file mode 100644 index 0000000000..e2825a137c --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Pills/WysiwygPermalinkReplacer.swift @@ -0,0 +1,32 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 Foundation +import HTMLParser +import UIKit + +final class WysiwygPermalinkReplacer: PermalinkReplacer { + func replacementForLink(_ link: String, text: String) -> NSAttributedString? { + if #available(iOS 15.0, *), + link.starts(with: "https://matrix.to/#/"), + let attachment = WysiwygTextAttachment(displayName: text, + font: UIFont.preferredFont(forTextStyle: .body)) { + return NSAttributedString(attachment: attachment) + } else { + return nil + } + } +} diff --git a/platforms/ios/example/Wysiwyg/Pills/WysiwygTextAttachment.swift b/platforms/ios/example/Wysiwyg/Pills/WysiwygTextAttachment.swift new file mode 100644 index 0000000000..855ef5bfc2 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Pills/WysiwygTextAttachment.swift @@ -0,0 +1,85 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 UIKit + +/// Text attachment for pills display. +@available(iOS 15.0, *) +@objcMembers +class WysiwygTextAttachment: NSTextAttachment { + // MARK: - Properties + + /// Return `WysiwygTextAttachmentData` contained in the text attachment. + var data: WysiwygTextAttachmentData? { + get { + guard let contents = contents else { return nil } + return try? Self.serializationService.deserialize(contents) + } + set { + guard let newValue = newValue else { + contents = nil + return + } + contents = try? Self.serializationService.serialize(newValue) + updateBounds() + } + } + + private static let serializationService = SerializationService() + + // MARK: - Init + + override init(data contentData: Data?, ofType uti: String?) { + super.init(data: contentData, ofType: uti) + + updateBounds() + } + + /// Create a Mention Pill text attachment for given display name. + /// + /// - Parameters: + /// - displayName: the display name for the pill + /// - font: the text font + convenience init?(displayName: String, + font: UIFont) { + let data = WysiwygTextAttachmentData(displayName: displayName, + font: font) + + guard let encodedData = try? Self.serializationService.serialize(data) else { + return nil + } + self.init(data: encodedData, ofType: WysiwygAttachmentViewProvider.pillUTType) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + updateBounds() + } +} + +// MARK: - Private + +@available(iOS 15.0, *) +private extension WysiwygTextAttachment { + func updateBounds() { + guard let data = data else { return } + let pillSize = WysiwygAttachmentViewProvider.size(forDisplayText: data.displayName, andFont: data.font) + // Offset to align pill centerY with text centerY. + let offset = data.font.descender + (data.font.lineHeight - pillSize.height) / 2.0 + bounds = CGRect(origin: CGPoint(x: 0.0, y: offset), size: pillSize) + } +} diff --git a/platforms/ios/example/Wysiwyg/Pills/WysiwygTextAttachmentData.swift b/platforms/ios/example/Wysiwyg/Pills/WysiwygTextAttachmentData.swift new file mode 100644 index 0000000000..465bd69738 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Pills/WysiwygTextAttachmentData.swift @@ -0,0 +1,71 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 Foundation +import UIKit + +/// Data associated with a Pill text attachment. +@available(iOS 15.0, *) +struct WysiwygTextAttachmentData: Codable { + // MARK: - Properties + + /// Display name. + var displayName: String + /// Font for the display name + var font: UIFont + + // MARK: - Init + + /// Init. + /// + /// - Parameters: + /// - displayName: Item display name (user or room display name) + /// - font: Font for the display name + init(displayName: String, + font: UIFont) { + self.displayName = displayName + self.font = font + } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case displayName + case font + } + + enum WysiwygTextAttachmentDataError: Error { + case noFontData + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + displayName = try container.decode(String.self, forKey: .displayName) + let fontData = try container.decode(Data.self, forKey: .font) + if let font = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIFont.self, from: fontData) { + self.font = font + } else { + throw WysiwygTextAttachmentDataError.noFontData + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(displayName, forKey: .displayName) + let fontData = try NSKeyedArchiver.archivedData(withRootObject: font, requiringSecureCoding: false) + try container.encode(fontData, forKey: .font) + } +} diff --git a/platforms/ios/example/Wysiwyg/Views/Composer.swift b/platforms/ios/example/Wysiwyg/Views/Composer.swift index 85298e991f..e6269c0b8d 100644 --- a/platforms/ios/example/Wysiwyg/Views/Composer.swift +++ b/platforms/ios/example/Wysiwyg/Views/Composer.swift @@ -51,6 +51,10 @@ struct Composer: View { .onTapGesture { focused = true } + if let suggestion = viewModel.suggestionPattern { + WysiwygSuggestionList(suggestion: suggestion) + .environmentObject(viewModel) + } if !viewModel.plainTextMode { WysiwygActionToolbar { action in viewModel.apply(action) diff --git a/platforms/ios/example/Wysiwyg/Views/ContentView.swift b/platforms/ios/example/Wysiwyg/Views/ContentView.swift index 774debe68c..0644a856ab 100644 --- a/platforms/ios/example/Wysiwyg/Views/ContentView.swift +++ b/platforms/ios/example/Wysiwyg/Views/ContentView.swift @@ -23,10 +23,12 @@ struct ContentView: View { /// A composer content "saved" and displayed upon hitting the send button. @State private var sentMessage: WysiwygComposerContent? @State private var showTree = true + @State private var isShowingUrlAlert = false /// View model for the composer. @StateObject private var viewModel = WysiwygComposerViewModel( minHeight: WysiwygSharedConstants.composerMinHeight, - maxExpandedHeight: WysiwygSharedConstants.composerMaxExtendedHeight + maxExpandedHeight: WysiwygSharedConstants.composerMaxExtendedHeight, + permalinkReplacer: WysiwygPermalinkReplacer() ) var body: some View { @@ -37,6 +39,10 @@ struct ContentView: View { viewModel.setHtmlContent("") } .accessibilityIdentifier(.forceCrashButton) + Button("Set HTML") { + isShowingUrlAlert.toggle() + } + .accessibilityIdentifier(.setHtmlButton) Button("Min/Max") { viewModel.maximised.toggle() } @@ -78,5 +84,27 @@ struct ContentView: View { } } Spacer() + .alert(isPresented: $isShowingUrlAlert, makeAlertConfig()) + } + + func makeAlertConfig() -> AlertConfig { + let actions: [AlertConfig.Action] = [ + .textAction( + title: "Ok", + textFieldsData: [ + .init( + accessibilityIdentifier: .setHtmlField, + placeholder: "HTML", + defaultValue: nil + ), + ], + action: { action in + var html = action.first ?? "" + html = html.replacingOccurrences(of: "\\\"", with: "\"") + viewModel.setHtmlContent(html) + } + ), + ] + return AlertConfig(title: "Set HTML", actions: actions) } } diff --git a/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift b/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift new file mode 100644 index 0000000000..1a62f0b227 --- /dev/null +++ b/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift @@ -0,0 +1,84 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 +import WysiwygComposer + +struct WysiwygSuggestionList: View { + @EnvironmentObject private var viewModel: WysiwygComposerViewModel + var suggestion: SuggestionPattern + + var body: some View { + HStack { + VStack(alignment: .leading) { + switch suggestion.key { + case .at: + let users = Users.filtered(with: suggestion.text) + if !users.isEmpty { + Text(Users.title).underline() + ForEach(users) { user in + Button { + viewModel.setMention(link: user.url, name: user.name, key: .at) + } label: { + HStack(spacing: 4) { + Image(systemName: user.iconSystemName) + Text(user.name) + } + } + .accessibilityIdentifier(user.accessibilityIdentifier) + } + } + case .hash: + let rooms = Rooms.filtered(with: suggestion.text) + if !rooms.isEmpty { + Text(Rooms.title).underline() + ForEach(rooms) { room in + Button { + viewModel.setMention(link: room.url, name: room.name, key: .hash) + } label: { + HStack(spacing: 4) { + Image(systemName: room.iconSystemName) + Text(room.name) + } + } + .accessibilityIdentifier(room.accessiblityIdentifier) + } + } + case .slash: + let commands = Commands.filtered(with: suggestion.text) + if !commands.isEmpty { + Text(Commands.title).underline() + ForEach(Commands.allCases.filter { $0.name.contains("/" + suggestion.text.lowercased()) }) { command in + Button { + viewModel.setCommand(name: command.name) + } label: { + HStack(spacing: 4) { + Image(systemName: command.iconSystemName) + Text(command.name) + } + } + .accessibilityIdentifier(command.accessibilityIdentifier) + } + } + } + } + .padding(.horizontal, 8) + Spacer() + } + .overlay(Rectangle().stroke(Color.gray, lineWidth: 1)) + .padding(.horizontal, 12) + } +} diff --git a/platforms/ios/example/Wysiwyg/WysiwygApp.swift b/platforms/ios/example/Wysiwyg/WysiwygApp.swift index b1a802d183..cf2c620072 100644 --- a/platforms/ios/example/Wysiwyg/WysiwygApp.swift +++ b/platforms/ios/example/Wysiwyg/WysiwygApp.swift @@ -18,6 +18,8 @@ import SwiftUI @main struct WysiwygApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { ContentView() diff --git a/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift b/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift new file mode 100644 index 0000000000..25b667f9a9 --- /dev/null +++ b/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift @@ -0,0 +1,62 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 XCTest + +extension WysiwygUITests { + func testAtMention() throws { + textView.typeTextCharByChar("@ali") + XCTAssertTrue(button(.aliceButton).exists) + button(.aliceButton).tap() + // Mention is replaced by a pill view, so there + // is only the space after it in the field. + assertTextViewContent("\u{00A0}") + assertTreeEquals( + """ + ├>a "https://matrix.to/#/@alice:matrix.org" + │ └>"Alice" + └>" " + """ + ) + } + + func testHashMention() throws { + textView.typeTextCharByChar("#roo") + XCTAssertTrue(button(.room1Button).exists) + button(.room1Button).tap() + // FIXME: room links are not considered valid links through parsing, so no mention is displayed atm + // assertTextViewContent(" ") + assertTreeEquals( + """ + ├>a "https://matrix.to/#/#room1:matrix.org" + │ └>"Room 1" + └>" " + """ + ) + } + + func testCommand() throws { + textView.typeTextCharByChar("/inv") + XCTAssertTrue(button(.inviteCommandButton).exists) + button(.inviteCommandButton).tap() + assertTextViewContent("/invite\u{00A0}") + assertTreeEquals( + """ + └>"/invite " + """ + ) + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift index 2706d70aad..15717178a5 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift @@ -81,6 +81,37 @@ extension NSAttributedString { return ranges } + /// Compute an array of all parts of the attributed string that have been replaced + /// with `PermalinkReplacer` usage within the given range. Also computes + /// the offset between the replacement and the original part (i.e. if the original length + /// is greater than the replacement range, this offset will be negative). + /// + /// - Parameter range: the range on which the elements should be detected. Entire range if omitted + /// - Returns: an array of range and offsets. + func replacementTextRanges(in range: NSRange? = nil) -> [(range: NSRange, offset: Int)] { + var ranges = [(NSRange, Int)]() + + enumerateTypedAttribute(.originalLength) { (originalLength: Int, range: NSRange, _) in + ranges.append((range, range.length - originalLength)) + } + + return ranges + } + + /// Find occurences of parts of the attributed string that have been replaced + /// within the range before given attributed index and compute the total offset + /// that should be subtracted (HTML to attributed) or added (attributed to HTML) + /// in order to compute the index properly. + /// + /// - Parameter attributedIndex: the index inside the attributed representation + /// - Returns: Total offset of replacement ranges + func replacementsOffsetAt(at attributedIndex: Int) -> Int { + let range = NSRange(location: 0, length: attributedIndex) + return replacementTextRanges(in: range) + .map { range.contains($0.range) ? $0.offset : 0 } + .reduce(0) { $0 - $1 } + } + /// Computes index inside the HTML raw text from the index /// inside the attributed representation. /// @@ -93,11 +124,12 @@ extension NSAttributedString { .outOfBoundsAttributedIndex(index: attributedIndex) } + let replacementsOffset = replacementsOffsetAt(at: attributedIndex) return discardableTextRanges(in: .init(location: 0, length: attributedIndex)) // All ranges length should be counted out, unless the last one end strictly after the // attributed index, in that case we only count out the difference (i.e. chars before the index) .map { $0.upperBound <= attributedIndex ? $0.length : attributedIndex - $0.location } - .reduce(attributedIndex) { $0 - $1 } + .reduce(attributedIndex) { $0 - $1 } + replacementsOffset } /// Computes index inside the attributed representation from the index @@ -107,11 +139,14 @@ extension NSAttributedString { /// - htmlIndex: the index inside the HTML raw text /// - Returns: the index inside the attributed representation func attributedPosition(at htmlIndex: Int) throws -> Int { - let attributedIndex = try discardableTextRanges() + var attributedIndex = try discardableTextRanges() // All ranges that have a HTML position before the provided index should be entirely counted. .filter { try htmlPosition(at: $0.location) <= htmlIndex } .reduce(htmlIndex) { $0 + $1.length } + let replacementsOffset = replacementsOffsetAt(at: attributedIndex) + attributedIndex -= replacementsOffset + guard attributedIndex <= length else { throw AttributedRangeError .outOfBoundsHtmlIndex(index: htmlIndex) @@ -120,3 +155,9 @@ extension NSAttributedString { return attributedIndex } } + +extension NSRange { + func contains(_ otherRange: NSRange) -> Bool { + contains(otherRange.location) && contains(otherRange.upperBound - 1) + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift index a52d7fb238..525d070f43 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift @@ -31,4 +31,7 @@ extension NSAttributedString.Key { /// Attribute for parts of the string that should be removed for HTML selection computation. /// Should include both placeholder characters such as NBSP and ZWSP, as well as list prefixes. static let discardableText: NSAttributedString.Key = .init(rawValue: "DiscardableAttributeKey") + /// Attributes for original length of a replaced element. This should be added anytime a part of the attributed string + /// is replaced, in order for the composer to compute the expected HTML/attributed range properly. + static let originalLength: NSAttributedString.Key = .init(rawValue: "OriginalLengthAttributeKey") } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift index 2fe4c7a8c5..b9fa3fb927 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift @@ -40,6 +40,24 @@ extension NSMutableAttributedString { replacePlaceholderTexts() applyDiscardableToListPrefixes() } + + /// Replace parts of the attributed string that represents links by + /// a new attributed string part provided by the hosting app `PermalinkReplacer`. + /// + /// - Parameter permalinkReplacer: The permalink replacer providing new attributed strings. + func replaceLinks(with permalinkReplacer: PermalinkReplacer) { + enumerateTypedAttribute(.link) { (url: URL, range: NSRange, _) in + if let replacement = permalinkReplacer.replacementForLink( + url.absoluteString, + text: self.mutableString.substring(with: range) + ) { + self.replaceCharacters(in: range, with: replacement) + self.addAttribute(.originalLength, + value: range.length, + range: .init(location: range.location, length: replacement.length)) + } + } + } } private extension NSMutableAttributedString { diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift index df4ac8c0ff..03fbe4d939 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift @@ -62,7 +62,8 @@ public final class HTMLParser { /// - Returns: an attributed string representation of the HTML content public static func parse(html: String, encoding: String.Encoding = .utf16, - style: HTMLParserStyle = .standard) throws -> NSAttributedString { + style: HTMLParserStyle = .standard, + permalinkReplacer: PermalinkReplacer? = nil) throws -> NSAttributedString { guard !html.isEmpty else { return NSAttributedString(string: "") } @@ -77,8 +78,9 @@ public final class HTMLParser { DTUseiOS6Attributes: true, DTDefaultFontDescriptor: defaultFont.fontDescriptor, DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: defaultCSS) as Any, + DTDocumentPreserveTrailingSpaces: true, ] - + guard let builder = DTHTMLAttributedStringBuilder(html: data, options: parsingOptions, documentAttributes: nil) else { throw BuildHtmlAttributedError.dataError(encoding: encoding) } @@ -97,7 +99,13 @@ public final class HTMLParser { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) mutableAttributedString.applyPostParsingCustomAttributes(style: style) + + if let permalinkReplacer { + mutableAttributedString.replaceLinks(with: permalinkReplacer) + } + removeTrailingNewlineIfNeeded(from: mutableAttributedString, given: html) + return mutableAttributedString } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/PermalinkReplacer.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/PermalinkReplacer.swift new file mode 100644 index 0000000000..d8f7affe61 --- /dev/null +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/PermalinkReplacer.swift @@ -0,0 +1,31 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 Foundation + +/// Defines an API for permalink replacement with other objects (e.g. pills) +public protocol PermalinkReplacer { + /// Called when the parser of the composer steps upon a link. + /// This can be used to provide custom attributed string parts, such + /// as a pillified representation of a link. + /// If nothing is provided, the composer will use a standard link. + /// + /// - Parameters: + /// - link: URL of the link + /// - text: Text of the link + /// - Returns: Replacement for the attributed link. + func replacementForLink(_ link: String, text: String) -> NSAttributedString? +} diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift index deacfc2cd1..028214d03f 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift @@ -46,8 +46,10 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa @Published public var isContentEmpty = true /// Published value for the composer required height to fit entirely without scrolling. @Published public var idealHeight: CGFloat = .zero - /// Published value for the composer current action states + /// Published value for the composer current action states. @Published public var actionStates: [ComposerAction: ActionState] = [:] + /// Published value for current detected suggestion pattern. + @Published public var suggestionPattern: SuggestionPattern? /// Published value for the composer maximised state. @Published public var maximised = false { didSet { @@ -114,16 +116,20 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa private var hasPendingFormats = false + private var permalinkReplacer: PermalinkReplacer? + // MARK: - Public public init(minHeight: CGFloat = 22, maxCompressedHeight: CGFloat = 200, maxExpandedHeight: CGFloat = 300, - parserStyle: HTMLParserStyle = .standard) { + parserStyle: HTMLParserStyle = .standard, + permalinkReplacer: PermalinkReplacer? = nil) { self.minHeight = minHeight self.maxCompressedHeight = maxCompressedHeight self.maxExpandedHeight = maxExpandedHeight self.parserStyle = parserStyle + self.permalinkReplacer = permalinkReplacer textView.linkTextAttributes[.foregroundColor] = parserStyle.linkColor model = ComposerModelWrapper() @@ -203,6 +209,36 @@ public extension WysiwygComposerViewModel { func treeRepresentation() -> String { model.toTree() } + + /// Set a mention with given pattern. Usually used + /// to mention a user (@) or a room/channel (#). + /// + /// - Parameters: + /// - link: The link to the user. + /// - name: The display name of the user. + /// - key: The pattern key to use. + func setMention(link: String, name: String, key: PatternKey) { + let update: ComposerUpdate + if let suggestionPattern, suggestionPattern.key == key { + update = model.setLinkSuggestion(link: link, text: name, suggestion: suggestionPattern) + } else { + _ = model.setLinkWithText(link: link, text: name) + // FIXME: remove this if Rust adds this space for free + update = model.replaceText(newText: " ") + } + applyUpdate(update) + hasPendingFormats = true + } + + /// Set a command with `Slash` pattern. + /// + /// - Parameters: + /// - name: The name of the command. + func setCommand(name: String) { + guard let suggestionPattern, suggestionPattern.key == .slash else { return } + let update = model.replaceTextSuggestion(newText: name, suggestion: suggestionPattern) + applyUpdate(update) + } } // MARK: - WysiwygComposerViewModelProtocol @@ -363,6 +399,15 @@ private extension WysiwygComposerViewModel { default: break } + + switch update.menuAction() { + case .keep: + break + case .none: + suggestionPattern = nil + case let .suggestion(suggestionPattern: pattern): + suggestionPattern = pattern + } } /// Apply a replaceAll update to the composer @@ -374,7 +419,9 @@ private extension WysiwygComposerViewModel { func applyReplaceAll(codeUnits: [UInt16], start: UInt32, end: UInt32) { do { let html = String(utf16CodeUnits: codeUnits, count: codeUnits.count) - let attributed = try HTMLParser.parse(html: html, style: parserStyle) + let attributed = try HTMLParser.parse(html: html, + style: parserStyle, + permalinkReplacer: permalinkReplacer) // FIXME: handle error for out of bounds index let htmlSelection = NSRange(location: Int(start), length: Int(end - start)) let textSelection = try attributed.attributedRange(from: htmlSelection) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygPillsFlusher.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygPillsFlusher.swift new file mode 100644 index 0000000000..97eb1633b8 --- /dev/null +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygPillsFlusher.swift @@ -0,0 +1,42 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 Foundation +import UIKit + +/// Provides flushing of views created by NSTextAttachmentViewProvider. +/// This is needed because of an issue with iOS not removing properly views +/// that are created by `NSTextAttachmentViewProvider`. +final class WysiwygPillsFlusher { + private var pillViews = [UIView]() + + /// Register a view to be flushed on attributed text updates. + /// Should be called when creating a view from NSTextAttachmentViewProvider. + /// + /// - Parameter pillView: View to register. + func registerPillView(_ pillView: UIView) { + pillViews.append(pillView) + } + + /// Flush all the registered view, should be called before setting a new attributed string. + func flush() { + for view in pillViews { + view.alpha = 0.0 + view.removeFromSuperview() + } + pillViews.removeAll() + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift index f9902e20a0..dc0163d171 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift @@ -41,6 +41,8 @@ public class WysiwygTextView: UITextView { } } + private let flusher = WysiwygPillsFlusher() + override public init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) contentMode = .redraw @@ -51,6 +53,14 @@ public class WysiwygTextView: UITextView { contentMode = .redraw } + /// Register a pill view that has been added through `NSTextAttachmentViewProvider`. + /// Should be called within the `loadView` function in order to clear the pills properly on text updates. + /// + /// - Parameter pillView: View to register. + public func registerPillView(_ pillView: UIView) { + flusher.registerPillView(pillView) + } + /// Apply given content to the text view. This will temporary disrupt the text view /// delegate in order to avoid having multiple unnecessary selection frowarded to /// the model. This is especially useful since setting the attributed text automatically @@ -62,6 +72,7 @@ public class WysiwygTextView: UITextView { guard content.text != attributedText || content.selection != selectedRange else { return } performWithoutDelegate { + flusher.flush() self.attributedText = content.text // Set selection to {0, 0} then to expected position // avoids an issue with autocapitalization. diff --git a/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests.swift b/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests.swift index b034deb9e5..1e55dae335 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests.swift @@ -67,4 +67,39 @@ final class HTMLParserTests: XCTestCase { XCTAssertEqual(attributed.backgroundColor(at: 0), HTMLParserStyle.standard.codeBlockStyle.backgroundColor) } + + func testReplaceLinks() throws { + let html = "Alice:\(String.nbsp)" + let attributed = try HTMLParser.parse(html: html, permalinkReplacer: CustomPermalinkReplacer()) + // A text attachment is added. + XCTAssertTrue(attributed.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + // The original length is added to the new part of the attributed string. + XCTAssertEqual( + attributed.attribute(.originalLength, at: 0, effectiveRange: nil) as? Int, + 5 + ) + // HTML and attriubted range matches + let htmlRange = NSRange(location: 0, length: 5) + let attributedRange = NSRange(location: 0, length: 1) + XCTAssertEqual( + try attributed.attributedRange(from: htmlRange), + attributedRange + ) + XCTAssertEqual( + try attributed.htmlRange(from: attributedRange), + htmlRange + ) + } +} + +private class CustomPermalinkReplacer: PermalinkReplacer { + func replacementForLink(_ link: String, text: String) -> NSAttributedString? { + if link.starts(with: "https://matrix.to/#/"), + let image = UIImage(systemName: "link") { + // Set a text attachment with an arbitrary image. + return NSAttributedString(attachment: NSTextAttachment(image: image)) + } else { + return nil + } + } } diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift new file mode 100644 index 0000000000..ab5ff13479 --- /dev/null +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift @@ -0,0 +1,175 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// 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 Combine +@testable import WysiwygComposer +import XCTest + +extension WysiwygComposerViewModelTests { + func testAtSuggestionsArePublished() { + let expectation = expectSuggestionPattern( + expectedPattern: SuggestionPattern(key: .at, text: "ali", start: 0, end: 4) + ) + _ = viewModel.replaceText(range: .zero, replacementText: "@ali") + waitExpectation(expectation: expectation, timeout: 2.0) + let expectation2 = expectSuggestionPattern( + expectedPattern: SuggestionPattern(key: .at, text: "alice", start: 0, end: 6) + ) + _ = viewModel.replaceText(range: .init(location: 4, length: 0), replacementText: "ce") + waitExpectation(expectation: expectation2, timeout: 2.0) + } + + func testHashSuggestionsArePublished() { + let expectation = expectSuggestionPattern( + expectedPattern: SuggestionPattern(key: .hash, text: "room", start: 0, end: 5) + ) + _ = viewModel.replaceText(range: .zero, replacementText: "#room") + waitExpectation(expectation: expectation, timeout: 2.0) + } + + func testSlashSuggestionArePublished() { + let expectation = expectSuggestionPattern( + expectedPattern: SuggestionPattern(key: .slash, text: "inv", start: 0, end: 4) + ) + _ = viewModel.replaceText(range: .zero, replacementText: "/inv") + waitExpectation(expectation: expectation, timeout: 2.0) + } + + func testAtSuggestionCanBeUsed() { + _ = viewModel.replaceText(range: .zero, replacementText: "@ali") + viewModel.setMention(link: "https://matrix.to/#/@alice:matrix.org", name: "Alice", key: .at) + XCTAssertEqual( + viewModel.content.html, + """ + Alice\u{00A0} + """ + ) + } + + func testAtMentionWithNoSuggestion() { + _ = viewModel.replaceText(range: .zero, replacementText: "Text") + viewModel.select(range: .init(location: 0, length: 4)) + viewModel.setMention(link: "https://matrix.to/#/@alice:matrix.org", name: "Alice", key: .at) + // Text is not removed, and the + // mention is added after the text + XCTAssertEqual( + viewModel.content.html, + """ + TextAlice\u{00A0} + """ + ) + } + + func testAtMentionWithNoSuggestionAtLeading() { + _ = viewModel.replaceText(range: .zero, replacementText: "Text") + viewModel.select(range: .init(location: 0, length: 0)) + viewModel.setMention(link: "https://matrix.to/#/@alice:matrix.org", name: "Alice", key: .at) + // Text is not removed, and the mention is added before the text + XCTAssertEqual( + viewModel.content.html, + """ + Alice Text + """ + ) + } + + func testHashSuggestionCanBeUsed() { + _ = viewModel.replaceText(range: .zero, replacementText: "#roo") + viewModel.setMention(link: "https://matrix.to/#/#room1:matrix.org", name: "Room 1", key: .hash) + XCTAssertEqual( + viewModel.content.html, + """ + Room 1\u{00A0} + """ + ) + } + + func testHashMentionWithNoSuggestion() { + _ = viewModel.replaceText(range: .zero, replacementText: "Text") + viewModel.select(range: .init(location: 0, length: 4)) + viewModel.setMention(link: "https://matrix.to/#/#room1:matrix.org", name: "Room 1", key: .hash) + XCTAssertEqual( + viewModel.content.html, + """ + TextRoom 1\u{00A0} + """ + ) + } + + func testHashMentionWithNoSuggestionAtLeading() { + _ = viewModel.replaceText(range: .zero, replacementText: "Text") + viewModel.select(range: .init(location: 0, length: 0)) + viewModel.setMention(link: "https://matrix.to/#/#room1:matrix.org", name: "Room 1", key: .hash) + XCTAssertEqual( + viewModel.content.html, + """ + Room 1 Text + """ + ) + } + + func testSlashSuggestionCanBeUsed() { + _ = viewModel.replaceText(range: .zero, replacementText: "/inv") + viewModel.setCommand(name: "/invite") + XCTAssertEqual( + viewModel.content.html, + """ + /invite\u{00A0} + """ + ) + } +} + +private extension WysiwygComposerViewModelTests { + /// Defines a test expectation. + struct WysiwygTestExpectation { + let value: XCTestExpectation + let cancellable: AnyCancellable + } + + /// Create an expectation for a `SuggestionPattern` to be published by the view model. + /// + /// - Parameters: + /// - expectedPattern: Expected `SuggestionPattern`. + /// - description: Description for expectation. + /// - Returns: Expectation to be fulfilled. Can be used with `waitExpectation`. + func expectSuggestionPattern(expectedPattern: SuggestionPattern, + description: String = "Await suggestion pattern") -> WysiwygTestExpectation { + let expectSuggestionPattern = expectation(description: description) + let cancellable = viewModel.$suggestionPattern + // Ignore on subscribe publish. + .removeDuplicates() + .dropFirst() + .sink(receiveValue: { suggestionPattern in + XCTAssertEqual( + suggestionPattern, + expectedPattern + ) + expectSuggestionPattern.fulfill() + }) + return WysiwygTestExpectation(value: expectSuggestionPattern, cancellable: cancellable) + } + + /// Wait for an expectation to be fulfilled. + /// + /// - Parameters: + /// - expectation: Expectation to fulfill. + /// - timeout: Timeout for failure. + func waitExpectation(expectation: WysiwygTestExpectation, timeout: TimeInterval) { + wait(for: [expectation.value], timeout: timeout) + expectation.cancellable.cancel() + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift index 0c6180dd0d..bb53ed229b 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests.swift @@ -19,7 +19,7 @@ import Combine import XCTest final class WysiwygComposerViewModelTests: XCTestCase { - private let viewModel = WysiwygComposerViewModel() + let viewModel = WysiwygComposerViewModel() override func setUpWithError() throws { viewModel.clearContent()