From c71da91d54752ec09b64efb852cecb7ecb75bc7c Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:38:10 +0200 Subject: [PATCH] PinnedBanner is now managed by the RoomScreenViewModel (#3163) --- ElementX.xcodeproj/project.pbxproj | 8 ++ .../Mocks/EventTimelineItemSDKMock.swift | 38 ++++++ .../InteractiveQuickLook.swift | 7 +- .../PinnedEventsTimelineScreenModels.swift | 7 +- .../View/PinnedEventsTimelineScreen.swift | 14 +- .../RoomScreen/RoomScreenCoordinator.swift | 17 ++- .../Screens/RoomScreen/RoomScreenModels.swift | 111 ++++++++++++++- .../RoomScreen/RoomScreenViewModel.swift | 128 +++++++++++++++++- .../RoomScreenViewModelProtocol.swift | 2 + .../Screens/RoomScreen/View/RoomScreen.swift | 10 +- .../Screens/Timeline/TimelineModels.swift | 106 +-------------- .../Screens/Timeline/TimelineViewModel.swift | 86 +----------- .../Sources/RoomScreenViewModelTests.swift | 104 ++++++++++++++ .../Sources/TimelineViewModelTests.swift | 58 ++++++++ 14 files changed, 483 insertions(+), 213 deletions(-) create mode 100644 ElementX/Sources/Mocks/EventTimelineItemSDKMock.swift create mode 100644 UnitTests/Sources/RoomScreenViewModelTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f63f6aef0..dd92b0d7d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -302,6 +302,7 @@ 454F8DDC4442C0DE54094902 /* LABiometryType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F219838588C62198E726E3 /* LABiometryType.swift */; }; 4557192F5B15A8D9BB920232 /* AdvancedSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E492690C8B27A892C194CC4 /* AdvancedSettingsScreenCoordinator.swift */; }; 4610C57A4785FFF5E67F0C6D /* DSWaveformImageViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2A4106A0A96DC4C273128AA5 /* DSWaveformImageViews */; }; + 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */; }; 46A183C6125A669AEB005699 /* UserProfileScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F134D2D91DFF732FB75B2CB7 /* UserProfileScreenViewModelProtocol.swift */; }; 46A261AA898344A1F3C406B1 /* ReportContentScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCE3636E3D01477C8B2E9D0 /* ReportContentScreenModels.swift */; }; @@ -1036,6 +1037,7 @@ EDF8919F15DE0FF00EF99E70 /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5567A7EF6F2AB9473236F6 /* DocumentPicker.swift */; }; EE4E2C1922BBF5169E213555 /* PillAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B53D6C5C0D14B04D3AB3F6E /* PillAttachmentViewProvider.swift */; }; EE56238683BC3ECA9BA00684 /* GlobalSearchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */; }; + EE57A96130DD8DB053790AE2 /* EventTimelineItemSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C7A6BBC686B1F840FA807FB /* EventTimelineItemSDKMock.swift */; }; EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; }; EEAE954289DE813A61656AE0 /* LayoutDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */; }; EEB9C1555C63B93CA9C372C2 /* EmojiPickerScreenHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B5E29E9A22F45534FBD5B58 /* EmojiPickerScreenHeaderView.swift */; }; @@ -1287,6 +1289,7 @@ 1BA5A62DA4B543827FF82354 /* LAContextMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAContextMock.swift; sourceTree = ""; }; 1C21A715237F2B6D6E80998C /* SecureBackupControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupControllerProtocol.swift; sourceTree = ""; }; 1C25B6EBEB414431187D73B7 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; + 1C7A6BBC686B1F840FA807FB /* EventTimelineItemSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTimelineItemSDKMock.swift; sourceTree = ""; }; 1C7F63EB1525E697CAEB002B /* BlankFormCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlankFormCoordinator.swift; sourceTree = ""; }; 1CC575D1895FA62591451A93 /* RoomMemberDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreen.swift; sourceTree = ""; }; 1CD7C0A2750998C2D77AD00F /* JoinRoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRoomScreenViewModel.swift; sourceTree = ""; }; @@ -1790,6 +1793,7 @@ 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = ""; }; 935C2FB18EFB8EEE96B26330 /* CreateRoomFlowParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomFlowParameters.swift; sourceTree = ""; }; 93C713D124FE915ABF47A6B7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; + 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelTests.swift; sourceTree = ""; }; 93E1FF0DFBB3768F79FDBF6D /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVMetadataMachineReadableCodeObjectExtensionsTest.swift; sourceTree = ""; }; 93E7304F5ECB4CB11CB10E60 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupRecoveryKeyScreenViewModelProtocol.swift; sourceTree = ""; }; 94028A227645FA880B966211 /* WaveformSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformSource.swift; sourceTree = ""; }; @@ -2816,6 +2820,7 @@ E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */, 4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */, E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */, + 1C7A6BBC686B1F840FA807FB /* EventTimelineItemSDKMock.swift */, 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */, 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, B2AD8A56CD37E23071A2F4BF /* PHGPostHogMock.swift */, @@ -3755,6 +3760,7 @@ F0096BC5DA86AF6B6E5742AC /* RoomPermissionsTests.swift */, B40233F2989AD49906BB310D /* RoomPollsHistoryScreenViewModelTests.swift */, 48FEFF746DB341CDB18D7AAA /* RoomRolesAndPermissionsScreenViewModelTests.swift */, + 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */, AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */, 046C0D3F53B0B5EF0A1F5BEA /* RoomSummaryTests.swift */, 2E88534A39781D76487D59DF /* SecureBackupKeyBackupScreenViewModelTests.swift */, @@ -6009,6 +6015,7 @@ 2335D1AB954C151FD8779F45 /* RoomPermissionsTests.swift in Sources */, 7B1605C6FFD4D195F264A684 /* RoomPollsHistoryScreenViewModelTests.swift in Sources */, 84C631E734FD2555B39B681C /* RoomRolesAndPermissionsScreenViewModelTests.swift in Sources */, + 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */, CC0D088F505F33A20DC5590F /* RoomStateEventStringBuilderTests.swift in Sources */, 15913A5B07118C1268A840E4 /* RoomSummaryTests.swift in Sources */, 7691233E3572A9173FD96CB3 /* SecureBackupKeyBackupScreenViewModelTests.swift in Sources */, @@ -6281,6 +6288,7 @@ 50539366B408780B232C1910 /* EstimatedWaveformView.swift in Sources */, F78BAD28482A467287A9A5A3 /* EventBasedMessageTimelineItemProtocol.swift in Sources */, 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */, + EE57A96130DD8DB053790AE2 /* EventTimelineItemSDKMock.swift in Sources */, 63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */, 5F06AD3C66884CE793AE6119 /* FileManager.swift in Sources */, D33AC79A50DFC26D2498DD28 /* FileRoomTimelineItem.swift in Sources */, diff --git a/ElementX/Sources/Mocks/EventTimelineItemSDKMock.swift b/ElementX/Sources/Mocks/EventTimelineItemSDKMock.swift new file mode 100644 index 000000000..2829d6c7c --- /dev/null +++ b/ElementX/Sources/Mocks/EventTimelineItemSDKMock.swift @@ -0,0 +1,38 @@ +// +// Copyright 2024 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 + +struct EventTimelineItemSDKMockConfiguration { + var eventID: String = UUID().uuidString +} + +extension EventTimelineItemSDKMock { + convenience init(configuration: EventTimelineItemSDKMockConfiguration) { + self.init() + eventIdReturnValue = configuration.eventID + isOwnReturnValue = false + timestampReturnValue = 0 + isEditableReturnValue = false + canBeRepliedToReturnValue = false + senderReturnValue = "" + senderProfileReturnValue = .pending + + let timelineItemContent = TimelineItemContentSDKMock() + timelineItemContent.kindReturnValue = .redactedMessage + contentReturnValue = timelineItemContent + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift b/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift index 1573b557e..8b46e8f13 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift @@ -87,8 +87,11 @@ private struct MediaPreviewViewController: UIViewControllerRepresentable { // The QLPreviewController will not automatically dismiss itself when the underlying view is removed // (e.g. switching rooms from a notification) and it continues to hold on to the whole hierarcy. // Manually tell it to dismiss itself here. - dismissalObserver = dismissalPublisher.sink { _ in - self.dismiss(animated: true) + dismissalObserver = dismissalPublisher.sink { [weak self] _ in + // Dispatching on main.async with weak self we avoid doing an extra dismiss if the view is presented on top of another modal + DispatchQueue.main.async { [weak self] in + self?.dismiss(animated: true) + } } } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift index 40b07d635..432e53138 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift @@ -20,12 +20,7 @@ enum PinnedEventsTimelineScreenViewModelAction { case dismiss } -struct PinnedEventsTimelineScreenViewState: BindableState { - var title: String { - // TODO: Implement the non empty case - L10n.screenPinnedTimelineScreenTitleEmpty - } -} +struct PinnedEventsTimelineScreenViewState: BindableState { } enum PinnedEventsTimelineScreenViewAction { case close diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 269b96a2d..23171ad98 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -21,17 +21,27 @@ struct PinnedEventsTimelineScreen: View { @ObservedObject var context: PinnedEventsTimelineScreenViewModel.Context @ObservedObject var timelineContext: TimelineViewModel.Context + private var title: String { + let pinnedEventIDs = timelineContext.viewState.pinnedEventIDs + guard !pinnedEventIDs.isEmpty else { + return L10n.screenPinnedTimelineScreenTitleEmpty + } + return L10n.screenPinnedTimelineScreenTitle(pinnedEventIDs.count) + } + var body: some View { content - .navigationTitle(context.viewState.title) + .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .background(.compound.bgCanvasDefault) + .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) + .interactiveDismissDisabled() } @ViewBuilder private var content: some View { - if timelineContext.viewState.timelineViewState.itemsDictionary.isEmpty { + if timelineContext.viewState.pinnedEventIDs.isEmpty { VStack(spacing: 16) { HeroImage(icon: \.pin, style: .normal) Text(L10n.screenPinnedTimelineEmptyStateHeadline) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 94fbe3dab..b3376b338 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -62,7 +62,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol { } init(parameters: RoomScreenCoordinatorParameters) { - roomViewModel = RoomScreenViewModel() + roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, + appMediator: parameters.appMediator, + appSettings: ServiceLocator.shared.settings) timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, focussedEventID: parameters.focussedEventID, @@ -129,8 +131,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { composerViewModel.process(timelineAction: action) case .displayCallScreen: actionsSubject.send(.presentCallScreen) - case .displayPinnedEventsTimeline: - actionsSubject.send(.presentPinnedEventsTimeline) + case .hasScrolled(direction: let direction): + roomViewModel.timelineHasScrolled(direction: direction) } } .store(in: &cancellables) @@ -144,8 +146,15 @@ final class RoomScreenCoordinator: CoordinatorProtocol { .store(in: &cancellables) roomViewModel.actions - .sink { [weak self] _ in + .sink { [weak self] actions in guard let self else { return } + + switch actions { + case .focusEvent(eventID: let eventID): + focusOnEvent(eventID: eventID) + case .displayPinnedEventsTimeline: + actionsSubject.send(.presentPinnedEventsTimeline) + } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index f356e149e..ef1480f06 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -15,18 +15,119 @@ // import Foundation +import OrderedCollections -enum RoomScreenViewModelAction { } +enum RoomScreenViewModelAction { + case focusEvent(eventID: String) + case displayPinnedEventsTimeline +} -enum RoomScreenViewAction { } +enum RoomScreenViewAction { + case tappedPinnedEventsBanner + case viewAllPins +} struct RoomScreenViewState: BindableState { + var lastScrollDirection: ScrollDirection? + var isPinningEnabled = false + // This is used to control the banner + var pinnedEventsBannerState: PinnedEventsBannerState = .loading(numbersOfEvents: 0) + var shouldShowPinnedEventsBanner: Bool { + isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top + } + var bindings: RoomScreenViewStateBindings } struct RoomScreenViewStateBindings { } -enum RoomScreenComposerAction { - case saveDraft - case loadDraft +enum PinnedEventsBannerState: Equatable { + case loading(numbersOfEvents: Int) + case loaded(state: PinnedEventsState) + + var isEmpty: Bool { + switch self { + case .loaded(let state): + return state.pinnedEventContents.isEmpty + case .loading(let numberOfEvents): + return numberOfEvents == 0 + } + } + + var isLoading: Bool { + switch self { + case .loading: + return true + default: + return false + } + } + + var selectedPinEventID: String? { + switch self { + case .loaded(let state): + return state.selectedPinEventID + default: + return nil + } + } + + var count: Int { + switch self { + case .loaded(let state): + return state.pinnedEventContents.count + case .loading(let numberOfEvents): + return numberOfEvents + } + } + + var selectedPinIndex: Int { + switch self { + case .loaded(let state): + return state.selectedPinIndex + case .loading(let numbersOfEvents): + // We always want the index to be the last one when loading, since is the default one. + return numbersOfEvents - 1 + } + } + + var displayedMessage: AttributedString { + switch self { + case .loading: + return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription) + case .loaded(let state): + return state.selectedPinContent + } + } + + var bannerIndicatorDescription: AttributedString { + let index = selectedPinIndex + 1 + let boldPlaceholder = "{bold}" + var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder)) + var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count)) + boldString.bold() + finalString.replace(boldPlaceholder, with: boldString) + return finalString + } + + mutating func previousPin() { + switch self { + case .loaded(var state): + state.previousPin() + self = .loaded(state: state) + default: + break + } + } + + mutating func setPinnedEventContents(_ pinnedEventContents: OrderedDictionary) { + switch self { + case .loading: + // The default selected event should always be the last one. + self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last)) + case .loaded(var state): + state.pinnedEventContents = pinnedEventContents + self = .loaded(state: state) + } + } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 169141391..78e23b164 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -16,24 +16,148 @@ import Combine import Foundation +import OrderedCollections import SwiftUI typealias RoomScreenViewModelType = StateStoreViewModel class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol { + private let roomProxy: RoomProxyProtocol + private let appMediator: AppMediatorProtocol + private let appSettings: AppSettings + private let pinnedEventStringBuilder: RoomEventStringBuilder + private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init() { + private var pinnedEventsTimelineProvider: RoomTimelineProviderProtocol? { + didSet { + guard let pinnedEventsTimelineProvider else { + return + } + + buildPinnedEventContents(timelineItems: pinnedEventsTimelineProvider.itemProxies) + pinnedEventsTimelineProvider.updatePublisher + // When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] updatedItems, _ in + guard let self else { return } + buildPinnedEventContents(timelineItems: updatedItems) + } + .store(in: &cancellables) + } + } + + init(roomProxy: RoomProxyProtocol, + appMediator: AppMediatorProtocol, + appSettings: AppSettings) { + self.roomProxy = roomProxy + self.appMediator = appMediator + self.appSettings = appSettings + pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) + super.init(initialViewState: .init(bindings: .init())) + + setupSubscriptions() + } + + override func process(viewAction: RoomScreenViewAction) { + switch viewAction { + case .tappedPinnedEventsBanner: + if let eventID = state.pinnedEventsBannerState.selectedPinEventID { + actionsSubject.send(.focusEvent(eventID: eventID)) + } + state.pinnedEventsBannerState.previousPin() + case .viewAllPins: + actionsSubject.send(.displayPinnedEventsTimeline) + } + } + + func timelineHasScrolled(direction: ScrollDirection) { + state.lastScrollDirection = direction + } + + private func setupSubscriptions() { + let roomInfoSubscription = roomProxy + .actionsPublisher + .filter { $0 == .roomInfoUpdate } + + Task { [weak self] in + // Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle. + // If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update. + await self?.updatePinnedEventIDs() + for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values { + guard !Task.isCancelled else { + return + } + await self?.updatePinnedEventIDs() + } + } + .store(in: &cancellables) + + let pinningEnabledPublisher = appSettings.$pinningEnabled + + pinningEnabledPublisher + .weakAssign(to: \.state.isPinningEnabled, on: self) + .store(in: &cancellables) + + pinningEnabledPublisher + .combineLatest(appMediator.networkMonitor.reachabilityPublisher) + .filter { $0.0 && $0.1 == .reachable } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.setupPinnedEventsTimelineProviderIfNeeded() + } + .store(in: &cancellables) + } + + private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) { + var pinnedEventContents = OrderedDictionary() + + for item in timelineItems { + // Only remote events are pinned + if case let .event(event) = item, + let eventID = event.id.eventID { + pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent), + forKey: eventID) + } + } + + state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents) + } + + private func updatePinnedEventIDs() async { + let pinnedEventIDs = await roomProxy.pinnedEventIDs + // Only update the loading state of the banner + if state.pinnedEventsBannerState.isLoading { + state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count) + } + } + + private func setupPinnedEventsTimelineProviderIfNeeded() { + guard pinnedEventsTimelineProvider == nil else { + return + } + + Task { + guard let timelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else { + return + } + + if pinnedEventsTimelineProvider == nil { + pinnedEventsTimelineProvider = timelineProvider + } + } } } extension RoomScreenViewModel { static func mock() -> RoomScreenViewModel { - RoomScreenViewModel() + RoomScreenViewModel(roomProxy: RoomProxyMock(.init()), + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift index 558f3acb6..523d0ae7e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift @@ -20,4 +20,6 @@ import Foundation protocol RoomScreenViewModelProtocol { var actions: AnyPublisher { get } var context: RoomScreenViewModel.Context { get } + + func timelineHasScrolled(direction: ScrollDirection) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 7b258a9d4..6ab1dcd7f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -54,11 +54,11 @@ struct RoomScreen: View { } .overlay(alignment: .top) { Group { - if timelineContext.viewState.shouldShowPinnedEventsBanner { + if roomContext.viewState.shouldShowPinnedEventsBanner { pinnedItemsBanner } } - .animation(.elementDefault, value: timelineContext.viewState.shouldShowPinnedEventsBanner) + .animation(.elementDefault, value: roomContext.viewState.shouldShowPinnedEventsBanner) } .navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text. .navigationBarTitleDisplayMode(.inline) @@ -114,9 +114,9 @@ struct RoomScreen: View { } private var pinnedItemsBanner: some View { - PinnedItemsBannerView(state: timelineContext.viewState.pinnedEventsBannerState, - onMainButtonTap: { timelineContext.send(viewAction: .tappedPinnedEventsBanner) }, - onViewAllButtonTap: { timelineContext.send(viewAction: .viewAllPins) }) + PinnedItemsBannerView(state: roomContext.viewState.pinnedEventsBannerState, + onMainButtonTap: { roomContext.send(viewAction: .tappedPinnedEventsBanner) }, + onViewAllButtonTap: { roomContext.send(viewAction: .viewAllPins) }) .transition(.move(edge: .top)) } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 9743c8587..aef65a55d 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -33,7 +33,7 @@ enum TimelineViewModelAction { case displayLocation(body: String, geoURI: GeoURI, description: String?) case composer(action: TimelineComposerAction) case displayCallScreen - case displayPinnedEventsTimeline + case hasScrolled(direction: ScrollDirection) } enum TimelineViewPollAction { @@ -83,8 +83,6 @@ enum TimelineViewAction { case hasSwitchedTimeline // t case hasScrolled(direction: ScrollDirection) // t - case tappedPinnedEventsBanner // not t - case viewAllPins // not t } enum TimelineComposerAction { @@ -110,19 +108,10 @@ struct TimelineViewState: BindableState { var canCurrentUserRedactSelf = false var canCurrentUserPin = false var isViewSourceEnabled: Bool - - var isPinningEnabled = false - var lastScrollDirection: ScrollDirection? - + // The `pinnedEventIDs` are used only to determine if an item is already pinned or not. // It's updated from the room info, so it's faster than using the timeline var pinnedEventIDs: Set = [] - // This is used to control the banner - var pinnedEventsBannerState: PinnedEventsBannerState = .loading(numbersOfEvents: 0) - - var shouldShowPinnedEventsBanner: Bool { - isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top - } var canJoinCall = false var hasOngoingCall = false @@ -291,94 +280,3 @@ struct PinnedEventsState: Equatable { } } } - -enum PinnedEventsBannerState: Equatable { - case loading(numbersOfEvents: Int) - case loaded(state: PinnedEventsState) - - var isEmpty: Bool { - switch self { - case .loaded(let state): - return state.pinnedEventContents.isEmpty - case .loading(let numberOfEvents): - return numberOfEvents == 0 - } - } - - var isLoading: Bool { - switch self { - case .loading: - return true - default: - return false - } - } - - var selectedPinEventID: String? { - switch self { - case .loaded(let state): - return state.selectedPinEventID - default: - return nil - } - } - - var count: Int { - switch self { - case .loaded(let state): - return state.pinnedEventContents.count - case .loading(let numberOfEvents): - return numberOfEvents - } - } - - var selectedPinIndex: Int { - switch self { - case .loaded(let state): - return state.selectedPinIndex - case .loading(let numbersOfEvents): - // We always want the index to be the last one when loading, since is the default one. - return numbersOfEvents - 1 - } - } - - var displayedMessage: AttributedString { - switch self { - case .loading: - return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription) - case .loaded(let state): - return state.selectedPinContent - } - } - - var bannerIndicatorDescription: AttributedString { - let index = selectedPinIndex + 1 - let boldPlaceholder = "{bold}" - var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder)) - var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count)) - boldString.bold() - finalString.replace(boldPlaceholder, with: boldString) - return finalString - } - - mutating func previousPin() { - switch self { - case .loaded(var state): - state.previousPin() - self = .loaded(state: state) - default: - break - } - } - - mutating func setPinnedEventContents(_ pinnedEventContents: OrderedDictionary) { - switch self { - case .loading: - // The default selected event should always be the last one. - self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last)) - case .loaded(var state): - state.pinnedEventContents = pinnedEventContents - self = .loaded(state: state) - } - } -} diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 82e7c3fb2..4634b2de2 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -36,7 +36,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { private let appMediator: AppMediatorProtocol private let appSettings: AppSettings private let analyticsService: AnalyticsService - private let pinnedEventStringBuilder: RoomEventStringBuilder private let timelineInteractionHandler: TimelineInteractionHandler @@ -49,24 +48,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { private var paginateBackwardsTask: Task? private var paginateForwardsTask: Task? - - private var pinnedEventsTimelineProvider: RoomTimelineProviderProtocol? { - didSet { - guard let pinnedEventsTimelineProvider else { - return - } - - buildPinnedEventContent(timelineItems: pinnedEventsTimelineProvider.itemProxies) - pinnedEventsTimelineProvider.updatePublisher - // When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .sink { [weak self] updatedItems, _ in - guard let self else { return } - buildPinnedEventContent(timelineItems: updatedItems) - } - .store(in: &cancellables) - } - } init(roomProxy: RoomProxyProtocol, focussedEventID: String? = nil, @@ -85,7 +66,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { self.analyticsService = analyticsService self.userIndicatorController = userIndicatorController self.appMediator = appMediator - pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider) @@ -159,7 +139,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { Task { await timelineController.processItemAppearance(id) } case .itemDisappeared(let id): Task { await timelineController.processItemDisappearance(id) } - case .itemTapped(let id): Task { await handleItemTapped(with: id) } case .itemSendInfoTapped(let itemID): @@ -174,12 +153,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { paginateForwards() case .scrollToBottom: scrollToBottom() - case .displayTimelineItemMenu(let itemID): timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) case .handleTimelineItemMenuAction(let itemID, let action): timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID) - case .displayRoomDetails: actionsSubject.send(.displayRoomDetails) case .displayRoomMemberDetails(userID: let userID): @@ -199,7 +176,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { handlePollAction(pollAction) case .handleAudioPlayerAction(let audioPlayerAction): handleAudioPlayerAction(audioPlayerAction) - case .focusOnEventID(let eventID): Task { await focusOnEvent(eventID: eventID) } case .focusLive: @@ -209,14 +185,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { case .hasSwitchedTimeline: Task { state.timelineViewState.isSwitchingTimelines = false } case let .hasScrolled(direction): - state.lastScrollDirection = direction - case .tappedPinnedEventsBanner: - if let eventID = state.pinnedEventsBannerState.selectedPinEventID { - Task { await focusOnEvent(eventID: eventID) } - } - state.pinnedEventsBannerState.previousPin() - case .viewAllPins: - actionsSubject.send(.displayPinnedEventsTimeline) + actionsSubject.send(.hasScrolled(direction: direction)) } } @@ -383,7 +352,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { state.canCurrentUserRedactSelf = false } - if state.isPinningEnabled, + if appSettings.pinningEnabled, case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) { state.canCurrentUserPin = value } else { @@ -491,15 +460,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } } .store(in: &cancellables) - - appSettings.$pinningEnabled - .combineLatest(appMediator.networkMonitor.reachabilityPublisher) - .filter { $0.0 && $0.1 == .reachable } - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.setupPinnedEventsTimelineProviderIfNeeded() - } - .store(in: &cancellables) } private func setupAppSettingsSubscriptions() { @@ -510,35 +470,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { appSettings.$viewSourceEnabled .weakAssign(to: \.state.isViewSourceEnabled, on: self) .store(in: &cancellables) - - appSettings.$pinningEnabled - .weakAssign(to: \.state.isPinningEnabled, on: self) - .store(in: &cancellables) - } - - private func setupPinnedEventsTimelineProviderIfNeeded() { - guard pinnedEventsTimelineProvider == nil else { - return - } - - Task { - guard let timelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else { - return - } - - if pinnedEventsTimelineProvider == nil { - pinnedEventsTimelineProvider = timelineProvider - } - } } private func updatePinnedEventIDs() async { - let pinnedEventIDs = await roomProxy.pinnedEventIDs - // Only update the loading state of the banner - if state.pinnedEventsBannerState.isLoading { - state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count) - } - state.pinnedEventIDs = pinnedEventIDs + state.pinnedEventIDs = await roomProxy.pinnedEventIDs } private func setupDirectRoomSubscriptionsIfNeeded() { @@ -701,21 +636,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { // MARK: - Timeline Item Building - private func buildPinnedEventContent(timelineItems: [TimelineItemProxy]) { - var pinnedEventContents = OrderedDictionary() - - for item in timelineItems { - // Only remote events are pinned - if case let .event(event) = item, - let eventID = event.id.eventID { - pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent), - forKey: eventID) - } - } - - state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents) - } - private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) { var timelineItemsDictionary = OrderedDictionary() diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift new file mode 100644 index 000000000..e3011801d --- /dev/null +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -0,0 +1,104 @@ +// +// Copyright 2024 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. +// + +@testable import ElementX + +import Combine +import XCTest + +@MainActor +class RoomScreenViewModelTests: XCTestCase { + private var viewModel: RoomScreenViewModel! + + override func setUp() async throws { + AppSettings.resetAllSettings() + } + + override func tearDown() { + viewModel = nil + } + + func testPinnedEventsBanner() async throws { + ServiceLocator.shared.settings.pinningEnabled = true + let timelineSubject = PassthroughSubject() + let updateSubject = PassthroughSubject() + let roomProxyMock = RoomProxyMock(.init()) + // setup a way to inject the mock of the pinned events timeline + roomProxyMock.pinnedEventsTimelineClosure = { + await timelineSubject.values.first() + } + // setup the room proxy actions publisher + roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings) + self.viewModel = viewModel + + // check if in the default state is not showing but is indeed loading + var deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + viewState.pinnedEventsBannerState.count == 0 + } + try await deferred.fulfill() + XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading) + XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner) + + // check if if after the pinned event ids are set the banner is still in a loading state, but is both loading and showing with a counter + deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + viewState.pinnedEventsBannerState.count == 2 + } + roomProxyMock.underlyingPinnedEventIDs = ["test1", "test2"] + updateSubject.send(.roomInfoUpdate) + try await deferred.fulfill() + XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading) + XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + + // setup the loaded pinned events injection in the timeline + let pinnedTimelineMock = TimelineProxyMock() + let pinnedTimelineProviderMock = RoomTimelineProviderMock() + let providerUpdateSubject = PassthroughSubject<([TimelineItemProxy], PaginationState), Never>() + pinnedTimelineProviderMock.underlyingUpdatePublisher = providerUpdateSubject.eraseToAnyPublisher() + pinnedTimelineMock.timelineProvider = pinnedTimelineProviderMock + pinnedTimelineProviderMock.itemProxies = [.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "1")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "2"))] + + // check if the banner is now in a loaded state and is showing the counter + deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + !viewState.pinnedEventsBannerState.isLoading + } + timelineSubject.send(pinnedTimelineMock) + try await deferred.fulfill() + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 2) + XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + + // check if the banner is updating alongside the timeline + deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + viewState.pinnedEventsBannerState.count == 3 + } + providerUpdateSubject.send(([.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "1")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "2")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "3"))], .initial)) + XCTAssertFalse(viewModel.context.viewState.pinnedEventsBannerState.isLoading) + XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + try await deferred.fulfill() + + // check how the scrolling changes the banner visibility + viewModel.timelineHasScrolled(direction: .top) + XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner) + + viewModel.timelineHasScrolled(direction: .bottom) + XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + } +} diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index 897e968c9..6e9319084 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -385,6 +385,64 @@ class TimelineViewModelTests: XCTestCase { try await deferred.fulfill() } + // MARK: - Pins + + func testPinnedEvents() async throws { + let roomProxyMock = RoomProxyMock(.init(name: "", + pinnedEventIDs: .init(["test1"]))) + let actionsSubject = PassthroughSubject() + roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher() + let viewModel = TimelineViewModel(roomProxy: roomProxyMock, + timelineController: MockRoomTimelineController(), + mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + userIndicatorController: userIndicatorControllerMock, + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics) + + var deferred = deferFulfillment(viewModel.context.$viewState) { value in + value.pinnedEventIDs == ["test1"] + } + try await deferred.fulfill() + + roomProxyMock.underlyingPinnedEventIDs = ["test1", "test2"] + deferred = deferFulfillment(viewModel.context.$viewState) { value in + value.pinnedEventIDs == ["test1", "test2"] + } + actionsSubject.send(.roomInfoUpdate) + try await deferred.fulfill() + } + + func testCanUserPinEvents() async throws { + ServiceLocator.shared.settings.pinningEnabled = true + let roomProxyMock = RoomProxyMock(.init(name: "", canUserPin: false)) + let actionsSubject = PassthroughSubject() + roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher() + let viewModel = TimelineViewModel(roomProxy: roomProxyMock, + timelineController: MockRoomTimelineController(), + mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + userIndicatorController: userIndicatorControllerMock, + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics) + + var deferred = deferFulfillment(viewModel.context.$viewState) { value in + !value.canCurrentUserPin + } + try await deferred.fulfill() + + roomProxyMock.canUserPinOrUnpinUserIDReturnValue = .success(true) + deferred = deferFulfillment(viewModel.context.$viewState) { value in + value.canCurrentUserPin + } + actionsSubject.send(.roomInfoUpdate) + try await deferred.fulfill() + } + // MARK: - Helpers private func makeViewModel(roomProxy: RoomProxyProtocol? = nil,