diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 13004ad73..8652a41fa 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -551,6 +551,12 @@ A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; }; A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; }; + A78643F02ACDBA54005B2BFB /* CompletionSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78643EF2ACDBA54005B2BFB /* CompletionSuggestionView.swift */; }; + A78643F22ACDBAEF005B2BFB /* MentionSuggestionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78643F12ACDBAEF005B2BFB /* MentionSuggestionItemView.swift */; }; + A7AD63BA2ACED2FA00E1B12F /* CompletionSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AD63B92ACED2FA00E1B12F /* CompletionSuggestionService.swift */; }; + A7AD63BC2ACF0B4D00E1B12F /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AD63BB2ACF0B4D00E1B12F /* CompletionSuggestionServiceTests.swift */; }; + A7AD63BE2AD019AD00E1B12F /* CompletionSuggestionServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AD63BD2AD019AD00E1B12F /* CompletionSuggestionServiceProtocol.swift */; }; + A7AD63C02AD01A1000E1B12F /* CompletionSuggestionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AD63BF2AD01A1000E1B12F /* CompletionSuggestionModels.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; }; @@ -964,7 +970,7 @@ 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.swift; sourceTree = ""; }; 12EDAFB64FA5F6812D54F39A /* MigrationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenViewModel.swift; sourceTree = ""; }; 12F1E7F9C2BE8BB751037826 /* WaitlistScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenCoordinator.swift; sourceTree = ""; }; - 1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; path = IntegrationTests.xctestplan; sourceTree = ""; }; + 1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = IntegrationTests.xctestplan; sourceTree = ""; }; 130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNTextInputNotificationResponse+Creator.swift"; sourceTree = ""; }; 13802897C7AFA360EA74C0B0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; 1423AB065857FA546444DB15 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; @@ -1121,7 +1127,7 @@ 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = ""; }; 471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineView.swift; sourceTree = ""; }; 47873756E45B46683D97DC32 /* LegalInformationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenModels.swift; sourceTree = ""; }; - 478BE8591BD13E908EF70C0C /* DesignKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = DesignKit; path = DesignKit; sourceTree = SOURCE_ROOT; }; + 478BE8591BD13E908EF70C0C /* DesignKit */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DesignKit; sourceTree = SOURCE_ROOT; }; 4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramePreferenceKey.swift; sourceTree = ""; }; 47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModel.swift; sourceTree = ""; }; @@ -1322,7 +1328,7 @@ 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilder.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenModels.swift; sourceTree = ""; }; 8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = ""; }; 8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = ""; }; @@ -1393,6 +1399,12 @@ A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; + A78643EF2ACDBA54005B2BFB /* CompletionSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionView.swift; sourceTree = ""; }; + A78643F12ACDBAEF005B2BFB /* MentionSuggestionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSuggestionItemView.swift; sourceTree = ""; }; + A7AD63B92ACED2FA00E1B12F /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = ""; }; + A7AD63BB2ACF0B4D00E1B12F /* CompletionSuggestionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionServiceTests.swift; sourceTree = ""; }; + A7AD63BD2AD019AD00E1B12F /* CompletionSuggestionServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionServiceProtocol.swift; sourceTree = ""; }; + A7AD63BF2AD01A1000E1B12F /* CompletionSuggestionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionModels.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; @@ -1439,7 +1451,7 @@ B4CFE236419E830E8946639C /* Analytics+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Analytics+SwiftUI.swift"; sourceTree = ""; }; B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = ""; }; B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = ""; }; - B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; path = ConfettiScene.scn; sourceTree = ""; }; + B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; path = ConfettiScene.scn; sourceTree = ""; }; B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = ""; }; B63B69F9A2BC74DD40DC75C8 /* AdvancedSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenViewModel.swift; sourceTree = ""; }; B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -1531,7 +1543,7 @@ CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverter.swift; sourceTree = ""; }; CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; - CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = ""; }; + CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTests.xctestplan; sourceTree = ""; }; CF48AF076424DBC1615C74AD /* AuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxy.swift; sourceTree = ""; }; D0140615D2232612C813FD6C /* EncryptedHistoryRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedHistoryRoomTimelineItem.swift; sourceTree = ""; }; D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; @@ -1621,7 +1633,7 @@ ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; ED983D4DCA5AFA6E1ED96099 /* StateRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineView.swift; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -1635,7 +1647,7 @@ F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenModels.swift; sourceTree = ""; }; F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = ""; }; F1B8500C152BC59445647DA8 /* UnsupportedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineItem.swift; sourceTree = ""; }; - F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */ = {isa = PBXFileReference; path = portrait_test_video.mp4; sourceTree = ""; }; + F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = portrait_test_video.mp4; sourceTree = ""; }; F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixEntityRegexTests.swift; sourceTree = ""; }; F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = ""; }; F36C0A6D59717193F49EA986 /* UserSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionTests.swift; sourceTree = ""; }; @@ -2022,8 +2034,11 @@ children = ( BA188BB0A216D763E46E3279 /* ComposerToolbarModels.swift */, CD95B3714F806AC9CF9A557B /* ComposerToolbarViewModel.swift */, + A7AD63B92ACED2FA00E1B12F /* CompletionSuggestionService.swift */, E44928D844E16EE48A311FCA /* ComposerToolbarViewModelProtocol.swift */, 4BBA16517DB72736545D0F6E /* View */, + A7AD63BD2AD019AD00E1B12F /* CompletionSuggestionServiceProtocol.swift */, + A7AD63BF2AD01A1000E1B12F /* CompletionSuggestionModels.swift */, ); path = ComposerToolbar; sourceTree = ""; @@ -2426,6 +2441,8 @@ 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */, A0A01AECFF54281CF35909A6 /* MessageComposer.swift */, 3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */, + A78643EF2ACDBA54005B2BFB /* CompletionSuggestionView.swift */, + A78643F12ACDBAEF005B2BFB /* MentionSuggestionItemView.swift */, ); path = View; sourceTree = ""; @@ -2811,6 +2828,7 @@ 7583EAC171059A86B767209F /* MediaProvider */, 7DBC911559934065993A5FF4 /* NotificationManager */, 1C62F5382CC9D9F7DCEC344A /* UserDiscoveryService */, + A7AD63BB2ACF0B4D00E1B12F /* CompletionSuggestionServiceTests.swift */, ); path = Sources; sourceTree = ""; @@ -4567,6 +4585,7 @@ 4E8F17EBA24FBBA6ABB62ECB /* MockBackgroundTaskService.swift in Sources */, 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */, DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */, + A7AD63BC2ACF0B4D00E1B12F /* CompletionSuggestionServiceTests.swift in Sources */, A896998A6784DB6F16E912F4 /* MockMediaLoader.swift in Sources */, 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */, 69C7B956B74BEC3DB88224EA /* NavigationSplitCoordinatorTests.swift in Sources */, @@ -4635,6 +4654,7 @@ 62910B515BCB4B455E24D7C1 /* AdvancedSettingsScreenViewModelProtocol.swift in Sources */, 53C1E7F6A7D6409D89F36ED7 /* AggregatedReactionMock.swift in Sources */, 4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */, + A7AD63BE2AD019AD00E1B12F /* CompletionSuggestionServiceProtocol.swift in Sources */, 64D05250CEDE8B604119F6E6 /* Alert.swift in Sources */, 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */, 155063E980E763D4910EA3CF /* Analytics+SwiftUI.swift in Sources */, @@ -4941,6 +4961,7 @@ EE4E2C1922BBF5169E213555 /* PillAttachmentViewProvider.swift in Sources */, 767D366C40F1311CFA333763 /* PillContext.swift in Sources */, 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */, + A78643F22ACDBAEF005B2BFB /* MentionSuggestionItemView.swift in Sources */, 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */, 7E2BB42805C59DB57E95610F /* PillView.swift in Sources */, 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */, @@ -4982,6 +5003,7 @@ D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */, 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */, FA4296218444C48BC890F46B /* RoomMemberDetails.swift in Sources */, + A78643F02ACDBA54005B2BFB /* CompletionSuggestionView.swift in Sources */, 19FE025AE9BA2959B6589B0D /* RoomMemberDetailsScreen.swift in Sources */, 899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */, A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */, @@ -5047,6 +5069,7 @@ 5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */, 401BB28CD6B7DD6B4E7863E7 /* ServerConfirmationScreenModels.swift in Sources */, F0A26CD502C3A5868353B0FA /* ServerConfirmationScreenViewModel.swift in Sources */, + A7AD63BA2ACED2FA00E1B12F /* CompletionSuggestionService.swift in Sources */, FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */, 43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */, E5F4C992845388B50BABACAA /* ServerSelectionScreenCoordinator.swift in Sources */, @@ -5174,6 +5197,7 @@ 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */, 4A618590DEB72C4F186BFED4 /* UserSessionFlowCoordinator.swift in Sources */, 3113065AABBC14CEAE6843FA /* UserSessionFlowCoordinatorStateMachine.swift in Sources */, + A7AD63C02AD01A1000E1B12F /* CompletionSuggestionModels.swift in Sources */, 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */, 7E91BAC17963ED41208F489B /* UserSessionStore.swift in Sources */, AC69B6DF15FC451AB2945036 /* UserSessionStoreProtocol.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 52bc1ddf1..2d2b48425 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -340,10 +340,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace) + let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy, areSuggestionsEnabled: appSettings.mentionsEnabled) + let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, timelineController: timelineController, mediaProvider: userSession.mediaProvider, - emojiProvider: emojiProvider) + emojiProvider: emojiProvider, + completionSuggestionService: completionSuggestionService) let coordinator = RoomScreenCoordinator(parameters: parameters) coordinator.actions .sink { [weak self] action in diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 5c4395683..808616c99 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -197,6 +197,35 @@ class BugReportServiceMock: BugReportServiceProtocol { } } } +class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol { + var areSuggestionsEnabled: Bool { + get { return underlyingAreSuggestionsEnabled } + set(value) { underlyingAreSuggestionsEnabled = value } + } + var underlyingAreSuggestionsEnabled: Bool! + var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { + get { return underlyingSuggestionsPublisher } + set(value) { underlyingSuggestionsPublisher = value } + } + var underlyingSuggestionsPublisher: AnyPublisher<[SuggestionItem], Never>! + + //MARK: - setSuggestionTrigger + + var setSuggestionTriggerCallsCount = 0 + var setSuggestionTriggerCalled: Bool { + return setSuggestionTriggerCallsCount > 0 + } + var setSuggestionTriggerReceivedSuggestionTrigger: SuggestionPattern? + var setSuggestionTriggerReceivedInvocations: [SuggestionPattern?] = [] + var setSuggestionTriggerClosure: ((SuggestionPattern?) -> Void)? + + func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?) { + setSuggestionTriggerCallsCount += 1 + setSuggestionTriggerReceivedSuggestionTrigger = suggestionTrigger + setSuggestionTriggerReceivedInvocations.append(suggestionTrigger) + setSuggestionTriggerClosure?(suggestionTrigger) + } +} class NotificationCenterMock: NotificationCenterProtocol { //MARK: - post diff --git a/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionModels.swift b/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionModels.swift new file mode 100644 index 000000000..3a459349a --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionModels.swift @@ -0,0 +1,48 @@ +// +// 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 Foundation + +import WysiwygComposer + +enum SuggestionItem: Identifiable, Equatable { + case user(item: MentionSuggestionItem) + + var id: String { + switch self { + case .user(let user): + return user.id + } + } +} + +struct MentionSuggestionItem: Identifiable, Equatable { + let id: String + let displayName: String? + let avatarURL: URL? +} + +extension WysiwygComposer.SuggestionPattern { + var toElementPattern: SuggestionPattern? { + switch key { + case .at: + return SuggestionPattern(type: .user, text: text) + // Not yet supported + default: + return nil + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionService.swift b/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionService.swift new file mode 100644 index 000000000..2c0c592ff --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionService.swift @@ -0,0 +1,67 @@ +// +// 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 Combine +import Foundation + +final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { + let areSuggestionsEnabled: Bool + let suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> + + private let suggestionTriggerSubject = CurrentValueSubject(nil) + + init(roomProxy: RoomProxyProtocol, areSuggestionsEnabled: Bool) { + self.areSuggestionsEnabled = areSuggestionsEnabled + guard areSuggestionsEnabled else { + suggestionsPublisher = Empty().eraseToAnyPublisher() + return + } + suggestionsPublisher = suggestionTriggerSubject + .combineLatest(roomProxy.members) + .map { suggestionTrigger, members -> [SuggestionItem] in + guard let suggestionTrigger else { + return [] + } + + switch suggestionTrigger.type { + case .user: + return members.filter { member in + guard !member.isAccountOwner && member.membership == .join else { + return false + } + + let containedInUserID = member.userID.localizedStandardContains(suggestionTrigger.text.lowercased()) + + let containedInDisplayName: Bool + if let displayName = member.displayName { + containedInDisplayName = displayName.localizedStandardContains(suggestionTrigger.text.lowercased()) + } else { + containedInDisplayName = false + } + + return containedInUserID || containedInDisplayName + } + .map { SuggestionItem.user(item: .init(id: $0.userID, displayName: $0.displayName, avatarURL: $0.avatarURL)) } + } + } + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?) { + suggestionTriggerSubject.value = suggestionTrigger + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionServiceProtocol.swift b/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionServiceProtocol.swift new file mode 100644 index 000000000..df214c55e --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/CompletionSuggestionServiceProtocol.swift @@ -0,0 +1,39 @@ +// +// 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 Combine + +// sourcery: AutoMockable +protocol CompletionSuggestionServiceProtocol { + // To be removed once we suggestions and mentions are always enabled + var areSuggestionsEnabled: Bool { get } + var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get } + + func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?) +} + +extension CompletionSuggestionServiceMock { + struct CompletionSuggestionServiceMockConfiguration { + var areSuggestionsEnabled = true + var suggestions: [SuggestionItem] = [] + } + + convenience init(configuration: CompletionSuggestionServiceMockConfiguration) { + self.init() + underlyingAreSuggestionsEnabled = configuration.areSuggestionsEnabled + underlyingSuggestionsPublisher = Just(configuration.suggestions).eraseToAnyPublisher() + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index 6f2c0f925..2bb739048 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -50,6 +50,8 @@ enum ComposerToolbarViewAction { struct ComposerToolbarViewState: BindableState { var composerMode: RoomScreenComposerMode = .default var composerEmpty = true + var areSuggestionsEnabled = true + var suggestions: [SuggestionItem] = [] var bindings: ComposerToolbarViewStateBindings diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index 84b163973..2e39b2b99 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -23,6 +23,7 @@ typealias ComposerToolbarViewModelType = StateStoreViewModel = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -37,10 +38,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private var currentLinkData: WysiwygLinkData? - init(wysiwygViewModel: WysiwygComposerViewModel) { + init(wysiwygViewModel: WysiwygComposerViewModel, completionSuggestionService: CompletionSuggestionServiceProtocol, mediaProvider: MediaProviderProtocol) { self.wysiwygViewModel = wysiwygViewModel + self.completionSuggestionService = completionSuggestionService - super.init(initialViewState: ComposerToolbarViewState(bindings: .init())) + super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled, bindings: .init()), imageProvider: mediaProvider) context.$viewState .map(\.composerMode) @@ -69,6 +71,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } .weakAssign(to: \.state.bindings.formatItems, on: self) .store(in: &cancellables) + + wysiwygViewModel.$suggestionPattern + .sink { [weak self] suggestionPattern in + self?.completionSuggestionService.setSuggestionTrigger(suggestionPattern?.toElementPattern) + } + .store(in: &cancellables) + + completionSuggestionService.suggestionsPublisher + .weakAssign(to: \.state.suggestions, on: self) + .store(in: &cancellables) } // MARK: - Public diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModelProtocol.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModelProtocol.swift index 7906ca5c9..7efdd3824 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModelProtocol.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModelProtocol.swift @@ -16,6 +16,15 @@ import Combine +struct SuggestionPattern: Equatable { + enum SuggestionType: Equatable { + case user + } + + let type: SuggestionType + let text: String +} + protocol ComposerToolbarViewModelProtocol { var actions: AnyPublisher { get } var context: ComposerToolbarViewModelType.Context { get } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift new file mode 100644 index 000000000..02165121b --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift @@ -0,0 +1,145 @@ +// +// Copyright 2021 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 CompletionSuggestionView: View { + let imageProvider: ImageProviderProtocol? + let items: [SuggestionItem] + + private enum Constants { + static let topPadding: CGFloat = 8.0 + static let listItemPadding: CGFloat = 4.0 + static let lineSpacing: CGFloat = 10.0 + static let maxHeight: CGFloat = 300.0 + static let maxVisibleRows = 4 + + /* + As of iOS 16.0, SwiftUI's List uses `UICollectionView` instead + of `UITableView` internally, this value is an adjustment to apply + to the list items in order to be as close as possible as the + `UITableView` display. + */ + @available(iOS 16.0, *) + static let collectionViewPaddingCorrection: CGFloat = -5.0 + } + + // MARK: Public + + var showBackgroundShadow = true + @State private var prototypeListItemFrame: CGRect = .zero + + var body: some View { + if items.isEmpty { + EmptyView() + } else { + ZStack { + MentionSuggestionItemView(imageProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil)) + .background(ViewFrameReader(frame: $prototypeListItemFrame)) + .hidden() + if showBackgroundShadow { + BackgroundView { + list() + } + } else { + list() + } + } + } + } + + private func list() -> some View { + List(items) { item in + Button { } label: { + switch item { + case .user(let mention): + MentionSuggestionItemView(imageProvider: imageProvider, item: mention) + } + } + .modifier(ListItemPaddingModifier(isFirst: items.first?.id == item.id)) + } + .listStyle(PlainListStyle()) + .frame(height: min(Constants.maxHeight, + min(contentHeightForRowCount(Constants.maxVisibleRows), + contentHeightForRowCount(items.count)))) + .background(Color.compound.bgCanvasDefault) + } + + private func contentHeightForRowCount(_ count: Int) -> CGFloat { + (prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding + } + + private struct ListItemPaddingModifier: ViewModifier { + private let isFirst: Bool + + init(isFirst: Bool) { + self.isFirst = isFirst + } + + func body(content: Content) -> some View { + var topPadding: CGFloat = isFirst ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding + var bottomPadding: CGFloat = Constants.listItemPadding + if #available(iOS 16.0, *) { + topPadding += Constants.collectionViewPaddingCorrection + bottomPadding += Constants.collectionViewPaddingCorrection + } + + return content + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + } + } +} + +private struct BackgroundView: View { + var content: () -> Content + + private let shadowRadius: CGFloat = 20.0 + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + content() + .background(Color.compound.bgSubtlePrimary) + .clipShape(RoundedCornerShape(radius: shadowRadius, corners: [.topLeft, .topRight])) + .shadow(color: .black.opacity(0.20), radius: 20.0, x: 0.0, y: 3.0) + .mask(Rectangle().padding(.init(top: -(shadowRadius * 2), leading: 0.0, bottom: 0.0, trailing: 0.0))) + .edgesIgnoringSafeArea(.all) + } +} + +// MARK: - Previews + +struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview { + static let multipleItems: [SuggestionItem] = (0...10).map { index in + SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil)) + } + + static var previews: some View { + // Putting them is VStack allows the preview to work properly in tests + VStack { + CompletionSuggestionView(imageProvider: MockMediaProvider(), + items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), + .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))]) + } + VStack { + CompletionSuggestionView(imageProvider: MockMediaProvider(), + items: multipleItems) + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index f48ecf9c2..4eb86fe1a 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -26,6 +26,8 @@ struct ComposerToolbar: View { @FocusState private var composerFocused: Bool @ScaledMetric private var sendButtonIconSize = 16 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 + + @State private var frame: CGRect = .zero var body: some View { VStack(spacing: 8) { @@ -34,8 +36,21 @@ struct ComposerToolbar: View { bottomBar } } + .background { + ViewFrameReader(frame: $frame) + } + .overlay(alignment: .bottom) { + if context.viewState.areSuggestionsEnabled { + suggestionView + .offset(y: -frame.height) + } + } .alert(item: $context.alertInfo) } + + private var suggestionView: some View { + CompletionSuggestionView(imageProvider: context.imageProvider, items: context.viewState.suggestions) + } private var topBar: some View { HStack(alignment: .bottom, spacing: 5) { @@ -176,8 +191,24 @@ struct ComposerToolbar: View { } struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { + static let wysiwygViewModel = WysiwygComposerViewModel() + static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)), + mediaProvider: MockMediaProvider()) + static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), + .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))] + static var previews: some View { ComposerToolbar.mock() + + // Putting them is VStack allows the completion suggestion preview to work properly in tests + VStack { + // The mock functon can't be used in this context because it does not hold a reference to the view model, losing the combine subscriptions + ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommandHandler: { _ in false }) + } + .previewDisplayName("With Suggestions") } } @@ -186,7 +217,9 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { extension ComposerToolbar { static func mock() -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() - let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) + let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider()) return ComposerToolbar(context: composerViewModel.context, wysiwygViewModel: wysiwygViewModel, keyCommandHandler: { _ in false }) diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MentionSuggestionItemView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MentionSuggestionItemView.swift new file mode 100644 index 000000000..88981c56c --- /dev/null +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MentionSuggestionItemView.swift @@ -0,0 +1,53 @@ +// +// 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 MentionSuggestionItemView: View { + let imageProvider: ImageProviderProtocol? + let item: MentionSuggestionItem + + var body: some View { + HStack(alignment: .center) { + LoadableAvatarImage(url: item.avatarURL, + name: item.displayName, + contentID: item.id, + avatarSize: .custom(42), + imageProvider: imageProvider) + VStack(alignment: .leading) { + Text(item.displayName ?? item.id) + .font(.compound.bodyLG) + .foregroundColor(.compound.textPrimary) + .lineLimit(1) + if item.displayName != nil { + Text(item.id) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + .lineLimit(1) + } + } + } + } +} + +struct MentionSuggestionItemView_Previews: PreviewProvider, TestablePreview { + static let mockMediaProvider = MockMediaProvider() + + static var previews: some View { + MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: URL.documentsDirectory)) + MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil)) + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift b/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift index ed28ee097..bd0c33bc6 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/RoomAttachmentPicker.swift @@ -115,7 +115,9 @@ struct RoomAttachmentPicker: View { } struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview { - static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel()) + static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider()) static var previews: some View { RoomAttachmentPicker(context: viewModel.context) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 18d276b75..712a07186 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -24,6 +24,7 @@ struct RoomScreenCoordinatorParameters { let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let emojiProvider: EmojiProviderProtocol + let completionSuggestionService: CompletionSuggestionServiceProtocol } enum RoomScreenCoordinatorAction { @@ -54,7 +55,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { init(parameters: RoomScreenCoordinatorParameters) { self.parameters = parameters - + viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, mediaProvider: parameters.mediaProvider, roomProxy: parameters.roomProxy, @@ -66,7 +67,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { maxCompressedHeight: ComposerConstant.maxHeight, maxExpandedHeight: ComposerConstant.maxHeight, parserStyle: .elementX) - composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) + composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, completionSuggestionService: parameters.completionSuggestionService, mediaProvider: parameters.mediaProvider) } // MARK: - Public diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 96360c4cb..d7ac7bda2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -233,7 +233,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol appSettings.$readReceiptsEnabled .weakAssign(to: \.state.readReceiptsEnabled, on: self) .store(in: &cancellables) - + roomProxy.members .map { members in members.reduce(into: [String: RoomMemberState]()) { dictionary, member in diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 256f2f562..17a681e7e 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -235,7 +235,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -244,7 +245,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -255,7 +257,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -266,7 +269,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -278,7 +282,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -292,7 +297,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -306,7 +312,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -320,7 +327,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -335,7 +343,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -349,7 +358,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -363,7 +373,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -377,7 +388,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -391,7 +403,8 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init())) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) diff --git a/UnitTests/Sources/CompletionSuggestionServiceTests.swift b/UnitTests/Sources/CompletionSuggestionServiceTests.swift new file mode 100644 index 000000000..befa46d1f --- /dev/null +++ b/UnitTests/Sources/CompletionSuggestionServiceTests.swift @@ -0,0 +1,53 @@ +// +// 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 Combine +import XCTest + +@testable import ElementX + +final class CompletionSuggestionServiceTests: XCTestCase { + private var cancellables = Set() + + override func setUp() { + cancellables.removeAll() + } + + func testUserSuggestons() async throws { + let alice: RoomMemberProxyMock = .mockAlice + let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] + let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", members: members)) + let service = CompletionSuggestionService(roomProxy: roomProxyMock, areSuggestionsEnabled: true) + + var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in + suggestions == [] + } + + try await deferred.fulfill() + + deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in + suggestions == [.user(item: .init(id: alice.userID, displayName: alice.displayName, avatarURL: alice.avatarURL))] + } + service.setSuggestionTrigger(.init(type: .user, text: "ali")) + try await deferred.fulfill() + + deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in + suggestions == [] + } + service.setSuggestionTrigger(.init(type: .user, text: "me")) + try await deferred.fulfill() + } +} diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index 33f435619..e6d143988 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -14,15 +14,18 @@ // limitations under the License. // +import Combine @testable import ElementX -import WysiwygComposer import XCTest +import WysiwygComposer + @MainActor class ComposerToolbarViewModelTests: XCTestCase { private var appSettings: AppSettings! private var wysiwygViewModel: WysiwygComposerViewModel! private var viewModel: ComposerToolbarViewModel! + private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! override func setUp() { AppSettings.reset() @@ -30,7 +33,10 @@ class ComposerToolbarViewModelTests: XCTestCase { appSettings.richTextEditorEnabled = true ServiceLocator.shared.register(appSettings: appSettings) wysiwygViewModel = WysiwygComposerViewModel() - viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel) + completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init()) + viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: completionSuggestionServiceMock, + mediaProvider: MockMediaProvider()) } func testComposerFocus() { @@ -92,4 +98,23 @@ class ComposerToolbarViewModelTests: XCTestCase { viewModel.process(viewAction: .composerAction(action: .link)) XCTAssertNotNil(viewModel.state.bindings.alertInfo) } + + func testSuggestions() { + let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), + .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))] + let mockCompletionSuggestionService = CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)) + viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: mockCompletionSuggestionService, + mediaProvider: MockMediaProvider()) + + XCTAssertEqual(viewModel.state.suggestions, suggestions) + } + + func testSuggestionTrigger() { + wysiwygViewModel.setMarkdownContent("@test") + wysiwygViewModel.setMarkdownContent("#not_implemented_yey") + + // The first one is nil because when initialised the view model is empty + XCTAssertEqual(completionSuggestionServiceMock.setSuggestionTriggerReceivedInvocations, [nil, .init(type: .user, text: "test"), nil]) + } } diff --git a/UnitTests/__Snapshots__/PreviewTests/test_completionSuggestion.1.png b/UnitTests/__Snapshots__/PreviewTests/test_completionSuggestion.1.png new file mode 100644 index 000000000..56189798d --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_completionSuggestion.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1053b6b8361e483fdbfc3cab89610b5e6ba9642ee0c659a90880dc05779a0ee1 +size 87735 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_completionSuggestion.2.png b/UnitTests/__Snapshots__/PreviewTests/test_completionSuggestion.2.png new file mode 100644 index 000000000..ad35d5626 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_completionSuggestion.2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bcd00bc4f83ce36c67954aa301858ad9ea1e35eb04c735e596f6481e75070eb +size 77023 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png new file mode 100644 index 000000000..63b954161 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9b4ab3c5f407fd0cd1086e74b8b8097ce26c6a980e009b03ee2bbcf6009d088 +size 99560 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_mentionSuggestionItemView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_mentionSuggestionItemView.1.png new file mode 100644 index 000000000..80dae5d06 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_mentionSuggestionItemView.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc30346f730c3ceaf00311d6d1f50c7ebb97879d658424c8fc6624e8f78c80ee +size 63677 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_mentionSuggestionItemView.2.png b/UnitTests/__Snapshots__/PreviewTests/test_mentionSuggestionItemView.2.png new file mode 100644 index 000000000..7289f9c8a --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_mentionSuggestionItemView.2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:792706480d9eb6b51fb407226f5ffa729ac54fc967c8f9925f2e30a6e3fe9ef2 +size 60057 diff --git a/changelog.d/1826.feature b/changelog.d/1826.feature new file mode 100644 index 000000000..90d419413 --- /dev/null +++ b/changelog.d/1826.feature @@ -0,0 +1 @@ +Added the user suggestions view when trying to mention a user (but it doesn't react to tap yet). \ No newline at end of file