mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
+48
-10
@@ -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
|
||||
+40
-7
@@ -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()
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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
|
||||
+84
-15
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -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 }
|
||||
+4
-4
@@ -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(),
|
||||
+6
-3
@@ -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)
|
||||
+2
-2
@@ -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()))
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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,
|
||||
+52
-17
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user