Plain composer suggestions and pills (#2751)

* Move ComposerToolbar files to withing the RoomScreen folder (as it's not a screen on its own)

* Switch the plain composer to NSAttributedStrings

* Enable suggestions on the plain composer

* Introduce a new `MatrixUserDisplayName` attributed string attributed and use it for mention building

* Implement mention pill rendering and sending on the plain message composer

* Fix plain composer snapshot tests

* Fix broken formatting options layout

* Add clarifying comment.
This commit is contained in:
Stefan Ceriu
2024-05-03 12:06:16 +03:00
committed by GitHub
parent d067e844da
commit 38416dbb5c
27 changed files with 418 additions and 232 deletions
@@ -1,35 +0,0 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
// sourcery: AutoMockable
protocol CompletionSuggestionServiceProtocol {
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?)
}
extension CompletionSuggestionServiceMock {
struct CompletionSuggestionServiceMockConfiguration {
var suggestions: [SuggestionItem] = []
}
convenience init(configuration: CompletionSuggestionServiceMockConfiguration) {
self.init()
underlyingSuggestionsPublisher = Just(configuration.suggestions).eraseToAnyPublisher()
}
}
@@ -17,40 +17,53 @@
import Combine
import Foundation
private enum SuggestionTriggerPattern: Character {
case at = "@"
}
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
private let roomProxy: RoomProxyProtocol
private var canMentionAllUsers = false
private(set) var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> = Empty().eraseToAnyPublisher()
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionPattern?, Never>(nil)
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionTrigger?, Never>(nil)
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
suggestionsPublisher = suggestionTriggerSubject
.combineLatest(roomProxy.membersPublisher)
.map { [weak self, ownUserID = roomProxy.ownUserID] suggestionPattern, members -> [SuggestionItem] in
.map { [weak self, ownUserID = roomProxy.ownUserID] suggestionTrigger, members -> [SuggestionItem] in
guard let self,
let suggestionPattern else {
let suggestionTrigger else {
return []
}
switch suggestionPattern.type {
switch suggestionTrigger.type {
case .user:
var membersSuggestion = members
.compactMap { member -> SuggestionItem? in
guard member.userID != ownUserID,
member.membership == .join,
Self.isIncluded(searchText: suggestionPattern.text, userID: member.userID, displayName: member.displayName) else {
Self.shouldIncludeMember(userID: member.userID, displayName: member.displayName, searchText: suggestionTrigger.text) else {
return nil
}
return SuggestionItem.user(item: .init(id: member.userID, displayName: member.displayName, avatarURL: member.avatarURL))
return SuggestionItem.user(item: .init(id: member.userID,
displayName: member.displayName,
avatarURL: member.avatarURL,
range: suggestionTrigger.range))
}
if self.canMentionAllUsers,
!self.roomProxy.isEncryptedOneToOneRoom,
Self.isIncluded(searchText: suggestionPattern.text, userID: PillConstants.atRoom, displayName: PillConstants.everyone) {
Self.shouldIncludeMember(userID: PillConstants.atRoom, displayName: PillConstants.everyone, searchText: suggestionTrigger.text) {
membersSuggestion
.insert(SuggestionItem.allUsers(item: .allUsersMention(roomAvatar: self.roomProxy.avatarURL)), at: 0)
.insert(SuggestionItem.allUsers(item: .init(id: PillConstants.atRoom,
displayName: PillConstants.everyone,
avatarURL: self.roomProxy.avatarURL,
range: suggestionTrigger.range)), at: 0)
}
return membersSuggestion
}
}
@@ -69,11 +82,36 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
}
}
func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?) {
func processTextMessage(_ textMessage: String?) {
setSuggestionTrigger(detectTriggerInText(textMessage))
}
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) {
suggestionTriggerSubject.value = suggestionTrigger
}
private static func isIncluded(searchText: String, userID: String, displayName: String?) -> Bool {
// MARK: - Private
private func detectTriggerInText(_ text: String?) -> SuggestionTrigger? {
guard let text else {
return nil
}
let components = text.components(separatedBy: .whitespaces)
guard var lastComponent = components.last,
let range = text.range(of: lastComponent, options: .backwards),
lastComponent.count > 0,
let suggestionKey = SuggestionTriggerPattern(rawValue: lastComponent.removeFirst()),
// If a second character exists and is the same as the key it shouldn't trigger.
lastComponent.first != suggestionKey.rawValue else {
return nil
}
return .init(type: .user, text: lastComponent, range: NSRange(range, in: text))
}
private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool {
// If the search text is empty give back all the results
guard !searchText.isEmpty else {
return true
@@ -14,10 +14,20 @@
// limitations under the License.
//
import Combine
import Foundation
import WysiwygComposer
struct SuggestionTrigger: Equatable {
enum SuggestionType: Equatable {
case user
}
let type: SuggestionType
let text: String
let range: NSRange
}
enum SuggestionItem: Identifiable, Equatable {
case user(item: MentionSuggestionItem)
case allUsers(item: MentionSuggestionItem)
@@ -30,26 +40,49 @@ enum SuggestionItem: Identifiable, Equatable {
return PillConstants.atRoom
}
}
var range: NSRange {
switch self {
case .user(let item), .allUsers(let item):
return item.range
}
}
}
struct MentionSuggestionItem: Identifiable, Equatable {
let id: String
let displayName: String?
let avatarURL: URL?
let range: NSRange
}
// sourcery: AutoMockable
protocol CompletionSuggestionServiceProtocol {
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
static func allUsersMention(roomAvatar: URL?) -> Self {
MentionSuggestionItem(id: PillConstants.atRoom, displayName: PillConstants.everyone, avatarURL: roomAvatar)
}
func processTextMessage(_ textMessage: String?)
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?)
}
extension WysiwygComposer.SuggestionPattern {
var toElementPattern: SuggestionPattern? {
var toElementPattern: SuggestionTrigger? {
switch key {
case .at:
return SuggestionPattern(type: .user, text: text)
// Not yet supported
return SuggestionTrigger(type: .user, text: text, range: .init(location: Int(start), length: Int(end)))
default:
return nil
}
}
}
extension CompletionSuggestionServiceMock {
struct CompletionSuggestionServiceMockConfiguration {
var suggestions: [SuggestionItem] = []
}
convenience init(configuration: CompletionSuggestionServiceMockConfiguration) {
self.init()
underlyingSuggestionsPublisher = Just(configuration.suggestions).eraseToAnyPublisher()
}
}
@@ -56,6 +56,8 @@ enum ComposerToolbarViewAction {
case selectedSuggestion(_ suggestion: SuggestionItem)
case voiceMessage(ComposerToolbarVoiceMessageAction)
case plainComposerTextChanged
}
enum ComposerAttachmentType {
@@ -94,7 +96,7 @@ struct ComposerToolbarViewState: BindableState {
if ServiceLocator.shared.settings.richTextEditorEnabled {
return !composerEmpty
} else {
return !bindings.composerPlainText.isEmpty
return !bindings.plainComposerText.string.isEmpty
}
}
}
@@ -107,7 +109,7 @@ struct ComposerToolbarViewState: BindableState {
if ServiceLocator.shared.settings.richTextEditorEnabled {
return composerEmpty
} else {
return bindings.composerPlainText.isEmpty
return bindings.plainComposerText.string.isEmpty
}
}
@@ -122,7 +124,7 @@ struct ComposerToolbarViewState: BindableState {
}
struct ComposerToolbarViewStateBindings {
var composerPlainText = ""
var plainComposerText: NSAttributedString = .init(string: "")
var composerFocused = false
var composerActionsEnabled = false
var composerExpanded = false
@@ -27,6 +27,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private let wysiwygViewModel: WysiwygComposerViewModel
private let completionSuggestionService: CompletionSuggestionServiceProtocol
private let appSettings: AppSettings
private let mentionBuilder: MentionBuilderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
private var hasAppeard = false
private let actionsSubject: PassthroughSubject<ComposerToolbarViewModelAction, Never> = .init()
@@ -48,6 +52,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
self.completionSuggestionService = completionSuggestionService
self.appSettings = appSettings
mentionBuilder = MentionBuilder()
attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder)
super.init(initialViewState: ComposerToolbarViewState(audioPlayerState: .init(id: .recorderPreview, duration: 0),
audioRecorderState: .init(),
bindings: .init()),
@@ -95,14 +102,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
.store(in: &cancellables)
completionSuggestionService.suggestionsPublisher
.combineLatest(appSettings.$richTextEditorEnabled)
.map { suggestions, richTextEditorEnabled in
// We ignore user suggestions when RTE is disabled since mentions would not work
guard richTextEditorEnabled else {
return []
}
return suggestions
}
.weakAssign(to: \.state.suggestions, on: self)
.store(in: &cancellables)
@@ -133,7 +132,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
mode: state.composerMode,
intentionalMentions: wysiwygViewModel.getMentionsState().toIntentionalMentions()))
} else {
actionsSubject.send(.sendMessage(plain: context.composerPlainText, html: nil, mode: state.composerMode, intentionalMentions: .empty))
sendPlainComposerText()
}
}
case .cancelReply:
@@ -159,6 +158,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
handleSuggestion(suggestion)
case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction)
case .plainComposerTextChanged:
completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string)
}
}
@@ -186,6 +187,58 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
// MARK: - Private
private func sendPlainComposerText() {
let attributedString = NSMutableAttributedString(attributedString: context.plainComposerText)
var shouldMakeAnotherPass = false
var userIDs = Set<String>()
var containsAtRoom = false
repeat { // Don't enumerate and mutate at the same time, big no no
shouldMakeAnotherPass = false
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, stop in
guard let value else { return }
shouldMakeAnotherPass = true
// Remove the attribute so it doesn't get inherited by the new string
attributedString.removeAttribute(.link, range: range)
guard let userID = attributedString.attribute(.MatrixUserID, at: range.location, effectiveRange: nil) as? String else {
return
}
let displayName = attributedString.attribute(.MatrixUserDisplayName, at: range.location, effectiveRange: nil)
attributedString.replaceCharacters(in: range, with: "[\(displayName ?? userID)](\(value))")
userIDs.insert(userID)
stop.pointee = true
}
} while shouldMakeAnotherPass
repeat {
shouldMakeAnotherPass = false
attributedString.enumerateAttribute(.MatrixAllUsersMention, in: .init(location: 0, length: attributedString.length), options: []) { value, range, stop in
guard value != nil else { return }
shouldMakeAnotherPass = true
// Remove the attribute so it doesn't get inherited by the new string
attributedString.removeAttribute(.MatrixAllUsersMention, range: range)
attributedString.replaceCharacters(in: range, with: PillConstants.atRoom)
containsAtRoom = true
stop.pointee = true
}
} while shouldMakeAnotherPass
actionsSubject.send(.sendMessage(plain: attributedString.string, html: nil,
mode: state.composerMode,
intentionalMentions: .init(userIDs: userIDs, atRoom: containsAtRoom)))
}
private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) {
switch action {
case .startRecording:
@@ -213,9 +266,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private func setupMentionsHandling(mentionDisplayHelper: MentionDisplayHelper) {
wysiwygViewModel.mentionDisplayHelper = mentionDisplayHelper
let attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: MentionBuilder())
wysiwygViewModel.mentionReplacer = ComposerMentionReplacer { urlString, string in
wysiwygViewModel.mentionReplacer = ComposerMentionReplacer { [weak self] urlString, string in
guard let self else {
return NSMutableAttributedString(string: string)
}
let attributedString: NSMutableAttributedString
// This is the all room mention special case
if urlString == PillConstants.composerAtRoomURLString {
@@ -223,6 +278,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} else {
attributedString = NSMutableAttributedString(string: string, attributes: [.link: URL(string: urlString) as Any])
}
attributedStringBuilder.detectPermalinks(attributedString)
// In RTE mentions don't need to be handled as links
@@ -238,9 +294,22 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
MXLog.error("Could not build user permalink")
return
}
wysiwygViewModel.setMention(url: url.absoluteString, name: item.displayName ?? item.id, mentionType: .user)
if appSettings.richTextEditorEnabled {
wysiwygViewModel.setMention(url: url.absoluteString, name: item.displayName ?? item.id, mentionType: .user)
} else {
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
mentionBuilder.handleUserMention(for: attributedString, in: suggestion.range, url: url, userID: item.id, userDisplayName: item.displayName)
state.bindings.plainComposerText = attributedString
}
case .allUsers:
wysiwygViewModel.setAtRoomMention()
if appSettings.richTextEditorEnabled {
wysiwygViewModel.setAtRoomMention()
} else {
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
mentionBuilder.handleAllUsersMention(for: attributedString, in: suggestion.range)
state.bindings.plainComposerText = attributedString
}
}
}
@@ -267,7 +336,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
wysiwygViewModel.setHtmlContent(text)
} else {
state.bindings.composerPlainText = text
state.bindings.plainComposerText = .init(string: text)
}
}
@@ -16,15 +16,6 @@
import Combine
struct SuggestionPattern: Equatable {
enum SuggestionType: Equatable {
case user
}
let type: SuggestionType
let text: String
}
// periphery: ignore - markdown protocol
protocol ComposerToolbarViewModelProtocol {
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
@@ -41,7 +41,7 @@ struct CompletionSuggestionView: View {
EmptyView()
} else {
ZStack {
MentionSuggestionItemView(imageProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil))
MentionSuggestionItemView(imageProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil, range: .init()))
.readFrame($prototypeListItemFrame)
.hidden()
if showBackgroundShadow {
@@ -118,15 +118,15 @@ private struct BackgroundView<Content: View>: View {
struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview {
static let multipleItems: [SuggestionItem] = (0...10).map { index in
SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil))
SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil, range: .init()))
}
static var previews: some View {
// Putting them is VStack allows the preview to work properly in tests
VStack(spacing: 8) {
CompletionSuggestionView(imageProvider: MockMediaProvider(),
items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))]) { _ in }
items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory, range: .init()))]) { _ in }
}
VStack(spacing: 8) {
CompletionSuggestionView(imageProvider: MockMediaProvider(),
@@ -162,7 +162,7 @@ struct ComposerToolbar: View {
}
private var messageComposer: some View {
MessageComposer(plainText: $context.composerPlainText,
MessageComposer(plainComposerText: $context.plainComposerText,
composerView: composerView,
mode: context.viewState.composerMode,
showResizeGrabber: context.viewState.bindings.composerActionsEnabled,
@@ -186,6 +186,9 @@ struct ComposerToolbar: View {
.onChange(of: composerFocused) { newValue in
context.composerFocused = newValue
}
.onChange(of: context.plainComposerText) { _ in
context.send(viewAction: .plainComposerTextChanged)
}
.onAppear {
composerFocused = context.composerFocused
}
@@ -279,8 +282,8 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
mediaProvider: MockMediaProvider(),
appSettings: ServiceLocator.shared.settings,
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))]
static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory, range: .init()))]
static var previews: some View {
ComposerToolbar.mock(focused: true)
@@ -47,7 +47,7 @@ struct MentionSuggestionItemView_Previews: PreviewProvider, TestablePreview {
static let mockMediaProvider = MockMediaProvider()
static var previews: some View {
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: URL.documentsDirectory))
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil))
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: URL.documentsDirectory, range: .init()))
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil, range: .init()))
}
}
@@ -22,7 +22,7 @@ typealias EnterKeyHandler = () -> Void
typealias PasteHandler = (NSItemProvider) -> Void
struct MessageComposer: View {
@Binding var plainText: String
@Binding var plainComposerText: NSAttributedString
let composerView: WysiwygComposerView
let mode: RoomScreenComposerMode
let showResizeGrabber: Bool
@@ -85,7 +85,7 @@ struct MessageComposer: View {
}
} else {
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
text: $plainText,
text: $plainComposerText,
isMultiline: $isMultiline,
maxHeight: 300,
enterKeyHandler: sendAction,
@@ -228,11 +228,11 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
.loading(eventID: "")
]
static func messageComposer(_ content: String = "",
static func messageComposer(_ content: NSAttributedString = .init(string: ""),
mode: RoomScreenComposerMode = .default) -> MessageComposer {
let viewModel = WysiwygComposerViewModel(minHeight: 22,
maxExpandedHeight: 250)
viewModel.setMarkdownContent(content)
viewModel.setMarkdownContent(content.string)
let composerView = WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder,
viewModel: viewModel,
@@ -240,7 +240,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
keyCommands: nil,
pasteHandler: nil)
return MessageComposer(plainText: .constant(content),
return MessageComposer(plainComposerText: .constant(content),
composerView: composerView,
mode: mode,
showResizeGrabber: false,
@@ -256,7 +256,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
VStack(spacing: 8) {
messageComposer()
messageComposer("Some message",
messageComposer(.init(string: "Some message"),
mode: .edit(originalItemId: .random))
messageComposer(mode: .reply(itemID: .random,
@@ -17,7 +17,7 @@ import SwiftUI
struct MessageComposerTextField: View {
let placeholder: String
@Binding var text: String
@Binding var text: NSAttributedString
@Binding var isMultiline: Bool
let maxHeight: CGFloat
@@ -36,7 +36,7 @@ struct MessageComposerTextField: View {
@ViewBuilder
private var placeholderView: some View {
if text.isEmpty {
if text.string.isEmpty {
Text(placeholder)
.foregroundColor(.compound.textPlaceholder)
.accessibilityHidden(true)
@@ -45,9 +45,9 @@ struct MessageComposerTextField: View {
}
private struct UITextViewWrapper: UIViewRepresentable {
typealias UIViewType = UITextView
@Environment(\.roomContext) private var roomContext
@Binding var text: String
@Binding var text: NSAttributedString
@Binding var isMultiline: Bool
let maxHeight: CGFloat
@@ -60,6 +60,8 @@ private struct UITextViewWrapper: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
// Need to use TextKit 1 for mentions
let textView = ElementTextView(usingTextLayoutManager: false)
textView.roomContext = roomContext
textView.isMultiline = $isMultiline
textView.delegate = context.coordinator
textView.elementDelegate = context.coordinator
@@ -75,7 +77,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
textView.keyboardType = .default
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
@@ -90,10 +92,14 @@ private struct UITextViewWrapper: UIViewRepresentable {
}
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if textView.text != text {
textView.text = text
if text.isEmpty {
if textView.attributedText != text {
textView.attributedText = text
// Prevent the textView from randomly using the tint color
textView.typingAttributes = [.font: font,
.foregroundColor: UIColor(.compound.textPrimary)]
if text.string.isEmpty {
// text cleared, probably because the written text is sent
// reload keyboard type
if textView.isFirstResponder {
@@ -114,14 +120,14 @@ private struct UITextViewWrapper: UIViewRepresentable {
}
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
private var text: Binding<String>
private var text: Binding<NSAttributedString>
private let maxHeight: CGFloat
private let enterKeyHandler: EnterKeyHandler
private let pasteHandler: PasteHandler
init(text: Binding<String>,
init(text: Binding<NSAttributedString>,
maxHeight: CGFloat,
enterKeyHandler: @escaping EnterKeyHandler,
pasteHandler: @escaping PasteHandler) {
@@ -132,7 +138,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
text.wrappedValue = textView.attributedText
}
func textViewDidReceiveEnterKeyPress(_ textView: UITextView) {
@@ -155,10 +161,13 @@ private protocol ElementTextViewDelegate: AnyObject {
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider)
}
private class ElementTextView: UITextView {
weak var elementDelegate: ElementTextViewDelegate?
private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
var roomContext: RoomScreenViewModel.Context?
var isMultiline: Binding<Bool>?
weak var elementDelegate: ElementTextViewDelegate?
private var pillViews = NSHashTable<UIView>.weakObjects()
override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
@@ -215,6 +224,31 @@ private class ElementTextView: UITextView {
elementDelegate?.textView(self, didReceivePasteWith: provider)
}
// MARK: PillAttachmentViewProviderDelegate
func invalidateTextAttachmentsDisplay() {
attributedText.enumerateAttribute(.attachment,
in: NSRange(location: 0, length: attributedText.length),
options: []) { value, range, _ in
guard value != nil else {
return
}
self.layoutManager.invalidateDisplay(forCharacterRange: range)
}
}
func registerPillView(_ pillView: UIView) {
pillViews.add(pillView)
}
func flushPills() {
for view in pillViews.allObjects {
view.alpha = 0.0
view.removeFromSuperview()
}
pillViews.removeAllObjects()
}
}
struct MessageComposerTextField_Previews: PreviewProvider, TestablePreview {
@@ -227,11 +261,12 @@ struct MessageComposerTextField_Previews: PreviewProvider, TestablePreview {
}
struct PreviewWrapper: View {
@State var text: String
@State var text: NSAttributedString
@State var isMultiline: Bool
init(text: String) {
_text = .init(initialValue: text)
_text = .init(initialValue: .init(string: text, attributes: [.font: UIFont.preferredFont(forTextStyle: .body),
.foregroundColor: UIColor(.compound.textPrimary)]))
_isMultiline = .init(initialValue: false)
}