Threaded timeline composer

This commit is contained in:
Stefan Ceriu
2025-05-30 16:15:17 +03:00
committed by Stefan Ceriu
parent c6c32079f6
commit 9804be70eb
9 changed files with 185 additions and 45 deletions
@@ -578,15 +578,22 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return
}
let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(roomProxy: roomProxy,
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy,
roomListPublisher: userSession.clientProxy.staticRoomSummaryProvider.roomListPublisher.eraseToAnyPublisher())
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)
let coordinator = ThreadTimelineScreenCoordinator(parameters: .init(clientProxy: userSession.clientProxy,
roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(),
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
appMediator: appMediator,
emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory,
clientProxy: userSession.clientProxy))
completionSuggestionService: completionSuggestionService,
appMediator: appMediator,
appSettings: appSettings,
composerDraftService: composerDraftService,
timelineControllerFactory: timelineControllerFactory))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
@@ -0,0 +1,27 @@
//
// Copyright 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 Compound
import HTMLParser
import SwiftUI
extension HTMLParserStyle {
static let elementX = HTMLParserStyle(textColor: UIColor.label,
linkColor: UIColor.link,
codeBlockStyle: BlockStyle(backgroundColor: UIColor.compound._bgCodeBlock,
borderColor: UIColor.compound.borderInteractiveSecondary,
borderWidth: 1.0,
cornerRadius: 2.0,
padding: BlockStyle.Padding(horizontal: 10, vertical: 12),
type: .background),
quoteBlockStyle: BlockStyle(backgroundColor: UIColor.compound.iconTertiary,
borderColor: UIColor.compound.borderInteractiveSecondary,
borderWidth: 0.0,
cornerRadius: 0.0,
padding: BlockStyle.Padding(horizontal: 25, vertical: 12),
type: .side(offset: 5, width: 4)))
}
@@ -219,8 +219,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
return AnyView(RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
return AnyView(RoomScreen(roomContext: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: composerToolbar)
.onDisappear { [weak self] in
self?.composerViewModel.saveDraft()
@@ -234,20 +234,3 @@ enum ComposerConstant {
static let allowedHeightRange = minHeight...maxHeight
static let translationThreshold: CGFloat = 60
}
private extension HTMLParserStyle {
static let elementX = HTMLParserStyle(textColor: UIColor.label,
linkColor: UIColor.link,
codeBlockStyle: BlockStyle(backgroundColor: UIColor.compound._bgCodeBlock,
borderColor: UIColor.compound.borderInteractiveSecondary,
borderWidth: 1.0,
cornerRadius: 2.0,
padding: BlockStyle.Padding(horizontal: 10, vertical: 12),
type: .background),
quoteBlockStyle: BlockStyle(backgroundColor: UIColor.compound.iconTertiary,
borderColor: UIColor.compound.borderInteractiveSecondary,
borderWidth: 0.0,
cornerRadius: 0.0,
padding: BlockStyle.Padding(horizontal: 25, vertical: 12),
type: .side(offset: 5, width: 4)))
}
@@ -10,17 +10,17 @@ import SwiftUI
import WysiwygComposer
struct RoomScreen: View {
@ObservedObject var roomContext: RoomScreenViewModel.Context
@ObservedObject var timelineContext: TimelineViewModel.Context
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModel.Context
@ObservedObject private var roomContext: RoomScreenViewModelType.Context
@ObservedObject private var timelineContext: TimelineViewModelType.Context
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModelType.Context
@State private var dragOver = false
let composerToolbar: ComposerToolbar
init(roomViewModel: RoomScreenViewModelProtocol,
timelineViewModel: TimelineViewModelProtocol,
init(roomContext: RoomScreenViewModelType.Context,
timelineContext: TimelineViewModelType.Context,
composerToolbar: ComposerToolbar) {
roomContext = roomViewModel.context
timelineContext = timelineViewModel.context
self.roomContext = roomContext
self.timelineContext = timelineContext
self.composerToolbar = composerToolbar
composerToolbarContext = composerToolbar.context
}
@@ -261,15 +261,15 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
NavigationStack {
RoomScreen(roomViewModel: viewModels.room,
timelineViewModel: viewModels.timeline,
RoomScreen(roomContext: viewModels.room.context,
timelineContext: viewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Normal")
NavigationStack {
RoomScreen(roomViewModel: readOnlyViewModels.room,
timelineViewModel: readOnlyViewModels.timeline,
RoomScreen(roomContext: readOnlyViewModels.room.context,
timelineContext: readOnlyViewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Read-only")
@@ -6,18 +6,23 @@
//
import Combine
import HTMLParser
import SwiftUI
import WysiwygComposer
struct ThreadTimelineScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let roomProxy: JoinedRoomProxyProtocol
let timelineController: TimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
let appMediator: AppMediatorProtocol
let emojiProvider: EmojiProviderProtocol
let completionSuggestionService: CompletionSuggestionServiceProtocol
let appMediator: AppMediatorProtocol
let appSettings: AppSettings
let composerDraftService: ComposerDraftServiceProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol
let clientProxy: ClientProxyProtocol
}
enum ThreadTimelineScreenCoordinatorAction {
@@ -37,6 +42,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
private let parameters: ThreadTimelineScreenCoordinatorParameters
private let viewModel: ThreadTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol
private var composerViewModel: ComposerToolbarViewModelProtocol
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>()
@@ -57,11 +64,28 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: ServiceLocator.shared.settings,
appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider,
timelineControllerFactory: parameters.timelineControllerFactory,
clientProxy: parameters.clientProxy)
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")
composerViewModel = ComposerToolbarViewModel(initialText: nil,
roomProxy: parameters.roomProxy,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: parameters.completionSuggestionService,
mediaProvider: parameters.mediaProvider,
mentionDisplayHelper: ComposerMentionDisplayHelper(timelineContext: timelineViewModel.context),
appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: parameters.composerDraftService)
}
func start() {
@@ -103,6 +127,14 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
}
}
.store(in: &cancellables)
composerViewModel.actions
.sink { [weak self] action in
guard let self else { return }
timelineViewModel.process(composerAction: action)
}
.store(in: &cancellables)
}
func stop() {
@@ -110,6 +142,12 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
}
func toPresentable() -> AnyView {
AnyView(ThreadTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context))
let composerToolbar = ComposerToolbar(context: composerViewModel.context,
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
return AnyView(ThreadTimelineScreen(context: viewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: composerToolbar))
}
}
@@ -9,15 +9,96 @@ import Compound
import SwiftUI
struct ThreadTimelineScreen: View {
@ObservedObject var context: ThreadTimelineScreenViewModel.Context
@ObservedObject var timelineContext: TimelineViewModel.Context
@ObservedObject private var context: ThreadTimelineScreenViewModelType.Context
@ObservedObject private var timelineContext: TimelineViewModelType.Context
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModelType.Context
@State private var dragOver = false
private let composerToolbar: ComposerToolbar
init(context: ThreadTimelineScreenViewModelType.Context,
timelineContext: TimelineViewModelType.Context,
composerToolbar: ComposerToolbar) {
self.context = context
self.timelineContext = timelineContext
self.composerToolbar = composerToolbar
composerToolbarContext = composerToolbar.context
}
var body: some View {
TimelineView(timelineContext: timelineContext)
.navigationTitle("Thread")
.navigationBarTitleDisplayMode(.inline)
.background(.compound.bgCanvasDefault)
.interactiveDismissDisabled()
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton
}
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
.padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12)
.background {
if composerToolbarContext.composerFormattingEnabled {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
.ignoresSafeArea()
}
}
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
// Make sure the reply header honours the hideTimelineMedia setting too.
.environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia)
}
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
provider.isSupportedForPasteOrDrop else {
return false
}
timelineContext.send(viewAction: .handlePasteOrDrop(provider: provider))
return true
}
}
@ViewBuilder
private var composer: some View {
#warning("Check permissions here too")
// if roomContext.viewState.canSendMessage {
composerToolbar
// } else {
// Text(L10n.screenRoomTimelineNoPermissionToPost)
// .font(.compound.bodyLG)
// .foregroundStyle(.compound.textDisabled)
// .multilineTextAlignment(.center)
// .padding(.vertical, 10) // Matches the MessageComposerStyleModifier
// }
}
private var scrollToBottomButton: some View {
Button { timelineContext.send(viewAction: .scrollToBottom) } label: {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
.fontWeight(.semibold)
.foregroundColor(.compound.iconSecondary)
.padding(13)
.offset(y: 1)
.background {
Circle()
.fill(Color.compound.iconOnSolidPrimary)
// Intentionally using system primary colour to get white/black.
.shadow(color: .primary.opacity(0.33), radius: 2.0)
}
.padding()
}
.opacity(isAtBottomAndLive ? 0.0 : 1.0)
.accessibilityHidden(isAtBottomAndLive)
.animation(.elementDefault, value: isAtBottomAndLive)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
}
private var isAtBottomAndLive: Bool {
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
}
}
@@ -103,8 +103,8 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static var previews: some View {
NavigationStack {
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
RoomScreen(roomContext: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Timeline")
@@ -142,8 +142,8 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
NavigationStack {
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
RoomScreen(roomContext: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: ComposerToolbar.mock())
}
}