Resizable Composer With Completion Suggestion View (#1971)

* resizable composer with suggestions view

* FF cleanup

* removing the view when the vertical size is compact

* merge conflict fix

* done

* solving a conflict

* Update ElementX/Sources/Screens/ComposerToolbar/View/CompletionSuggestionView.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* pr suggestion

---------

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Mauro
2023-10-27 15:54:30 +02:00
committed by GitHub
parent 84a16091b2
commit 0b31446817
28 changed files with 50 additions and 71 deletions
@@ -337,7 +337,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
mediaProvider: userSession.mediaProvider,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL,
mentionBuilder: MentionBuilder(mentionsEnabled: appSettings.mentionsEnabled)),
mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID),
appSettings: appSettings)
@@ -351,7 +351,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace)
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy, areSuggestionsEnabled: appSettings.mentionsEnabled)
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
timelineController: timelineController,
@@ -304,7 +304,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private func presentHomeScreen() {
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
mentionBuilder: MentionBuilder()),
bugReportService: bugReportService,
navigationStackCoordinator: detailNavigationStackCoordinator,
selectedRoomPublisher: selectedRoomSubject.asCurrentValuePublisher())
@@ -686,11 +686,6 @@ class BugReportServiceMock: BugReportServiceProtocol {
}
}
class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
var areSuggestionsEnabled: Bool {
get { return underlyingAreSuggestionsEnabled }
set(value) { underlyingAreSuggestionsEnabled = value }
}
var underlyingAreSuggestionsEnabled: Bool!
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> {
get { return underlyingSuggestionsPublisher }
set(value) { underlyingSuggestionsPublisher = value }
@@ -18,15 +18,7 @@ import Foundation
import UIKit
struct MentionBuilder: MentionBuilderProtocol {
// Can be removed when mentions are enabled by default
let mentionsEnabled: Bool
func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String) {
guard mentionsEnabled else {
attributedString.addAttributes([.MatrixUserID: userID], range: range)
return
}
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range)
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
let blockquote = attributes[.MatrixBlockquote]
@@ -48,10 +40,6 @@ struct MentionBuilder: MentionBuilderProtocol {
}
func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) {
guard mentionsEnabled else {
return
}
let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range)
let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body)
let blockquote = attributes[.MatrixBlockquote]
@@ -177,7 +177,7 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
private static let htmlStringWithList = "<p>This is a list</p>\n<ul>\n<li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n"
private static let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true))
private static let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder())
static var attachmentPreview: some View {
MessageText(attributedString: attributedStringWithAttachment)
@@ -80,7 +80,7 @@ struct ShimmerOverlay_Previews: PreviewProvider, TestablePreview {
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock()),
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
mentionBuilder: MentionBuilder()),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -20,17 +20,12 @@ import Foundation
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
private let roomProxy: RoomProxyProtocol
private var canMentionAllUsers = false
let areSuggestionsEnabled: Bool
private(set) var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> = Empty().eraseToAnyPublisher()
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionPattern?, Never>(nil)
init(roomProxy: RoomProxyProtocol, areSuggestionsEnabled: Bool) {
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
self.areSuggestionsEnabled = areSuggestionsEnabled
guard areSuggestionsEnabled else {
return
}
suggestionsPublisher = suggestionTriggerSubject
.combineLatest(roomProxy.members)
.map { [weak self] suggestionPattern, members -> [SuggestionItem] in
@@ -18,8 +18,6 @@ import Combine
// sourcery: AutoMockable
protocol CompletionSuggestionServiceProtocol {
// To be removed once we suggestions and mentions are always enabled
var areSuggestionsEnabled: Bool { get }
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?)
@@ -27,13 +25,11 @@ protocol CompletionSuggestionServiceProtocol {
extension CompletionSuggestionServiceMock {
struct CompletionSuggestionServiceMockConfiguration {
var areSuggestionsEnabled = true
var suggestions: [SuggestionItem] = []
}
convenience init(configuration: CompletionSuggestionServiceMockConfiguration) {
self.init()
underlyingAreSuggestionsEnabled = configuration.areSuggestionsEnabled
underlyingSuggestionsPublisher = Just(configuration.suggestions).eraseToAnyPublisher()
}
}
@@ -67,7 +67,6 @@ enum ComposerToolbarViewAction {
struct ComposerToolbarViewState: BindableState {
var composerMode: RoomScreenComposerMode = .default
var composerEmpty = true
var areSuggestionsEnabled = true
var suggestions: [SuggestionItem] = []
var audioPlayerState: AudioPlayerState
var audioRecorderState: AudioRecorderState
@@ -45,8 +45,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
self.completionSuggestionService = completionSuggestionService
self.appSettings = appSettings
super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled,
audioPlayerState: .init(id: .recorderPreview, duration: 0),
super.init(initialViewState: ComposerToolbarViewState(audioPlayerState: .init(id: .recorderPreview, duration: 0),
audioRecorderState: .init(),
bindings: .init()),
imageProvider: mediaProvider)
@@ -89,9 +88,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
.weakAssign(to: \.state.suggestions, on: self)
.store(in: &cancellables)
if appSettings.mentionsEnabled {
setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper)
}
setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper)
}
// MARK: - Public
@@ -190,7 +187,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private func setupMentionsHandling(mentionDisplayHelper: MentionDisplayHelper) {
wysiwygViewModel.textView.mentionDisplayHelper = mentionDisplayHelper
let attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", permalinkBaseURL: appSettings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: appSettings.mentionsEnabled))
let attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", permalinkBaseURL: appSettings.permalinkBaseURL, mentionBuilder: MentionBuilder())
wysiwygViewModel.mentionReplacer = ComposerMentionReplacer { urlString, string in
let attributedString: NSMutableAttributedString
@@ -19,6 +19,7 @@ import SwiftUI
struct CompletionSuggestionView: View {
let imageProvider: ImageProviderProtocol?
let items: [SuggestionItem]
var showBackgroundShadow = true
let onTap: (SuggestionItem) -> Void
private enum Constants {
@@ -27,12 +28,12 @@ struct CompletionSuggestionView: View {
// added by the list itself when presenting the divider
static let listItemSpacing: CGFloat = 4.0
static let leadingPadding: CGFloat = 16.0
static let maxVisibleRows = 4
// To make the scrolling more apparent we show a factional amount
static let maxVisibleRows: CGFloat = 4.5
}
// MARK: Public
var showBackgroundShadow = true
@State private var prototypeListItemFrame: CGRect = .zero
var body: some View {
@@ -68,13 +69,12 @@ struct CompletionSuggestionView: View {
.listRowInsets(.init(top: 0, leading: Constants.leadingPadding, bottom: 0, trailing: 0))
}
.listStyle(PlainListStyle())
.frame(height: min(contentHeightForRowCount(Constants.maxVisibleRows),
contentHeightForRowCount(items.count)))
.frame(height: contentHeightForRowCount(min(CGFloat(items.count), Constants.maxVisibleRows)))
.background(Color.compound.bgCanvasDefault)
}
private func contentHeightForRowCount(_ count: Int) -> CGFloat {
(prototypeListItemFrame.height + Constants.listItemPadding * 2 + Constants.listItemSpacing) * CGFloat(count) - Constants.listItemSpacing / 2 + Constants.topPadding - Constants.listItemPadding
private func contentHeightForRowCount(_ count: CGFloat) -> CGFloat {
(prototypeListItemFrame.height + Constants.listItemPadding * 2 + Constants.listItemSpacing) * count - Constants.listItemSpacing / 2 + Constants.topPadding - Constants.listItemPadding
}
private struct ListItemPaddingModifier: ViewModifier {
@@ -38,12 +38,19 @@ struct ComposerToolbar: View {
private let voiceMessageTooltipDuration = 1.0
@State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass
var body: some View {
VStack(spacing: 8) {
topBar
if context.composerActionsEnabled {
if verticalSizeClass != .compact,
context.composerExpanded {
suggestionView
.padding(.leading, -5)
.padding(.trailing, -8)
}
bottomBar
}
}
@@ -53,7 +60,7 @@ struct ComposerToolbar: View {
ViewFrameReader(frame: $frame)
}
.overlay(alignment: .bottom) {
if context.viewState.areSuggestionsEnabled {
if verticalSizeClass != .compact, !context.composerExpanded {
suggestionView
.offset(y: -frame.height)
}
@@ -68,7 +75,9 @@ struct ComposerToolbar: View {
}
private var suggestionView: some View {
CompletionSuggestionView(imageProvider: context.imageProvider, items: context.viewState.suggestions) { suggestion in
CompletionSuggestionView(imageProvider: context.imageProvider,
items: context.viewState.suggestions,
showBackgroundShadow: !context.composerExpanded) { suggestion in
context.send(viewAction: .selectedSuggestion(suggestion))
}
}
@@ -62,13 +62,19 @@ struct MessageComposer: View {
}
// MARK: - Private
@State private var composerFrame = CGRect.zero
private var mainContent: some View {
VStack(alignment: .leading, spacing: -6) {
header
composerView
.frame(minHeight: composerHeight, alignment: .top)
Color.clear
.overlay(alignment: .top) {
composerView
.background(ViewFrameReader(frame: $composerFrame))
}
.frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height),
alignment: .top)
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
.focused($focused)
@@ -339,7 +339,7 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview {
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
return HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder()),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -155,7 +155,7 @@ struct HomeScreenEmptyStateView_Previews: PreviewProvider, TestablePreview {
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
return HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder()),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -190,7 +190,7 @@ struct HomeScreenRoomCell_Previews: PreviewProvider, TestablePreview {
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
mentionBuilder: MentionBuilder()),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -201,7 +201,7 @@ struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
"<p>This is a list</p>\n<ul>\n<li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n"
]
let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled))
let attributedStringBuilder = AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder())
ScrollView {
VStack(alignment: .leading, spacing: 24.0) {
@@ -49,7 +49,6 @@ protocol DeveloperOptionsProtocol: AnyObject {
var userSuggestionsEnabled: Bool { get set }
var readReceiptsEnabled: Bool { get set }
var swiftUITimelineEnabled: Bool { get set }
var mentionsEnabled: Bool { get set }
var appLockFlowEnabled: Bool { get set }
var elementCallEnabled: Bool { get set }
var chatBackupEnabled: Bool { get set }
@@ -58,11 +58,6 @@ struct DeveloperOptionsScreen: View {
Text("Resets on reboot")
}
Toggle(isOn: $context.mentionsEnabled) {
Text("Show user mentions")
Text("Requires app reboot")
}
Toggle(isOn: $context.elementCallEnabled) {
Text("Element Call")
}
@@ -98,7 +98,7 @@ enum RoomTimelineItemFixtures {
sender: .init(id: "", displayName: "Helena"),
content: .init(body: "",
formattedBody: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled))
mentionBuilder: MentionBuilder())
.fromHTML("Hol' up <blockquote>New home office set up!</blockquote>That's amazing! Congrats 🥳")))
]
@@ -176,7 +176,7 @@ class MockScreen: Identifiable {
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let coordinator = HomeScreenCoordinator(parameters: .init(userSession: session,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true)),
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder()),
bugReportService: BugReportServiceMock(),
navigationStackCoordinator: navigationStackCoordinator,
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher()))