Move ComposerToolbar view required dependencies to the view model and read them, similarly to the other views, through the context's view state.

This commit is contained in:
Stefan Ceriu
2025-06-06 14:15:58 +03:00
committed by Stefan Ceriu
parent 4072031f4e
commit af97567edc
7 changed files with 40 additions and 57 deletions
@@ -65,6 +65,8 @@ enum ComposerAttachmentType {
}
struct ComposerToolbarViewState: BindableState {
let wysiwygViewModel: WysiwygComposerViewModel
var composerMode: ComposerMode = .default
var composerEmpty = true
/// Could be false if sending is disabled in the room
@@ -76,6 +78,8 @@ struct ComposerToolbarViewState: BindableState {
var isRoomEncrypted: Bool
var isLocationSharingEnabled: Bool
var keyCommands: [WysiwygKeyCommand] = []
var bindings: ComposerToolbarViewStateBindings
var isUploading: Bool {
@@ -63,13 +63,20 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
mentionBuilder = MentionBuilder()
attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder)
super.init(initialViewState: ComposerToolbarViewState(audioPlayerState: .init(id: .recorderPreview, title: L10n.commonVoiceMessage, duration: 0),
super.init(initialViewState: ComposerToolbarViewState(wysiwygViewModel: wysiwygViewModel,
audioPlayerState: .init(id: .recorderPreview, title: L10n.commonVoiceMessage, duration: 0),
audioRecorderState: .init(),
isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted,
isLocationSharingEnabled: appSettings.mapTilerConfiguration.isEnabled,
bindings: .init()),
mediaProvider: mediaProvider)
state.keyCommands = [
.enter { [weak self] in
self?.process(viewAction: .sendMessage)
}
]
roomProxy.infoPublisher
.map(\.isEncrypted)
.receive(on: DispatchQueue.main)
@@ -100,6 +107,13 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
.store(in: &cancellables)
// Needs to be observable or the placeholder and the dictation state will not be managed correctly.
wysiwygViewModel.objectWillChange
.sink { [weak self] _ in
self?.context.objectWillChange.send()
}
.store(in: &cancellables)
context.$viewState
.map(\.bindings.plainComposerText)
.removeDuplicates()
@@ -269,14 +283,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
handleSaveDraft(isVolatile: false)
}
var keyCommands: [WysiwygKeyCommand] {
[
.enter { [weak self] in
self?.process(viewAction: .sendMessage)
}
]
}
// MARK: - Private
private func handleLoadDraft(_ draft: ComposerDraftProxy) {
@@ -13,7 +13,6 @@ import WysiwygComposer
protocol ComposerToolbarViewModelProtocol {
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
var context: ComposerToolbarViewModelType.Context { get }
var keyCommands: [WysiwygKeyCommand] { get }
func process(timelineAction: TimelineComposerAction)
func loadDraft() async
@@ -13,12 +13,6 @@ import WysiwygComposer
struct ComposerToolbar: View {
@ObservedObject var context: ComposerToolbarViewModel.Context
// Needs to be observable or the placeholder and the dictation state are not managed correctly.
@ObservedObject var wysiwygViewModel: WysiwygComposerViewModel
let keyCommands: [WysiwygKeyCommand]
@FocusState private var composerFocused: Bool
@State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -249,9 +243,9 @@ struct ComposerToolbar: View {
private var composerView: WysiwygComposerView {
WysiwygComposerView(placeholder: placeholder,
placeholderColor: .compound.textSecondary,
viewModel: wysiwygViewModel,
viewModel: context.viewState.wysiwygViewModel,
itemProviderHelper: ItemProviderHelper(),
keyCommands: keyCommands) { provider in
keyCommands: context.viewState.keyCommands) { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider))
}
}
@@ -343,9 +337,7 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
// Putting them is VStack allows the completion suggestion preview to work properly in tests
VStack(spacing: 8) {
// The mock functon can't be used in this context because it does not hold a reference to the view model, losing the combine subscriptions
ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
ComposerToolbar(context: composerViewModel.context)
}
.previewDisplayName("With Suggestions")
@@ -386,9 +378,7 @@ extension ComposerToolbar {
model.state.composerEmpty = focused
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
return ComposerToolbar(context: composerViewModel.context)
}
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
@@ -405,9 +395,7 @@ extension ComposerToolbar {
model.state.composerEmpty = focused
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
return ComposerToolbar(context: composerViewModel.context)
}
static func voiceMessageRecordingMock() -> ComposerToolbar {
@@ -424,9 +412,7 @@ extension ComposerToolbar {
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
return ComposerToolbar(context: composerViewModel.context)
}
static func voiceMessagePreviewMock(uploading: Bool) -> ComposerToolbar {
@@ -448,9 +434,7 @@ extension ComposerToolbar {
isUploading: uploading)
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
return ComposerToolbar(context: composerViewModel.context)
}
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
@@ -473,9 +457,7 @@ extension ComposerToolbar {
eventID: "", eventContent: .message(.text(.init(body: "Hello World!")))), isThread: false)
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
return ComposerToolbar(context: composerViewModel.context)
}
static func disabledPreviewMock() -> ComposerToolbar {
@@ -492,8 +474,6 @@ extension ComposerToolbar {
model.state.canSend = false
return model
}
return ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
return ComposerToolbar(context: composerViewModel.context)
}
}
@@ -52,7 +52,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
private var roomViewModel: RoomScreenViewModelProtocol
private var timelineViewModel: TimelineViewModelProtocol
private var composerViewModel: ComposerToolbarViewModelProtocol
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>()
@@ -90,11 +89,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX)
let wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX)
let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText,
roomProxy: parameters.roomProxy,
wysiwygViewModel: wysiwygViewModel,
@@ -215,9 +214,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
}
func toPresentable() -> AnyView {
let composerToolbar = ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
let composerToolbar = ComposerToolbar(context: composerViewModel.context)
return AnyView(RoomScreen(context: roomViewModel.context,
timelineContext: timelineViewModel.context,
@@ -43,7 +43,6 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
private let viewModel: ThreadTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol
private var composerViewModel: ComposerToolbarViewModelProtocol
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>()
@@ -70,10 +69,10 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX)
let wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX)
#warning("Drafts are not handled and they can't be without rust side changes")
@@ -144,9 +143,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
}
func toPresentable() -> AnyView {
let composerToolbar = ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
let composerToolbar = ComposerToolbar(context: composerViewModel.context)
return AnyView(ThreadTimelineScreen(context: viewModel.context,
timelineContext: timelineViewModel.context,
@@ -67,7 +67,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
func testHandleKeyCommand() {
XCTAssertTrue(viewModel.keyCommands.count == 1)
XCTAssertTrue(viewModel.context.viewState.keyCommands.count == 1)
}
func testComposerFocusAfterEnablingRTE() {