// // Copyright 2025 Element Creations Ltd. // Copyright 2022-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Combine import Foundation /// A common ViewModel implementation for handling of `State` and `ViewAction`s. This original version /// is implemented using SwiftUI's original `ObservableObject` and `@Published` pattern. /// /// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) /// a specific portion of state that can be safely bound to. /// If we decide to add more features to our state management (like doing state processing off the main thread) /// we can do it in this centralised place. class StateStoreViewModel { /// For storing subscription references. /// /// Left as public for `ViewModel` implementations convenience. var cancellables = Set() /// Constrained interface for passing to Views. var context: Context var state: State { get { context.viewState } set { context.viewState = newValue } } init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil, contentScannerService: ContentScannerServiceProtocol? = nil) { context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider, contentScannerService: contentScannerService) context.viewModel = self } /// Override to handles incoming `ViewAction`s from the `ViewModel`. /// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation. func process(viewAction: ViewAction) { // Default implementation, -no-op } // MARK: - Context /// A constrained and concise interface for interacting with the ViewModel. /// /// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact /// ViewModel (as modelled on our previous template architecture with the addition of two-way binding): /// - The ability read/observe view state /// - The ability to send view events /// - The ability to bind state to a specific portion of the view state safely. /// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published` /// properties which which are property wrappers and therefore can't be defined within protocols. /// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback). /// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks /// can't be made into the `ViewModel`. @dynamicMemberLookup final class Context: ObservableObject { fileprivate weak var viewModel: StateStoreViewModel? /// Get-able/Observable `Published` property for the `ViewState` @Published fileprivate(set) var viewState: State /// An optional image loading service so that views can manage themselves /// Intentionally non-generic so that it doesn't grow uncontrollably let mediaProvider: MediaProviderProtocol? /// An optional content scanning service so that views can validate media themselves. /// `nil` when no content scanner is configured for the server, or when the screen /// intentionally doesn't provide one because it has no media to scan. let contentScannerService: ContentScannerServiceProtocol? /// Set-able/Bindable access to the bindable state. subscript(dynamicMember keyPath: WritableKeyPath) -> T { get { viewState.bindings[keyPath: keyPath] } set { viewState.bindings[keyPath: keyPath] = newValue } } /// Send a `ViewAction` to the `ViewModel` for processing. /// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`. func send(viewAction: ViewAction) { viewModel?.process(viewAction: viewAction) } fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?, contentScannerService: ContentScannerServiceProtocol?) { self.viewState = initialViewState self.mediaProvider = mediaProvider self.contentScannerService = contentScannerService } } }