diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 17ab27fc4..e13b5c038 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5154,6 +5154,11 @@ static CGSize kThreadListBarButtonItemImageSize; [self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern]; } +- (UserSuggestionSharedContext *)userSuggestionContext +{ + return [self.userSuggestionCoordinator sharedContext]; +} + - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView { // Consider opening the action menu as beginning to type and share encryption keys if requested. diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index ebbb8305a..454134d28 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -22,6 +22,7 @@ @class RoomInputToolbarView; @class LinkActionWrapper; @class SuggestionPatternWrapper; +@class UserSuggestionSharedContext; /** Destination of the message in the composer @@ -83,6 +84,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; +- (UserSuggestionSharedContext *)userSuggestionContext; + @end /** diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 78989a23e..d50be4e3a 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -76,7 +76,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override var delegate: MXKRoomInputToolbarViewDelegate! { didSet { - wysiwygViewModel.permalinkReplacer = permalinkReplacer + setComposer() + //wysiwygViewModel.permalinkReplacer = permalinkReplacer } } @@ -134,6 +135,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp var maxCompressedHeight: CGFloat { wysiwygViewModel.maxCompressedHeight } + + var userSuggestionSharedContext: UserSuggestionSharedContext { + return toolbarViewDelegate!.userSuggestionContext() + } // MARK: - Setup @@ -148,23 +153,24 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private var permalinkReplacer: PermalinkReplacer? { return (delegate as? PermalinkReplacer) } - - override func awakeFromNib() { - super.awakeFromNib() + + func setComposer() { viewModel = ComposerViewModel( initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, - isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false))) - + isLandscapePhone: isLandscapePhone, + bindings: ComposerBindings(focused: false))) + viewModel.callback = { [weak self] result in self?.handleViewModelResult(result) } wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting - + inputAccessoryViewForKeyboard = UIView(frame: .zero) - + let composer = Composer( viewModel: viewModel.context, wysiwygViewModel: wysiwygViewModel, + userSuggestionSharedContext: userSuggestionSharedContext, resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } @@ -176,13 +182,13 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp guard let self = self else { return } textView.inputAccessoryView = self.inputAccessoryViewForKeyboard } - + hostingViewController = VectorHostingController(rootView: composer) hostingViewController.publishHeightChanges = true let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height let subView: UIView = hostingViewController.view self.addSubview(subView) - + self.translatesAutoresizingMaskIntoConstraints = false subView.translatesAutoresizingMaskIntoConstraints = false heightConstraint = subView.heightAnchor.constraint(equalToConstant: height) @@ -192,7 +198,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) ]) - + cancellables = [ hostingViewController.heightPublisher .removeDuplicates() @@ -206,7 +212,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp .sink { [weak hostingViewController] _ in hostingViewController?.view.setNeedsLayout() }, - + wysiwygViewModel.$maximised .dropFirst() .removeDuplicates() @@ -228,7 +234,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self) } ] - + update(theme: ThemeService.shared().theme) registerThemeServiceDidChangeThemeNotification() NotificationCenter.default.addObserver( @@ -246,6 +252,14 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) } + override func awakeFromNib() { + super.awakeFromNib() + + if delegate != nil { + setComposer() + } + } + override func customizeRendering() { super.customizeRendering() self.backgroundColor = .clear diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 35a628d02..b7d20d38a 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,12 +29,24 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel + let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) + let userSuggestionSharedContext = UserSuggestionSharedContext(context: userSuggestionViewModel.context, + mediaManager: MXMediaManager()) let bindings = ComposerBindings(focused: false) switch self { - case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) + case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", + sendMode: .reply, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) } let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360) @@ -57,6 +69,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, + userSuggestionSharedContext: userSuggestionSharedContext, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -70,3 +83,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { ) } } + +private final class MockUserSuggestionViewModel: UserSuggestionViewModelType { + +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index c96453667..6f7bab165 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -256,3 +256,12 @@ final class SuggestionPatternWrapper: NSObject { super.init() } } + +final class UserSuggestionViewModelWrapper: NSObject { + let userSuggestionViewModel: UserSuggestionViewModel + + init(_ userSuggestionViewModel: UserSuggestionViewModel) { + self.userSuggestionViewModel = userSuggestionViewModel + super.init() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index fb6ed8851..93793fb72 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,6 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel + private let userSuggestionSharedContext: UserSuggestionSharedContext private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -31,15 +32,42 @@ struct Composer: View { @Environment(\.theme) private var theme: ThemeSwiftUI @State private var isActionButtonShowing = false - + private let horizontalPadding: CGFloat = 12 private let borderHeight: CGFloat = 40 - private var verticalPadding: CGFloat { + private let standardVerticalPadding: CGFloat = 8.0 + private let contextBannerHeight: CGFloat = 14.5 + + /// Spacing applied within the VStack holding the context banner and the composer text view. + private let verticalComponentSpacing: CGFloat = 12.0 + /// Padding for the main composer text view. Always applied on bottom. + /// Applied on top only if no context banner is present. + private var composerVerticalPadding: CGFloat { (borderHeight - wysiwygViewModel.minHeight) / 2 } - - private var topPadding: CGFloat { - viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding + + /// Computes the top padding to apply on the composer text view depending on context. + private var composerTopPadding: CGFloat { + viewModel.viewState.shouldDisplayContext ? 0 : composerVerticalPadding + } + + /// Computes the additional height required to display the context banner. + /// Returns 0.0 if the banner is not displayed. + /// Note: height of the actual banner + its added standard top padding + VStack spacing + private var additionalHeightForContextBanner: CGFloat { + viewModel.viewState.shouldDisplayContext ? contextBannerHeight + standardVerticalPadding + verticalComponentSpacing : 0 + } + + /// Computes the total height of the composer (excluding the RTE formatting bar). + /// This height includes the text view, as well as the context banner + /// and user suggestion list when displayed. + private var composerHeight: CGFloat { + wysiwygViewModel.idealHeight + + composerTopPadding + + composerVerticalPadding + // Extra padding added on top of the VStack containing the composer + + standardVerticalPadding + + additionalHeightForContextBanner } private var cornerRadius: CGFloat { @@ -84,7 +112,7 @@ struct Composer: View { private var composerContainer: some View { let rect = RoundedRectangle(cornerRadius: cornerRadius) - return VStack(spacing: 12) { + return VStack(spacing: verticalComponentSpacing) { if viewModel.viewState.shouldDisplayContext { HStack { if let imageName = viewModel.viewState.contextImageName { @@ -106,7 +134,8 @@ struct Composer: View { } .accessibilityIdentifier("cancelButton") } - .padding(.top, 8) + .frame(height: contextBannerHeight) + .padding(.top, standardVerticalPadding) .padding(.horizontal, horizontalPadding) } HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) { @@ -116,7 +145,6 @@ struct Composer: View { ) .tintColor(theme.colors.accent) .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent) - .frame(height: wysiwygViewModel.idealHeight) .onAppear { if wysiwygViewModel.isContentEmpty { wysiwygViewModel.setup() @@ -137,13 +165,13 @@ struct Composer: View { } } .padding(.horizontal, horizontalPadding) - .padding(.top, topPadding) - .padding(.bottom, verticalPadding) + .padding(.top, composerTopPadding) + .padding(.bottom, composerVerticalPadding) } .clipShape(rect) .overlay(rect.stroke(borderColor, lineWidth: 1)) .animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight) - .padding(.top, 8) + .padding(.top, standardVerticalPadding) .onTapGesture { if viewModel.focused { viewModel.focused = true @@ -195,11 +223,13 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, + userSuggestionSharedContext: UserSuggestionSharedContext, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { self.viewModel = viewModel self.wysiwygViewModel = wysiwygViewModel + self.userSuggestionSharedContext = userSuggestionSharedContext self.resizeAnimationDuration = resizeAnimationDuration self.sendMessageAction = sendMessageAction self.showSendMediaActions = showSendMediaActions @@ -213,17 +243,24 @@ struct Composer: View { .frame(width: 36, height: 5) .padding(.top, 10) } - HStack(alignment: .bottom, spacing: 0) { - if !viewModel.viewState.textFormattingEnabled { - sendMediaButton - .padding(.bottom, 1) + VStack { + HStack(alignment: .bottom, spacing: 0) { + if !viewModel.viewState.textFormattingEnabled { + sendMediaButton + .padding(.bottom, 1) + } + composerContainer + if !viewModel.viewState.textFormattingEnabled { + sendButton + .padding(.bottom, 1) + } } - composerContainer - if !viewModel.viewState.textFormattingEnabled { - sendButton - .padding(.bottom, 1) + if wysiwygViewModel.maximised { + UserSuggestionList(viewModel: userSuggestionSharedContext.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: userSuggestionSharedContext.mediaManager))) } } + .frame(height: composerHeight) if viewModel.viewState.textFormattingEnabled { HStack(alignment: .center, spacing: 0) { sendMediaButton diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index 59b25ef86..38776fbd3 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -30,6 +30,19 @@ struct UserSuggestionCoordinatorParameters { let room: MXRoom } +/// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple +/// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` +/// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload data. +final class UserSuggestionSharedContext: NSObject { + let context: UserSuggestionViewModelType.Context + let mediaManager: MXMediaManager + + init(context: UserSuggestionViewModelType.Context, mediaManager: MXMediaManager) { + self.context = context + self.mediaManager = mediaManager + } +} + final class UserSuggestionCoordinator: Coordinator, Presentable { // MARK: - Properties @@ -105,6 +118,11 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionHostingController } + func sharedContext() -> UserSuggestionSharedContext { + UserSuggestionSharedContext(context: userSuggestionViewModel.sharedContext, + mediaManager: parameters.mediaManager) + } + // MARK: - Private private func calculateViewHeight() -> CGFloat { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index 4605547eb..a7615e43f 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -52,6 +52,10 @@ final class UserSuggestionCoordinatorBridge: NSObject { func toPresentable() -> UIViewController? { userSuggestionCoordinator.toPresentable() } + + func sharedContext() -> UserSuggestionSharedContext { + userSuggestionCoordinator.sharedContext() + } } extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 1e1f490fc..3999447b7 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -27,7 +27,11 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo private let userSuggestionService: UserSuggestionServiceProtocol // MARK: Public - + + var sharedContext: UserSuggestionViewModelType.Context { + return self.context + } + var completion: ((UserSuggestionViewModelResult) -> Void)? // MARK: - Setup diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift index 1d89ca9b4..40318c5df 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift @@ -17,5 +17,6 @@ import Foundation protocol UserSuggestionViewModelProtocol { + var sharedContext: UserSuggestionViewModelType.Context { get } var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } }