diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9cc766909..7f9672a02 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1173,6 +1173,7 @@ E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; }; E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */; }; E8B290CBB7E5FF5E3C1B6124 /* KnockRequestsListEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */; }; + E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; }; E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; }; E9347F56CF0683208F4D9249 /* RoomNotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A9B5225D0881CEFA2CF7C9 /* RoomNotificationSettingsScreenViewModel.swift */; }; E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */; }; @@ -2493,6 +2494,7 @@ E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModel.swift; sourceTree = ""; }; E321E840DCC63790049984F4 /* ElementCallServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceMock.swift; sourceTree = ""; }; E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; + E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLParserStyle.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; E4103AB4340F2974D690A12A /* CallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreen.swift; sourceTree = ""; }; E413F4CBD7BF0588F394A9DD /* RoomDetailsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModel.swift; sourceTree = ""; }; @@ -3703,6 +3705,7 @@ 7FB2253D36E81E045E1CB432 /* Duration.swift */, 260004737C573A56FA01E86E /* Encodable.swift */, 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */, + E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */, 4E2245243369B99216C7D84E /* ImageCache.swift */, 045253F9967A535EE5B16691 /* Label.swift */, C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */, @@ -7234,6 +7237,7 @@ D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */, 55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */, E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */, + E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */, 62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index b27997911..8ff89cd22 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -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 } diff --git a/ElementX/Sources/Other/Extensions/HTMLParserStyle.swift b/ElementX/Sources/Other/Extensions/HTMLParserStyle.swift new file mode 100644 index 000000000..72d84a0e9 --- /dev/null +++ b/ElementX/Sources/Other/Extensions/HTMLParserStyle.swift @@ -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))) +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 863bfb1e8..b0952500b 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -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))) -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index a5b7b1d39..92a0f796f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -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") diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 5275c3216..e82a2fc73 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -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() @@ -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)) } } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index c00ca36c4..252cf367f 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -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 } } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift index 2ecd7780f..b972d3978 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift @@ -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") diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index b01725166..123b1ad6a 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -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()) } }