Skip to content

Commit

Permalink
Display user suggestion list in fullscreen mode with shared context f…
Browse files Browse the repository at this point in the history
…rom `UserSuggestionCoordinator`
  • Loading branch information
aringenbach committed Mar 22, 2023
1 parent 268f197 commit 47375d4
Show file tree
Hide file tree
Showing 10 changed files with 148 additions and 36 deletions.
5 changes: 5 additions & 0 deletions Riot/Modules/Room/RoomViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -5154,6 +5154,11 @@ - (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern
[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.
Expand Down
3 changes: 3 additions & 0 deletions Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
@class RoomInputToolbarView;
@class LinkActionWrapper;
@class SuggestionPatternWrapper;
@class UserSuggestionSharedContext;

/**
Destination of the message in the composer
Expand Down Expand Up @@ -83,6 +84,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)

- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern;

- (UserSuggestionSharedContext *)userSuggestionContext;

@end

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp

override var delegate: MXKRoomInputToolbarViewDelegate! {
didSet {
wysiwygViewModel.permalinkReplacer = permalinkReplacer
setComposer()
//wysiwygViewModel.permalinkReplacer = permalinkReplacer
}
}

Expand Down Expand Up @@ -134,6 +135,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
var maxCompressedHeight: CGFloat {
wysiwygViewModel.maxCompressedHeight
}

var userSuggestionSharedContext: UserSuggestionSharedContext {
return toolbarViewDelegate!.userSuggestionContext()
}

// MARK: - Setup

Expand All @@ -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 }
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -206,7 +212,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
.sink { [weak hostingViewController] _ in
hostingViewController?.view.setNeedsLayout()
},

wysiwygViewModel.$maximised
.dropFirst()
.removeDuplicates()
Expand All @@ -228,7 +234,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self)
}
]

update(theme: ThemeService.shared().theme)
registerThemeServiceDidChangeThemeNotification()
NotificationCenter.default.addObserver(
Expand All @@ -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
Expand Down
23 changes: 20 additions & 3 deletions RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -57,6 +69,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
Spacer()
Composer(viewModel: viewModel.context,
wysiwygViewModel: wysiwygviewModel,
userSuggestionSharedContext: userSuggestionSharedContext,
resizeAnimationDuration: 0.1,
sendMessageAction: { _ in },
showSendMediaActions: { })
Expand All @@ -70,3 +83,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
)
}
}

private final class MockUserSuggestionViewModel: UserSuggestionViewModelType {

}
9 changes: 9 additions & 0 deletions RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
75 changes: 56 additions & 19 deletions RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ final class UserSuggestionCoordinatorBridge: NSObject {
func toPresentable() -> UIViewController? {
userSuggestionCoordinator.toPresentable()
}

func sharedContext() -> UserSuggestionSharedContext {
userSuggestionCoordinator.sharedContext()
}
}

extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 47375d4

Please sign in to comment.