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 { struct ComposerToolbarViewState: BindableState {
let wysiwygViewModel: WysiwygComposerViewModel
var composerMode: ComposerMode = .default var composerMode: ComposerMode = .default
var composerEmpty = true var composerEmpty = true
/// Could be false if sending is disabled in the room /// Could be false if sending is disabled in the room
@@ -76,6 +78,8 @@ struct ComposerToolbarViewState: BindableState {
var isRoomEncrypted: Bool var isRoomEncrypted: Bool
var isLocationSharingEnabled: Bool var isLocationSharingEnabled: Bool
var keyCommands: [WysiwygKeyCommand] = []
var bindings: ComposerToolbarViewStateBindings var bindings: ComposerToolbarViewStateBindings
var isUploading: Bool { var isUploading: Bool {
@@ -63,13 +63,20 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
mentionBuilder = MentionBuilder() mentionBuilder = MentionBuilder()
attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", 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(), audioRecorderState: .init(),
isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted, isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted,
isLocationSharingEnabled: appSettings.mapTilerConfiguration.isEnabled, isLocationSharingEnabled: appSettings.mapTilerConfiguration.isEnabled,
bindings: .init()), bindings: .init()),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
state.keyCommands = [
.enter { [weak self] in
self?.process(viewAction: .sendMessage)
}
]
roomProxy.infoPublisher roomProxy.infoPublisher
.map(\.isEncrypted) .map(\.isEncrypted)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -100,6 +107,13 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
.store(in: &cancellables) .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 context.$viewState
.map(\.bindings.plainComposerText) .map(\.bindings.plainComposerText)
.removeDuplicates() .removeDuplicates()
@@ -269,14 +283,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
handleSaveDraft(isVolatile: false) handleSaveDraft(isVolatile: false)
} }
var keyCommands: [WysiwygKeyCommand] {
[
.enter { [weak self] in
self?.process(viewAction: .sendMessage)
}
]
}
// MARK: - Private // MARK: - Private
private func handleLoadDraft(_ draft: ComposerDraftProxy) { private func handleLoadDraft(_ draft: ComposerDraftProxy) {
@@ -13,7 +13,6 @@ import WysiwygComposer
protocol ComposerToolbarViewModelProtocol { protocol ComposerToolbarViewModelProtocol {
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get } var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
var context: ComposerToolbarViewModelType.Context { get } var context: ComposerToolbarViewModelType.Context { get }
var keyCommands: [WysiwygKeyCommand] { get }
func process(timelineAction: TimelineComposerAction) func process(timelineAction: TimelineComposerAction)
func loadDraft() async func loadDraft() async
@@ -13,12 +13,6 @@ import WysiwygComposer
struct ComposerToolbar: View { struct ComposerToolbar: View {
@ObservedObject var context: ComposerToolbarViewModel.Context @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 @FocusState private var composerFocused: Bool
@State private var frame: CGRect = .zero @State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -249,9 +243,9 @@ struct ComposerToolbar: View {
private var composerView: WysiwygComposerView { private var composerView: WysiwygComposerView {
WysiwygComposerView(placeholder: placeholder, WysiwygComposerView(placeholder: placeholder,
placeholderColor: .compound.textSecondary, placeholderColor: .compound.textSecondary,
viewModel: wysiwygViewModel, viewModel: context.viewState.wysiwygViewModel,
itemProviderHelper: ItemProviderHelper(), itemProviderHelper: ItemProviderHelper(),
keyCommands: keyCommands) { provider in keyCommands: context.viewState.keyCommands) { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider)) 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 // Putting them is VStack allows the completion suggestion preview to work properly in tests
VStack(spacing: 8) { 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 // 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, ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
.previewDisplayName("With Suggestions") .previewDisplayName("With Suggestions")
@@ -386,9 +378,7 @@ extension ComposerToolbar {
model.state.composerEmpty = focused model.state.composerEmpty = focused
return model return model
} }
return ComposerToolbar(context: composerViewModel.context, return ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar { static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
@@ -405,9 +395,7 @@ extension ComposerToolbar {
model.state.composerEmpty = focused model.state.composerEmpty = focused
return model return model
} }
return ComposerToolbar(context: composerViewModel.context, return ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
static func voiceMessageRecordingMock() -> ComposerToolbar { static func voiceMessageRecordingMock() -> ComposerToolbar {
@@ -424,9 +412,7 @@ extension ComposerToolbar {
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState()) model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
return model return model
} }
return ComposerToolbar(context: composerViewModel.context, return ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
static func voiceMessagePreviewMock(uploading: Bool) -> ComposerToolbar { static func voiceMessagePreviewMock(uploading: Bool) -> ComposerToolbar {
@@ -448,9 +434,7 @@ extension ComposerToolbar {
isUploading: uploading) isUploading: uploading)
return model return model
} }
return ComposerToolbar(context: composerViewModel.context, return ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar { static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
@@ -473,9 +457,7 @@ extension ComposerToolbar {
eventID: "", eventContent: .message(.text(.init(body: "Hello World!")))), isThread: false) eventID: "", eventContent: .message(.text(.init(body: "Hello World!")))), isThread: false)
return model return model
} }
return ComposerToolbar(context: composerViewModel.context, return ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
static func disabledPreviewMock() -> ComposerToolbar { static func disabledPreviewMock() -> ComposerToolbar {
@@ -492,8 +474,6 @@ extension ComposerToolbar {
model.state.canSend = false model.state.canSend = false
return model return model
} }
return ComposerToolbar(context: composerViewModel.context, return ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: [])
} }
} }
@@ -52,7 +52,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
private var roomViewModel: RoomScreenViewModelProtocol private var roomViewModel: RoomScreenViewModelProtocol
private var timelineViewModel: TimelineViewModelProtocol private var timelineViewModel: TimelineViewModelProtocol
private var composerViewModel: ComposerToolbarViewModelProtocol private var composerViewModel: ComposerToolbarViewModelProtocol
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -91,10 +90,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
timelineControllerFactory: parameters.timelineControllerFactory, timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy) clientProxy: parameters.clientProxy)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight, let wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight, maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight, maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX) parserStyle: .elementX)
let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText, let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText,
roomProxy: parameters.roomProxy, roomProxy: parameters.roomProxy,
wysiwygViewModel: wysiwygViewModel, wysiwygViewModel: wysiwygViewModel,
@@ -215,9 +214,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
} }
func toPresentable() -> AnyView { func toPresentable() -> AnyView {
let composerToolbar = ComposerToolbar(context: composerViewModel.context, let composerToolbar = ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
return AnyView(RoomScreen(context: roomViewModel.context, return AnyView(RoomScreen(context: roomViewModel.context,
timelineContext: timelineViewModel.context, timelineContext: timelineViewModel.context,
@@ -43,7 +43,6 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
private let viewModel: ThreadTimelineScreenViewModelProtocol private let viewModel: ThreadTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol private let timelineViewModel: TimelineViewModelProtocol
private var composerViewModel: ComposerToolbarViewModelProtocol private var composerViewModel: ComposerToolbarViewModelProtocol
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -70,10 +69,10 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
timelineControllerFactory: parameters.timelineControllerFactory, timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy) clientProxy: parameters.clientProxy)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight, let wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight, maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight, maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX) parserStyle: .elementX)
#warning("Drafts are not handled and they can't be without rust side changes") #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 { func toPresentable() -> AnyView {
let composerToolbar = ComposerToolbar(context: composerViewModel.context, let composerToolbar = ComposerToolbar(context: composerViewModel.context)
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
return AnyView(ThreadTimelineScreen(context: viewModel.context, return AnyView(ThreadTimelineScreen(context: viewModel.context,
timelineContext: timelineViewModel.context, timelineContext: timelineViewModel.context,
@@ -67,7 +67,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
} }
func testHandleKeyCommand() { func testHandleKeyCommand() {
XCTAssertTrue(viewModel.keyCommands.count == 1) XCTAssertTrue(viewModel.context.viewState.keyCommands.count == 1)
} }
func testComposerFocusAfterEnablingRTE() { func testComposerFocusAfterEnablingRTE() {