From 4b2aba93675937cc2d131dda8e439c69c33dd071 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Tue, 6 Jun 2023 10:46:04 +0200 Subject: [PATCH] Tapping on user avatar/name in the timeline opens the room member details (#1020) * Implementation completed * changelog * code improvement * Apply suggestions from code review Co-authored-by: Stefan Ceriu * pr suggestions --------- Co-authored-by: Stefan Ceriu --- ElementX.xcodeproj/project.pbxproj | 22 ++-- .../en.lproj/Localizable.strings | 1 + .../RoomFlowCoordinator.swift | 39 +++++- ElementX/Sources/Generated/Strings.swift | 2 + .../Mocks/Generated/GeneratedMocks.swift | 113 +++++++++++++++++- .../UserIndicatorControllerMock.swift} | 22 ++-- .../UserIndicatorControllerProtocol.swift | 12 ++ .../View/MediaUploadPreviewScreen.swift | 2 +- .../View/RoomDetailsEditScreen.swift | 2 +- .../RoomScreen/RoomScreenCoordinator.swift | 3 + .../Screens/RoomScreen/RoomScreenModels.swift | 2 + .../RoomScreen/RoomScreenViewModel.swift | 51 ++++++-- .../Style/TimelineItemBubbledStylerView.swift | 3 + .../Style/TimelineItemPlainStylerView.swift | 17 ++- .../RoomMember/RoomMemberProxyProtocol.swift | 2 +- .../Sources/Services/Room/RoomProxy.swift | 16 +++ .../Services/Room/RoomProxyProtocol.swift | 5 +- .../UITests/UITestsAppCoordinator.swift | 10 +- .../UnitTests/UnitTestsAppCoordinator.swift | 2 +- .../Sourcery/sourcery_automockable_config.yml | 2 +- .../Sources/InviteUsersViewModelTests.swift | 2 +- .../RoomDetailsEditScreenViewModelTests.swift | 13 +- .../Sources/RoomScreenViewModelTests.swift | 88 ++++++++++++++ changelog.d/1017.feature | 1 + 24 files changed, 371 insertions(+), 61 deletions(-) rename ElementX/Sources/{Other/UserIndicator/MockUserIndicatorController.swift => Mocks/UserIndicatorControllerMock.swift} (56%) create mode 100644 changelog.d/1017.feature diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 0cf6ffb32..6e9083e60 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -107,7 +107,6 @@ 29EE1791E0AFA1ABB7F23D2F /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; 2A73C8580C39DA8EE697C161 /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E6DD75A81D07CD91997D8C /* SettingsScreenViewModelProtocol.swift */; }; 2A90DD14DE5C891BFA433950 /* TimelineReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */; }; - 2AA684867C20F62CF03E8698 /* MockUserIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13673F95EBA78D40C09CCE35 /* MockUserIndicatorController.swift */; }; 2ABF11717C64054CEF2819A3 /* RoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F85164F9475FF2867F71AAA /* RoomTimelineController.swift */; }; 2AD59AD5B09498EF8B3B04EC /* InvitesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */; }; 2BA59D0AEFB4B82A2EC2A326 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50009897F60FAE7D63EF5E5B /* Kingfisher */; }; @@ -427,6 +426,7 @@ A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; }; A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; A6F713461DB62AC06293E7B7 /* FilePreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820637A0F9C2F562FF40CBC8 /* FilePreviewScreenModels.swift */; }; + A713320F2A2E40BD00D1E950 /* UserIndicatorControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A713320E2A2E40BD00D1E950 /* UserIndicatorControllerMock.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 */; }; @@ -750,9 +750,8 @@ 1222DB76B917EB8A55365BA5 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 127A57D053CE8C87B5EFB089 /* Consumable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumable.swift; sourceTree = ""; }; 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.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 = ""; }; - 13673F95EBA78D40C09CCE35 /* MockUserIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserIndicatorController.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 = ""; }; 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = ""; }; @@ -865,7 +864,7 @@ 46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsAppCoordinator.swift; sourceTree = ""; }; 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = ""; }; 471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineView.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 = ""; }; 47E6DD75A81D07CD91997D8C /* SettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; @@ -1014,7 +1013,7 @@ 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.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 = ""; }; 8F61A0DD8243B395499C99A2 /* InvitesScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenUITests.swift; sourceTree = ""; }; @@ -1074,6 +1073,7 @@ A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A6F5CDE754D53A9A403EDBA9 /* DeveloperOptionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModelProtocol.swift; sourceTree = ""; }; + A713320E2A2E40BD00D1E950 /* UserIndicatorControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerMock.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.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 = ""; }; @@ -1112,7 +1112,7 @@ B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemContextMenu.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 = ""; }; B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; B7DBA101D643B31E813F3AC1 /* AnalyticsSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreen.swift; sourceTree = ""; }; @@ -1179,7 +1179,7 @@ CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; CECF45B5E8E795666B8C5013 /* SettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenModels.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 = ""; }; D06A27D9C70E0DCC1E199163 /* OnboardingBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingBackgroundView.swift; sourceTree = ""; }; D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; @@ -1245,7 +1245,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 = ""; }; @@ -1640,6 +1640,7 @@ 1ABDE6F66532CBEB0E016F94 /* RoomProxyMock.swift */, 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */, AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */, + A713320E2A2E40BD00D1E950 /* UserIndicatorControllerMock.swift */, B23135B06B044CB811139D2F /* Generated */, E5E545F92D01588360A9BAC5 /* SDK */, ); @@ -2768,7 +2769,6 @@ B687E3E8C23415A06A3D5C65 /* UserIndicator */ = { isa = PBXGroup; children = ( - 13673F95EBA78D40C09CCE35 /* MockUserIndicatorController.swift */, E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */, FD1275D9CE0FFBA6E8E85426 /* UserIndicatorController.swift */, A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */, @@ -3917,7 +3917,6 @@ 447E8580A0A2569E32529E17 /* MockRoomTimelineProvider.swift in Sources */, B721125D17A0BA86794F29FB /* MockServerSelectionScreenState.swift in Sources */, AF2ABA2794E376B64104C964 /* MockSoftLogoutScreenState.swift in Sources */, - 2AA684867C20F62CF03E8698 /* MockUserIndicatorController.swift in Sources */, D8359F67AF3A83516E9083C1 /* MockUserSession.swift in Sources */, F9842667B68DC6FA1F9ECCBB /* NSItemProvider.swift in Sources */, EA01A06EEDFEF4AE7652E5F3 /* NSRegularExpresion.swift in Sources */, @@ -3956,6 +3955,7 @@ 13853973A5E24374FCEDE8A3 /* RedactedRoomTimelineView.swift in Sources */, C413D36D44F89DE63D3ADFA4 /* ReportContentScreen.swift in Sources */, C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */, + A713320F2A2E40BD00D1E950 /* UserIndicatorControllerMock.swift in Sources */, 46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */, 42A5A42ACF063EEE6B1980D2 /* ReportContentScreenViewModel.swift in Sources */, 8285FF4B2C2331758C437FF7 /* ReportContentScreenViewModelProtocol.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 6f5a68fab..3eb266578 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -245,6 +245,7 @@ "screen_room_details_room_name_label" = "Room name"; "screen_room_details_share_room_title" = "Share room"; "screen_room_details_updating_room" = "Updating room…"; +"screen_room_error_failed_retrieving_user_details" = "Could not retrieve user details"; "screen_room_member_details_block_alert_action" = "Block"; "screen_room_member_details_block_alert_description" = "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."; "screen_room_member_details_block_user" = "Block user"; diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 2eeb31bcb..61462fbc3 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -95,6 +95,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .room(roomID: roomID) case (.dismissRoom, .roomDetails): return .initial + + case (.presentRoomMemberDetails(let member), .room(let roomID)): + return .roomMemberDetails(roomID: roomID, member: member) + case (.dismissRoomMemberDetails, .roomMemberDetails(let roomID, _)): + return .room(roomID: roomID) case (.presentMediaViewer(let file, let title), .room(let roomID)): return .mediaViewer(roomID: roomID, file: file, title: title) @@ -122,7 +127,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .emojiPicker(roomID: roomID, itemID: itemID) case (.dismissEmojiPicker, .emojiPicker(let roomID, _)): return .room(roomID: roomID) - + default: return nil } @@ -179,6 +184,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { presentEmojiPicker(for: itemID) case (.emojiPicker, .dismissEmojiPicker, .room): break + + case (.room, .presentRoomMemberDetails, .roomMemberDetails(_, let member)): + presentRoomMemberDetails(member: member.value) + case (.roomMemberDetails, .dismissRoomMemberDetails, .room): + break default: fatalError("Unknown transition: \(context)") @@ -256,6 +266,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url)) case .presentEmojiPicker(let itemID): stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID)) + case .presentRoomMemberDetails(member: let member): + stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member))) } } .store(in: &cancellables) @@ -460,12 +472,33 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } + private func presentRoomMemberDetails(member: RoomMemberProxyProtocol) { + let params = RoomMemberDetailsScreenCoordinatorParameters(roomMemberProxy: member, mediaProvider: userSession.mediaProvider) + let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params) + + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissRoomMemberDetails) + } + } + private func showSuccess(label: String) { ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark")) } } private extension RoomFlowCoordinator { + struct HashableRoomMemberWrapper: Hashable { + let value: RoomMemberProxyProtocol + + static func == (lhs: HashableRoomMemberWrapper, rhs: HashableRoomMemberWrapper) -> Bool { + lhs.value.userID == rhs.value.userID + } + + func hash(into hasher: inout Hasher) { + hasher.combine(value.userID) + } + } + enum State: StateType { case initial case room(roomID: String) @@ -475,6 +508,7 @@ private extension RoomFlowCoordinator { case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource) case mediaUploadPreview(roomID: String, fileURL: URL) case emojiPicker(roomID: String, itemID: String) + case roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper) } struct EventUserInfo { @@ -502,5 +536,8 @@ private extension RoomFlowCoordinator { case presentEmojiPicker(itemID: String) case dismissEmojiPicker + + case presentRoomMemberDetails(member: HashableRoomMemberWrapper) + case dismissRoomMemberDetails } } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 39b25d42e..0fdccc624 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -638,6 +638,8 @@ public enum L10n { public static var screenRoomDetailsUpdatingRoom: String { return L10n.tr("Localizable", "screen_room_details_updating_room") } /// Failed processing media to upload, please try again. public static var screenRoomErrorFailedProcessingMedia: String { return L10n.tr("Localizable", "screen_room_error_failed_processing_media") } + /// Could not retrieve user details + public static var screenRoomErrorFailedRetrievingUserDetails: String { return L10n.tr("Localizable", "screen_room_error_failed_retrieving_user_details") } /// Block public static var screenRoomMemberDetailsBlockAlertAction: String { return L10n.tr("Localizable", "screen_room_member_details_block_alert_action") } /// Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime. diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 2b19b4cba..1ac16d9ee 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4,8 +4,9 @@ // swiftlint:disable all import Combine import Foundation -import MatrixRustSDK +import SwiftUI import AnalyticsEvents +import MatrixRustSDK class AnalyticsClientMock: AnalyticsClientProtocol { var isRunning: Bool { get { return underlyingIsRunning } @@ -813,6 +814,27 @@ class RoomProxyMock: RoomProxyProtocol { updateMembersCallsCount += 1 await updateMembersClosure?() } + //MARK: - getMember + + var getMemberUserIDCallsCount = 0 + var getMemberUserIDCalled: Bool { + return getMemberUserIDCallsCount > 0 + } + var getMemberUserIDReceivedUserID: String? + var getMemberUserIDReceivedInvocations: [String] = [] + var getMemberUserIDReturnValue: Result! + var getMemberUserIDClosure: ((String) async -> Result)? + + func getMember(userID: String) async -> Result { + getMemberUserIDCallsCount += 1 + getMemberUserIDReceivedUserID = userID + getMemberUserIDReceivedInvocations.append(userID) + if let getMemberUserIDClosure = getMemberUserIDClosure { + return await getMemberUserIDClosure(userID) + } else { + return getMemberUserIDReturnValue + } + } //MARK: - inviter var inviterCallsCount = 0 @@ -1121,4 +1143,93 @@ class UserDiscoveryServiceMock: UserDiscoveryServiceProtocol { } } } +class UserIndicatorControllerMock: UserIndicatorControllerProtocol { + var alertInfo: AlertInfo? + + //MARK: - submitIndicator + + var submitIndicatorCallsCount = 0 + var submitIndicatorCalled: Bool { + return submitIndicatorCallsCount > 0 + } + var submitIndicatorReceivedIndicator: UserIndicator? + var submitIndicatorReceivedInvocations: [UserIndicator] = [] + var submitIndicatorClosure: ((UserIndicator) -> Void)? + + func submitIndicator(_ indicator: UserIndicator) { + submitIndicatorCallsCount += 1 + submitIndicatorReceivedIndicator = indicator + submitIndicatorReceivedInvocations.append(indicator) + submitIndicatorClosure?(indicator) + } + //MARK: - retractIndicatorWithId + + var retractIndicatorWithIdCallsCount = 0 + var retractIndicatorWithIdCalled: Bool { + return retractIndicatorWithIdCallsCount > 0 + } + var retractIndicatorWithIdReceivedId: String? + var retractIndicatorWithIdReceivedInvocations: [String] = [] + var retractIndicatorWithIdClosure: ((String) -> Void)? + + func retractIndicatorWithId(_ id: String) { + retractIndicatorWithIdCallsCount += 1 + retractIndicatorWithIdReceivedId = id + retractIndicatorWithIdReceivedInvocations.append(id) + retractIndicatorWithIdClosure?(id) + } + //MARK: - retractAllIndicators + + var retractAllIndicatorsCallsCount = 0 + var retractAllIndicatorsCalled: Bool { + return retractAllIndicatorsCallsCount > 0 + } + var retractAllIndicatorsClosure: (() -> Void)? + + func retractAllIndicators() { + retractAllIndicatorsCallsCount += 1 + retractAllIndicatorsClosure?() + } + //MARK: - start + + var startCallsCount = 0 + var startCalled: Bool { + return startCallsCount > 0 + } + var startClosure: (() -> Void)? + + func start() { + startCallsCount += 1 + startClosure?() + } + //MARK: - stop + + var stopCallsCount = 0 + var stopCalled: Bool { + return stopCallsCount > 0 + } + var stopClosure: (() -> Void)? + + func stop() { + stopCallsCount += 1 + stopClosure?() + } + //MARK: - toPresentable + + var toPresentableCallsCount = 0 + var toPresentableCalled: Bool { + return toPresentableCallsCount > 0 + } + var toPresentableReturnValue: AnyView! + var toPresentableClosure: (() -> AnyView)? + + func toPresentable() -> AnyView { + toPresentableCallsCount += 1 + if let toPresentableClosure = toPresentableClosure { + return toPresentableClosure() + } else { + return toPresentableReturnValue + } + } +} // swiftlint:enable all diff --git a/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift b/ElementX/Sources/Mocks/UserIndicatorControllerMock.swift similarity index 56% rename from ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift rename to ElementX/Sources/Mocks/UserIndicatorControllerMock.swift index c5e0982f3..ae80cd71c 100644 --- a/ElementX/Sources/Other/UserIndicator/MockUserIndicatorController.swift +++ b/ElementX/Sources/Mocks/UserIndicatorControllerMock.swift @@ -1,5 +1,5 @@ // -// Copyright 2022 New Vector Ltd +// 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. @@ -17,18 +17,12 @@ import Combine import Foundation -class MockUserIndicatorController: UserIndicatorControllerProtocol { - func submitIndicator(_ indicator: UserIndicator) { } - - func retractIndicatorWithId(_ id: String) { } - - func retractAllIndicators() { } - - var alertInfo: AlertInfo? { - didSet { - alertInfoPublisher.send(alertInfo) - } +extension UserIndicatorControllerMock { + static var `default`: UserIndicatorControllerMock { + let mock = UserIndicatorControllerMock() + mock.submitIndicatorClosure = { _ in } + mock.retractIndicatorWithIdClosure = { _ in } + mock.retractAllIndicatorsClosure = { } + return mock } - - let alertInfoPublisher: PassthroughSubject?, Never> = .init() } diff --git a/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift b/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift index e65aac20e..f5f25e673 100644 --- a/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift +++ b/ElementX/Sources/Other/UserIndicator/UserIndicatorControllerProtocol.swift @@ -16,9 +16,21 @@ import Foundation +// sourcery: AutoMockable protocol UserIndicatorControllerProtocol: CoordinatorProtocol { func submitIndicator(_ indicator: UserIndicator) func retractIndicatorWithId(_ id: String) func retractAllIndicators() var alertInfo: AlertInfo? { get set } } + +extension UserIndicatorControllerProtocol { + /// Allows to submit a delayed indicator, this returns a Task so that it's also possible to cancel the action + func submitIndicator(_ indicator: UserIndicator, delay: Duration) -> Task { + Task { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + submitIndicator(indicator) + } + } +} diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift index 28bea67e1..54936c229 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift @@ -102,7 +102,7 @@ private class PreviewItem: NSObject, QLPreviewItem { // MARK: - Previews struct MediaUploadPreviewScreen_Previews: PreviewProvider { - static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: MockUserIndicatorController(), + static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: UserIndicatorControllerMock.default, roomProxy: RoomProxyMock(), mediaUploadingPreprocessor: MediaUploadingPreprocessor(), title: nil, diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift index 92a2de957..319e1071b 100644 --- a/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/View/RoomDetailsEditScreen.swift @@ -165,7 +165,7 @@ struct RoomDetailsEditScreen_Previews: PreviewProvider { static let viewModel = RoomDetailsEditScreenViewModel(accountOwner: RoomMemberProxyMock.mockAlice, mediaProvider: MockMediaProvider(), roomProxy: RoomProxyMock(with: .init(name: "Room", displayName: "Room")), - userIndicatorController: MockUserIndicatorController()) + userIndicatorController: UserIndicatorControllerMock.default) static var previews: some View { NavigationStack { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index ad27cb713..61817aada 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -31,6 +31,7 @@ enum RoomScreenCoordinatorAction { case presentMediaUploadPreviewScreen(URL) case presentRoomDetails case presentEmojiPicker(itemID: String) + case presentRoomMemberDetails(member: RoomMemberProxyProtocol) } final class RoomScreenCoordinator: CoordinatorProtocol { @@ -74,6 +75,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentMediaUploadPicker(.documents)) case .displayMediaUploadPreviewScreen(let url): actionsSubject.send(.presentMediaUploadPreviewScreen(url)) + case .displayRoomMemberDetails(let member): + actionsSubject.send(.presentRoomMemberDetails(member: member)) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index ffad66961..e67578802 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -27,6 +27,7 @@ enum RoomScreenViewModelAction { case displayMediaPicker case displayDocumentPicker case displayMediaUploadPreviewScreen(url: URL) + case displayRoomMemberDetails(member: RoomMemberProxyProtocol) } enum RoomScreenComposerMode: Equatable { @@ -65,6 +66,7 @@ enum RoomScreenViewAction { case displayDocumentPicker case handlePasteOrDrop(provider: NSItemProvider) + case tappedOnUser(userID: String) } struct RoomScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index fd73c79ae..01eb1fce1 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -29,12 +29,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let roomProxy: RoomProxyProtocol private let timelineController: RoomTimelineControllerProtocol + private unowned let userIndicatorController: UserIndicatorControllerProtocol + private var loadingTask: Task? init(timelineController: RoomTimelineControllerProtocol, mediaProvider: MediaProviderProtocol, - roomProxy: RoomProxyProtocol) { + roomProxy: RoomProxyProtocol, + userIndicatorController: UserIndicatorControllerProtocol = ServiceLocator.shared.userIndicatorController) { self.roomProxy = roomProxy self.timelineController = timelineController + self.userIndicatorController = userIndicatorController super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID, roomTitle: roomProxy.roomTitle, @@ -130,6 +134,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol callback?(.displayDocumentPicker) case .handlePasteOrDrop(let provider): handlePasteOrDrop(provider) + case .tappedOnUser(userID: let userID): + Task { await handleTappedUser(userID: userID) } } } @@ -246,10 +252,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol title: L10n.commonError, message: message) case .toast(let message): - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID, - type: .toast, - title: message, - iconName: "xmark")) + userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID, + type: .toast, + title: message, + iconName: "xmark")) } } @@ -376,9 +382,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol _ = provider.loadDataRepresentation(for: contentType) { data, error in Task { @MainActor in let loadingIndicatorIdentifier = UUID().uuidString - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) + self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) defer { - ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) + self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } if let error { @@ -421,6 +427,37 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol return messageItem.contentType } + + private func handleTappedUser(userID: String) async { + // This is generally fast but it could take some time for rooms with thousands of users on first load + // Show a loader only if it takes more than 0.1 seconds + loadingTask = showLoadingIndicator(with: .milliseconds(100)) + let result = await roomProxy.getMember(userID: userID) + loadingTask?.cancel() + hideLoadingIndicator() + + switch result { + case .success(let member): + callback?(.displayRoomMemberDetails(member: member)) + case .failure(let error): + displayError(.alert(L10n.screenRoomErrorFailedRetrievingUserDetails)) + MXLog.error("Failed retrieving the user given the following id \(userID) with error: \(error)") + } + } + + private static let loadingIndicatorIdentifier = "RoomScreenLoadingIndicator" + + private func showLoadingIndicator(with delay: Duration) -> Task { + userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal(interactiveDismissDisabled: true), + title: L10n.commonLoading, + persistent: true), + delay: delay) + } + + private func hideLoadingIndicator() { + userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } } // MARK: - Mocks diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 1f0e90d30..f3cd1133d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -68,6 +68,9 @@ struct TimelineItemBubbledStylerView: View { .padding(.vertical, senderNameVerticalPadding) } .accessibilityElement(children: .combine) + .onTapGesture { + context.send(viewAction: .tappedOnUser(userID: timelineItem.sender.id)) + } } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index b2e2e2372..9e678cafa 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -70,12 +70,17 @@ struct TimelineItemPlainStylerView: View { private var header: some View { if shouldShowSenderDetails { HStack { - TimelineSenderAvatarView(timelineItem: timelineItem) - Text(timelineItem.sender.displayName ?? timelineItem.sender.id) - .font(.subheadline) - .foregroundColor(.element.primaryContent) - .fontWeight(.semibold) - .lineLimit(1) + HStack { + TimelineSenderAvatarView(timelineItem: timelineItem) + Text(timelineItem.sender.displayName ?? timelineItem.sender.id) + .font(.subheadline) + .foregroundColor(.element.primaryContent) + .fontWeight(.semibold) + .lineLimit(1) + } + .onTapGesture { + context.send(viewAction: .tappedOnUser(userID: timelineItem.sender.id)) + } Spacer() Text(timelineItem.timestamp) .foregroundColor(Color.element.tertiaryContent) diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index dce8ce03d..382fd95b3 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -23,7 +23,7 @@ enum RoomMemberProxyError: Error { } // sourcery: AutoMockable -protocol RoomMemberProxyProtocol { +protocol RoomMemberProxyProtocol: AnyObject { var userID: String { get } var displayName: String? { get } var avatarURL: URL? { get } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index d1464732e..dc3a41e44 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -389,6 +389,22 @@ class RoomProxy: RoomProxyProtocol { return } } + + func getMember(userID: String) async -> Result { + sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) + defer { + sendMessageBackgroundTask?.stop() + } + + return await Task.dispatch(on: userInitiatedDispatchQueue) { + do { + let member = try self.room.member(userId: userID) + return .success(RoomMemberProxy(member: member, backgroundTaskService: self.backgroundTaskService)) + } catch { + return .failure(.failedRetrievingMember) + } + } + } func ignoreUser(_ userID: String) async -> Result { sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 53c228600..c5d8339b4 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -33,6 +33,7 @@ enum RoomProxyError: Error { case failedReportingContent case failedAddingTimelineListener case failedRetrievingMembers + case failedRetrievingMember case failedLeavingRoom case failedAcceptingInvite case failedRejectingInvite @@ -113,7 +114,9 @@ protocol RoomProxyProtocol { func leaveRoom() async -> Result func updateMembers() async - + + func getMember(userID: String) async -> Result + func inviter() async -> RoomMemberProxyProtocol? func rejectInvitation() async -> Result diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index d67f65afd..a5adab395 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -30,7 +30,7 @@ class UITestsAppCoordinator: AppCoordinatorProtocol { navigationRootCoordinator = NavigationRootCoordinator() - ServiceLocator.shared.register(userIndicatorController: MockUserIndicatorController()) + ServiceLocator.shared.register(userIndicatorController: UserIndicatorControllerMock.default) AppSettings.configureWithSuiteName("io.element.elementx.uitests") AppSettings.reset() @@ -93,13 +93,13 @@ class MockScreen: Identifiable { case .serverSelection: let navigationStackCoordinator = NavigationStackCoordinator() let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), - userIndicatorController: MockUserIndicatorController(), + userIndicatorController: UserIndicatorControllerMock.default, isModallyPresented: true)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .serverSelectionNonModal: return ServerSelectionScreenCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), - userIndicatorController: MockUserIndicatorController(), + userIndicatorController: UserIndicatorControllerMock.default, isModallyPresented: false)) case .analyticsPrompt: return AnalyticsPromptScreenCoordinator() @@ -361,7 +361,7 @@ class MockScreen: Identifiable { mediaProvider: MockMediaProvider(), navigationStackCoordinator: navigationStackCoordinator, roomProxy: roomProxy, - userIndicatorController: MockUserIndicatorController())) + userIndicatorController: UserIndicatorControllerMock.default)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomMembersListScreen: @@ -477,7 +477,7 @@ class MockScreen: Identifiable { let mediaProvider = MockMediaProvider() let usersSubject = CurrentValueSubject<[UserProfileProxy], Never>([]) let members: [RoomMemberProxyMock] = id == .inviteUsersInRoomExistingMembers ? [.mockInvitedAlice, .mockBob] : [] - let roomType: InviteUsersScreenRoomType = id == .inviteUsers ? .draft : .room(members: members, userIndicatorController: MockUserIndicatorController()) + let roomType: InviteUsersScreenRoomType = id == .inviteUsers ? .draft : .room(members: members, userIndicatorController: UserIndicatorControllerMock.default) let coordinator = InviteUsersScreenCoordinator(parameters: .init(selectedUsers: usersSubject.asCurrentValuePublisher(), roomType: roomType, mediaProvider: mediaProvider, userDiscoveryService: userDiscoveryMock)) coordinator.actions.sink { action in switch action { diff --git a/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift b/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift index 9a6be438e..63ea8ba69 100644 --- a/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift +++ b/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift @@ -20,7 +20,7 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol { let notificationManager: NotificationManagerProtocol = NotificationManagerMock() init() { - ServiceLocator.shared.register(userIndicatorController: MockUserIndicatorController()) + ServiceLocator.shared.register(userIndicatorController: UserIndicatorControllerMock.default) AppSettings.configureWithSuiteName("io.element.elementx.unittests") AppSettings.reset() diff --git a/Tools/Sourcery/sourcery_automockable_config.yml b/Tools/Sourcery/sourcery_automockable_config.yml index 1aa83e515..ec5ca402c 100644 --- a/Tools/Sourcery/sourcery_automockable_config.yml +++ b/Tools/Sourcery/sourcery_automockable_config.yml @@ -9,4 +9,4 @@ output: ../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift args: automMockableTestableImports: [] - autoMockableImports: [Combine, Foundation, MatrixRustSDK, AnalyticsEvents] + autoMockableImports: [Combine, Foundation, SwiftUI, AnalyticsEvents, MatrixRustSDK] diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift index c56e9494b..be99b9ab9 100644 --- a/UnitTests/Sources/InviteUsersViewModelTests.swift +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -61,7 +61,7 @@ class InviteUsersScreenViewModelTests: XCTestCase { func testInviteButton() async { let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob] - setupWithRoomType(roomType: .room(members: mockedMembers, userIndicatorController: MockUserIndicatorController())) + setupWithRoomType(roomType: .room(members: mockedMembers, userIndicatorController: UserIndicatorControllerMock.default)) _ = await viewModel.context.$viewState.values.first(where: { $0.membershipState.isEmpty == false }) context.send(viewAction: .toggleUser(.mockAlice)) diff --git a/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift b/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift index 017f2ed1c..36e1e71e8 100644 --- a/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsEditScreenViewModelTests.swift @@ -23,7 +23,7 @@ import XCTest class RoomDetailsEditScreenViewModelTests: XCTestCase { var viewModel: RoomDetailsEditScreenViewModel! - var userIndicatorController: MockUserIndicatorController! + var userIndicatorController: UserIndicatorControllerMock! var context: RoomDetailsEditScreenViewModelType.Context { viewModel.context @@ -104,19 +104,14 @@ class RoomDetailsEditScreenViewModelTests: XCTestCase { setupViewModel(accountOwner: .mockOwner(allowedStateEvents: [.roomAvatar, .roomName, .roomTopic]), roomProxyConfiguration: .init(name: "Some room", displayName: "Some room")) viewModel.didSelectMediaUrl(url: .picturesDirectory) - - let alertInfo = await userIndicatorController.alertInfoPublisher - .compactMap { $0 } - .values - .first() - - XCTAssertNotNil(alertInfo) + try? await Task.sleep(for: .milliseconds(100)) + XCTAssertNotNil(userIndicatorController.alertInfo) } // MARK: - Private private func setupViewModel(accountOwner: RoomMemberProxyMock, roomProxyConfiguration: RoomProxyMockConfiguration) { - userIndicatorController = MockUserIndicatorController() + userIndicatorController = UserIndicatorControllerMock.default viewModel = .init(accountOwner: accountOwner, mediaProvider: MockMediaProvider(), roomProxy: RoomProxyMock(with: roomProxyConfiguration), diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index ade55710d..e365f36d2 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -150,6 +150,94 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertEqual(viewModel.state.items[1].timelineGroupStyle, .middle, "Nothing should prevent the second message from being grouped.") XCTAssertEqual(viewModel.state.items[2].timelineGroupStyle, .last, "Reactions on the last message should not prevent it from being grouped.") } + + func testGoToUserDetailsSuccessNoDelay() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + let roomMemberMock = RoomMemberProxyMock() + roomMemberMock.userID = "bob" + roomProxyMock.getMemberUserIDReturnValue = .success(roomMemberMock) + let userIndicatorControllerMock = UserIndicatorControllerMock.default + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock, + userIndicatorController: userIndicatorControllerMock) + viewModel.callback = { action in + switch action { + case .displayRoomMemberDetails(let member): + XCTAssert(member === roomMemberMock) + default: + XCTFail("Did not received the expected action") + } + } + + // Test + viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) + await Task.yield() + XCTAssertFalse(userIndicatorControllerMock.submitIndicatorCalled) + XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) + XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") + } + + func testGoToUserDetailsSuccessWithDelay() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + let roomMemberMock = RoomMemberProxyMock() + roomMemberMock.userID = "bob" + roomProxyMock.getMemberUserIDClosure = { _ in + try? await Task.sleep(for: .milliseconds(200)) + return .success(roomMemberMock) + } + let userIndicatorControllerMock = UserIndicatorControllerMock.default + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock, + userIndicatorController: userIndicatorControllerMock) + viewModel.callback = { action in + switch action { + case .displayRoomMemberDetails(let member): + XCTAssert(member === roomMemberMock) + default: + XCTFail("Did not received the expected action") + } + } + + // Test + viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) + try? await Task.sleep(for: .milliseconds(300)) + XCTAssert(userIndicatorControllerMock.submitIndicatorCallsCount == 1) + XCTAssert(userIndicatorControllerMock.retractIndicatorWithIdCallsCount == 1) + XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) + XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") + } + + func testGoToUserDetailsFailure() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + let roomMemberMock = RoomMemberProxyMock() + roomMemberMock.userID = "bob" + roomProxyMock.getMemberUserIDClosure = { _ in + .failure(.failedRetrievingMember) + } + let userIndicatorControllerMock = UserIndicatorControllerMock.default + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock, + userIndicatorController: userIndicatorControllerMock) + viewModel.callback = { _ in + XCTFail("Should not receive any action") + } + + // Test + viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) + await viewModel.context.nextViewState() + XCTAssertFalse(viewModel.state.bindings.alertInfo.isNil) + XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) + XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") + } } private extension TextRoomTimelineItem { diff --git a/changelog.d/1017.feature b/changelog.d/1017.feature new file mode 100644 index 000000000..837e925d0 --- /dev/null +++ b/changelog.d/1017.feature @@ -0,0 +1 @@ +Tapping on a user avatar/name in the timeline opens the User Details view for that user. \ No newline at end of file